# Terminal49 Webhooks

Terminal49 is a container and shipment tracking API. When a tracking request resolves, a container
hits a transport milestone, an ETA changes or a container record updates, Terminal49 POSTs a JSON:API
`webhook_notification` document to your webhook URL. Every delivery is signed: the
`X-T49-Webhook-Signature` header carries the lowercase hex HMAC-SHA256 of the raw request body,
keyed with the `secret` returned when you created the webhook.

## When to Use This Skill

* How do I receive Terminal49 webhooks?
* How do I verify the `X-T49-Webhook-Signature` header?
* Why does my Terminal49 webhook endpoint return 401?
* How do I handle `tracking_request.succeeded` or `container.transport.vessel_arrived` events?
* How do I deduplicate Terminal49 notifications (`data.id`) and recover missed ones?

## Verification (core)

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

function verifyTerminal49Signature(rawBody, signature, secret) {
  // Validate the format first: Buffer.from(x, 'hex') silently drops bad characters.
  if (!secret || typeof signature !== 'string' || !/^[0-9a-fA-F]{64}$/.test(signature)) return false;
  const expected = crypto.createHmac('sha256', Buffer.from(secret, 'utf8')).update(rawBody).digest();
  const received = Buffer.from(signature.toLowerCase(), 'hex'); // hex is case-insensitive
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

```

* Header: `X-T49-Webhook-Signature: <64 hex chars>`, no prefix. Compare the whole value as one digest.
* Signed content is the **raw body bytes only**: no timestamp, id or header is signed, so there is
  no replay window to check (don't invent one). The key is the secret's UTF-8 bytes as-is, not
  hex- or base64-decoded.
* Read the body raw (`express.raw({ type: '*/*' })`, `await request.arrayBuffer()`,
  `await request.body()`) and parse JSON only after the check. Never `JSON.parse`/`stringify` first.
* Missing, empty, malformed or mismatched signature: `401`.
* Python: `hmac.compare_digest` on **bytes** (`bytes.fromhex` after `re.fullmatch(r"[0-9a-fA-F]{64}", sig)`).

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/terminal49-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/terminal49-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/terminal49-webhooks/examples/fastapi/).

## Event Types

Dispatch on `data.attributes.event`. `data.type` is always `webhook_notification`;
`data.relationships.reference_object.data.type` names the cause (`transport_event`,
`container_updated_event`, ...) and `included` carries the related shipment, container and events.
No header names the event.

| `data.attributes.event` | Sent When |
| --- | --- |
| `tracking_request.succeeded` | A tracking request was resolved to a shipment and containers |
| `tracking_request.failed` | A tracking request could not be resolved |
| `container.transport.vessel_arrived` | A container's vessel arrived at port |
| `container.transport.vessel_departed` | A container's vessel departed |
| `container.transport.available` | A container is available for pickup |
| `container.updated` | A container's attributes changed |
| `shipment.estimated.arrival` | A shipment's estimated arrival changed |

The catalog has dozens more (`container.transport.*` milestones, `container.pickup_lfd.changed`,
`tracking_request.awaiting_manifest`, `tracking_request.tracking_stopped`, ...); see
[references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/terminal49-webhooks/references/overview.md). Handle the ones you need and log the rest.

## Delivery and Retries

* Return `200`, `201`, `202` or `204` once you've safely accepted the event. Any other status, or a
  timeout, is retried up to 12 times, then the notification is marked `failed`.
* Retries can repeat a notification: use `data.id` as the idempotency key.
* **Missed notifications cannot be replayed.** Re-fetch the affected shipments and containers from
  the REST API to backfill.

## Environment Variables

```bash
TERMINAL49_WEBHOOK_SECRET=your_webhook_secret   # the `secret` attribute returned when the webhook is created

```

## Local Development

Terminal49 can't reach `localhost`. Tunnel with the Hookdeck CLI (no account required):

```bash
npx hookdeck-cli listen 3000 terminal49 --path /webhooks/terminal49

```

Use the printed URL as the webhook URL in the Terminal49 dashboard.

## Resources

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/terminal49-webhooks/references/overview.md) - What Terminal49 webhooks are, events and payload shape
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/terminal49-webhooks/references/setup.md) - Create the webhook and get its secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/terminal49-webhooks/references/verification.md) - Signature check details, gotchas, debugging 401s
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/terminal49-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests