Skip to content

Webhooks

OpenPartner posts JSON envelopes to subscriber URLs whenever named events fire. Used by the Zapier and ActivePieces integrations and by any custom endpoint you wire up.

Event types

EventFires when
attribution.createdA conversion event is credited to a partner. Once per credited partner, so a multi-touch model fires several.
commission.accruedA commission is written: one per matching commission rule. Also fires for the smaller replacement a partial refund leaves (with replacesCommissionId), and for a recruiter’s share of a recruit’s commission (with sourceCommissionId, see Recruiter commissions). Most-used integration trigger.
commission.approvedA commission is approved, by an admin or automatically once its holdback ends.
commission.paidA commission is included in a payout. For a manual payout this fires when the payout is planned, before you’ve sent the money.
commission.reversedA commission is reversed: by an admin (reason: "manual"), or by a refund or chargeback ("refund", "chargeback").
partner.createdA partner is added through POST /partners (the admin portal, an integration or a Network approval: source: "api"), or applies on your public join page (source: "join_page").
partner.activatedA partner becomes active. See below for the three ways in.
partnership.approvedA Network creator’s application is approved and the partner is created on your account.
payout.createdA payout is recorded: a Stripe Connect transfer that went out, or a manual payout you still have to send. statementUrl is where its payout statement is.

Plus the wildcard * for endpoint subscriptions only — subscribers using * receive every event.

Partners imported with POST /import/partners-csv fire neither partner.created nor partner.activated.

When they’re sent

Every event above is sent after the change it describes has been saved. A receiver that calls the API as soon as a webhook arrives finds the partner, attribution or commission already there, and a change that fails part-way announces nothing.

Related events arrive in order:

  • partner.created comes before partner.activated.
  • A refund that reduces a commission sends commission.reversed for the original first, then commission.accrued for the replacement. If the original was already approved, a commission.approved for the replacement follows. See Refunds.

Envelope

Every delivery has the same outer shape:

{
"id": "evt_01HXX...",
"event": "commission.accrued",
"created": "2026-04-24T12:00:00.000Z",
"data": { ... }
}

The data field’s shape is event-specific — see payload reference below. Dedupe on id: a manual redelivery reuses it.

Subscribe

POST /webhooks — registers a new endpoint.

Auth: Admin API key OR scoped key with webhooks:write.

POST <base>/webhooks
Authorization: Bearer <key>
Content-Type: application/json
{
"url": "https://your-server.example.com/openpartner-webhook",
"events": ["commission.accrued", "payout.created"],
"label": "Production Slack notifier"
}
FieldNotes
urlYour subscriber URL. It must be publicly reachable: private, loopback and link-local addresses, and on the hosted plan any port but 80 and 443, are refused with 400 destination_not_allowed.
eventsArray of event types from the table above. Use ["*"] to receive everything.
labelOptional human-readable label, up to 80 characters.

Response

{
"endpoint": {
"id": "01HXX...",
"url": "...",
"events": ["commission.accrued", "payout.created"],
"active": true,
"label": "Production Slack notifier",
"createdAt": "2026-04-24T12:00:00Z"
},
"secret": "whsec_xxx"
}

The secret is shown once — you’ll use it to verify HMAC signatures. Store it immediately; it’s never returned again.

Manage

  • GET /webhooks — list endpoints (admin key)
  • GET /webhooks/:id — single endpoint detail (admin key)
  • PATCH /webhooks/:id — update url, events, label, active flag (admin key)
  • DELETE /webhooks/:id — deactivate, keeping the delivery log; ?hard=1 deletes the endpoint and its log (admin key or webhooks:write)

Test fire

Two modes:

POST <base>/webhooks/:id/test
Authorization: Bearer <key>

Fires a generic webhook.test envelope to verify URL reachability + signature parsing.

POST <base>/webhooks/:id/test?event=commission.accrued
Authorization: Bearer <key>

Fires a synthetic event of the chosen type with the real payload shape, plus _synthetic: true and _endpointId. Useful for end-to-end testing Zapier triggers / ActivePieces pieces / custom integrations without seeding fake conversion data. Bypasses subscription filtering — the synthetic event goes to the chosen endpoint regardless of whether it subscribes.

