Stripe
Connect Stripe to Supapulse with a per-site webhook. Purchases, subscriptions, renewals, refunds and cancellations are credited to the visits that brought each customer.
Supapulse has a built-in Stripe webhook. You add one endpoint in Stripe and Supapulse turns Stripe's events into revenue, MRR and refunds, with no handler code on your server. You identify the customer in the browser so Supapulse can credit each payment to the visit that first brought them.
Before you start
- Supapulse is installed on the site where visitors sign up (quick start).
- You can add webhook endpoints in the Stripe dashboard.
1. Identify the customer in the browser
After login or signup, tell Supapulse who the visitor is:
supapulse.identify(user.id); // window.supapulse with the script tagIn React, call useIdentify() from an effect. See Hooks. Use the same ID as supapulse_customer_id in step 3.
2. Add the endpoint in Stripe
- In Supapulse, open the site's settings and find the Revenue section. Copy the endpoint URL. It has the form
https://supapulse.app/api/webhooks/stripe/{publicToken}. - In the Stripe dashboard, open Developers, then Webhooks, and add an endpoint with that URL.
- Subscribe it to these events and no others:
checkout.session.completedinvoice.paidcharge.refundedcustomer.subscription.updatedcustomer.subscription.deleted
- Copy the endpoint's signing secret (
whsec_…) and paste it into the Revenue section of the site's settings.
Until the secret is saved the endpoint answers 404. The settings show when the last event arrived, so you can check the connection. Use a separate Supapulse site for Stripe test mode, so test payments stay out of your real numbers.
3. Pass your user ID in metadata
Set metadata when you create the checkout. These keys are all optional, but they make attribution stronger:
| Metadata key | Value |
|---|---|
supapulse_customer_id | Your user ID, the one you pass to identify(). |
supapulse_visitor_fingerprint | await supapulse.getVisitorFingerprint(). supapulse_fingerprint also works. |
supapulse_session_id | supapulse.getSessionId(). |
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function createCheckout(
priceId: string,
user: { id: string },
visitor: { fingerprint?: string; sessionId?: string },
mode: 'payment' | 'subscription',
) {
const metadata = {
supapulse_customer_id: user.id,
supapulse_visitor_fingerprint: visitor.fingerprint ?? '',
supapulse_session_id: visitor.sessionId ?? '',
};
return stripe.checkout.sessions.create({
mode,
line_items: [{ price: priceId, quantity: 1 }],
success_url: 'https://acme.com/thanks',
cancel_url: 'https://acme.com/pricing',
metadata,
// Subscriptions: copy it onto the subscription so every invoice carries it.
...(mode === 'subscription' && { subscription_data: { metadata } }),
});
}The browser reads the values when checkout starts and sends them with the checkout request:
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
priceId,
fingerprint: await supapulse.getVisitorFingerprint(), // string | null
sessionId: supapulse.getSessionId(),
}),
});
window.location.href = (await response.json()).url;Empty values are ignored. Without any metadata Supapulse still works, using the customer's Stripe ID and whatever identify() linked.
Payment Links
Payment Links can't take metadata, but they do pass client_reference_id from the link's URL to the checkout. Set it to supapulse_ plus the session ID when the visitor clicks, and Supapulse attributes the purchase to that visit:
document.addEventListener('click', (event) => {
const link = event.target.closest('a[href*="buy.stripe.com"]');
const sessionId = window.supapulse?.getSessionId();
if (!link || !sessionId) return;
const url = new URL(link.href);
url.searchParams.set('client_reference_id', `supapulse_${sessionId}`);
link.href = url.toString();
});This works for guest checkouts too: the customer is keyed on the checkout email, and the purchase is attributed even when the link creates an invoice. Other client_reference_id values are left alone.
What Supapulse records
| Stripe event | Becomes |
|---|---|
checkout.session.completed, payment mode, paid | A purchase with the session's total. |
checkout.session.completed, subscription mode | No revenue. It links the Stripe customer to the visit from the metadata. |
invoice.paid with an amount above zero | subscription for the first invoice, renewal for later cycles, upgrade for plan changes, otherwise purchase. The first invoice sets MRR. |
charge.refunded | A refund for the newly refunded amount, stored as negative. |
customer.subscription.updated | An upgrade or downgrade when the monthly price changes, and a subscription when a trial turns into a paid plan. |
customer.subscription.deleted | A churn that removes the subscription's MRR. Subscriptions that never paid are skipped. |
Other events are ignored. Amounts are converted from Stripe's minor units, including zero-decimal currencies like JPY. MRR normalizes weekly, monthly and yearly prices to a month. Tiered and metered prices count as 0 MRR.
The customer key
The customer in Supapulse is the Stripe customer ID (cus_…). If a payment has no Stripe customer, Supapulse uses supapulse_customer_id from the metadata, then the checkout email. Events with none of these are skipped.
Link Stripe customers to your users
identify(user.id) records your user ID, and Stripe's events use cus_…. When an event carries both supapulse_customer_id in its metadata and a Stripe customer ID, Supapulse links them and, when the visit isn't found directly, copies the attribution already stored for your user ID to the Stripe customer. That is why step 3 matters for subscriptions.
Attribution
Supapulse tries the visitor fingerprint, then the session ID, then the customer's stored touch. Renewals take the touch saved on the first payment. See Attribution.
Idempotency
Each revenue row is keyed on the site, a transaction ID taken from Stripe and the event type, so Stripe's retries don't double count. Supapulse verifies the signature with a five-minute tolerance and answers 400 for a bad one.
Plans
If the site's team has no active plan or trial, the endpoint answers 200 with { "ignored": "plan_inactive" } and stores nothing. Revenue isn't blocked by the monthly event limit.
Testing
stripe listen --forward-to https://supapulse.app/api/webhooks/stripe/{publicToken}stripe listen prints its own signing secret. Paste that one into the site's settings while you test, then switch back to the dashboard endpoint's secret.
For an end-to-end test, run a real test-mode checkout from your site. Events from stripe trigger have no metadata or customer link, so they appear in totals without attribution.
Manual webhook handler
Prefer to send revenue from your own server, or need fields the built-in webhook doesn't set? Skip steps 2 and 3, and post revenue events yourself. Don't do both for the same payments, or each payment is counted by both paths.
import Stripe from 'stripe';
import { Revenue } from '@supapulse/revenue';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const revenue = new Revenue({ apiToken: process.env.SUPAPULSE_SECRET_TOKEN! });
export async function POST(request: Request) {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
await request.text(),
request.headers.get('stripe-signature')!,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response('Invalid signature', { status: 400 });
}
if (event.type === 'invoice.paid') {
const invoice = event.data.object;
if (invoice.amount_paid > 0) {
await revenue.track({
customer_id: invoice.customer as string,
amount: invoice.amount_paid / 100,
currency: invoice.currency.toUpperCase(),
transaction_id: invoice.id!,
event_type: invoice.billing_reason === 'subscription_create' ? 'subscription' : 'renewal',
payment_processor: 'stripe',
});
}
}
return new Response('ok');
}If track() throws, the handler returns an error and Stripe retries. Supapulse keeps only the first event per transaction_id and event type. Divide Stripe amounts by 100 for two-decimal currencies; zero-decimal currencies like JPY are already in major units.
Common issues
Nothing shows up. Check the settings: if the last event time is empty, Stripe isn't reaching the endpoint. A 404 in Stripe's delivery log means the signing secret isn't saved. A 400 means it's the wrong secret.
Revenue shows up as direct. Supapulse found no visit for the customer. Call identify() after signup, and pass supapulse_customer_id in the checkout metadata. Supapulse only looks back over the site's salt rotation window, 1 to 30 days.
Revenue counted twice. Both the built-in webhook and your own handler are sending the same payments. Keep one.
The 200 says plan_inactive. The team's trial ended or its plan lapsed. Events arriving in that time aren't stored.