Skip to main content

Stripe webhook adapter

Turn Stripe webhooks into canonical events with zero code: invoice.paid, customer.subscription.updated, charge.refunded and friends become timestamped facts about your customer entities — billing behavior is some of the highest-signal behavior there is.

Prerequisites​

  • An API key with the admin scope.
  • A Stripe account with permission to add webhook endpoints.

How authentication works (least privilege)​

The adapter authenticates Stripe to Tessera with the webhook endpoint's signing secret (whsec_…) — no Stripe API key is ever stored. Every delivery must carry a valid Stripe-Signature header (HMAC-SHA256 over the raw body, verified with a constant-time comparison and a 5-minute timestamp tolerance) before the payload is even parsed. Tessera cannot read your Stripe account; it can only verify signatures on what Stripe sends. The secret is envelope-encrypted with Cloud KMS the moment the source is created and is never returned by any API.

Create the source​

Create a kind=stream source with the adapter subtype:

{
"name": "stripe-billing",
"kind": "stream",
"config": {
"subtype": "adapter",
"provider": "stripe",
"webhook_secret": "whsec_REPLACE_WITH_ENDPOINT_SECRET"
}
}

New sources default to the latest released mapping catalog; pass "catalog_version": 1 to pin one explicitly.

The webhook_secret is write-only. Reads show {"set": true} in its place, never the secret or its encrypted form, and an update that sends {"set": true} back keeps the stored secret. An update that changes provider must send the new provider's secret.

Register the webhook endpoint in Stripe​

The source id (src_…) in the create response is the last path segment of your receiver URL:

https://ingest.staging.tessera.zone/v1/adapters/stripe/{src_id}

In the Stripe dashboard (Developers → Webhooks → Add endpoint), register that URL for the event types you want (the coverage table below lists what maps out of the box — subscribing to unmapped types is harmless: they are acknowledged and dropped, never guessed). Then copy the endpoint's signing secret and create the source with it — or, if you created the source first, update it:

curl -X PATCH https://api.staging.tessera.zone/v1/sources/$SRC_ID \
-H "Authorization: Bearer $TESSERA_API_KEY" \
-d '{"config": {"subtype": "adapter", "provider": "stripe", "webhook_secret": "whsec_…"}}'

Secret rotation works the same way and takes effect within a minute.

Event coverage​

Generated from the released stripe mapping catalog — the catalog file is the single source of truth:

Catalog version v1 (new sources default to the latest released version; existing sources pin the version they were created with).

Provider eventCanonical event_typeEntityTimestampAttributesSkip rule
charge.refundedcharge.refundedcustomer ← /body/data/object/customer/body/createdamount, amount_refunded, currency, statusskipped when /body/data/object/customer is null
charge.succeededcharge.succeededcustomer ← /body/data/object/customer/body/createdamount, currency, statusskipped when /body/data/object/customer is null
checkout.session.completedcheckout.session.completedcustomer ← /body/data/object/customer/body/createdamount_total, currency, payment_status, statusskipped when /body/data/object/customer is null
customer.subscription.createdcustomer.subscription.createdcustomer ← /body/data/object/customer/body/createdcurrency, statusskipped when /body/data/object/customer is null
customer.subscription.deletedcustomer.subscription.deletedcustomer ← /body/data/object/customer/body/createdcurrency, statusskipped when /body/data/object/customer is null
customer.subscription.updatedcustomer.subscription.updatedcustomer ← /body/data/object/customer/body/createdcancel_at_period_end, currency, statusskipped when /body/data/object/customer is null
invoice.paidinvoice.paidcustomer ← /body/data/object/customer/body/createdamount_due, amount_paid, currency, statusskipped when /body/data/object/customer is null
invoice.payment_failedinvoice.payment_failedcustomer ← /body/data/object/customer/body/createdamount_due, attempt_count, currency, statusskipped when /body/data/object/customer is null

Pointers resolve against the delivery document {"headers": {…lowercased…}, "body": {…payload…}}; the event-type discriminator is /body/type.

Events are attributed to your customer entities via the Stripe customer id; deliveries whose payload has no customer (for example anonymous charges) are skipped by the baseline skip_if rule. Amounts map exactly as Stripe sends them — integer minor units, never converted.

Retries and idempotency​

Stripe's event id (evt_…) becomes the canonical event_id, so Stripe's at-least-once redeliveries are exact no-ops downstream. The receiver answers 200 only after the mapped events are durably queued.

Customize the mapping​

The mapping surface lives on the standard source-mapping routes:

  • GET /v1/sources/{src_id}/mapping — the resolved catalog + your overrides.
  • PUT /v1/sources/{src_id}/mapping — per-event overrides (rename the canonical event_type, re-point attributes, add skip_if rules, or set enabled: false to drop a type). An override can also normalize vendor values with a value_map (exact {from, to} pairs per attribute; other values pass through unchanged) or declare a timestamp_format (rfc3339 for RFC 3339 strings only, or unix_s, unix_ms, unix_us for epoch numbers). Leave it out to keep the default, which reads both RFC 3339 strings and Stripe's epoch-second created — an explicit rfc3339 would refuse those numbers. Overrides are validated field by field; the released catalog itself is read-only.
  • POST /v1/sources/{src_id}/mapping:preview with {"sample_payload": {…}} — runs the full merged mapping on a sample webhook body with zero side effects and returns the exact events the live path would publish (requires the admin scope).

Test and verify​

Use Stripe's "send test webhook", then read the timeline of the test customer. Signature failures are rejected at the door (401), not parked; mapping failures on a mapped type are parked in the dead-letter view with reason=mapping_failed and can be replayed after you fix the override.

Troubleshooting​

SymptomCauseFix
401 on deliverieswrong signing secretcopy the endpoint's whsec_… from Stripe and update the source config
404 on deliverieswrong src_id in the URL, or a disabled sourcere-check the receiver URL against the source id; re-enable the source
events missing for a typetype not in the catalog (acknowledged + dropped)check the coverage table; add an override entry or file a request
deliveries counted as skippedpayload has no customerexpected for anonymous objects; override the skip_if rule if you map them elsewhere
events parked as mapping_failedan override points at a missing/invalid fieldfix the override, then replay from the dead-letter view