Skip to content

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_id and most other systems’ reference fields.
  • A Stripe Payment Link destination (buy.stripe.com) without client_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:

Terminal window
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": [ … ] }
}
outcomereasonMeaning
attributedA partner was credited and earned commission.
attributedno_commissionCredited, but nothing accrued: the program has ended, a first rule already paid, a cap was reached, or the amount was zero.
no_matching_ruleCredited, but no commission rule takes this type, so nothing is paid. Usually a typo’d stage name (funded for loan_funded).
not_attributedoutside_windowNo partner credited: the click is older than the program’s attribution window, or newer than the conversion’s ts.
not_attributedno_clickNo partner credited: the partner was revoked.
not_attributedcorrectiveA 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_attributedA 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&currency=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/conversions instead. Always use https://.
  • txn_id makes 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: ok followed by the outcome above, such as ok attributed, ok attributed no_commission, ok no_matching_rule or ok not_attributed outside_window. Every one of those is a 200, so match on the ok prefix. Problems come back as a short code with a 4xx status, such as invalid_token, ip_not_allowed or click_not_found. Add &pixel=1 to 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/redeem and the Stripe webhook. Reusing an id for a different conversion is 409 external_event_id_conflict, and that conversion is lost, not recorded.
  • Use one id per stage, not per deal: loan-881:approved, then loan-881:funded. Reporting the second stage under the first stage’s id is a 409.
  • 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 programId to send, and a click from any of your programs is accepted. A click id your account doesn’t have is 404 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 type no 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 approved and $400 on loan_funded, overridden for one partner with only a loan_funded rule, pays that partner nothing on approved. See Per-partner commission overrides.

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 signup plus the paid amount as invoice_paid, credited to the partner whose click is in client_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:

  1. Listen for the checkout. In Stripe, add a webhook endpoint on your server for checkout.session.completed and checkout.session.async_payment_succeeded. Verify Stripe’s signature on the raw request body before you trust anything in it.

  2. 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) and payment_status is "paid". A bank debit (SEPA, ACH, Bacs) is still unpaid at checkout.session.completed; checkout.session.async_payment_succeeded reports it when the money clears.

  3. 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 units
    currency,
    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.

  4. Answer Stripe. Stripe retries anything but a 2xx for up to three days.

    • 200, including a 200 with "replayed": true: answer 2xx.
    • 404 click_not_found, 409 external_event_id_conflict, 409 recorded_from_stripe, 422 click_flagged: no retry changes these. Log them and answer 2xx.
    • 429, a 5xx or a network error: answer 5xx, so Stripe tries again later. Do the same on 401 or 403 if you’d rather Stripe kept retrying while you fix the key.
  5. Check what it came to. A 200 means recorded, not paid. Log any outcome other than attributed.

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.customer against the click id on your side.
  • On each later invoice.paid for that customer (billing_reason is subscription_cycle, or subscription_update for a mid-cycle change), report { clickId, type: "invoice_paid", value, currency, externalEventId: invoice.id }, with value from amount_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 a subscription_cycle invoice.

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. Pass userId when 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, call identify() 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, approved on day 10 and loan_funded on day 100 of a 60-day window). Past the window, only sub-rules marked recurring pay, so a one-off bounty on loan_funded there comes back attributed with no_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.