Skip to content

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.

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.

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.

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:

  1. Customer metadata. revturbine_account_id (preferred), or revturbine_user_id, which resolves through your users to their account.
  2. An existing link between the Stripe customer and an account.
  3. 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.

TypeScript
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:

TypeScript
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.

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.

React
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().

  • 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.

  • 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 the payment_failed retention qualifier server-side (and clear again on invoice.payment_succeeded).
  • Account/person (v2) events → connected-account status shown in Settings.

Walk this once after connecting, before anything real depends on the pipeline:

  1. 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_…
  2. 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).

  3. 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.

  4. Confirm recovery. stripe trigger invoice.payment_succeeded should clear the billing-health issue again.

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.

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.

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:

TypeScript
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:

TypeScript
// 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 },
});
  • metadata on the session is what checkout.session.completed carries.
  • subscription_data.metadata mirrors the bag onto the Subscription, so every customer.subscription.* event carries it and every invoice snapshots it under parent.subscription_details.metadata — renewals, failed payments and recoveries stay attributed to the placement without a customer mapping.
  • client_reference_id is 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.