Skip to content

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/events
Authorization: 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"
}
FieldTypeRequiredNotes
userIdstringyesSame identifier you assigned at signup time so OpenPartner can stitch the event to a click.
typestringyesStandard or custom event name. Conventions: signup, trial_started, subscription_created, invoice_paid, opportunity_won, trial_converted.
valuenumberconditionalRequired 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.
currencystringnoISO 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.
externalEventIdstringrecommendedYour 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.
metadataobjectnoFree-form. Stored on the Event row; surfaced in attribution + webhook payloads.
tsstringnoISO 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 written
  • no_identity — nothing ever stitched this userId to 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 partners
  • outside_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 partner
  • corrective — a refund, chargeback, dispute_created or invoice_payment_failed event: recorded, never attributed, and it reverses nothing. Report refunds to POST /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

StatuserrorMeaning
400invalid_bodyA field is missing or malformed, such as a value sent as a string or null, or a ts with an offset. detail says which.
409external_event_id_conflictexternalEventId was already used for an event with a different userId, type, value or currency. eventId names the original. Nothing is recorded.
409recorded_from_stripeOnly 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 200 with the original eventId and 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_created and its invoice_paid need different keys.
  • Reusing a key for a different userId, type, value or currency returns 409 external_event_id_conflict, and nothing is recorded.
  • Keys are unique across your whole account, in one namespace shared with /attribution/conversions, /postback, /coupons/redeem and the Stripe webhook. When several systems send events, prefix each one’s keys (hubspot:deal-881:closedwon).
  • An empty externalEventId ("" or null, 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/refunds
Authorization: Bearer <key>
Content-Type: application/json
{
"conversionExternalEventId": "in_12345",
"amount": 19.00,
"reason": "downgraded",
"externalEventId": "re_2001"
}
FieldTypeRequiredNotes
conversionExternalEventIdstringone ofThe externalEventId you reported the conversion with.
conversionEventIdstringone ofOr the eventId OpenPartner returned for it. Send exactly one of the two.
amountnumbernoThe refunded amount, in major units. Leave it out to refund everything not yet refunded.
kindstringnorefund (the default) or chargeback.
reasonstringnoFree text, up to 500 characters.
externalEventIdstringyesYour id for this refund: refund id, credit note id. Makes retries safe.
currencystringnoIf sent, must match the conversion’s currency.
tsstringnoISO 8601 in UTC, ending in Z. Defaults to server time.
metadataobjectnoFree-form.

Each of the conversion’s commissions ends up worth its original amount times the share of the payment the customer kept:

Commission wasWhat happensstatus in the response
accrued or approvedReversed (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
paidStays 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 nowNothing 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 externalEventId returns 200 with "replayed": true and 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.refunded already. Don’t report those here too.
  • A refund or chargeback event sent to POST /attribution/events instead is recorded but reverses nothing ("status": "corrective").
StatuserrorMeaning
400invalid_bodyA field is missing or malformed, or both (or neither) conversion references were sent.
404conversion_not_foundNo event with that id in your account.
409external_event_id_conflictexternalEventId was already used for a different conversion, kind or amount.
422not_a_conversionThe referenced event is itself a refund or other corrective event.
422currency_mismatchcurrency differs from the conversion’s.
422partial_refund_needs_valueAn amount was sent for a conversion with no value.
422refund_exceeds_remainingamount is more than what’s left to refund. remaining says how much.
422nothing_to_refundThe conversion is already fully refunded.