Conversions by click id
The usual path ties a conversion to a user your site identified with the SDK’s
identify(). Plenty of conversions happen where the SDK never runs: a lender funds a loan,
a sales rep closes a deal from a lead form, an affiliate network confirms a sale, a buyer
pays on a Stripe Payment Link. Those systems can carry the click id instead, and report
the conversion with it.
1. Get the click id to the system that converts
Every click on a partner link gets a click id, a 26-character ULID such as
01K7ZQ3M5X8V2N4P6R8T0W2Y4A. By default the click router appends it to your destination
as ?cref=<click id>.
To send it in a parameter your destination already reads, put the {click_id} macro in
the program’s Destination URL (or a partner’s deep link). The router substitutes the
click id there and appends no cref:
https://apply.lender.example/start?ref={click_id}https://buy.stripe.com/abc123?client_reference_id={click_id}- Write it exactly as
{click_id}: single braces, lower case, no spaces. The URL-encoded form,%7Bclick_id%7D, works too. - Any other brace text is rejected when you save, because the router would send it
through as literal text and the click id would never arrive:
{{click_id}}(Zapier and Make syntax),{ click_id },{CLICK_ID},{subid}. - The macro can go in the path, the query or the fragment, but not the host.
- Click ids are letters and digits only, so they fit Stripe’s
client_reference_idand most other systems’ reference fields. - A Stripe Payment Link destination (
buy.stripe.com) withoutclient_reference_id={click_id}still saves, with a warning: no purchase through it can be credited to a partner.
Have the receiving system store the id with the lead, application or order.
2a. Report the conversion with one API call
When the conversion happens, post it with the click id. Use an API key with the
events:write scope (Admin → Integrations). There’s no identify step:
curl -X POST <base>/attribution/conversions \ -H "Authorization: Bearer <your-key>" \ -H "Content-Type: application/json" \ -d '{ "clickId": "01K7ZQ3M5X8V2N4P6R8T0W2Y4A", "type": "loan_funded", "value": 5000.00, "currency": "USD", "externalEventId": "loan-881:funded" }'OpenPartner stitches the click and records the event in one transaction, then attributes
it and accrues the commission. value is a JSON number in major units, never a string or
null. currency can be any case. Leave it out and the conversion is recorded in your
default currency (Admin → Settings → Brand info). Field details and error codes are
in the API reference.
From Node, the server SDK makes the same call:
import { OpenPartnerServer } from '@openpartner/sdk/server';
const op = new OpenPartnerServer({ apiUrl: '<base>', apiKey: process.env.OPENPARTNER_API_KEY! });
await op.trackConversion({ clickId: '01K7ZQ3M5X8V2N4P6R8T0W2Y4A', type: 'loan_funded', value: 5000.0, currency: 'USD', externalEventId: 'loan-881:funded', // required});An unknown click throws an OpenPartnerError with code click_not_found, and a flagged one
click_flagged.
Read the outcome
Every recorded conversion answers 200, and its outcome says what it came to:
{ "ok": true, "eventId": "01K…", "userId": "click:01K7ZQ3M5X8V2N4P6R8T0W2Y4A", "outcome": "attributed", "attribution": { "status": "attributed", "commission": "accrued", "touches": [ … ] }}outcome | reason | Meaning |
|---|---|---|
attributed | A partner was credited and earned commission. | |
attributed | no_commission | Credited, but nothing accrued: the program has ended, a first rule already paid, a cap was reached, or the amount was zero. |
no_matching_rule | Credited, but no commission rule takes this type, so nothing is paid. Usually a typo’d stage name (funded for loan_funded). | |
not_attributed | outside_window | No partner credited: the click is older than the program’s attribution window, or newer than the conversion’s ts. |
not_attributed | no_click | No partner credited: the partner was revoked. |
not_attributed | corrective | A refund, chargeback, dispute_created or invoice_payment_failed type. It’s recorded but never attributed, and reverses nothing: report refunds to POST /attribution/refunds. |
not_attributed | A retry of a conversion that wasn’t attributed the first time. |
The conversion is recorded in every case, so don’t retry on any of these. Fix the cause and send the next conversion. Types that were credited but matched no rule in the last 30 days are also listed as a warning on Admin → Integrations.
2b. Or give the sender a postback URL
Affiliate networks and many lenders can only fire a URL when a conversion happens. Under Admin → Integrations → Postback URL, click Generate postback URL:
<base>/postback?click_id=CLICK_ID&type=sale&amount=AMOUNT¤cy=USD&txn_id=TRANSACTION_ID&token=opcp_…Replace CLICK_ID, AMOUNT and TRANSACTION_ID with the sender’s own macros, and type
with your conversion name. The full URL, token included, is shown only once.
- The token can only record conversions. It isn’t an API key, isn’t accepted as one,
and is redacted from OpenPartner’s request logs. Still, proxies and the sender’s own logs
keep query strings, so treat a postback URL as public once you hand it out. When a sender
can set a header, give it an API key and
POST /attribution/conversionsinstead. Always usehttps://. txn_idmakes repeats safe. A network that fires the same postback twice records one conversion.- Pin it to the sender’s IPs with Allowed source IPs (single addresses or CIDR ranges). Ask the network for its postback IPs. The check uses the address the API sees, so on a self-hosted install point the network at your API host directly, not through an extra proxy.
- Rotate or turn it off from the same panel. The old URL stops working immediately, so update it at every sender.
- It answers in plain text:
okfollowed by the outcome above, such asok attributed,ok attributed no_commission,ok no_matching_ruleorok not_attributed outside_window. Every one of those is a200, so match on theokprefix. Problems come back as a short code with a 4xx status, such asinvalid_token,ip_not_allowedorclick_not_found. Add&pixel=1to get a 1×1 GIF instead, for senders that fire an image pixel.
Transaction ids
externalEventId on the API, txn_id on the postback, is what makes a retry safe. A
retry with the same id answers 200 with the original result and records nothing new.
What trips senders up:
- Ids are unique across your whole account, in one namespace shared with
/attribution/events,/coupons/redeemand the Stripe webhook. Reusing an id for a different conversion is409 external_event_id_conflict, and that conversion is lost, not recorded. - Use one id per stage, not per deal:
loan-881:approved, thenloan-881:funded. Reporting the second stage under the first stage’s id is a409. - Prefix ids per sender. Two suppliers can both have an order
881:lender-a:loan-881:funded,lender-b:loan-881:funded.
One account, every program
- Conversions are scoped to your account, not to a program. The click id already names
the program it came through, so there’s no
programIdto send, and a click from any of your programs is accepted. A click id your account doesn’t have is404 click_not_found. - Keys and the postback token cover every program. A sender holding one can report a conversion for any of your clicks. Give each sender its own API key where it can send a header, pin the postback token to your senders’ addresses, and rotate it when a sender leaves.
- Stage names must match a rule exactly. A conversion whose
typeno commission rule takes is credited and pays nothing (no_matching_rule). - A partner’s custom terms replace the program’s whole rule set for that partner. A
program that pays $25 on
approvedand $400 onloan_funded, overridden for one partner with only aloan_fundedrule, pays that partner nothing onapproved. See Per-partner commission overrides.
Stripe Payment Links
A Payment Link takes the buyer straight to Stripe, so no page of yours runs the SDK. Let the
partner link fill in the referral instead: set the program’s destination URL to the Payment
Link with the click id in client_reference_id, the only parameter Stripe reads it from:
https://buy.stripe.com/<link>?client_reference_id={click_id}Self-hosted
With OpenPartner’s Stripe webhook connected, the purchase is recorded automatically: a
signup, plus the paid amount as invoice_paid. Refunds in Stripe are applied for you. See
Stripe Billing.
On the hosted plan
Connect Stripe and there’s nothing to forward. Admin → Integrations → Connect Stripe installs the OpenPartner Stripe App (beta) on your own Stripe account, and OpenPartner reads each purchase from Stripe itself:
- A paid purchase is recorded as a
signupplus the paid amount asinvoice_paid, credited to the partner whose click is inclient_reference_id. - A bank debit (SEPA, ACH, Bacs) is recorded when the money clears.
- A subscription’s renewals are recorded as its invoices are paid, and stay with the partner who won the customer. Past the attribution window, only commission rules marked recurring pay on them. See Attribution window.
- Refunds in Stripe, full or partial, are applied to the commissions. Disputes are recorded.
The destination URL above is the only setup. Payment Links, a Pricing Table or a Buy Button on your own pages, rather than a partner link straight to Stripe? One script tag adds the click to them: see Connect Stripe.
While the connection is healthy, a conversion forwarded under a Stripe invoice or Checkout
Session id (in_…, cs_…) answers 409 recorded_from_stripe, so a forwarder left running
records nothing twice. Treat that 409 as final, and switch the forwarder off.
Without connecting Stripe
If you’d rather not install the app, forward each paid checkout yourself. Stripe sends its webhooks to your own Stripe account, not to OpenPartner, so report each one with a call:
-
Listen for the checkout. In Stripe, add a webhook endpoint on your server for
checkout.session.completedandcheckout.session.async_payment_succeeded. Verify Stripe’s signature on the raw request body before you trust anything in it. -
Skip anything that isn’t a referred, paid purchase. Forward a session only if it has a
client_reference_id(it came through a partner link) andpayment_statusis"paid". A bank debit (SEPA, ACH, Bacs) is stillunpaidatcheckout.session.completed;checkout.session.async_payment_succeededreports it when the money clears. -
Report it, with a key that has only
events:write:// `session`: the Checkout Session from a verified Stripe event.const ZERO_DECIMAL = ['BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'VND', 'VUV', 'XAF', 'XOF', 'XPF'];const THREE_DECIMAL = ['BHD', 'JOD', 'KWD', 'OMR', 'TND'];if (session.client_reference_id && session.payment_status === 'paid') {const currency = session.currency.toUpperCase();const divisor = ZERO_DECIMAL.includes(currency) ? 1 : THREE_DECIMAL.includes(currency) ? 1000 : 100;const res = await fetch(`${process.env.OPENPARTNER_API_URL}/attribution/conversions`, {method: 'POST',headers: {Authorization: `Bearer ${process.env.OPENPARTNER_API_KEY}`,'Content-Type': 'application/json',},body: JSON.stringify({clickId: session.client_reference_id,type: 'invoice_paid',value: session.amount_total / divisor, // major unitscurrency,externalEventId: session.id, // both checkout events record the purchase once}),});}Stripe amounts are in the currency’s smallest unit: divide by 100, except for zero-decimal currencies such as JPY and KRW, which are already whole units, and three-decimal ones such as KWD, which divide by 1000.
-
Answer Stripe. Stripe retries anything but a
2xxfor up to three days.200, including a200with"replayed": true: answer2xx.404 click_not_found,409 external_event_id_conflict,409 recorded_from_stripe,422 click_flagged: no retry changes these. Log them and answer2xx.429, a5xxor a network error: answer5xx, so Stripe tries again later. Do the same on401or403if you’d rather Stripe kept retrying while you fix the key.
-
Check what it came to. A
200means recorded, not paid. Log anyoutcomeother thanattributed.
Subscriptions. A subscription checkout’s amount_total is its first invoice, so the
checkout above already reports it. Stripe also sends invoice.paid for that invoice
(billing_reason: "subscription_create"): don’t forward that one too, or the payment counts
twice. To credit renewals:
- At checkout, store
session.customeragainst the click id on your side. - On each later
invoice.paidfor that customer (billing_reasonissubscription_cycle, orsubscription_updatefor a mid-cycle change), report{ clickId, type: "invoice_paid", value, currency, externalEventId: invoice.id }, withvaluefromamount_paid, converted as above. - A checkout that starts a free trial is
payment_status: "no_payment_required", so step 2 skips it. Store the customer anyway: the first charge after the trial arrives as asubscription_cycleinvoice.
Renewals stay with the partner who won the customer, past the attribution window, unless the program turns that off. Past the window, only commission rules marked recurring pay on them. See Attribution window.
Refunds. Report each Stripe refund to
POST /attribution/refunds with the same key:
conversionExternalEventId is the id you reported the payment under (the session id, or the
invoice id for a renewal), amount is the refunded amount in major units (leave it out for a
full refund), and externalEventId is the Stripe refund id (re_…). To find a refund’s
Checkout Session, look it up by the refund’s payment_intent with Stripe’s
GET /v1/checkout/sessions?payment_intent=pi_….
Good to know
- Which user gets the conversion. Without a
userId, the conversion is recorded under the click’s own user,click:<clickId>. Every conversion you report for that click lands on that one user, so a “first conversion” commission rule fires once. PassuserIdwhen you also identify that person elsewhere: attribution then follows the program’s model across all of that user’s clicks, as for events. - Earlier events aren’t backfilled. Only the conversion you’re reporting is attributed.
To credit events already recorded under a
userId, callidentify()for it. - The attribution window decides who wins the customer. A customer’s first conversion
of a type is credited only while the click is inside the program’s window. After that the
partner keeps them: later conversions of the same type stay with that partner however old
the click, and so does a first conversion of a new type when no click is left in the window
(say,
approvedon day 10 andloan_fundedon day 100 of a 60-day window). Past the window, only sub-rules marked recurring pay, so a one-off bounty onloan_fundedthere comes backattributedwithno_commission. A program with renewals turned off decides every conversion by the window alone. See Attribution window. - Flagged clicks earn nothing. A click flagged for velocity or a revoked partner is
refused with
422 click_flagged, and nothing is recorded. A retry of a conversion that was recorded before the flag still answers with its original result.