How a delivery works
1
Event occurs
An action inside VEXiON cards - a transaction, a customer update, a pass install - creates an event.
2
Signed POST to your endpoint
We POST the event envelope to your endpoint, with the three signature headers attached.
3
You verify and ack
Your receiver checks the HMAC, queues the real work, and returns a 2xx status within 10 seconds.
The event envelope
Every delivery uses the same outer envelope:idis a ULID, stable across retries - this is your dedup keydata.objectis the full sanitized resource at the moment the event firedrequestis present only when an API call caused the event (server-emitted events omit it)
The 22-event catalog
A balance change arrives as
transaction.created. Branch on the embedded transaction’s type field.
A level change is not an event: on a reward or cashback card nothing is sent when a customer moves to
another level (by earning, by a recount or by level retention), so read reward_tier on the customer.Customer
Customer
Lifecycle of a customer record (
created · updated · deleted) plus wallet pass state (pass_installed · pass_uninstalled).customer.updated is sent when a customer’s profile or card status is edited (dashboard, API, or the customer’s own profile page). A balance change does not send it: that arrives as transaction.created.customer.deleted carries a minimal deletion record, not the customer: id, object, deleted: true, erased, voided_card_count, preserved_transaction_count and deleted_at. deleted_at is in unix seconds. No personal data is included.Card
Card
Loyalty card object lifecycle -
created · updated · deleted.Product
Product
Catalog changes -
created · updated.Custom field
Custom field
Custom field definition lifecycle -
created · updated · deleted.Transaction
Transaction
The money- and points-moving events -
created · reversed. This is also where balance changes surface, per the note above. transaction.created is sent for stamp, points, cashback and discount entries, and for a coupon redeem made through POST /v1/transactions. Gift card and membership entries are listed by GET /v1/transactions but send no event.The transaction in data.object carries team_member_id (the staff member who booked it, when a team member did) and products (the line items of the bill). The customer events carry currency on the card, the business currency. See Money and currency.Notification
Notification
Outbound customer messaging -
sent and delivery status changes (delivery_updated).Location
Location
Location metadata changes -
updated.Team member
Team member
Staff account lifecycle on your organization -
added · updated · removed.Webhook
Webhook
Meta events about your own endpoints -
delivery_failed, sent to other subscribed endpoints when one endpoint’s delivery exhausts its retries. The payload carries webhook_endpoint_id, url, failed_event_id, failed_event_type, attempts, last_status_code and last_error. last_status_code and last_error are null when the last attempt did not produce that value.Signature verification
Every delivery carries the three Standard Webhooks headers, so battle-tested verification libraries exist for every language:{webhook-id}.{webhook-timestamp}.{raw body} - exact body bytes, no normalization. The HMAC-SHA256 key is the base64-decoded part of your whsec_... secret: strip whsec_, then decode the remainder with standard base64. Since 2026-08-23 secrets use the standard alphabet and can contain + and /. Legacy secrets use - and _, so use a decoder that accepts both, as the manual examples below do. A library that decodes the secret as strict standard base64 can fail on a legacy secret. If that happens, rotate the secret: a new secret uses the standard alphabet. During a secret rotation the header carries TWO space-delimited v1, signatures, the new one first and then the old one - accept if any matches.
With a library (recommended)
Manual verification
Read past events
GET /v1/events lists the events that were sent to at least one of your enabled endpoints, newest first. Use it to catch up after your receiver was down. GET /v1/webhook_endpoints/event_types lists the event types you can subscribe to. Both need the webhooks:read scope.
Test your receiver
POST /v1/webhook_endpoints/{id}/test sends a signed ping (or a realistic example of any catalog event via event_type) synchronously and returns what happened - TLS, signature, and 2xx proven in one call before you go live. Test sends never count toward auto-disable.
Delivery contract
- Success = any 2xx within 10 seconds. Ack fast; do the real work in a background queue.
- At-least-once - the same event id may arrive multiple times. Dedup on
event.idfrom the verified body (the header is an unsigned hint). Retain seen ids for ≥7 days. - No ordering guarantee - events can arrive in a different order than they happened. Use the
created_aton the envelope and the state of the resource to decide what is current. - Fresh connection per attempt - no keep-alive reuse; every attempt re-resolves DNS and re-validates the IP (rebinding defense).
Retry schedule
12 attempts across ~24 hours with exponential backoff + 20 % jitter:
After attempt 12 the delivery is
failed and a webhook_endpoint.delivery_failed event fires to any other subscribed endpoint.
Status code semantics
Auto-disable: 50 failed delivery attempts in a row, counted across all events sent to the endpoint, move it to
auto_disabled. Any 2xx resets the count, and the count can reach 50 in the middle of a retry cycle. Events emitted while an endpoint is disabled or auto_disabled are not delivered to it and are not queued for it. Deliveries already queued stop at send time. Re-enable via dashboard or PATCH with status: "enabled" to receive new events.
Retry and disable flow
Security checklist
- Verify the HMAC on every request with timing-safe comparison - reject everything unsigned
- Enforce the 5-minute anti-replay window
- Dedup on the event id - repeated ids get a 200 no-op, not an error
- Respond within 10 seconds - queue long work
- HTTPS with a publicly-trusted certificate on every endpoint
- Keep
whsec_out of source control and logs - rotate on suspected exposure - Tolerate unknown fields - we add optional fields under date versions
- Handle both signatures during rotation
- Never trust the source IP as auth - the signature is the only auth