# Webhooks

Webhooks push real-time notifications to your server whenever something happens
in your account — a ticket is issued, a payment succeeds, a resale completes, a
payout is sent. Instead of polling the API, you register a URL once and we
`POST` a signed JSON event to it every time.

If you've used Stripe webhooks, this will feel identical: signed payloads you can
verify with an HMAC, automatic retries with backoff, and a delivery log you can
inspect and replay.

## Scopes

All webhook-management endpoints require the **`webhooks:manage`** scope.

| Endpoint | Method | Scope |
| --- | --- | --- |
| `/v1/webhook_endpoints` | `POST` | `webhooks:manage` |
| `/v1/webhook_endpoints` | `GET` | `webhooks:manage` |
| `/v1/webhook_endpoints/{id}` | `DELETE` | `webhooks:manage` |
| `/v1/webhook_endpoints/{id}/ping` | `POST` | `webhooks:manage` |
| `/v1/events/deliveries` | `GET` | `webhooks:manage` |
| `/v1/events/deliveries/{id}/replay` | `POST` | `webhooks:manage` |

## 1. Register an endpoint

Register the HTTPS URL on your server that should receive events. You may
optionally subscribe to a subset of event types via `enabled_events`; omit it (or
pass `["*"]`) to receive **all** events.

```bash
curl https://api.ticketconnect.example/v1/webhook_endpoints \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.yourapp.com/ticketconnect",
    "enabled_events": ["ticket.issued", "payment.succeeded", "payout.paid"]
  }'
```

```json
{
  "id": "663b1c9e2a7d654321fedcba",
  "url": "https://hooks.yourapp.com/ticketconnect",
  "enabled_events": ["ticket.issued", "payment.succeeded", "payout.paid"],
  "status": "active",
  "secret": "whsec_Hk9...redacted..."
}
```

> **The signing `secret` is returned exactly once, here at creation.** It is never
> shown again in list or other responses. Store it securely — you need it to verify
> every incoming webhook. If you lose it, delete the endpoint and create a new one.


> **Your URL is validated at registration.** It must be `http(s)` — and **https
> in production** — and it must resolve to a publicly routable address. URLs that
> point at private, link-local, or otherwise internal address space are rejected
> with a `400 invalid_webhook_url` error. The same check re-runs on every
> delivery, so a URL that later starts resolving to an internal address has its
> deliveries marked `failed` rather than retried.


### List endpoints

```bash
curl https://api.ticketconnect.example/v1/webhook_endpoints \
  -H "Authorization: Bearer sk_test_..."
```

The `secret` is **not** included in list responses.

### Delete an endpoint

```bash
curl https://api.ticketconnect.example/v1/webhook_endpoints/663b1c9e2a7d654321fedcba \
  -X DELETE \
  -H "Authorization: Bearer sk_test_..."
```

```json
{ "id": "663b1c9e2a7d654321fedcba", "deleted": true }
```

### Ping your endpoint

Before wiring real traffic, send yourself a **test ping**. The ping travels
through the exact same delivery machinery as production events — same
`Webhook-Signature` and `Webhook-Id` headers, same 10-second timeout — so it
is an end-to-end check of your signature verification. The response is
synchronous:

```bash
curl https://api.ticketconnect.example/v1/webhook_endpoints/663b1c9e2a7d654321fedcba/ping \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{ "message": "hello from staging" }'
```

```json
{
  "id": "evt_8c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
  "endpoint_id": "663b1c9e2a7d654321fedcba",
  "type": "ping",
  "delivered": true,
  "response_code": 200,
  "error": null
}
```

