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
adminscope. - 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 event | Canonical event_type | Entity | Timestamp | Attributes | Skip rule |
|---|---|---|---|---|---|
charge.refunded | charge.refunded | customer ← /body/data/object/customer | /body/created | amount, amount_refunded, currency, status | skipped when /body/data/object/customer is null |
charge.succeeded | charge.succeeded | customer ← /body/data/object/customer | /body/created | amount, currency, status | skipped when /body/data/object/customer is null |
checkout.session.completed | checkout.session.completed | customer ← /body/data/object/customer | /body/created | amount_total, currency, payment_status, status | skipped when /body/data/object/customer is null |
customer.subscription.created | customer.subscription.created | customer ← /body/data/object/customer | /body/created | currency, status | skipped when /body/data/object/customer is null |
customer.subscription.deleted | customer.subscription.deleted | customer ← /body/data/object/customer | /body/created | currency, status | skipped when /body/data/object/customer is null |
customer.subscription.updated | customer.subscription.updated | customer ← /body/data/object/customer | /body/created | cancel_at_period_end, currency, status | skipped when /body/data/object/customer is null |
invoice.paid | invoice.paid | customer ← /body/data/object/customer | /body/created | amount_due, amount_paid, currency, status | skipped when /body/data/object/customer is null |
invoice.payment_failed | invoice.payment_failed | customer ← /body/data/object/customer | /body/created | amount_due, attempt_count, currency, status | skipped 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 canonicalevent_type, re-point attributes, addskip_ifrules, or setenabled: falseto drop a type). An override can also normalize vendor values with avalue_map(exact{from, to}pairs per attribute; other values pass through unchanged) or declare atimestamp_format(rfc3339for RFC 3339 strings only, orunix_s,unix_ms,unix_usfor epoch numbers). Leave it out to keep the default, which reads both RFC 3339 strings and Stripe's epoch-secondcreated— an explicitrfc3339would refuse those numbers. Overrides are validated field by field; the released catalog itself is read-only.POST /v1/sources/{src_id}/mapping:previewwith{"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 theadminscope).
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
| Symptom | Cause | Fix |
|---|---|---|
| 401 on deliveries | wrong signing secret | copy the endpoint's whsec_… from Stripe and update the source config |
| 404 on deliveries | wrong src_id in the URL, or a disabled source | re-check the receiver URL against the source id; re-enable the source |
| events missing for a type | type not in the catalog (acknowledged + dropped) | check the coverage table; add an override entry or file a request |
| deliveries counted as skipped | payload has no customer | expected for anonymous objects; override the skip_if rule if you map them elsewhere |
events parked as mapping_failed | an override points at a missing/invalid field | fix the override, then replay from the dead-letter view |