> ## Documentation Index
> Fetch the complete documentation index at: https://vexioncards.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive events the moment they happen - HMAC verification, retries, and the security checklist.

VEXiON cards delivers events to your HTTPS receivers with **at-least-once** delivery, HMAC-SHA-256 signing, and a 24-hour retry curve. Register receivers with the Webhook endpoints section of the API reference; this page covers what your receiver must do.

## 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](#the-event-envelope) to your endpoint, with the three [signature headers](#signature-verification) attached.

**3. You verify and ack**

Your receiver checks the HMAC, queues the real work, and returns a 2xx status within 10 seconds.

```mermaid theme={null}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E5F6FF','primaryBorderColor':'#36C6FA','primaryTextColor':'#0B3A4A','lineColor':'#2BA8D9','secondaryColor':'#F4FCFF','tertiaryColor':'#FFFFFF','fontFamily':'Manrope, ui-sans-serif, system-ui, sans-serif'}}}%%
sequenceDiagram
    participant VC as VEXiON cards
    participant EP as Your endpoint

    Note over VC: e.g. transaction.created
    VC->>EP: POST event envelope + webhook-id, webhook-timestamp, webhook-signature
    EP->>EP: Verify HMAC-SHA256 signature
    EP->>EP: Queue event for background processing
    EP-->>VC: 2xx within 10s
    Note over VC: Delivery marked successful
```