Your endpoint receives a normal envelope with `type: "ping"` and
`data: { message }` (`message` is optional, up to 256 characters; a default is
supplied when omitted). Pings are **never retried** — a failed attempt is
reported immediately in the response (`delivered: false`, plus `error` or a
non-2xx `response_code`). Like any real delivery, a failed ping counts toward
the endpoint's auto-disable failure streak, and every ping shows up in the
[deliveries log](#webhooks).

## 2. Event types

These are the event types the platform emits. Subscribe to the ones you care
about via `enabled_events`, or omit it to receive them all.

| Event type | When it fires |
| --- | --- |
| `event.created` | A new event is created. |
| `event.updated` | An event's details change. |
| `ticket.issued` | A ticket is issued to a customer. |
| `ticket.confirmed` | A purchased ticket's tamper-proof record is finalized shortly after issuance. Informational — nothing to act on. |
| `ticket.confirmation_failed` | Finalization did not complete after issuance. The ticket stays valid and scannable — treat this as an operational alert. |
| `ticket.transferred` | A ticket changes hands to another holder. |
| `ticket.refunded` | A ticket is refunded. |
| `ticket.revoked` | A ticket is invalidated without a refund (fraud, chargeback, comp clawback). It fails scans from that moment. |
| `ticket.upgraded` | A ticket is moved to a higher tier. |
| `ticket.redeemed` | A ticket is checked in / used at the door. |
| `payment.succeeded` | A card payment completes successfully. |
| `payment.failed` | A card payment fails. |
| `marketplace.sale.completed` | A secondary-market resale completes. |
| `attendance.verified` | A check-in is recorded for a ticket. |
| `payout.paid` | A payout to your bank account is sent. |

> **Note:** Every issuance produces `ticket.issued` immediately, followed shortly
> by exactly one of `ticket.confirmed` or `ticket.confirmation_failed`. On
> `ticket.confirmation_failed` the ticket itself keeps working (only refunded or
> used tickets fail a scan) — no customer action is needed, but if you see these
> recur, contact support. All event payloads are plain business data (ids,
> amounts, currency) — no settlement or platform internals ever appear.


## 3. The event payload

Every delivery is a `POST` with a JSON body in this envelope:

```json
{
  "id": "evt_4f8a2c1e9b7d6543210fedcba9876543",
  "type": "payout.paid",
  "created": 1749124800,
  "data": {
    "amount": 250.00,
    "currency": "USD",
    "reference": "tr_1Q9..."
  }
}
```

* `id` — unique event id (prefixed `evt_`). Use it for idempotent handling.
* `type` — one of the event types above.
* `created` — Unix timestamp (seconds) when the event was generated.
* `data` — the event-specific payload.

Each request also carries these headers:

| Header | Description |
| --- | --- |
| `Webhook-Signature` | The signature to verify, format `t=<timestamp>,v1=<hex>`. |
| `Webhook-Id` | The event id (matches `id` in the body) — handy for logging. |
| `Content-Type` | Always `application/json`. |

## 4. Verify the signature

The `Webhook-Signature` header is **Stripe-compatible**. Its value looks like:

```text
Webhook-Signature: t=1749124800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

To verify, recompute the HMAC and compare:

1. Parse `t` (timestamp) and `v1` (signature hex) from the header.
2. Build the signed payload string: `` `${t}.${rawBody}` `` — the timestamp, a
   literal `.`, then the **raw, unparsed** request body.
3. Compute `HMAC-SHA256(secret, signedPayload)` and hex-encode it.
4. Compare it to `v1` using a **constant-time** comparison.
5. Optionally, reject the request if `t` is too far from the current time (a
   tolerance window) to guard against replay.

> **Verify against the raw request body**, exactly as received — before any JSON
> parsing or re-serialization. If your framework reparses and re-stringifies the
> body, the bytes change and the signature will not match.


### Verification snippet (Node.js)

```javascript
const crypto = require('crypto');

/**
 * Verify a TicketConnect webhook signature.
 * @param {string} secret    - your endpoint's whsec_... signing secret
 * @param {string} header    - the Webhook-Signature header value
 * @param {string} rawBody   - the raw request body (string/Buffer, unparsed)
 * @param {number} [toleranceSec] - optional max age, in seconds
 */
function verifyWebhook(secret, header, rawBody, toleranceSec) {
  if (!header) return false;

  let timestamp = null;
  let signature = null;
  for (const part of header.split(',')) {
    const idx = part.indexOf('=');
    if (idx === -1) continue;
    const key = part.slice(0, idx).trim();
    const value = part.slice(idx + 1).trim();
    if (key === 't') timestamp = Number(value);
    else if (key === 'v1') signature = value;
  }
  if (timestamp === null || Number.isNaN(timestamp) || !signature) return false;

  if (toleranceSec !== undefined) {
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - timestamp) > toleranceSec) return false;
  }

  const expectedHex = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  const expectedBuf = Buffer.from(expectedHex, 'hex');
  let providedBuf;
  try {
    providedBuf = Buffer.from(signature, 'hex');
  } catch {
    return false;
  }
  if (expectedBuf.length !== providedBuf.length) return false;
  return crypto.timingSafeEqual(expectedBuf, providedBuf);
}
```

Wire it up in an Express handler. Note the use of the **raw** body parser:

```javascript
const express = require('express');
const app = express();

