Appearance
Webhooks
A webhook is how you find out that something happened at a till without asking. Register an https endpoint, choose events, and we POST a signed JSON body to you within seconds of the commit.
Status
Live today: the event catalogue below, subscription matching and the delivery rules, HMAC-signed delivery, the five-attempt retry, auto-disable, and the delivery log. Some events are in the catalogue but do not have a dispatch site yet — the Sends today column in the table below is generated from the running code and says exactly which, and subscribing to one now is harmless. Webhook CRUD over the REST API is implemented and ships with the integration module. Not yet on the wire: custom outbound headers and non-POST methods, and the generated per-event payload examples.
The envelope
Two payload versions exist. A webhook keeps the version it was created with, byte for byte, so nothing you have built can be changed underneath you.
v1 — ids only
json
{
"event": "stamp.assigned",
"event_id": "whe_01J9…",
"program_id": "prg_01J9…",
"shop_id": "shp_01J9…",
"occurred_at": "2026-09-04T10:15:00.000Z",
"data": {
"card_instance_id": "cin_01J9…",
"stamps_delta": 1,
"stamps_count": 4,
"transaction_id": "trx_01J9…"
}
}v2 — the same event, with the context you would otherwise have to fetch
json
{
"event": "stamp.assigned",
"event_id": "whe_01J9…",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": { "id": "shp_01J9…", "name": "Caffè Centrale" },
"location": { "id": "loc_01J9…", "name": "Milano Centrale" },
"program": { "id": "prg_01J9…", "name": "Coffee card", "type": "stamps" },
"card": {
"id": "cin_01J9…",
"external_ref": "EMP-00418",
"state": "active",
"holder": { "name": "A. Rossi", "email": "a.rossi@example.com" }
},
"transaction": { "id": "trx_01J9…", "type": "stamp", "metadata": null },
"actor": { "type": "device", "id": "dev_01J9…" },
"data": {
"card_instance_id": "cin_01J9…",
"stamps_delta": 1,
"stamps_count": 4,
"transaction_id": "trx_01J9…"
}
}Three rules make v2 safe to consume, and they are worth reading once:
location,program,card,transactionandactorarenullwhen they do not apply — never absent. Writepayload.card?.external_refand stop thinking about it. You never have to switch on the event key or sniff the version to know which keys exist.datacarries the v1 shape verbatim. Migrating v1 → v2 means changing nothing and reading more. That is the whole migration story.transaction.metadatais a pass-through of whatever the device or service recorded. It is not an extension point: transactions cannot be written over the API, so you cannot put anything into it. Read it if it helps; do not design around it.
Payload versions
New webhooks default to v2. Existing ones stay on v1 until you change them deliberately, and their bytes are pinned by a golden-file test on our side — a v1 receiver written three years ago keeps parsing exactly what it parsed then.
Which field is the user id?
This is the first question every integrator asks, so here it is in the words you searched for.
| Field | What it is |
|---|---|
card.external_ref | Your id. The value your system set when the card was issued — the employee number, the CRM contact id, the member number. This is the field to join on. |
card.id | Our id for the issued card. Stable, opaque, ours. Use it when calling us back. |
card.holder.name | A display label. Never an identifier — two people share a name, and holders rename themselves. |
card.holder.email | A join key of last resort. Present more often than external_ref, unique less often. |
There is no separate "user" object, and there is no user_id. A card instance is the person's record in this system. If you are looking for the endpoint that lists users, it is the endpoint that lists cards.
So the rule:
Prefer
card.external_ref. Fall back tocard.holder.email. Expect either to benull.
external_ref is only set when the card was created by a system that knew your id for the person — typically yours, through the API. A card that arrived by web enrollment, staff creation at a counter, or self-activation has none, and then email is the only join you have. A card created by your CRM with an external_ref and no email has the opposite gap. Handle both, and reconcile the unmatched ones rather than dropping them.
The event catalogue
event | Sends today | When it fires | data fields |
|---|---|---|---|
card.added | yes | A card instance is created — either a holder enrolled / activated a card, or a full card overflowed into a fresh one. | card_instance_id, parent_card_instance_id, source |
card.registered | yes | A holder installed the card in the consumer app. A re-register heartbeat does NOT re-fire it. Apple/Google Wallet registrations are deliberately not wired to this event. | card_instance_id, platform |
stamp.assigned | yes | A stamp scan on the shop app awarded one or more stamps. | card_instance_id, stamps_delta, stamps_count, transaction_id |
prize.redeemed | yes | A prize was redeemed on the shop app. | card_instance_id, prize_id, prize_label, transaction_id |
value.changed | yes | A prepaid or points balance moved. | card_instance_id, tx_type, delta, balance, transaction_id |
transaction.reversed | yes | A transaction was reversed on the shop app. | original_transaction_id, reversal_transaction_id, card_instance_id, reversed_delta |
card.used | yes | The access-card tap: a card whose purpose is entry or entitlement was presented and accepted at a till or a door. This is the event access-control integrations are built on. | card_instance_id, transaction_id, program_type |
card.expired | not yet | A card was presented after its expiry, or the expiry was applied. | card_instance_id, transaction_id, expired_at |
transaction.created | yes | THE FIREHOSE — one delivery per transaction of any type. Mutually exclusive with the specific transaction events; see the delivery rules below. | transaction_id, card_instance_id, tx_type, stamps_delta, value_delta |
card.activated | yes | A card instance became active — a holder completed activation. | card_instance_id |
card.reissued | yes | A card was reissued, e.g. a replacement badge for the same person. | card_instance_id |
card.archived | yes | A card was archived and is no longer usable, while its history is kept. | card_instance_id |
card.deleted | yes | A card was deleted. For a standard card this is a GDPR erase — the holder's personal data is scrubbed, including from past webhook delivery payloads. | card_instance_id |
program.created | not yet | A program (a card design and its rules) was created. | program_id |
program.updated | not yet | A program was edited. | program_id |
program.published | not yet | A program was published and can be issued. | program_id |
program.unpublished | not yet | A program was unpublished; existing cards keep working. | program_id |
program.suspended | not yet | A program was suspended. | program_id |
program.unsuspended | not yet | A suspended program was restored. | program_id |
program.deleted | not yet | A program was deleted (dashboard-only — the API cannot delete a program). | program_id |
location.created | yes | A location was created. If it was billable, the charge is disclosed in the API response that created it, not here. | location_id |
location.updated | not yet | A location was edited. | location_id |
location.disabled | not yet | A location was deactivated. Its cards stop being usable there. | location_id |
location.enabled | not yet | A location was reactivated. | location_id |
subscription.state_changed | yes | The shop's subscription state changed — including into suspension. It is delivered EVEN WHILE THE SHOP IS SUSPENDED, on purpose: the one event that tells a CRM it is suspended must not be the one withheld. | from_state, to_state, reason |
test.ping | yes | You pressed Send test on a webhook. Never fires on its own, and cannot be subscribed to — the test targets one webhook directly. | message, sample: true |
25 events are subscribable and 14 of them are sent by a dispatch site today. This table is generated throughout — the events from the server's own catalogue, the "sends today" column from its service layer, and the data fields from the generated payload examples below — so none of it can drift. Every field named here appears in data; the envelope around it carries the shop, location, program, card, transaction and actor blocks.
Subscribing to an event marked not yet is accepted and harmless: the subscription is stored, and deliveries start arriving the moment its dispatch site ships. Nothing about your webhook needs to change then — which is also why card.* is a better subscription than a list of exact keys.
Subscribing
Three forms, and you can mix them:
| Form | Example | Matches |
|---|---|---|
| Exact | card.used | that event |
| Prefix wildcard | card.* | every current and future card. event |
| Everything | * | every event except the firehose (see below) |
Wildcards expand when an event fires, not when you save. A webhook subscribed to card.* starts receiving card.whatever_we_add_next the day we ship it, without you re-saving anything. That is the reason to prefer a wildcard over a hand-typed list.
The delivery rules that generate day-one tickets
There are three, and an integrator debugging "why didn't this fire?" needs all three. Payload examples will not tell you any of them.
1. An empty filter means ALL
A webhook with no program filter receives events from every program in the shop. A webhook with no location filter receives events from every location. Empty is not "none pending configuration" — it is "all", and it is the default.
null and [] are the same thing; an empty array is normalised to "no filter" on write, so the two forms can never mean different things.
To narrow, list the ids you want. Note the asymmetry that follows from it: an event with no program at all (subscription.state_changed, the location.* events) can never satisfy a program filter, so a program-filtered webhook does not receive them. A webhook narrowed to "program X" asked for that program's traffic, not the shop's.
2. What a location-scoped webhook receives — include_shop_wide_events
Some events have no single location. A wallet install happens on the holder's phone; a program being published concerns the whole shop; a card being archived or deleted happens in a back office rather than at one till. Semantically they concern all locations, so a location-scoped webhook has to be told what to do with them. The toggle defaults to on.
location_ids | include_shop_wide_events | Receives |
|---|---|---|
| empty (all) | (ignored) | everything |
['loc_1'] | true (default) | loc_1 events + all shop-wide events |
['loc_1'] | false | loc_1 events only |
Under both settings it never receives another location's located events.
Turning it off is not hypothetical. Five location-scoped webhooks pointing at one CRM endpoint would otherwise deliver five copies of every shop-wide event, each with a different event_id — so your dedup key cannot collapse them and your CRM processes the same program update five times. Set the toggle off on four of them, or use one shop-wide webhook and filter on your side.
The shop-wide set, generated from the server:
card.registeredcard.activatedcard.reissuedcard.archivedcard.deletedprogram.createdprogram.updatedprogram.publishedprogram.unpublishedprogram.suspendedprogram.unsuspendedprogram.deletedlocation.createdlocation.updatedlocation.disabledlocation.enabledsubscription.state_changed
Generated from the server's own shop-wide set — 17 events. Every other event carries a location, so a location filter narrows it in the ordinary way.
One more consequence, stated because it looks like a bug when you meet it: an event that carries no location and is not in the set above is not broadcast to location-scoped webhooks. A stamp always happens somewhere, so a stamp without a location is a bug on our side, not a shop-wide event — delivering it across the boundary the filter exists to draw would be a leak.
3. transaction.created is mutually exclusive with the specific transaction events
transaction.created is mutually exclusive with all of:
stamp.assignedprize.redeemedvalue.changedtransaction.reversedcard.usedcard.expired
Generated from the server's own exclusion set — 6 events.
Subscribe to both and one stamp becomes two deliveries with two different event_ids — so dedup cannot collapse them and you double-count. We refuse the combination when you save the webhook, with a 400 that names the exact conflict rather than saying "invalid".
Because wildcards expand at match time, the rule has a consequence that surprises everyone exactly once:
*means "everything except the firehose". So doestransaction.*.
The firehose is opt-in only, by an exact subscription. Choose one shape:
- You want typed events (the normal case): subscribe to
*, or to specific keys. You getstamp.assigned,card.usedand the rest, each with its owndata. - You want one uniform stream (a data warehouse, an audit sink): subscribe to
transaction.createdexactly, and to nothing that overlaps it.
Delivery, retries and dedup
Delivery is at-least-once. Duplicates are normal — a slow 2xx, a network blip after your handler committed, a retry that crossed a successful response.
Dedup on
event_id. It is in the body and in theX-Tesserapp-Deliveryheader, and both carry the same value.
The integration kit has the storage shape and the single-statement claim in three languages. The short version: a table with event_id as the primary key, an INSERT … ON CONFLICT DO NOTHING, and if no row was inserted you return 200 and do nothing else.
Retries
| Limit | Default | Deployment setting |
|---|---|---|
| Webhooks per shop | 50 | WEBHOOK_MAX_PER_SHOP |
| Delivery attempts per event | 5 | lib/queues.ts (shared queue defaults) |
| Backoff between attempts | exponential from 5s | lib/queues.ts |
| Request timeout | 10000 ms | WEBHOOK_DELIVERY_TIMEOUT_MS |
| Consecutive failures before a webhook is auto-disabled | 15 | WEBHOOK_MAX_CONSECUTIVE_FAILURES |
| Delivery-log retention | 30 days | WEBHOOK_DELIVERY_RETENTION_DAYS |
| Custom headers per webhook | 20 | WEBHOOK_MAX_CUSTOM_HEADERS |
| Custom headers, total size | 4096 bytes | WEBHOOK_CUSTOM_HEADERS_MAX_BYTES |
- Any non-2xx response, or a network error or timeout, is a failure and is retried on the schedule above.
- Return 2xx quickly and do the work asynchronously. Enqueue and answer; a handler that takes longer than the delivery timeout is recorded as failed and retried, and your slow handler becomes duplicate deliveries.
- A
4xxis retried exactly like a5xx. We cannot tell "your validation rejected this" from "your load balancer was confused", so we assume the latter.
Auto-disable, and how to re-enable
After a run of consecutive failures (the count is in the table above) a webhook is deactivated. This is a real state: it stops firing and stops appearing as live, and it exists because an endpoint that has failed hundreds of times in a row is gone, not busy.
To re-enable it, set the webhook back to active in the dashboard (or with a PATCH). The consecutive-failure counter resets when you do, so a re-enabled webhook starts with a clean run and is not disabled again by history. A single success also resets the counter, so an endpoint that flaps below the threshold never trips it.
Events that fired while the webhook was disabled are not replayed. Backfill from GET /transactions for the window you missed, then let the stream carry on.
The delivery log, and what shed means
Every attempt is recorded: the event, the event_id, the response status, the attempt count, the duration, the headers we sent (with secrets masked), and the error. It is the first place to look for "did you send it?" — usually the answer is yes, with your status code next to it.
The log is retained for the window in the table above and then pruned. That window is a data-protection control, not a housekeeping one — payload v2 carries a holder's name and email, so a stored delivery body is personal data. Do not plan an audit around data that will be pruned; copy what you need into your own system as it arrives.
A row marked shed means something different from a failure: the shop exceeded its per-minute enqueue budget and we dropped the delivery instead of letting a fan-out backlog delay every other shop's webhooks. It is deliberately visible in the same log as real deliveries, because a silently dropped event is indistinguishable from a bug.
If you see shed rows: the cause is almost always many overlapping subscriptions — several shop-wide webhooks on the firehose, so every till scan multiplies. The fix is fewer, narrower webhooks (or one endpoint that fans out on your side), not a bigger budget.
Security
Verify the signature over the exact bytes, before parsing
Every delivery carries:
| Header | Value |
|---|---|
X-Tesserapp-Event | the event name |
X-Tesserapp-Delivery | the event_id — your dedup key |
X-Tesserapp-Timestamp | unix seconds, when we signed |
X-Tesserapp-Signature | sha256= + hex HMAC-SHA256 of {timestamp}.{rawBody}, keyed with your webhook's signing secret |
X-Tesserapp-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08Four things, in this order:
- Read the raw bytes. Not a parsed object you re-serialise — see the callout below.
- Check the timestamp against a tolerance you choose. We do not enforce one; five minutes is a sane default. The timestamp is inside the MAC, so a captured delivery cannot be re-dated to slip through.
- Compare in constant time (
timingSafeEqual,hash_equals,hmac.compare_digest). Never==. - Then parse.
The raw body is the number-one integration failure
The signature covers the exact bytes we sent. If your framework parsed the JSON and you re-encode it before verifying, your encoder's key order and spacing will differ from ours and the signature will not match — with a secret that is perfectly correct.
This is not theoretical on our side either: our delivery worker serialises the stored payload immediately before signing, so the key order on the wire comes out of a database round trip and is not something you can reproduce by re-encoding.
Verify bytes. Then parse. The integration kit has the framework-specific way to get those bytes in Node, PHP and Python, and every snippet there is executed by our tests against real generated payloads.
The rest of the posture
- https only. An http URL is refused when you save it, and refused again at delivery time.
- We resolve your hostname before every delivery and refuse private targets — loopback, RFC1918, link-local (including the cloud metadata address), CGNAT and their IPv6 equivalents. Redirects are never followed, so a public URL cannot bounce a delivery into an internal one.
- Platform headers always win. Custom headers are merged under ours, so a misconfigured custom header cannot displace the signature or the content type.
- We will never send an
X-Tesserapp-*header we did not compute. Any custom header matching that prefix is refused when you save it, case-insensitively — that is what stops a forgedX-Tesserapp-Signaturereaching a downstream receiver that trusts it. - Custom header values are treated as secrets: write-only, masked on read, encrypted at rest, and masked in the delivery log.
- A
Cookieheader, framing headers andContent-Typeare refused for the same class of reason.Authorizationis allowed — many CRMs want Basic or anX-Api-Key, not a bearer.
Rotating a signing secret
Rotating a webhook's secret returns the new plaintext once and is a hard cutover today: we start signing with the new secret immediately, so update your receiver in the same change. The safe order is to accept either secret in your verification code, rotate, then drop the old one.
WARNING
Overlapping webhook-secret rotation — where we sign with the new secret and you can verify against either during a window, with the key prefix carried in the signature header — is designed but not implemented. The header is sha256=<hex> with no key identifier, and no part of the platform sends two signatures. Do not write a receiver that depends on one.
API key rotation, which is overlapping, is a different mechanism — see Authentication.
Payload examples
One example per event, generated from the payload builders — the same bytes the dispatcher produces, not a hand-written illustration. 25 of them.
card.added
json
{
"event": "card.added",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": {
"id": "loc_docsfixture0001",
"name": "Via Roma 1"
},
"program": {
"id": "prg_docsfixture0001",
"name": "Coffee Card",
"type": "standard"
},
"card": {
"id": "cin_docsfixture0001",
"external_ref": "EMP-00417",
"state": "active",
"holder": {
"name": "Ada Lovelace",
"email": "ada@docsfixture.invalid"
}
},
"transaction": null,
"actor": {
"type": "consumer",
"id": "cin_docsfixture0001"
},
"data": {
"card_instance_id": "cin_docsfixture0001",
"parent_card_instance_id": null,
"source": "enrollment"
}
}card.registered
json
{
"event": "card.registered",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": null,
"program": {
"id": "prg_docsfixture0001",
"name": "Coffee Card",
"type": "standard"
},
"card": {
"id": "cin_docsfixture0001",
"external_ref": "EMP-00417",
"state": "active",
"holder": {
"name": "Ada Lovelace",
"email": "ada@docsfixture.invalid"
}
},
"transaction": null,
"actor": {
"type": "consumer",
"id": "cin_docsfixture0001"
},
"data": {
"card_instance_id": "cin_docsfixture0001",
"platform": "apple"
}
}stamp.assigned
json
{
"event": "stamp.assigned",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": {
"id": "loc_docsfixture0001",
"name": "Via Roma 1"
},
"program": {
"id": "prg_docsfixture0001",
"name": "Coffee Card",
"type": "standard"
},
"card": {
"id": "cin_docsfixture0001",
"external_ref": "EMP-00417",
"state": "active",
"holder": {
"name": "Ada Lovelace",
"email": "ada@docsfixture.invalid"
}
},
"transaction": {
"id": "trx_docsfixture0001",
"type": "stamp",
"metadata": {
"source": "shop_app"
}
},
"actor": {
"type": "device",
"id": "dev_docsfixture0001"
},
"data": {
"card_instance_id": "cin_docsfixture0001",
"stamps_delta": 1,
"stamps_count": 4,
"transaction_id": "trx_docsfixture0001"
}
}prize.redeemed
json
{
"event": "prize.redeemed",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": {
"id": "loc_docsfixture0001",
"name": "Via Roma 1"
},
"program": {
"id": "prg_docsfixture0001",
"name": "Coffee Card",
"type": "standard"
},
"card": {
"id": "cin_docsfixture0001",
"external_ref": "EMP-00417",
"state": "active",
"holder": {
"name": "Ada Lovelace",
"email": "ada@docsfixture.invalid"
}
},
"transaction": {
"id": "trx_docsfixture0001",
"type": "redeem",
"metadata": {
"source": "shop_app"
}
},
"actor": {
"type": "device",
"id": "dev_docsfixture0001"
},
"data": {
"card_instance_id": "cin_docsfixture0001",
"prize_id": "prz_docsfixture0001",
"prize_label": "Free coffee",
"transaction_id": "trx_docsfixture0001"
}
}value.changed
json
{
"event": "value.changed",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": {
"id": "loc_docsfixture0001",
"name": "Via Roma 1"
},
"program": {
"id": "prg_docsfixture0001",
"name": "Coffee Card",
"type": "standard"
},
"card": {
"id": "cin_docsfixture0001",
"external_ref": "EMP-00417",
"state": "active",
"holder": {
"name": "Ada Lovelace",
"email": "ada@docsfixture.invalid"
}
},
"transaction": {
"id": "trx_docsfixture0001",
"type": "points_add",
"metadata": {
"source": "shop_app"
}
},
"actor": {
"type": "device",
"id": "dev_docsfixture0001"
},
"data": {
"card_instance_id": "cin_docsfixture0001",
"tx_type": "points_add",
"delta": 25,
"balance": 125,
"transaction_id": "trx_docsfixture0001"
}
}transaction.reversed
json
{
"event": "transaction.reversed",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": {
"id": "loc_docsfixture0001",
"name": "Via Roma 1"
},
"program": {
"id": "prg_docsfixture0001",
"name": "Coffee Card",
"type": "standard"
},
"card": {
"id": "cin_docsfixture0001",
"external_ref": "EMP-00417",
"state": "active",
"holder": {
"name": "Ada Lovelace",
"email": "ada@docsfixture.invalid"
}
},
"transaction": {
"id": "trx_docsfixture0001",
"type": "reverse",
"metadata": {
"source": "shop_app"
}
},
"actor": {
"type": "device",
"id": "dev_docsfixture0001"
},
"data": {
"original_transaction_id": "trx_docsfixture0001",
"reversal_transaction_id": "trx_docsfixture0002",
"card_instance_id": "cin_docsfixture0001",
"reversed_delta": -1
}
}card.used
json
{
"event": "card.used",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": {
"id": "loc_docsfixture0001",
"name": "Via Roma 1"
},
"program": {
"id": "prg_docsfixture0001",
"name": "Coffee Card",
"type": "standard"
},
"card": {
"id": "cin_docsfixture0001",
"external_ref": "EMP-00417",
"state": "active",
"holder": {
"name": "Ada Lovelace",
"email": "ada@docsfixture.invalid"
}
},
"transaction": {
"id": "trx_docsfixture0001",
"type": "use",
"metadata": {
"source": "shop_app"
}
},
"actor": {
"type": "device",
"id": "dev_docsfixture0001"
},
"data": {
"card_instance_id": "cin_docsfixture0001",
"transaction_id": "trx_docsfixture0001",
"program_type": "access"
}
}card.expired
json
{
"event": "card.expired",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": {
"id": "loc_docsfixture0001",
"name": "Via Roma 1"
},
"program": {
"id": "prg_docsfixture0001",
"name": "Coffee Card",
"type": "standard"
},
"card": {
"id": "cin_docsfixture0001",
"external_ref": "EMP-00417",
"state": "active",
"holder": {
"name": "Ada Lovelace",
"email": "ada@docsfixture.invalid"
}
},
"transaction": {
"id": "trx_docsfixture0001",
"type": "expire",
"metadata": {
"source": "shop_app"
}
},
"actor": {
"type": "device",
"id": "dev_docsfixture0001"
},
"data": {
"card_instance_id": "cin_docsfixture0001",
"transaction_id": "trx_docsfixture0001",
"expired_at": "2026-09-04T10:15:00.000Z"
}
}transaction.created
json
{
"event": "transaction.created",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": {
"id": "loc_docsfixture0001",
"name": "Via Roma 1"
},
"program": {
"id": "prg_docsfixture0001",
"name": "Coffee Card",
"type": "standard"
},
"card": {
"id": "cin_docsfixture0001",
"external_ref": "EMP-00417",
"state": "active",
"holder": {
"name": "Ada Lovelace",
"email": "ada@docsfixture.invalid"
}
},
"transaction": {
"id": "trx_docsfixture0001",
"type": "stamp",
"metadata": {
"source": "shop_app"
}
},
"actor": {
"type": "device",
"id": "dev_docsfixture0001"
},
"data": {
"transaction_id": "trx_docsfixture0001",
"card_instance_id": "cin_docsfixture0001",
"tx_type": "stamp",
"stamps_delta": 1,
"value_delta": null
}
}card.activated
json
{
"event": "card.activated",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": null,
"program": {
"id": "prg_docsfixture0001",
"name": "Coffee Card",
"type": "standard"
},
"card": {
"id": "cin_docsfixture0001",
"external_ref": "EMP-00417",
"state": "active",
"holder": {
"name": "Ada Lovelace",
"email": "ada@docsfixture.invalid"
}
},
"transaction": null,
"actor": {
"type": "consumer",
"id": "cin_docsfixture0001"
},
"data": {
"card_instance_id": "cin_docsfixture0001"
}
}card.reissued
json
{
"event": "card.reissued",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": null,
"program": {
"id": "prg_docsfixture0001",
"name": "Coffee Card",
"type": "standard"
},
"card": {
"id": "cin_docsfixture0001",
"external_ref": "EMP-00417",
"state": "active",
"holder": {
"name": "Ada Lovelace",
"email": "ada@docsfixture.invalid"
}
},
"transaction": null,
"actor": {
"type": "device",
"id": "dev_docsfixture0001"
},
"data": {
"card_instance_id": "cin_docsfixture0001"
}
}card.archived
json
{
"event": "card.archived",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": null,
"program": {
"id": "prg_docsfixture0001",
"name": "Coffee Card",
"type": "standard"
},
"card": {
"id": "cin_docsfixture0001",
"external_ref": "EMP-00417",
"state": "active",
"holder": {
"name": "Ada Lovelace",
"email": "ada@docsfixture.invalid"
}
},
"transaction": null,
"actor": {
"type": "device",
"id": "dev_docsfixture0001"
},
"data": {
"card_instance_id": "cin_docsfixture0001"
}
}card.deleted
json
{
"event": "card.deleted",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": null,
"program": {
"id": "prg_docsfixture0001",
"name": "Coffee Card",
"type": "standard"
},
"card": {
"id": "cin_docsfixture0001",
"external_ref": "EMP-00417",
"state": "active",
"holder": {
"name": "Ada Lovelace",
"email": "ada@docsfixture.invalid"
}
},
"transaction": null,
"actor": {
"type": "device",
"id": "dev_docsfixture0001"
},
"data": {
"card_instance_id": "cin_docsfixture0001"
}
}program.created
json
{
"event": "program.created",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": null,
"program": {
"id": "prg_docsfixture0001",
"name": "Coffee Card",
"type": "standard"
},
"card": null,
"transaction": null,
"actor": {
"type": "shop_owner",
"id": "shp_docsfixture0001"
},
"data": {
"program_id": "prg_docsfixture0001"
}
}program.updated
json
{
"event": "program.updated",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": null,
"program": {
"id": "prg_docsfixture0001",
"name": "Coffee Card",
"type": "standard"
},
"card": null,
"transaction": null,
"actor": {
"type": "shop_owner",
"id": "shp_docsfixture0001"
},
"data": {
"program_id": "prg_docsfixture0001"
}
}program.published
json
{
"event": "program.published",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": null,
"program": {
"id": "prg_docsfixture0001",
"name": "Coffee Card",
"type": "standard"
},
"card": null,
"transaction": null,
"actor": {
"type": "shop_owner",
"id": "shp_docsfixture0001"
},
"data": {
"program_id": "prg_docsfixture0001"
}
}program.unpublished
json
{
"event": "program.unpublished",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": null,
"program": {
"id": "prg_docsfixture0001",
"name": "Coffee Card",
"type": "standard"
},
"card": null,
"transaction": null,
"actor": {
"type": "shop_owner",
"id": "shp_docsfixture0001"
},
"data": {
"program_id": "prg_docsfixture0001"
}
}program.suspended
json
{
"event": "program.suspended",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": null,
"program": {
"id": "prg_docsfixture0001",
"name": "Coffee Card",
"type": "standard"
},
"card": null,
"transaction": null,
"actor": {
"type": "shop_owner",
"id": "shp_docsfixture0001"
},
"data": {
"program_id": "prg_docsfixture0001"
}
}program.unsuspended
json
{
"event": "program.unsuspended",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": null,
"program": {
"id": "prg_docsfixture0001",
"name": "Coffee Card",
"type": "standard"
},
"card": null,
"transaction": null,
"actor": {
"type": "shop_owner",
"id": "shp_docsfixture0001"
},
"data": {
"program_id": "prg_docsfixture0001"
}
}program.deleted
json
{
"event": "program.deleted",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": null,
"program": {
"id": "prg_docsfixture0001",
"name": "Coffee Card",
"type": "standard"
},
"card": null,
"transaction": null,
"actor": {
"type": "shop_owner",
"id": "shp_docsfixture0001"
},
"data": {
"program_id": "prg_docsfixture0001"
}
}location.created
json
{
"event": "location.created",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": null,
"program": null,
"card": null,
"transaction": null,
"actor": {
"type": "shop_owner",
"id": "shp_docsfixture0001"
},
"data": {
"location_id": "loc_docsfixture0001"
}
}location.updated
json
{
"event": "location.updated",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": null,
"program": null,
"card": null,
"transaction": null,
"actor": {
"type": "shop_owner",
"id": "shp_docsfixture0001"
},
"data": {
"location_id": "loc_docsfixture0001"
}
}location.disabled
json
{
"event": "location.disabled",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": null,
"program": null,
"card": null,
"transaction": null,
"actor": {
"type": "shop_owner",
"id": "shp_docsfixture0001"
},
"data": {
"location_id": "loc_docsfixture0001"
}
}location.enabled
json
{
"event": "location.enabled",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": null,
"program": null,
"card": null,
"transaction": null,
"actor": {
"type": "shop_owner",
"id": "shp_docsfixture0001"
},
"data": {
"location_id": "loc_docsfixture0001"
}
}subscription.state_changed
json
{
"event": "subscription.state_changed",
"event_id": "evt_docsfixture000001",
"payload_version": "v2",
"occurred_at": "2026-09-04T10:15:00.000Z",
"shop": {
"id": "shp_docsfixture0001",
"name": "Caffè Docsfixture"
},
"location": null,
"program": null,
"card": null,
"transaction": null,
"actor": {
"type": "system",
"id": null
},
"data": {
"from_state": "active",
"to_state": "grace",
"reason": "payment_failed"
}
}Testing a webhook
Send a test ping from the dashboard. It targets one webhook directly, bypasses subscription matching entirely, and carries "event": "test.ping" with "sample": true in data. It is signed exactly like a real delivery — so it is a genuine test of your verification code, which is the point.
test.ping is not in the catalogue: you cannot subscribe to it, and it never fires on its own.