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
| Event | Fires when |
|---|---|
attribution.created | A conversion event is credited to a partner. Once per credited partner, so a multi-touch model fires several. |
commission.accrued | A 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.approved | A commission is approved, by an admin or automatically once its holdback ends. |
commission.paid | A commission is included in a payout. For a manual payout this fires when the payout is planned, before you’ve sent the money. |
commission.reversed | A commission is reversed: by an admin (reason: "manual"), or by a refund or chargeback ("refund", "chargeback"). |
partner.created | A 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.activated | A partner becomes active. See below for the three ways in. |
partnership.approved | A Network creator’s application is approved and the partner is created on your account. |
payout.created | A 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.createdcomes beforepartner.activated.- A refund that reduces a commission sends
commission.reversedfor the original first, thencommission.accruedfor the replacement. If the original was already approved, acommission.approvedfor 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>/webhooksAuthorization: Bearer <key>Content-Type: application/json
{ "url": "https://your-server.example.com/openpartner-webhook", "events": ["commission.accrued", "payout.created"], "label": "Production Slack notifier"}| Field | Notes |
|---|---|
url | Your 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. |
events | Array of event types from the table above. Use ["*"] to receive everything. |
label | Optional 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=1deletes the endpoint and its log (admin key orwebhooks:write)
Test fire
Two modes:
POST <base>/webhooks/:id/testAuthorization: Bearer <key>Fires a generic webhook.test envelope to verify URL reachability + signature parsing.
POST <base>/webhooks/:id/test?event=commission.accruedAuthorization: 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.accruedx-openpartner-delivery: evt_01HXX...x-openpartner-timestamp: 1714123200x-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
WebhookDeliverylog 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 envelopeidand 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, withreasonrefundorchargebackand therefundEventId. - Partial refund:
commission.reversedfor the original, carryingreplacementCommissionId, thencommission.accruedfor a replacement worth the share the customer kept, carryingreplacesCommissionId. 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 itspayout.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..."}sourceCommissionIdis the recruit’s commission it’s a share of.recruitedPartnerIdis the recruit. LikeclickId, it’s left out only if the lookup behind it fails.attributionId,clickId,eventId,eventType,eventValueandprogramIdare 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...", "name": "Alice", "activatedAt": null, "invited": true, "portalAccess": true, "programIds": ["01HXX..."], "source": "api"}activatedAtisnulluntil the partner is active: an invited partner who hasn’t accepted yet, or a join-page applicant held for review (POST /partners/:id/inviteapproves them).invitedsays an invite email with a sign-in link was sent:POST /partnerswithoutsendInvite: false, or a join-page applicant approved automatically.programIdsis the programs granted. Join-page applicants start with none.sourceisapiorjoin_page. New values may be added.recruitedByPartnerIdis set when a partner recruited them: the partner whose recruit link they applied through, or the onePOST /partnersnamed. It’s absent when nobody did. See Recruiting.
partner.activated
{ "partnerId": "01HXX...", "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 /partnersandsendInvite: false:invited: false,source: "api", right afterpartner.created. They haven’t signed in, and may not have portal access. - Your join page approved them automatically:
invited: false,source: "join_page", right afterpartner.created. They were emailed a sign-in link.
partnership.approved
{ "partnerId": "01HXX...", "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.