# Webhooks

Webhooks tell your system the moment something happens — a booking is ticketed, a hold is about to expire, a refund quote is ready — so you do not have to poll.

## 1. Register an endpoint

In the panel (**Developers → Webhooks**) or through the API:

```bash
curl -X POST https://quickota.com/api/v1/webhook-endpoints \
  -H "Authorization: Bearer tam_live_..." -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/webhooks/tam","eventTypes":["*"],"description":"Booking sync"}'
```

The response contains the **signing secret** (`whsec_…`). Store it on your server; you need it to verify requests.

- The URL must be public `https://`. Private and internal addresses are refused (`webhooks.url_not_allowed`).
- `eventTypes` is a list of types, or `["*"]` for all (including types added later).
- Send a test with `POST /webhook-endpoints/{id}/test` — a `ping` event arrives within seconds.

## 2. What you receive

```http
POST /webhooks/tam HTTP/1.1
Content-Type: application/json
User-Agent: TAM-Webhooks/1.0
TAM-Signature: t=1733040000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
TAM-Event-Id: 2f9d0c3e-…
TAM-Event-Type: flight.booking.ticketed
TAM-Delivery-Id: 8a1b…
TAM-Delivery-Attempt: 1
```

```json
{
  "id": "2f9d0c3e-5a0e-4a8e-9a57-0f3c6f4f8b11",
  "type": "flight.booking.ticketed",
  "apiVersion": "v1",
  "createdAt": "2026-12-01T08:31:12Z",
  "data": {
    "object": { "id": "…", "bookingNumber": "FB-…", "agencyReference": "ORDER-1042", "status": "ticketed", "pnr": "X7K2QP", "ticketNumbers": ["…"], "nextActions": [ … ], "…": "…" }
  }
}
```

`data.object` has exactly the shape the API returns for that object (for bookings: `GET /flights/bookings/{id}`), captured when the event happened.

## 3. Event types

| Type | When |
|---|---|
| `flight.booking.held` | A booking was created; seats are held until `deadlines.holdExpiresAt`. |
| `flight.booking.issuing` | Payment reserved; the airline is issuing. **Wait** — do not issue again. |
| `flight.booking.ticketed` | Tickets issued (`pnr`, `ticketNumbers`). |
| `flight.booking.failed` | Ticketing failed; any reserved money was returned to your wallet. |
| `flight.booking.cancelled` | Hold released or expired, or tickets cancelled. |
| `flight.booking.completed` | The journey is over. |
| `flight.booking.hold_expiring` | The hold expires soon. Issue now or let it lapse. |
| `flight.booking.attention_required` | Ticketing takes unusually long; our ticketing desk took over (`pendingReason: manual_ticketing_queue`). |
| `flight.booking.schedule_changed` | The airline changed a ticketed flight; see `openScheduleChanges`. |
| `flight.booking.refunded` | Money for the booking was refunded to your wallet. |
| `flight.post_booking_request.updated` | A refund/void/change request changed status (e.g. `quoted`, `completed`). `data.object` is the request. |
| `ping` | Test event. |

`GET /webhook-event-types` returns the current list.

Bookings made in your panel are reported too, so you can mirror all your bookings in your own system.

## 4. Verify the signature

`TAM-Signature` contains a timestamp `t` and one or more `v1` signatures:

```
v1 = hex( HMAC_SHA256( key = secret, message = t + "." + rawBody ) )
```

Accept the request only if one `v1` matches **and** `t` is within 5 minutes of now. Always use the **raw** request body — not a re-serialized JSON object.

**C#**
```csharp
static bool Verify(string header, string rawBody, string secret)
{
    var parts = header.Split(',').Select(p => p.Split('=', 2)).Where(p => p.Length == 2).ToList();
    var t = parts.First(p => p[0] == "t")[1];
    if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(t)) > 300) return false;
    using var hmac = new System.Security.Cryptography.HMACSHA256(System.Text.Encoding.UTF8.GetBytes(secret));
    var expected = Convert.ToHexString(hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(t + "." + rawBody))).ToLowerInvariant();
    return parts.Where(p => p[0] == "v1").Any(p => System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
        System.Text.Encoding.ASCII.GetBytes(p[1]), System.Text.Encoding.ASCII.GetBytes(expected)));
}
```

**Node.js**
```js
const crypto = require("crypto");
function verify(header, rawBody, secret) {
  const parts = header.split(",").map(p => p.split("="));
  const t = parts.find(p => p[0] === "t")?.[1];
  if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return parts.filter(p => p[0] === "v1").some(p =>
    p[1].length === expected.length && crypto.timingSafeEqual(Buffer.from(p[1]), Buffer.from(expected)));
}
// Express: app.post("/webhooks/tam", express.raw({ type: "application/json" }), (req, res) => { verify(req.get("TAM-Signature"), req.body.toString("utf8"), SECRET) … })
```

**Python**
```python
import hmac, hashlib, time
def verify(header: str, raw_body: bytes, secret: str) -> bool:
    parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
    t = next((v for k, v in parts if k == "t"), None)
    if t is None or abs(time.time() - int(t)) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(v, expected) for k, v in parts if k == "v1")
```

**PHP**
```php
function verify(string $header, string $rawBody, string $secret): bool {
    $t = null; $sigs = [];
    foreach (explode(',', $header) as $part) {
        [$k, $v] = array_pad(explode('=', $part, 2), 2, null);
        if ($k === 't') $t = $v; elseif ($k === 'v1') $sigs[] = $v;
    }
    if ($t === null || abs(time() - (int)$t) > 300) return false;
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    foreach ($sigs as $s) if (hash_equals($expected, $s)) return true;
    return false;
}
// $rawBody = file_get_contents('php://input');
```

## 5. Respond quickly, process later

Answer with any **2xx within 10 seconds**, then do the work (e.g. in a queue). Anything else — a non-2xx status, a timeout, a redirect — counts as a failure.

## 6. Retries and failures

A failed delivery is retried after **1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h and 24 h** (about 3 days in total). If every delivery to an endpoint fails for 3 days, the endpoint is **disabled** and your agency is e-mailed. Fix your receiver, re-enable it in the panel, and use the delivery log to catch up.

- `GET /webhook-deliveries?status=failed` — what did not arrive, with the last HTTP status and error.
- `POST /webhook-deliveries/{id}/retry` — send one again now.
- `GET /webhook-events?objectId={bookingId}` — every event of a booking, with the exact payload (kept 30 days).

## 7. Things to design for

- **Duplicates happen** (e.g. your 2xx got lost). Deduplicate on the event `id` (`TAM-Event-Id`).
- **Order is not guaranteed.** Compare `createdAt`, or simply `GET` the object when you need its latest state.
- Webhooks tell you *that* something changed; the API remains the source of truth. A missed webhook never loses data — the booking is always available with `GET`.

## 8. Rotating the secret

`POST /webhook-endpoints/{id}/rotate-secret` returns a new secret. For 24 hours every delivery carries **two** `v1` signatures (new and old secret), so you can deploy the new secret without dropping events.
