Webhooks

Partnely posts each event of a program to any address you run, as it happens: an order recorded, approved or reversed, a creator who joins or is approved, a payout paid, campaign content handed in. Every body is signed, so the receiving server can prove it came from here.

Adding a destination

In the brand dashboard: Settings → Integrations. Choose a server of your own or a Slack channel, paste the address, tick the events. Webhooks and Slack alerts come with the Essential plan and above.

  • The address has to be https on a public domain. A name that resolves to a private address is refused when it is added and skipped if it starts resolving to one later.
  • A webhook destination is given a signing secret (whsec_…) shown once. It is stored encrypted here and cannot be read again; add the destination again to get a new one.
  • "Send a test event" posts one event with round figures and "test": true in the envelope. Never count a test event in your own books.

What arrives

One POST per event per destination, content-type: application/json. Answer with any 2xx within 5 seconds; anything else counts as a failure. Redirects are not followed.

  • X-Partnely-Event — the event type, e.g. order.approved.
  • X-Partnely-Delivery — the id of this delivery. The same delivery can arrive twice if your answer was lost, so treat it as the key to dedupe on.
  • X-Partnely-Timestamp — when this attempt was signed, in whole seconds since the epoch.
  • X-Partnely-Signaturev1=<hex>, the signature below.

The envelope is the same for every event; only data differs.

{
  "id": "evt_1",
  "type": "order.approved",
  "created_at": "2026-09-16T09:30:00.000Z",
  "brand_id": "brnd_1",
  "data": {
    "order": {
      "id": "sample",
      "order_id": "1001",
      "order_number": "#1001",
      "status": "approved",
      "subtotal_cents": 12000,
      "commission_cents": 1200,
      "platform_fee_cents": 480,
      "currency": "USD",
      "coupon_code": "SAMPLE10",
      "attribution_method": "click",
      "source": "shopify",
      "verified": true,
      "program_kind": "creator",
      "risk_flags": [],
      "occurred_at": "2026-09-16T09:30:00.000Z",
      "approved_at": "2026-09-16T09:30:00.000Z",
      "reversed_at": null,
      "reversal_reason": null,
      "reversed_by": null,
      "created_at": "2026-09-16T09:30:00.000Z"
    },
    "affiliate": {
      "id": "sample",
      "display_name": "Sample Creator",
      "handle": "sample"
    }
  }
}

Checking the signature

The signature is the HMAC-SHA256 of the timestamp, a dot, and the raw request body, keyed with the destination's secret, as lower-case hex. Read the body as bytes before any JSON parsing: re-serialising it changes the signature.

signed = "<X-Partnely-Timestamp>" + "." + raw_body
expected = hex(hmac_sha256(secret, signed))
header   = "v1=" + expected

