Skip to content

Stripe Billing

There are two ways to turn Stripe revenue into OpenPartner attribution, and the right one depends on how you run OpenPartner. Neither needs your server to forward payments.

On the hosted plan (app.openpartner.dev)? Connect your Stripe account: Admin → Integrations → Connect Stripe installs the OpenPartner Stripe App (beta) on it. Your Checkout and Payment Link purchases, invoices, renewals, refunds, disputes and partner promo codes are then recorded from Stripe, and customer-reward promo codes are created in your Stripe for you. What’s left is getting the partner click into Stripe once, which is nothing at all for a partner link straight to a Payment Link, and one script tag for checkouts on your site: see Get the partner click into Stripe. Forwarding Stripe payments with trackEvent today? Turn it off once you’ve connected (why). The rest of this page is for self-hosters.

Self-hosting? This page is for you. OpenPartner runs with your Stripe key, so it can listen to your Stripe webhooks and map customer.subscription.created, invoice.paid and paid one-time Checkout sessions straight into attribution — no event-emission code in your app.

What you’ll need (self-hosted)

  • A self-hosted OpenPartner install running with your own Stripe key
  • The OpenPartner browser SDK already initialized on your marketing pages (not needed for Payment Links that use the {click_id} macro)
  • Access to your Stripe Dashboard to add a webhook endpoint

1. Add the webhook endpoint in Stripe

In your Stripe Dashboard → Developers → Webhooks → Add endpoint.

  • Endpoint URL: https://your-openpartner-host.com/api/webhooks/stripe
  • Events to send:
    • checkout.session.completed
    • checkout.session.async_payment_succeeded — one-time purchases paid by SEPA, ACH or Bacs, which clear after the checkout completes
    • customer.subscription.created
    • invoice.paid
    • charge.refunded — reverses the commissions on a refunded payment, or reduces them on a partial refund
    • charge.dispute.created — records the dispute against the customer. It doesn’t reverse anything, because disputes can be won.
    • customer.created — only if you set openpartner_user_id metadata on your Stripe customers yourself

Copy the Signing secret Stripe shows you (whsec_…) and set it as STRIPE_WEBHOOK_SECRET in your OpenPartner environment (comma-separate if you have more than one endpoint). Stripe’s test and live modes each issue their own secret — add both if you run test transactions.

2. Pass the referral on Stripe Checkout

In your checkout-session creation code, set client_reference_id to the visitor’s referral token. The OpenPartner browser SDK exposes it as op.getReferral():

import { OpenPartner } from '@openpartner/sdk';
const op = OpenPartner.init({ apiUrl: 'https://openpartner.yourbrand.com' }); // your self-hosted API URL
// When the user clicks "Subscribe":
const session = await fetch('/api/create-checkout-session', {
method: 'POST',
body: JSON.stringify({
referral: op.getReferral(),
}),
});

Then on your server, drop it into the Stripe Checkout session:

const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: 'price_xxx', quantity: 1 }],
client_reference_id: req.body.referral ?? undefined,
success_url: 'https://yourapp.com/welcome',
cancel_url: 'https://yourapp.com/pricing',
});

That’s it. No op.identify() call needed for Stripe-billed customers — the client_reference_id rides through Stripe to our webhook, and we stitch the attribution on checkout.session.completed.

Selling through Payment Links, a Pricing Table or a Buy Button on your own pages? Your portal serves the SDK as a script tag that adds the referral to them, with no checkout code. The tag also keeps the referral in an op_ref cookie, so a server that creates the session can pass client_reference_id: req.cookies.op_ref instead of sending op.getReferral() up from the browser.

3. Testing in Stripe Test Mode

Test-mode transactions attribute exactly like live ones — OpenPartner does not treat test and live events differently. If test purchases aren’t producing commissions, it’s almost never “because it’s test mode”; it’s that test mode is a completely separate Stripe environment and one of these three things didn’t carry over:

  1. The webhook endpoint is live-only. Stripe keeps test and live webhooks separate. Toggle your Stripe Dashboard to Test mode (top-right) and confirm the endpoint from step 1 exists there too — adding it in live mode does not create it in test mode.
  2. The test-mode signing secret isn’t set. The test endpoint has its own whsec_… secret, different from live — add it to STRIPE_WEBHOOK_SECRET (comma-separated alongside the live one).
  3. The Checkout didn’t carry client_reference_id. A charge created by hand in the Stripe Dashboard has no referral behind it and can never attribute. Test the real flow: open your referral link in a fresh incognito window (so a cref is set), then complete a test checkout as that visitor with client_reference_id: op.getReferral() wired up.

Quickest way to confirm the pipe works end-to-end in test mode: Stripe Dashboard (Test mode) → Developers → Webhooks → your endpoint → Send test webhook → checkout.session.completed. A 200 response means we’re receiving and verifying your test events; a 400 means the signing secret isn’t registered.

