Skip to content

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:

  1. location, program, card, transaction and actor are null when they do not apply — never absent. Write payload.card?.external_ref and stop thinking about it. You never have to switch on the event key or sniff the version to know which keys exist.
  2. data carries the v1 shape verbatim. Migrating v1 → v2 means changing nothing and reading more. That is the whole migration story.
  3. transaction.metadata is 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.

FieldWhat it is
card.external_refYour 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.idOur id for the issued card. Stable, opaque, ours. Use it when calling us back.
card.holder.nameA display label. Never an identifier — two people share a name, and holders rename themselves.
card.holder.emailA 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 to card.holder.email. Expect either to be null.

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

eventSends todayWhen it firesdata fields
card.addedyesA 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.registeredyesA 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.assignedyesA stamp scan on the shop app awarded one or more stamps.card_instance_id, stamps_delta, stamps_count, transaction_id
prize.redeemedyesA prize was redeemed on the shop app.card_instance_id, prize_id, prize_label, transaction_id
value.changedyesA prepaid or points balance moved.card_instance_id, tx_type, delta, balance, transaction_id
transaction.reversedyesA transaction was reversed on the shop app.original_transaction_id, reversal_transaction_id, card_instance_id, reversed_delta
card.usedyesThe 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.expirednot yetA card was presented after its expiry, or the expiry was applied.card_instance_id, transaction_id, expired_at
transaction.createdyesTHE 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.activatedyesA card instance became active — a holder completed activation.card_instance_id
card.reissuedyesA card was reissued, e.g. a replacement badge for the same person.card_instance_id
card.archivedyesA card was archived and is no longer usable, while its history is kept.card_instance_id
card.deletedyesA 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.creatednot yetA program (a card design and its rules) was created.program_id
program.updatednot yetA program was edited.program_id
program.publishednot yetA program was published and can be issued.program_id
program.unpublishednot yetA program was unpublished; existing cards keep working.program_id
program.suspendednot yetA program was suspended.program_id
program.unsuspendednot yetA suspended program was restored.program_id
program.deletednot yetA program was deleted (dashboard-only — the API cannot delete a program).program_id
location.createdyesA location was created. If it was billable, the charge is disclosed in the API response that created it, not here.location_id
location.updatednot yetA location was edited.location_id
location.disablednot yetA location was deactivated. Its cards stop being usable there.location_id
location.enablednot yetA location was reactivated.location_id
subscription.state_changedyesThe 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.pingyesYou 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:

FormExampleMatches
Exactcard.usedthat event
Prefix wildcardcard.*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_idsinclude_shop_wide_eventsReceives
empty (all)(ignored)everything
['loc_1']true (default)loc_1 events + all shop-wide events
['loc_1']falseloc_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.registered
  • card.activated
  • card.reissued
  • card.archived
  • card.deleted
  • program.created
  • program.updated
  • program.published
  • program.unpublished
  • program.suspended
  • program.unsuspended
  • program.deleted
  • location.created
  • location.updated
  • location.disabled
  • location.enabled
  • subscription.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.assigned
  • prize.redeemed
  • value.changed
  • transaction.reversed
  • card.used
  • card.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 does transaction.*.

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 get stamp.assigned, card.used and the rest, each with its own data.
  • You want one uniform stream (a data warehouse, an audit sink): subscribe to transaction.created exactly, 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 the X-Tesserapp-Delivery header, 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

LimitDefaultDeployment setting
Webhooks per shop50WEBHOOK_MAX_PER_SHOP
Delivery attempts per event5lib/queues.ts (shared queue defaults)
Backoff between attemptsexponential from 5slib/queues.ts
Request timeout10000 msWEBHOOK_DELIVERY_TIMEOUT_MS
Consecutive failures before a webhook is auto-disabled15WEBHOOK_MAX_CONSECUTIVE_FAILURES
Delivery-log retention30 daysWEBHOOK_DELIVERY_RETENTION_DAYS
Custom headers per webhook20WEBHOOK_MAX_CUSTOM_HEADERS
Custom headers, total size4096 bytesWEBHOOK_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 4xx is retried exactly like a 5xx. 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:

HeaderValue
X-Tesserapp-Eventthe event name
X-Tesserapp-Deliverythe event_id — your dedup key
X-Tesserapp-Timestampunix seconds, when we signed
X-Tesserapp-Signaturesha256= + hex HMAC-SHA256 of {timestamp}.{rawBody}, keyed with your webhook's signing secret
X-Tesserapp-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

Four things, in this order:

  1. Read the raw bytes. Not a parsed object you re-serialise — see the callout below.
  2. 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.
  3. Compare in constant time (timingSafeEqual, hash_equals, hmac.compare_digest). Never ==.
  4. 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 forged X-Tesserapp-Signature reaching 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 Cookie header, framing headers and Content-Type are refused for the same class of reason. Authorization is allowed — many CRMs want Basic or an X-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.

Requires the Business API add-on.