Returns the WebhookDelivery row inline so failures (HTTP 4xx/5xx, timeout, signature errors) surface immediately rather than dropping into the delivery log.

Signature verification

Every delivery carries four headers:

x-openpartner-event: commission.accrued
x-openpartner-delivery: evt_01HXX...
x-openpartner-timestamp: 1714123200
x-openpartner-signature: <hex>

Verify with HMAC-SHA256 over ${timestamp}.${rawBody}, keyed with the whole whsec_… secret:

const crypto = require('crypto');
function verify(req, secret) {
const timestamp = req.header('x-openpartner-timestamp');
const signature = req.header('x-openpartner-signature') ?? '';
const body = req.rawBody; // your framework's raw body, before any JSON parsing
// Replay protection: reject deliveries older than 5 minutes.
if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex'),
);
const given = Buffer.from(signature);
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}

Reject deliveries where the signature doesn’t match OR the timestamp is more than 5 minutes off your clock (replay protection).

Retry behavior

  • 10-second timeout per delivery.
  • Failed deliveries are recorded in the WebhookDelivery log but not auto-retried.
  • Manual retry via POST /webhooks/:id/deliveries/:deliveryId/retry (or the Retry button on the Webhooks admin page). The resend carries the same envelope id and a fresh signature.
  • 4xx responses and redirects from your subscriber count as failures — return 2xx for “received.”

Auto-retry with backoff is on the roadmap. In the meantime, integration platforms like Zapier and ActivePieces handle their own retries on their side. If you can’t afford to miss an event, treat webhooks as notifications and reconcile from GET /commissions.

Refunds, reversals and replacements

A refund or chargeback reported to POST /attribution/refunds (or, on a self-hosted install, a Stripe refund) changes the conversion’s commissions as follows:

  • Full refund of an accrued or approved commission: commission.reversed, with reason refund or chargeback and the refundEventId.
  • Partial refund: commission.reversed for the original, carrying replacementCommissionId, then commission.accrued for a replacement worth the share the customer kept, carrying replacesCommissionId. The replacement keeps the original’s status and accrual date.
  • Already paid: no event. The commission stays paid; see Commissions. A later payout that deducts the clawback says so in its payout.created.

A recruiter’s share of a refunded commission is on the same conversion, so it gets the same events, for its own amount.

Recruiter commissions

In a program that pays partners for the partners they recruit, a recruiter’s share of a recruit’s commission is a commission of its own (see Commissions). It fires the usual commission.accrued, .approved, .paid and .reversed, with partnerId the recruiter and two extra fields:

{
"commissionId": "01HXX...",
"partnerId": "01HXX...",
"sourceCommissionId": "01HXX...",
"recruitedPartnerId": "01HXX...",
"amount": "5.00",
"currency": "USD",
"attributionId": "01HXX...",
"clickId": "01HXX...",
"eventId": "01HXX...",
"eventType": "invoice_paid",
"programId": "01HXX..."
}
  • sourceCommissionId is the recruit’s commission it’s a share of.
  • recruitedPartnerId is the recruit. Like clickId, it’s left out only if the lookup behind it fails.
  • attributionId, clickId, eventId, eventType, eventValue and programId are the recruit’s sale.

Approving the recruit’s commission approves the share in the same step, and you get a commission.approved for each. Reversing it sends commission.reversed for each share it reverses. A share that was already paid gets a clawback instead, which sends no event, and one a payout is moving waits for a retry (see POST /commissions/:id/reverse). A recruiter’s own postback URLs don’t carry the recruit’s click, sub-ID, event or sale value.

Payload reference

Each event’s data shape. Amounts are decimal strings in major units ("12.50"). eventValue is the triggering event’s value as a decimal string, or null. Fields may be added at any time, so ignore ones you don’t know.

attribution.created

{
"attributionId": "01HXX...",
"eventId": "01HXX...",
"partnerId": "01HXX...",
"programId": "01HXX...",
"clickId": "01HXX...",
"model": "last_click",
"weight": 1,
"eventType": "invoice_paid",
"eventValue": "60.00",
"commissionId": "01HXX...",
"commissionCurrency": "USD"
}

