Connecting Stripe
Most of the outcomes that matter — paid, upgraded, churned, refunded, disputed — already live in Stripe. Connect it once and they arrive as funnel events, joined to the decision that let each customer in.
Step 1: put the trace id on the Stripe customer
When you create the Stripe customer for someone who came through /v1/decide, store that decision’s trace_id in the customer’s metadata under exactly this key:
// at signup
const d = await sortance.decide(email) // d.trace_id === "dc_01J..."
// when you create the Stripe customer
await stripe.customers.create({
email,
metadata: { sortance_trace_id: d.trace_id },
})That makes the join exact. Sortance accepts the id only if that trace belongs to your account — anything can be typed into Stripe metadata, and a mistyped or foreign id must not attach your payment to someone else’s decision.
Step 2: create a restricted key (optional, recommended)
In Stripe, create a restricted key with read access only:
- Customers — Read
- Subscriptions — Read
- Invoices — Read
- Charges — Read
- Disputes — Read
- Nothing with write access.
Sortance refuses a secret key outright: only rk_live_… or rk_test_… is accepted. With no key at all the webhook still works; you lose the backfill and the customer lookups below.
What the worker actually calls with the key today:
GET /v1/customersandGET /v1/subscriptions?status=all, paged, for the backfill.GET /v1/customers/:id, once per customer it has not seen, to read the trace id and email for the join.GET /v1/charges/:id, for a dispute that names a charge but not a customer.
Invoice and dispute data otherwise arrives through the webhook, not the key.
Step 3: connect, then add the webhook in Stripe
POST /v1/integrations/stripe
{
"webhook_secret": "whsec_...", // required: the endpoint's signing secret
"restricted_key": "rk_live_...", // optional
"rotate_token": false // optional: true mints a new webhook URL
}
// 201
{
"integration": { "provider": "stripe", "kind": "outcome_source", "status": "connected",
"config": { "webhook_token_set": true }, "has_secret": true,
"last_event_at": null, "created_at": 1759400000 },
"webhook_url": "https://api.sortance.com/v1/integrations/stripe/webhook/whk_..."
}- The
webhook_urlis shown once. A reconnect that keeps the existing token returnsnull, and the URL already in Stripe keeps working. - In Stripe, add that URL as a webhook endpoint subscribed to the events in the table below.
- The order is awkward, and here is the way through. Stripe issues the
whsec_signing secret only once the endpoint exists, and the endpoint needs the URL that connecting returns. So connect twice: first with any value of the formwhsec_plus at least eight letters or digits to get the URL, then create the endpoint in Stripe and connect again with the real secret. The second call replaces the secret and keeps the same URL. Deliveries in between fail verification with a400and are retried by Stripe. - Secrets are encrypted at rest and never returned by the API. The token in the URL is stored only as its hash.
- Every delivery is verified with HMAC-SHA256 over the raw body and Stripe’s timestamp, with a five-minute tolerance.
Events Sortance uses
| Stripe event | Becomes | When |
|---|---|---|
customer.created | — | Teaches the join: remembers the customer's sortance_trace_id and a hash of their email. No funnel event. |
customer.subscription.created | subscribed | When the subscription is active or past_due with a non-zero amount. Trials do not count. |
customer.subscription.updated | subscribed or upgraded | subscribed when a trial or incomplete subscription becomes active; upgraded when the monthly amount strictly rises. A downgrade maps to nothing. |
customer.subscription.deleted | churned | Always, with the cancellation reason when Stripe gives one. |
invoice.paid | subscribed | A paid amount above zero on a subscription. Counted once per subscription, however many paths report it. |
charge.refunded | refunded | Always. |
charge.dispute.created | fraud_flag | Always, with the dispute reason kept so non-fraud disputes can be filtered. |
Properties kept on each event are an allow-list of non-identifying fields: Stripe object ids, status, amounts, currency, livemode and reasons. Email addresses on Stripe objects are hashed for the join and then discarded.
How an event is joined to a decision
- exact — a
sortance_trace_idon the object or on its customer (remembered fromcustomer.created, or looked up with your restricted key) that matches a trace on your account. - by_email — otherwise, the customer’s email, hashed, matched to your most recent
/v1/decidefor that hash in the 90 days before the event. - none — otherwise. The event is kept, not dropped.
Someone who signs up as mia@acme.com and pays as billing@acme.com is not matched, and a shared address can be matched to the wrong signup. Sortance cannot tell these cases apart, so every event records its join_method and segment reports count exact, by-email and unmatched joins separately. If you want numbers you can rely on, set metadata.sortance_trace_id.
Backfill
When you connect with a restricted key, Sortance pages through your existing customers (to learn trace ids and email hashes) and then your subscriptions (to record past subscribed and churned events), 100 objects a page, so segment reports are useful on day one. Pages are idempotent: a conversion is never counted twice, even where the backfill overlaps live webhooks.
One limit to know: each connect call processes at most five pages — 500 objects — and saves its place. Continuing a larger backfill automatically is not wired yet; posting the connect call again with the restricted key resumes from where it stopped. If the key is revoked or missing a permission, the integration’s status becomes error.
Endpoints
| Method | Path | Does |
|---|---|---|
GET | /v1/integrations/stripe | Connection status: provider, status (connected, error, revoked), has_secret, last_event_at. Never the secret. |
POST | /v1/integrations/stripe | Connect. Returns the webhook URL once. |
DELETE | /v1/integrations/stripe | Revoke: deletes the stored secrets and the webhook token. |
POST | /v1/integrations/stripe/webhook/:account_token | Where Stripe sends events. Authenticated by the token and Stripe's signature, not by your API key. |
Webhook responses (to Stripe)
| Status | Means |
|---|---|
200 | Verified. { received, ignored, event, join_method } — ignored is true for an event type Sortance does not use. |
400 | Bad or missing Stripe-Signature, or a timestamp outside the five-minute tolerance. Usually the stored signing secret is not this endpoint's; connect again with the right one. |
404 | Unknown or revoked webhook token. |
500 | Sortance could not decrypt your signing secret or store the event. Stripe retries, so nothing is lost once it is fixed. |
The connect call returns 400 invalid_request for a missing or malformed webhook_secret or a key that is not a restricted key, and 404 not_found from GET or DELETE when Stripe is not connected.
Not yet
Stripe Connect — one click, revocable from the Stripe side, no key to paste — needs Stripe’s platform review and is not built. The restricted key plus webhook secret is the only way in today.