Billing & Stripe
RevTurbine reads billing truth from Stripe — the customer’s current plan, payment health, and subscription lifecycle all flow in through Stripe webhooks and land on the customer record, where decisions read them. Stripe is the supported billing provider today; others are planned. This page covers the three operational pieces: connecting the account, wiring the webhook destinations, and proving the pipeline works before real money moves.
Connect your Stripe account
Section titled “Connect your Stripe account”Connection happens in the dashboard — Settings → Stripe. There are two paths, and neither involves pasting API keys:
- Existing Stripe account — use Connect with Stripe (OAuth). You’re sent to Stripe, approve the connection, and land back in Settings with the account linked. RevTurbine receives scoped access through Stripe Connect; you can revoke it from either side at any time.
- New Stripe account — use Set up payments (embedded onboarding). Stripe’s onboarding flow runs inside the dashboard via an account session; when Stripe finishes provisioning, the account is linked automatically.
Once connected, plan variations link to Stripe prices (stripe_price_id), the
billing page gains a Sync action that compares RevTurbine’s plans against
your Stripe catalog, and webhook events begin updating customer records.
Webhooks: nothing to register
Section titled “Webhooks: nothing to register”RevTurbine is a Stripe Connect platform. When you connect, Stripe starts delivering your account’s events to RevTurbine’s platform-level webhook endpoints — subscription and invoice events, payment-method and person/account events — and RevTurbine verifies each one and resolves it to your tenant from the connected account it came from. There are no destinations to create in your Stripe Dashboard, no signing secrets to copy, and no URL to configure.
If billing state stops updating, the first place to look is Settings → Stripe in the RevTurbine dashboard: the connection status and the events-received summary show whether events are arriving.
Link Stripe customers to accounts
Section titled “Link Stripe customers to accounts”Stripe knows who paid. RevTurbine knows which account and user used the product. Until each paying Stripe customer is linked to one of your accounts, that revenue is a payer nobody can attribute: it is missing from cohort and retention figures, and no conversion can be credited to the prompt that drove it. Link them from day one, in your own Stripe calls. RevTurbine never writes to your Stripe account; the metadata below is yours to set.
RevTurbine looks for these three pieces of evidence for each payer:
- Customer metadata.
revturbine_account_id(preferred), orrevturbine_user_id, which resolves through your users to their account. - An existing link between the Stripe customer and an account.
- A customer your Checkout created, resolved through the session’s
metadata or
client_reference_id.
They are not a priority list where the first hit silently wins. RevTurbine collects all that exist, and when they agree on one account the payer is linked. When they point to two different accounts, the payer is left unlinked until you correct the data. Keep the keys, the Checkout metadata and your customer records consistent.
Matching a payer by email is not available: no SDK call writes a user’s email
to RevTurbine from your server yet, so there is nothing to match against. Do not
rely on identify() with an email to link a payer.
The first is explicit and the only one under your control from the start, so
use the revturbine_* keys as the primary path and set them everywhere you
create a customer. Use the same values you give the SDK: revturbine_user_id
is the user.id, and revturbine_account_id is the account_id.
Fallback for billing layers such as Clerk. When none of the three apply, a
plain user_id in the Customer metadata is used as a stand-in account
identity. It is a reasonable proxy for account_id, but it is not a
RevTurbine user id: RevTurbine user ids come from the user context you pass
to the SDK, never from this key. Do not rely on it when you can set
revturbine_account_id.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
/** Your signup or first-checkout code: create the customer already linked. */export async function createStripeCustomer( user: { id: string; email: string }, accountId: string,) { return stripe.customers.create({ email: user.email, metadata: { revturbine_account_id: accountId, // the account_id you pass to the SDK revturbine_user_id: user.id, // the user.id you pass to the SDK }, });}
/** Customers that already exist: a one-off backfill, then keep it on every update. */export async function linkExistingCustomer( stripeCustomerId: string, user: { id: string }, accountId: string,) { return stripe.customers.update(stripeCustomerId, { metadata: { revturbine_account_id: accountId, revturbine_user_id: user.id }, });}When Checkout is what creates the customer, Stripe cannot set Customer metadata
inline. Create the customer first and pass customer, so the link exists
before the purchase:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function startCheckout( user: { id: string }, accountId: string, stripeCustomerId: string, stripePriceId: string,) { const ids = { revturbine_account_id: accountId, revturbine_user_id: user.id }; return stripe.checkout.sessions.create({ mode: 'subscription', customer: stripeCustomerId, // created above, with the metadata line_items: [{ price: stripePriceId, quantity: 1 }], client_reference_id: user.id, metadata: ids, // on the session subscription_data: { metadata: ids }, // and on the subscription success_url: 'https://app.example.com/billing/done', });}If you cannot pre-create the customer (a Checkout in payment mode, say, with
customer_creation: 'always'), copy the same two keys onto the Customer from
your checkout.session.completed handler with stripe.customers.update. The
placement attribution bag from checkoutMetadata() below goes on the same
session and subscription metadata.
Use the same account id in the SDK
Section titled “Use the same account id in the SDK”Pass the same account_id to the SDK that you put in revturbine_account_id,
so what the SDK reports and what Stripe bills describe the same account. Never
use an email as the id.
import { useRevTurbine } from '@revturbine/sdk';import { useEffect } from 'react';
/** Mount once, inside the provider, once the signed-in user is known. */export function IdentifyUser({ user }: { user: { id: string; accountId: string } }) { const { sdk } = useRevTurbine();
useEffect(() => { if (!sdk) return; // the SDK is null until the provider has initialized sdk.identify(user.id, { account_id: user.accountId }); }, [sdk, user.id, user.accountId]);
return null;}The same field works at init (user: { id, account_id } on
RevTurbineProvider’s options) and through setUserContext() / update().
What linking unlocks
Section titled “What linking unlocks”- Cohort and retention figures for paying accounts, because revenue now belongs to an account with a signup date and usage.
- Attribution: a purchase is credited to the placement the user saw, even for renewals and checkout-less billing events, with no time-window guess.
- Server-derived plan state in the browser, through the client session.
A payer with none of these links cannot be attributed to an account until one exists, so set the metadata before the first purchase rather than after.
What the events drive
Section titled “What the events drive”customer.subscription.created/updated/deleted→ the customer’s current plan — which flows into SDK decisions automatically: the client context carries the server-derived plan handle, and hosted evaluation defaults to it when the caller doesn’t supply one.invoice.payment_failed/payment_intent.payment_failed→ the customer’s billing-health issues, which drive thepayment_failedretention qualifier server-side (and clear again oninvoice.payment_succeeded).- Account/person (v2) events → connected-account status shown in Settings.
Verify in test mode
Section titled “Verify in test mode”Walk this once after connecting, before anything real depends on the pipeline:
-
Fire synthetic events on your connected account. With the Stripe CLI, in test mode:
Terminal window stripe trigger customer.subscription.created --stripe-account acct_…stripe trigger invoice.payment_failed --stripe-account acct_… -
Watch them land. Settings → Stripe shows an events-received summary; a triggered subscription event should move a test customer’s plan, and the payment-failure event should flip that customer’s billing health to
attention_required(visible on the customer record). -
Run the catalog comparison. The billing page’s Sync action compares RevTurbine plans against the Stripe catalog and reports mismatched, missing, and orphaned prices — a clean report means plan↔price linkage is coherent.
-
Confirm recovery.
stripe trigger invoice.payment_succeededshould clear the billing-health issue again.
Integration settings
Section titled “Integration settings”Settings → Stripe is the home for the connection itself: connected-account
status, the events-received summary, and the Sync comparison. Stripe is the
first integration with a settings surface; outbound analytics forwarding
(PostHog — createPostHogIntegration in the SDK) is configured in code today,
with a settings surface planned.
Where checkout fits
Section titled “Where checkout fits”RevTurbine decides what to offer; your app owns the checkout itself. A CTA
with the open_checkout action resolves to open_checkout_modal carrying the
plan handle — your resolver opens Stripe Checkout (or your own flow) for the
matching price. After payment, the subscription webhook updates the customer’s
plan, and access follows without further app code.
Tell Stripe which placement led here
Section titled “Tell Stripe which placement led here”Your server creates the Checkout Session, and Stripe only knows what you put
on it. checkoutMetadata(outputId) returns the revturbine_* bag for the
output the user clicked — the user, account, placement, decision, output, rule
and target plan the SDK holds for it — so the purchase joins the presentation
and click it came from with confirmed credit instead of a time-window
inference. Send it to your server with the price the user chose:
import { useRevTurbine } from '@revturbine/sdk';
// Browser: `output` is the decision the slot rendered (`decision.output`).const { sdk: rt } = useRevTurbine(); // with a headless session: `session.sdk`const checkout = rt.checkoutMetadata(output.output_id);// `checkout` is null for an output this SDK did not decide.Then put the bag on the session and on the subscription, and the user id on
client_reference_id:
// Server: `checkout` is what the browser sent.const session = await stripe.checkout.sessions.create({ mode: 'subscription', line_items: [{ price: stripePriceId, quantity: 1 }], customer: stripeCustomerId, client_reference_id: checkout.client_reference_id, metadata: checkout.metadata, subscription_data: { metadata: checkout.metadata },});metadataon the session is whatcheckout.session.completedcarries.subscription_data.metadatamirrors the bag onto the Subscription, so everycustomer.subscription.*event carries it and every invoice snapshots it underparent.subscription_details.metadata— renewals, failed payments and recoveries stay attributed to the placement without a customer mapping.client_reference_idis Stripe’s own caller-side key for the session; RevTurbine reads it as the user id when the metadata is absent.
The bag’s keys are revturbine_user_id, revturbine_account_id,
revturbine_placement_id, revturbine_decision_id, revturbine_output_id,
revturbine_rule_id and revturbine_plan_handle. A key the SDK does not hold
is absent rather than empty — no id is ever invented — so an unidentified user
yields no revturbine_user_id and no client_reference_id, and a CTA that
names no plan yields no revturbine_plan_handle.