weight is this partner’s share of the event (0–1). commissionId is the first commission the attribution produced, and is left out when it produced none; listen to commission.accrued for every commission.

commission.accrued

{
"commissionId": "01HXX...",
"partnerId": "01HXX...",
"attributionId": "01HXX...",
"clickId": "01HXX...",
"eventId": "01HXX...",
"programId": "01HXX...",
"amount": "12.00",
"currency": "USD",
"eventType": "invoice_paid",
"eventValue": "60.00"
}

A replacement left by a partial refund also carries replacesCommissionId: the commission it replaces, reversed in the commission.reversed sent just before.

commission.approved

{
"commissionId": "01HXX...",
"partnerId": "01HXX...",
"attributionId": "01HXX...",
"amount": "12.00",
"currency": "USD",
"clickId": "01HXX...",
"eventId": "01HXX...",
"eventType": "invoice_paid",
"programId": "01HXX..."
}

clickId, eventId, eventType and programId trace the commission back to its click and event. They’re left out only if that lookup fails. The same holds for commission.paid and commission.reversed.

commission.paid

Same as commission.approved, plus payoutId.

commission.reversed

{
"commissionId": "01HXX...",
"partnerId": "01HXX...",
"attributionId": "01HXX...",
"amount": "12.00",
"currency": "USD",
"clickId": "01HXX...",
"eventId": "01HXX...",
"eventType": "invoice_paid",
"programId": "01HXX...",
"reason": "refund",
"refundEventId": "01HXX...",
"replacementCommissionId": "01HXX..."
}

reason is manual, refund or chargeback. refundEventId is the refund or chargeback event behind a refund / chargeback reversal. replacementCommissionId is set on partial refunds only.

partner.created

{
"partnerId": "01HXX...",
"email": "[email protected]",
"name": "Alice",
"activatedAt": null,
"invited": true,
"portalAccess": true,
"programIds": ["01HXX..."],
"source": "api"
}
  • activatedAt is null until the partner is active: an invited partner who hasn’t accepted yet, or a join-page applicant held for review (POST /partners/:id/invite approves them).
  • invited says an invite email with a sign-in link was sent: POST /partners without sendInvite: false, or a join-page applicant approved automatically.
  • programIds is the programs granted. Join-page applicants start with none.
  • source is api or join_page. New values may be added.
  • recruitedByPartnerId is set when a partner recruited them: the partner whose recruit link they applied through, or the one POST /partners named. It’s absent when nobody did. See Recruiting.

partner.activated

{
"partnerId": "01HXX...",
"email": "[email protected]",
"name": "Alice",
"activatedAt": "2026-04-24T12:05:00Z",
"invited": true,
"source": "api"
}

Fires once per partner, one of three ways:

  • They accepted an invite and signed in for the first time: invited: true. A join-page applicant held for review fires it this way too, on their first sign-in after approval.
  • They were created active with POST /partners and sendInvite: false: invited: false, source: "api", right after partner.created. They haven’t signed in, and may not have portal access.
  • Your join page approved them automatically: invited: false, source: "join_page", right after partner.created. They were emailed a sign-in link.

partnership.approved

{
"partnerId": "01HXX...",
"email": "[email protected]",
"name": "Alice",
"networkCreatorId": "crt_aaaaaaaaaaaaaaaaaaaa",
"programIds": ["01HXX..."]
}

payout.created

{
"payoutId": "01HXX...",
"partnerId": "01HXX...",
"amount": "120.00",
"currency": "USD",
"method": "stripe_connect",
"commissionIds": ["01HXX...", "01HXX..."]
}

method is stripe_connect, manual or external. A manual payout stays pending until you confirm you’ve sent it (POST /payouts/:id/confirm).

A payout that deducted refund clawbacks also carries grossAmount (the sum of commissionIds), clawbackAmount (what it deducted) and clawbackAdjustmentIds. amount is grossAmount minus clawbackAmount, rounded to whole units in a currency without cents, such as JPY.