app.post(
  '/ticketconnect',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const rawBody = req.body; // Buffer, untouched
    const sig = req.header('Webhook-Signature');

    if (!verifyWebhook(process.env.TC_WEBHOOK_SECRET, sig, rawBody, 300)) {
      return res.status(400).send('invalid signature');
    }

    const event = JSON.parse(rawBody.toString());
    // ... handle event.type ...

    res.status(200).send('ok'); // ack fast
  }
);
```

Because the scheme is Stripe-compatible, you can also verify with existing
Stripe-style SDK helpers — just point them at the `Webhook-Signature` header and
your `whsec_` secret.

## 5. Responding, retries, and backoff

Your endpoint should return a **2xx** status as soon as it has safely received
the event. Anything else (a non-2xx, a timeout, or a connection error) is treated
as a failed delivery and retried.

* **Timeout:** each delivery attempt waits up to **10 seconds** for your 2xx.
  Acknowledge first, then do slow work asynchronously.
* **Retries:** failed deliveries are retried on a fixed backoff schedule, up to
  **7 total attempts** (the initial send plus 6 retries):

  | Retry | Delay after previous attempt |
  | --- | --- |
  | 1 | 1 minute |
  | 2 | 5 minutes |
  | 3 | 30 minutes |
  | 4 | 2 hours |
  | 5 | 10 hours |
  | 6 | 24 hours |

  After the last attempt, the delivery is marked `failed`.
* **Auto-disable:** if an endpoint racks up **20 consecutive failures**, it is
  automatically disabled and stops receiving events until you fix and re-create
  (or re-enable) it.

> **Warning:** Return your `2xx` quickly — within the 10-second window — and offload heavy
> processing to a background job. A slow handler looks like a failed delivery and
> gets retried.


## 6. Idempotent handling on your side

Retries (and manual replays) mean your endpoint **can receive the same event more
than once**. Make your handler idempotent:

* Use the event `id` (the `evt_...` value, also in the `Webhook-Id` header) as a
  dedupe key.
* On receipt, check whether you've already processed that `id`; if so, return
  `200` and do nothing else.
* Only then apply the side effect (update a record, send an email, etc.).

This guarantees that a redelivered `payment.succeeded` doesn't double-fulfil an
order.

## 7. Inspect deliveries

Every delivery attempt is logged. List recent ones (cursor-paginated via `limit`)
to debug your endpoint:

```bash
curl "https://api.ticketconnect.example/v1/events/deliveries?limit=20" \
  -H "Authorization: Bearer sk_test_..."
```

```json
{
  "object": "list",
  "data": [
    {
      "id": "665c2a1f9e7d654321fedcba",
      "event_id": "evt_4f8a...",
      "type": "payout.paid",
      "status": "delivered",
      "attempts": 1,
      "last_response_code": 200,
      "created_at": "2026-06-05T12:00:00.000Z"
    }
  ],
  "has_more": false
}
```

`status` is one of `pending`, `delivered`, or `failed`. `last_response_code` is
the HTTP status your endpoint returned on the most recent attempt.

## 8. Replay a delivery

Fixed an outage on your side? Re-send a past delivery to your registered
endpoint:

```bash
curl https://api.ticketconnect.example/v1/events/deliveries/665c2a1f9e7d654321fedcba/replay \
  -X POST \
  -H "Authorization: Bearer sk_test_..."
```

```json
{ "id": "665c2a1f9e7d654321fedcba", "status": "pending", "replayed": true }
```

The delivery is re-queued and dispatched shortly after. (Because a replay can
re-deliver an event you already handled, idempotent handling — section 6 —
matters here too.)

## See also

* [Scanning & check-in](#scanning-and-check-in) — emits `attendance.verified`.
* [Payouts](#payouts) — emits `payout.paid`.
* [Marketplace & resale](#marketplace-and-resale) — emits
  `marketplace.sale.completed`.
* [API reference](#api-overview) — full endpoint details.
