Events
POST /attribution/events
Records a conversion event. The authoritative source for revenue + funnel state changes — emit from your server (or a CRM webhook), not the browser.
Auth: Admin API key OR scoped key with events:write.
Request
POST <base>/attribution/eventsAuthorization: Bearer <key>Content-Type: application/json
{ "userId": "user_abc", "type": "invoice_paid", "value": 49.00, "currency": "USD", "externalEventId": "in_12345", "metadata": { "stripe_invoice_id": "in_12345" }, "ts": "2026-04-24T12:00:00Z"}| Field | Type | Required | Notes |
|---|---|---|---|
userId | string | yes | Same identifier you assigned at signup time so OpenPartner can stitch the event to a click. |
type | string | yes | Standard or custom event name. Conventions: signup, trial_started, subscription_created, invoice_paid, opportunity_won, trial_converted. |
value | number | conditional | Required for events that drive percent commissions. A JSON number in major units (49.00, dollars not cents), never a string. Omit it for an event with no value: null is refused. |
currency | string | no | ISO 4217, any case (inr is stored as INR). Defaults to your default currency (Admin → Settings → Brand info, USD unless you changed it). Send it whenever you take more than one currency. |
externalEventId | string | recommended | Your stable id for this one event (invoice id, order id, deal id plus stage), up to 120 characters. A number is accepted as its string. Makes retries safe; see Idempotency. |
metadata | object | no | Free-form. Stored on the Event row; surfaced in attribution + webhook payloads. |
ts | string | no | ISO 8601 in UTC, ending in Z (2026-04-24T12:00:00Z). An offset such as +00:00 is refused. Defaults to server time. Set this when forwarding async events to preserve attribution-window math. |
Report payments as invoice_paid, once each. Sending both subscription_created and
invoice_paid with a value for the same charge counts its revenue twice.
Renewals. Send each renewal as its own event, with the same userId and type (one
invoice_paid per payment). Once the customer has an attributed event of that type, each
later one keeps that event’s partners and split, however old the click, unless the program
turns that off. Past the attribution window, only commission rules marked recurring pay on
them. See
Attribution window.
Response
{ "ok": true, "eventId": "01HXX...", "attribution": { "status": "attributed", "model": "last_click", "commission": "accrued", "touches": [ { "clickId": "01HXX...", "partnerId": "01HXX...", "weight": 1, "attributionId": "01HXX...", "commissionId": "01HXX..." } ] }}attribution.status is one of:
attributed— at least one click stitched to this user’s identity, attribution + commission rows writtenno_identity— nothing ever stitched thisuserIdto a click; event recorded but unattributed. If the user is stitched later, the event is attributed then.no_click— the user’s clicks are all flagged or belong to revoked partnersoutside_window— the user’s clicks fall outside the program’s attribution window, and the customer has no earlier attributed conversion to keep them with a partnercorrective— arefund,chargeback,dispute_createdorinvoice_payment_failedevent: recorded, never attributed, and it reverses nothing. Report refunds toPOST /attribution/refunds.not_attributed— only on a replay ("replayed": true) of an event that didn’t attribute the first time
When status is attributed, attribution.commission says what the partners earned:
accrued (at least one commission was written), no_matching_rule (no commission rule on
the partner’s terms takes this event type, usually a typo’d stage name) or none (a rule
takes the type but nothing accrued: the program has ended, a first rule already paid, a cap
was reached, or the amount came to zero).
Errors
| Status | error | Meaning |
|---|---|---|
| 400 | invalid_body | A field is missing or malformed, such as a value sent as a string or null, or a ts with an offset. detail says which. |
| 409 | external_event_id_conflict | externalEventId was already used for an event with a different userId, type, value or currency. eventId names the original. Nothing is recorded. |
| 409 | recorded_from_stripe | Only with the OpenPartner Stripe App connected: externalEventId is a Stripe invoice or Checkout Session id (in_…, cs_…), and that payment is recorded from Stripe itself. |
Common patterns
HubSpot Workflows / Zapier / Make: wire your CRM’s “deal stage = closed_won” trigger to
POST here with type: 'opportunity_won'. See CRM integration for
the full setup walkthrough.
Stripe webhooks: if your billing system is Stripe, subscribe to invoice.paid and POST
here with type: 'invoice_paid' + the customer’s userId. Or use the OpenPartner SDK to
emit directly from your server-side checkout handler. On a self-hosted install whose Stripe
webhook points at OpenPartner, those payments are already recorded: don’t report them here
too, or they’re recorded twice.
Only have a click id? A lender, an affiliate network or a lead form that captured the
click id can skip identify() and use
POST /attribution/conversions instead.
Idempotency
Send externalEventId on every call. Servers, queues, Zapier and CRM webhooks all retry
when a request times out, and without a key each retry records a second event and pays a
second commission.
- A retry with the same key returns
200with the originaleventIdand attribution plus"replayed": true, and nothing new is written. That holds for duplicates that arrive at the same moment too. - Use one key per event, not per order: an order’s
subscription_createdand itsinvoice_paidneed different keys. - Reusing a key for a different
userId,type,valueorcurrencyreturns409 external_event_id_conflict, and nothing is recorded. - Keys are unique across your whole account, in one namespace shared with
/attribution/conversions,/postback,/coupons/redeemand the Stripe webhook. When several systems send events, prefix each one’s keys (hubspot:deal-881:closedwon). - An empty
externalEventId(""ornull, such as an unmapped Zapier field) counts as no key.
POST /attribution/refunds
Reports that a conversion was refunded, in full or in part, or charged back. The refund is recorded as its own event, with a negative value, and the conversion’s commissions follow it.
Auth: Admin API key OR scoped key with events:write.
POST <base>/attribution/refundsAuthorization: Bearer <key>Content-Type: application/json
{ "conversionExternalEventId": "in_12345", "amount": 19.00, "reason": "downgraded", "externalEventId": "re_2001"}| Field | Type | Required | Notes |
|---|---|---|---|
conversionExternalEventId | string | one of | The externalEventId you reported the conversion with. |
conversionEventId | string | one of | Or the eventId OpenPartner returned for it. Send exactly one of the two. |
amount | number | no | The refunded amount, in major units. Leave it out to refund everything not yet refunded. |
kind | string | no | refund (the default) or chargeback. |
reason | string | no | Free text, up to 500 characters. |
externalEventId | string | yes | Your id for this refund: refund id, credit note id. Makes retries safe. |
currency | string | no | If sent, must match the conversion’s currency. |
ts | string | no | ISO 8601 in UTC, ending in Z. Defaults to server time. |
metadata | object | no | Free-form. |
Each of the conversion’s commissions ends up worth its original amount times the share of the payment the customer kept:
| Commission was | What happens | status in the response |
|---|---|---|
accrued or approved | Reversed (commission.reversed, with reason refund or chargeback). On a partial refund, a replacement commission for the kept amount follows (commission.accrued with replacesCommissionId). It keeps the original’s status and accrual date, so its holdback doesn’t restart. | reversed, or reduced with currentCommissionId |
paid | Stays paid: the partner has the money. A negative adjustment records what they hold for refunded revenue. OpenPartner takes it off the partner’s next manual or self-hosted Stripe Connect payout; a partner who owes at least what they’re due isn’t paid until enough accrues. Stripe Connect payouts on hosted OpenPartner don’t deduct it, so recover it with the partner yourself. | clawback_recorded, with clawbackAmount |
| In a payout right now | Nothing changes. Retry the same request, with the same externalEventId, once the payout settles. | held_in_transfer |
{ "ok": true, "refundEventId": "01J...", "conversionEventId": "01J...", "kind": "refund", "amount": "19.00", "refundedAmount": "19.00", "conversionValue": "49.00", "currency": "USD", "fullRefund": false, "actionRequired": false, "commissions": [ { "commissionId": "01J...", "originalAmount": "9.80", "keptAmount": "6.00", "status": "reduced", "currentCommissionId": "01J...", ... } ]}actionRequired: true means at least one commission is clawback_recorded or
held_in_transfer. For held_in_transfer, retry the refund once the payout settles. For
clawback_recorded, there’s nothing to do if the partner’s next payout deducts it (see the
table). The flag doesn’t know which kind of payout will pay the partner, so it’s true
either way.
- Partial refunds add up. Refunds of 30 and then 20 on a 100 payment leave each commission at 50% of its original amount. The share applies to every commission on the conversion, a fixed bounty included.
- A chargeback reverses commissions just like a refund, so report it once it’s final, not when the dispute opens.
- A conversion with no
value, such as a signup, can only be refunded in full. - A retry with the same
externalEventIdreturns200with"replayed": trueand records nothing new. It also finishes any commission that was held in transfer. - Refunds before the stitch. A refund that arrives before the customer’s click is stitched is kept, and when the payment is attributed later, its commissions are cut by the refund straight away.
- Self-hosted with the Stripe webhook? Stripe refunds of the payments it recorded are
applied from
charge.refundedalready. Don’t report those here too. - A
refundorchargebackevent sent toPOST /attribution/eventsinstead is recorded but reverses nothing ("status": "corrective").
| Status | error | Meaning |
|---|---|---|
| 400 | invalid_body | A field is missing or malformed, or both (or neither) conversion references were sent. |
| 404 | conversion_not_found | No event with that id in your account. |
| 409 | external_event_id_conflict | externalEventId was already used for a different conversion, kind or amount. |
| 422 | not_a_conversion | The referenced event is itself a refund or other corrective event. |
| 422 | currency_mismatch | currency differs from the conversion’s. |
| 422 | partial_refund_needs_value | An amount was sent for a conversion with no value. |
| 422 | refund_exceeds_remaining | amount is more than what’s left to refund. remaining says how much. |
| 422 | nothing_to_refund | The conversion is already fully refunded. |