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
trackEventtoday? 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.paidand 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.completedcheckout.session.async_payment_succeeded— one-time purchases paid by SEPA, ACH or Bacs, which clear after the checkout completescustomer.subscription.createdinvoice.paidcharge.refunded— reverses the commissions on a refunded payment, or reduces them on a partial refundcharge.dispute.created— records the dispute against the customer. It doesn’t reverse anything, because disputes can be won.customer.created— only if you setopenpartner_user_idmetadata 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:
- 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.
- The test-mode signing secret isn’t set. The test endpoint has its own
whsec_…secret, different from live — add it toSTRIPE_WEBHOOK_SECRET(comma-separated alongside the live one). - 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 acrefis set), then complete a test checkout as that visitor withclient_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
- 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. - The visitor completes a Stripe Checkout. Your server passes
client_reference_id: op.getReferral()to Stripe. - Stripe fires
checkout.session.completedto OpenPartner with theclient_reference_idand the new Stripe customer ID attached. - OpenPartner stitches an Identity row linking the click to the Stripe customer, then emits
a
signupEvent. Attribution runs automatically. - Every subsequent
invoice.paidfrom 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. - A
charge.refundedbrings 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 severalcharge.refundedevents.- 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.
One-time payments and Payment Links
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.completedand the revenue atcheckout.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.paidcarries it. Neither do sessions withinvoice_creationenabled, 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.completedevents with 200 responses. A 400 means the endpoint’s signing secret doesn’t matchSTRIPE_WEBHOOK_SECRET(see step 1 — check both test and live). - Verify the
client_reference_idis 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 wasunpaid(a delayed method like SEPA or ACH), the revenue arrives withcheckout.session.async_payment_succeeded: make sure your endpoint is subscribed to it. - If the session has
invoice_creationenabled, the revenue comes from itsinvoice.paidinstead. Make sure that event is subscribed too.
invoice.paid events aren’t attributing.
- The first
invoice.paidin a subscription usually arrives within seconds ofcheckout.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 showopenpartner_user_id).