Anything other than a 2xx response within that window starts the [retry schedule](#retry-schedule) below.

## The event envelope

Every delivery uses the same outer envelope:

```json theme={null}
{
  "id": "evt_01HW9RDZ4JK7N8MTX1RVEVTDEF",
  "object": "event",
  "type": "transaction.created",
  "created_at": 1750691720,
  "api_version": "2026-07",
  "data": {
    "object": { "...full resource snapshot..." },
    "previous_attributes": { "...only on *.updated events..." }
  },
  "request": {
    "id": "req_01HW9RDZ4JK7N8MTX1RVREQDEF",
    "idempotency_key": "your-key-if-you-sent-one"
  }
}
```

* `id` is a ULID, **stable across retries** - this is your dedup key
* `data.object` is the full sanitized resource at the moment the event fired
* `request` is present only when an API call caused the event (server-emitted events omit it)

## The 22-event catalog

| Subject | Events |
| - | - |
| Customer | `customer.created` · `customer.updated` · `customer.deleted` · `customer.pass_installed` · `customer.pass_uninstalled` |
| Card | `card.created` · `card.updated` · `card.deleted` |
| Product | `product.created` · `product.updated` |
| Custom field | `custom_field.created` · `custom_field.updated` · `custom_field.deleted` |
| Transaction | `transaction.created` · `transaction.reversed` |
| Notification | `notification.sent` · `notification.delivery_updated` |
| Location | `location.updated` |
| Team member | `team_member.added` · `team_member.updated` · `team_member.removed` |
| Webhook | `webhook_endpoint.delivery_failed` |

> [!NOTE]
>
> 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.

> [accordion] 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.

> [accordion] Card
>
> Loyalty card object lifecycle - `created` · `updated` · `deleted`.

> [accordion] Product
>
> Catalog changes - `created` · `updated`.

> [accordion] Custom field
>
> Custom field definition lifecycle - `created` · `updated` · `deleted`.

> [accordion] 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](https://vexioncards.mintlify.site/api-reference/money-and-currency.md).

> [accordion] Notification
>
> Outbound customer messaging - `sent` and delivery status changes (`delivery_updated`).

> [accordion] Location
>
> Location metadata changes - `updated`.

> [accordion] Team member
>
> Staff account lifecycle on your organization - `added` · `updated` · `removed`.

> [accordion] 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](https://www.standardwebhooks.com) headers, so battle-tested verification libraries exist for every language:

```
webhook-id:        evt_01HW9RDZ4JK7N8MTX1RVEVTDEF
webhook-timestamp: 1750691720
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
```

The signed content is `{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)

```js Node.js theme={null}
import { Webhook } from "standard-webhooks"; // npm i standard-webhooks

const wh = new Webhook(process.env.VXN_WEBHOOK_SECRET); // whsec_...
export function handler(req, res) {
  try {
    const event = wh.verify(req.rawBody, req.headers); // throws on bad signature/timestamp
    queue.push(event); // defer real work
    res.status(200).end(); // ack fast
  } catch {
    res.status(401).end();
  }
}
```

```python Python theme={null}
from standardwebhooks import Webhook  # pip install standardwebhooks
import os

wh = Webhook(os.environ["VXN_WEBHOOK_SECRET"])  # whsec_...

def handler(request):
    try:
        event = wh.verify(request.body, request.headers)  # raises on failure
        queue.enqueue(event)
        return 200
    except Exception:
        return 401
```

### Manual verification

```js Node.js theme={null}
import crypto from "node:crypto";

function verify(rawBody, headers, secret, toleranceSec = 300) {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) return false;

  // Node's decoder accepts both the standard and the URL-safe alphabet, so this
  // works for new secrets (+ and /) and legacy ones (- and _). A strict decoder
  // (Python, Go) needs to be told to accept both.
  const key = Buffer.from(secret.slice("whsec_".length), "base64url");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${ts}.${rawBody}`)
    .digest("base64");

  // space-delimited list; each entry is "v1,<base64>" (two entries during rotation)
  const expectedBuf = Buffer.from(expected);
  return headers["webhook-signature"].split(" ").some((part) => {
    const [version, sig] = part.split(",");
    if (version !== "v1" || !sig) return false;
    const sigBuf = Buffer.from(sig);
    // timingSafeEqual throws when the lengths differ, so compare lengths first
    return sigBuf.length === expectedBuf.length && crypto.timingSafeEqual(sigBuf, expectedBuf);
  });
}
```

```python Python theme={null}
import base64, hashlib, hmac, time

def verify(raw_body: bytes, headers: dict, secret: str, tolerance: int = 300) -> bool:
    msg_id = headers["webhook-id"]
    ts = headers["webhook-timestamp"]
    if abs(time.time() - int(ts)) > tolerance:
        return False

    # New secrets are standard base64 (they can contain "+" and "/"); legacy
    # secrets are base64url ("-" and "_"). base64.urlsafe_b64decode accepts both,
    # whereas base64.b64decode silently discards "-" and "_" and can return a
    # wrong key. The secret has no "=" padding, so re-add it.
    b64 = secret[len("whsec_"):]
    key = base64.urlsafe_b64decode(b64 + "=" * (-len(b64) % 4))
    expected = base64.b64encode(
        hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()
    ).decode()

    for part in headers["webhook-signature"].split(" "):
        version, _, sig = part.partition(",")
        if version == "v1" and hmac.compare_digest(sig, expected):
            return True
    return False
```

> [!WARNING]
>
> Verify against the **raw request bytes** - parsing and re-serializing JSON
> changes the bytes and breaks the signature. Reject timestamps older than 5
> minutes (replay protection). Compare signatures with a constant-time function.

### 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.id` from 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_at` on 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:

| Attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| - | - | - | - | - | - | - | - | - | - | - | - | - |
| Delay | 0 | 30s | 1m | 2m | 5m | 15m | 30m | 1h | 2h | 4h | 8h | 8h |

After attempt 12 the delivery is `failed` and a `webhook_endpoint.delivery_failed` event fires to any *other* subscribed endpoint.

### Status code semantics

| Receiver returns | We do |
| - | - |
| 2xx | Success |
| **410 Gone** | Permanent failure - no retries, the delivery is marked `failed` and `webhook_endpoint.delivery_failed` fires to your other endpoints. The endpoint is immediately `auto_disabled` (defense against a compromised receiver silently swallowing events) |
| 429 | Standard backoff. `Retry-After` is **not** read - a 429 is treated like any other non-2xx, non-410 response |
| Other 4xx / 5xx / timeout | Standard backoff |

**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

```mermaid theme={null}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E5F6FF','primaryBorderColor':'#36C6FA','primaryTextColor':'#0B3A4A','lineColor':'#2BA8D9','secondaryColor':'#F4FCFF','tertiaryColor':'#FFFFFF','fontFamily':'Manrope, ui-sans-serif, system-ui, sans-serif'}}}%%
flowchart TD
    A[Attempt delivery] --> B{Response}
    B -->|2xx| C[Delivery: success<br/>failure count resets to 0]
    B -->|410 Gone| J[Delivery: failed<br/>webhook_endpoint.delivery_failed fires<br/>to other endpoints]
    J --> D[Endpoint: auto_disabled<br/>no retries]
    B -->|429 / other 4xx / 5xx / timeout| K[Failure count +1]
    K --> H{50 failed attempts in a row<br/>on this endpoint?}
    H -->|Yes| D
    H -->|No| E{Attempts left?<br/>max 12 over ~24h}
    E -->|Yes| F[Wait backoff + jitter<br/>0 -> 30s -> 1m -> ... -> 8h]
    F --> A
    E -->|No - attempt 12 failed| G[Delivery: failed<br/>webhook_endpoint.delivery_failed fires<br/>to other endpoints]
    G --> I[Endpoint stays enabled<br/>next event delivers normally]

    classDef success fill:#36C6FA,stroke:#2BA8D9,color:#ffffff,font-weight:600;
    classDef disabled fill:#94A3B8,stroke:#94A3B8,color:#ffffff,font-weight:600;
    class C,I success;
    class D,G,J disabled;
```

## Security checklist

1. **Verify the HMAC on every request** with timing-safe comparison - reject everything unsigned
2. **Enforce the 5-minute anti-replay window**
3. **Dedup on the event id** - repeated ids get a 200 no-op, not an error
4. **Respond within 10 seconds** - queue long work
5. **HTTPS with a publicly-trusted certificate** on every endpoint
6. **Keep `whsec_` out of source control and logs** - rotate on suspected exposure
7. **Tolerate unknown fields** - we add optional fields under date versions
8. **Handle both signatures during rotation**
9. **Never trust the source IP as auth** - the signature is the only auth


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.