What happens under the hood

  1. A visitor clicks a partner link. The click router records the click and lands them on yoursite.com/?cref=<click id>. The OpenPartner SDK captures the click id and stashes it in localStorage.
  2. The visitor completes a Stripe Checkout. Your server passes client_reference_id: op.getReferral() to Stripe.
  3. Stripe fires checkout.session.completed to OpenPartner with the client_reference_id and the new Stripe customer ID attached.
  4. OpenPartner stitches an Identity row linking the click to the Stripe customer, then emits a signup Event. Attribution runs automatically.
  5. Every subsequent invoice.paid from Stripe is mapped to a revenue event for the same customer and attributed to the same partner — for the lifetime of the subscription, past the attribution window too (unless the program turns that off). Past the window, only commission rules marked recurring pay. See Attribution window.
  6. A charge.refunded brings that payment’s commissions in line with what the customer kept. A full refund reverses them. A partial refund reverses each one and accrues a replacement for the kept share, so refunding $30 of a $100 payment leaves 70% of each commission. Refunds add up across several charge.refunded events.
    • A commission that’s already paid stays paid. OpenPartner records a negative adjustment for the refunded revenue the partner holds, and takes it off that partner’s next payout.
    • A commission in a payout that’s moving money right now is left as it is. The refund event records how many were held.

A Checkout in mode: 'payment', which includes every Stripe Payment Link, sends no invoice.paid. OpenPartner reads the money from the session instead: a paid one-time session records a signup plus its amount_total as an invoice_paid event, so it counts toward your partners’ paid conversions and pays any commission rule on invoice_paid.

Payment Links. A Payment Link can’t run your checkout code, so let the partner link fill in the referral. Set the program’s Destination URL to the Payment Link with the {click_id} macro:

https://buy.stripe.com/abc123?client_reference_id={click_id}

The click router puts the click id there instead of appending ?cref=. See Conversions by click id for the macro’s rules.

What gets recorded, and when:

  • Guest checkouts. A Payment Link creates no Stripe Customer by default. The click is stitched to its own user (click:<click id>), and the purchase is credited all the same.
  • Delayed payment methods (SEPA Direct Debit, ACH, Bacs). The checkout completes before the money clears, so the signup is recorded at checkout.session.completed and the revenue at checkout.session.async_payment_succeeded. Subscribe your endpoint to both.
  • Returning customers. A customer an earlier checkout already tied to a partner is credited on a later one-time purchase, even one without a client_reference_id.
  • Not double-counted. Subscription checkouts take no revenue from the session, because their invoice.paid carries it. Neither do sessions with invoice_creation enabled, for the same reason, or sessions that charged nothing.
  • Refunds. A refund of a one-time purchase, full or partial, is applied to its commissions as for invoices.

When to use the SDK path instead

The Stripe Billing flow only covers events Stripe knows about: subscription creation, recurring invoices, and Checkout sessions. If you want to track non-Stripe events (demo_booked, trial_started, custom milestones), or you bill through Paddle / Chargebee / Lemon Squeezy / your own server, emit the event from your backend with the server SDK (the browser SDK only does identify() — conversion events are server-to-server so they can’t be spoofed from the page):

import { OpenPartnerServer } from '@openpartner/sdk/server';
const op = new OpenPartnerServer({
apiUrl: process.env.OPENPARTNER_API_URL!,
apiKey: process.env.OPENPARTNER_API_KEY!,
});
// `userId` must be the SAME id you passed to the browser SDK's op.identify(userId).
await op.trackEvent({ userId: user.id, type: 'demo_booked', value: 5000, currency: 'USD' });

The two paths coexist — you can use Stripe Billing for revenue events and the server SDK for custom milestones. (value is in major units — dollars, not cents.)

Troubleshooting

Conversions aren’t attributing.

  • Check Stripe Dashboard → Webhooks → your endpoint → Recent deliveries. You should see checkout.session.completed events with 200 responses. A 400 means the endpoint’s signing secret doesn’t match STRIPE_WEBHOOK_SECRET (see step 1 — check both test and live).
  • Verify the client_reference_id is present on the Checkout session. If it’s empty, the visitor either didn’t arrive via a partner link or the SDK didn’t initialize before checkout creation.

A one-time purchase recorded a signup but no revenue.

  • Check the session’s payment_status. If it was unpaid (a delayed method like SEPA or ACH), the revenue arrives with checkout.session.async_payment_succeeded: make sure your endpoint is subscribed to it.
  • If the session has invoice_creation enabled, the revenue comes from its invoice.paid instead. Make sure that event is subscribed too.

invoice.paid events aren’t attributing.

  • The first invoice.paid in a subscription usually arrives within seconds of checkout.session.completed. If the order is reversed, OpenPartner falls back to a database-side Identity lookup so attribution still works — but if it persists, check that the customer’s metadata was successfully backfilled (Stripe Dashboard → Customer → Metadata should show openpartner_user_id).