Compare with a constant-time function, and refuse a timestamp more than five minutes from your own clock so an old delivery cannot be replayed.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody: string, headers: Headers, secret: string): boolean {
  const ts = headers.get("x-partnely-timestamp") ?? "";
  const sent = (headers.get("x-partnely-signature") ?? "").replace(/^v1=/, "");
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${ts}.${rawBody}`, "utf8").digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(sent, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

One worked example to test your own code against:

secret    whsec_example
timestamp 1700000000
body      {"id":"evt_1"}
signed    1700000000.{"id":"evt_1"}
header    X-Partnely-Signature: v1=2f6f24854ba5c8d505c37e6fc0a06fc74456f1a4042208e7acdd4bd0bdbd599e

Events and payloads

A payload carries what the brand already sees in its own API: the creator's display name and handle, the order's own figures, the timestamps. Never the shopper, never an order's metadata, never how a creator is paid. Money is integer cents in the field's own currency; amounts in different currencies are never added together.

  • order.recordedan order is recorded.
  • order.approvedan order is approved.
  • order.reversedan order is reversed.
  • partner.joineda creator joins the program.
  • partner.approveda creator is approved.
  • payout.paida payout to a creator is paid or recorded.
  • content.submittedcampaign content is handed in.

order.recorded fires while the order is still pending; commission is not owed until order.approved, and an approved order is final. payout.paid covers both a payout recorded by hand and one paid automatically; payout.method says which. New fields may be added to a payload at any time, so ignore what you do not know.

Retries and pausing

A delivery that is refused, times out or cannot be reached waits 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours before each further attempt — 6 attempts in all — and is then given up. The log on the destination's page shows every attempt, the status code and the error, and "Send again" starts the ladder over once the receiving end is fixed.

20 failed attempts in a row pause the destination and email the store's owner once. Nothing is lost while it is paused: orders, approvals and payouts stay in the ledger, and events start being queued again when it is resumed.

Slack

A Slack destination takes an incoming webhook address from Slack (https://hooks.slack.com/services/…) and receives Slack's own body — one line of plain text plus a Block Kit section — instead of the envelope. It is not signed: the address Slack gives you is the secret, so keep it out of anything public.

Order #1001 approved — 12.00 USD to Sample Creator.

Zapier and Make

There is nothing to install: both tools hand out an address that catches a POST, and that address is a webhook destination like any other. Paste it under Settings → Integrations, tick the events, and every one of them starts a run.

  • Zapier: a Webhooks by Zapier trigger with the event Catch Raw Hook. Take the raw one: the plain Catch Hook parses the JSON and flattens it, and the signature can no longer be checked against what it hands you.
  • Make: a Webhooks → Custom webhook module. Press Re-determine data structure, then send a test event from the destination's page so Make learns the shape from a real envelope.

Both answer 200 the moment they receive the request, so a slow scenario never causes a retry. Check the signature before you act on anything: the catch-hook address is unguessable, but it is not a secret you can rotate, and anyone who learns it can post to it.

In Zapier that is a Code by Zapier (JavaScript) step right after the trigger. Map three fields into inputData — the raw body, and the X-Partnely-Timestamp and X-Partnely-Signature headers the trigger collected — and put the destination's whsec_… secret in a fourth. Then filter the run on ok.

// inputData: rawBody, timestamp, signature, secret
const { createHmac, timingSafeEqual } = require("crypto");
const sent = String(inputData.signature || "").replace(/^v1=/, "");
const fresh = Math.abs(Date.now() / 1000 - Number(inputData.timestamp)) <= 300;
const expected = createHmac("sha256", inputData.secret).update(inputData.timestamp + "." + inputData.rawBody, "utf8").digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(sent, "hex");
const ok = fresh && a.length === b.length && timingSafeEqual(a, b);
output = { ok, event: ok ? JSON.parse(inputData.rawBody) : null };

Make has no code step of its own. If you cannot check the signature there, treat the webhook address as a secret, keep the scenario to things that are safe to repeat, and read anything that moves money back from the API before acting on it.

This is the body a run receives, whichever tool catches it:

{
  "id": "evt_1",
  "type": "order.approved",
  "created_at": "2026-09-16T09:30:00.000Z",
  "brand_id": "brnd_1",
  "data": {
    "order": {
      "id": "sample",
      "order_id": "1001",
      "order_number": "#1001",
      "status": "approved",
      "subtotal_cents": 12000,
      "commission_cents": 1200,
      "platform_fee_cents": 480,
      "currency": "USD",
      "coupon_code": "SAMPLE10",
      "attribution_method": "click",
      "source": "shopify",
      "verified": true,
      "program_kind": "creator",
      "risk_flags": [],
      "occurred_at": "2026-09-16T09:30:00.000Z",
      "approved_at": "2026-09-16T09:30:00.000Z",
      "reversed_at": null,
      "reversal_reason": null,
      "reversed_by": null,
      "created_at": "2026-09-16T09:30:00.000Z"
    },
    "affiliate": {
      "id": "sample",
      "display_name": "Sample Creator",
      "handle": "sample"
    }
  }
}

Prefer the API for pulling history: the API reference lists every endpoint. Base URL https://partnely.app/api/v1, and GET /api/v1/webhooks manages these destinations from a script.