# Guide to Terminal49 Webhooks: Features and Best Practices

[Terminal49](https://www.terminal49.com) tracks ocean containers from booking to empty return, pulling data from shipping lines, terminals and railroads into one API. Its webhooks replace polling: once a tracking request resolves, Terminal49 sends a notification for each milestone, ETA change, last free day update and terminal hold change it sees. Freight forwarders, importers and drayage operators use them to plan pickups before demurrage starts and keep customers informed of arrivals.

This guide covers creating Terminal49 webhooks, reading the JSON:API notification format, verifying `X-T49-Webhook-Signature`, and planning for Terminal49's retry model, which gives up after 12 attempts and offers no replay.

## What are Terminal49 webhooks?

A Terminal49 webhook is a URL, an `active` flag and a list of event names, created from the developer dashboard or with `POST /v2/webhooks`. You can subscribe to specific events or to everything and filter in your receiver. The response includes a `secret` used to sign each delivery.

Each notification is a JSON:API document. The top-level `data` is a `webhook_notification` record and `included` contains the shipment, container and event records it refers to, so most handlers can act without an extra API call. Container-level events are sent once per container, which means a shipment of eight containers produces eight `container.transport.vessel_arrived` notifications.

## Terminal49 webhook features

| Feature | Details |
| --- | --- |
| Signature | hex HMAC-SHA256 |
| Signature header | `x-t49-webhook-signature` |
| Body | JSON |
| Event routing | Body field `data.attributes.event` |

## Events and payloads

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `tracking_request.succeeded` | A tracking request resolved to a shipment and containers | Store the shipment/container IDs |
| `tracking_request.failed` | A tracking request could not be resolved | Surface the failure to the user |
| `tracking_request.awaiting_manifest` | Terminal49 is waiting for the carrier manifest | Show a pending state |
| `tracking_request.tracking_stopped` | Tracking of the request stopped | Stop expecting updates |
| `container.transport.vessel_loaded` | A container was loaded on a vessel | Milestone timeline |
| `container.transport.vessel_departed` | A vessel departed with the container | Departure alerts |
| `container.transport.vessel_arrived` | A vessel arrived at port | Arrival alerts |
| `container.transport.vessel_berthed` | A vessel berthed at the port of discharge | Arrival alerts |
| `container.transport.vessel_discharged` | A container was discharged from a vessel | Terminal pickup planning |
| `container.transport.available` | A container is available for pickup | Dispatch drayage |
| `container.transport.empty_out` / `full_in` | Empty released / full container gated in | Origin-side milestones |
| `container.transport.transshipment_arrived` / `_discharged` / `_loaded` / `_departed` | Transshipment steps | Route tracking |
| `container.transport.feeder_arrived` / `_discharged` / `_loaded` / `_departed` | Feeder vessel steps | Route tracking |
| `container.transport.rail_loaded` / `_departed` / `_arrived` / `_unloaded`, `arrived_at_inland_destination` | Rail legs | Inland tracking |
| `container.transport.delivered`, `empty_in`, `full_out`, `not_available` | Delivery-side milestones | Completion, returns |
| `container.transport.estimated.vessel_arrived` / `vessel_departed` / `arrived_at_inland_destination` | Estimated milestone changed | ETA updates |
| `container.created`, `container.updated` | A container record was created or changed | Re-fetch container state |
| `container.pickup_lfd.changed` (and `_line`, `_rail`, `_terminal` variants), `container.pickup_appointment.changed`, `container.delivery_appointment.changed`, `container.detention_lfd_line.changed`, `container.pod_terminal_changed` | Last free day, appointment or terminal changes | Demurrage and pickup planning |
| `shipment.estimated.arrival` | A shipment's estimated arrival changed | ETA updates |
| `shipment.routing.filled` | Routing data was filled in | Show the route |
| `document.extracted`, `document.extraction_failed`, `link.created` | A document was processed or linked | Document workflows |

`container.pickup_lfd.changed` tracks the combined last free day, which Terminal49 takes from the shipping line first, then the terminal, then the railroad. It is not the earliest of the three dates. Subscribe to the `_line`, `_terminal` or `_rail` variant if you need one source specifically. `container.pickup_lfd_rail.changed` is available on the Rail plan only, and the `arrived_at_inland_destination` events only fire for shipments with an inland rail leg.

### Event Payload Structure

* `data.id` is unique per notification and is the idempotency key. `data.type` is always `webhook_notification`.
* `data.attributes` has `event`, `delivery_status` and `created_at`.
* `data.relationships.reference_object` points at the record that caused the notification, with types such as `transport_event`, `container_updated_event`, `estimated_event` or `tracking_request`.
* `included` holds that record plus the related shipment and container, each with its own `id`, `type` and `attributes`.

For `container.updated`, the referenced `container_updated_event` carries a `changeset` of the fields that changed, such as holds, fees, last free day or pickup appointment. Terminal49's Payload Examples page has a full example for 36 of the 47 events; the four feeder milestones, the line pickup and detention LFD changes, delivery appointment changes and the four document events have none.

## Setting up Terminal49 webhooks

### Prerequisites

* A Terminal49 account with an API key from the developer portal
* A public HTTPS endpoint that accepts `POST` (locally: `npx hookdeck-cli listen 3000 terminal49 --path /webhooks/terminal49`)

### Create the Webhook

**Dashboard:** open **Developer > Webhooks** and click **Create Webhook Endpoint**.

**API:**

```bash
curl -X POST "https://api.terminal49.com/v2/webhooks" \
  -H "Authorization: Token YOUR_API_KEY" \
  -H "Content-Type: application/vnd.api+json" \
  -d '{
    "data": {
      "type": "webhook",
      "attributes": {
        "url": "https://example.com/webhooks/terminal49",
        "active": true,
        "events": ["tracking_request.succeeded", "tracking_request.failed", "container.updated"]
      }
    }
  }'

```

`List Webhook Events` returns the event categories available to your account. Subscribe only to what your integration handles.

### Get Your Signing Secret

The created webhook has a `secret` attribute. Put it in `WEBHOOK_SECRET` and use the string directly as the HMAC key. Don't hex- or base64-decode it.

### Register Your Endpoint

1. Set `url` to your endpoint, e.g. `https://example.com/webhooks/terminal49`.
2. Pick the events (see [overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/terminal49-webhooks/references/overview.md) for the full list).
3. Make sure the endpoint answers with `200`, `201`, `202` or `204`. Any other success code, such as `207`, counts as a failure.

### Network Notes

Terminal49 doesn't publish a fixed IP list in its docs. Instead, the **List Webhook IPs** API endpoint returns the current sender addresses. If you use an allow list, fetch it from that endpoint, cache it and refresh it on a schedule, and still verify the signature.

### Test a delivery

Start a local receiver and forward requests with the [Hookdeck CLI](/docs/cli):

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

```

Create a webhook for the CLI URL, then use **Trigger Webhook** to send a sample payload. Follow it with a tracking request for a real bill of lading to see `tracking_request.succeeded` and the first milestones with real `included` records.

## Securing Terminal49 webhooks

`X-T49-Webhook-Signature` holds a bare hex HMAC-SHA256 of the raw body, with no `sha256=` prefix. Compute the HMAC with the `secret` string as the key and compare in constant time. Terminal49's Ruby, Node, Python and Go samples all do the same. No timestamp is signed, so use `data.id` to recognise repeats. See the [webhooks guide](https://terminal49.com/docs/api-docs/in-depth-guides/webhooks).

### Node.js verification

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

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-t49-webhook-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

The handler deduplicates on `data.id`, routes on `data.attributes.event`, and finds the referenced record in `included` so the handler for each event gets the shipment and container it relates to.

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

function createTerminal49App({ claimNotification, releaseNotification, handlers }) {
  const app = express();
  app.post('/webhooks/terminal49', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
    let parsed;
    try {
      parsed = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return res.sendStatus(400);
    const { data, included = [] } = parsed;
    if (!data || typeof data !== 'object' || !data.id || !data.attributes || typeof data.attributes !== 'object' || !Array.isArray(included)) {
      return res.sendStatus(400);
    }
    const eventName = data.attributes.event;
    const ref = data.relationships?.reference_object?.data;
    const findIncluded = (type, id) => included.find((item) => item.type === type && (!id || item.id === id));
    try {
      if (!(await claimNotification(data.id))) return res.sendStatus(200);
      const handler = handlers[eventName];
      if (handler) {
        await handler({
          reference: ref ? findIncluded(ref.type, ref.id) : undefined,
          shipment: findIncluded('shipment'),
          container: findIncluded('container'),
        });
      } else {
        console.log('Unhandled Terminal49 event', eventName);
      }
    } catch {
      await Promise.resolve()
        .then(() => releaseNotification(data.id))
        .catch(() => {});
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

`releaseNotification` frees the ID on failure so the next retry is processed. Without it, a failed first attempt would block every retry.

Hookdeck's Terminal49 source checks the header against an HMAC of the body using the webhook secret. It answers Terminal49 with `200`, which is in the accepted set, and keeps any request that fails the check as a failed verification instead of forwarding it.

> **Never miss a container milestone.** [Hookdeck Event Gateway](/event-gateway) verifies Terminal49 notifications, acknowledges with a `200` and keeps every delivery, so events that would exhaust Terminal49's 12 retries can still be replayed.

## Terminal49 webhook limitations and pain points

### No replay after 12 attempts

**The Problem:** A long outage leaves notifications marked failed, with no way to have them sent again.

**Why It Happens:** Terminal49 retries up to 12 times and then stops. Missed notifications can't be replayed.

**Workarounds:**

* List what was missed with `GET /v2/webhook_notifications` and re-fetch shipment and container state from the REST API.
* Alert on gaps in milestone traffic so outages are found within the retry window.

**How Hookdeck Can Help:** Hookdeck acknowledges every notification, so Terminal49's retries aren't used up by your outage. Hookdeck keeps the events and retries or replays them to you.

### Unstated timeout and retry spacing

**The Problem:** You can't calculate how long an outage you can survive, or how long a handler may take.

**Why It Happens:** A timeout triggers a retry, but Terminal49 doesn't give its length or the time between attempts.

**Workarounds:**

* Respond within a few seconds and process from a queue.

**How Hookdeck Can Help:** Hookdeck responds immediately and lets you set your own delivery timeout and retry schedule.

### Narrow success codes

**The Problem:** A valid but unusual response code triggers retries and duplicate work.

**Why It Happens:** Only `200`, `201`, `202` and `204` count as accepted.

**Workarounds:**

* Return `200` for every notification you have stored, including ones you choose to ignore.

**How Hookdeck Can Help:** Hookdeck always returns `200` to Terminal49, whatever your service responds.

### Per-container fan-out

**The Problem:** One vessel arrival produces many notifications at once.

**Why It Happens:** Container events are sent once per container.

**Workarounds:**

* Group by shipment in your handler when you only need one alert per shipment.
* Size your receiver for bursts when large shipments arrive.

**How Hookdeck Can Help:** Hookdeck can rate-limit delivery to your service, smoothing bursts from large shipments.

### Gaps in payload examples

**The Problem:** Eleven events have no published example.

**Why It Happens:** The Payload Examples page covers 36 of the 47 catalogued events.

**Workarounds:**

* Read fields through `included` lookups by `type` rather than fixed positions, and log any event without a handler.

**How Hookdeck Can Help:** Hookdeck stores every delivery in full, including the events Terminal49 hasn't documented with examples.

## Best practices

### Act on included records

Use the shipment and container in `included` instead of calling the API on every notification. Fetch from the API only when you need data the notification doesn't carry.

### Pick the right last free day event

Use `container.pickup_lfd.changed` for a single best-available LFD. Subscribe to the line, terminal or rail variant only when you need to show where a date came from.

### Store notification IDs for long enough

Keep processed `data.id` values for at least as long as Terminal49 might retry. When in doubt, keep them for days rather than hours.

### Refresh allowed IPs from the API

If you filter by IP, load the list from List Webhook IPs on a schedule rather than copying addresses into firewall rules once.

## Conclusion

Terminal49 sends detailed, self-contained notifications: each one carries the related shipment and container, a stable ID and a clear event name. The weak point is recovery. Once 12 attempts fail, a notification is gone, and the timeout and spacing behind those attempts aren't published.

[Hookdeck Event Gateway](https://hookdeck.com) closes that gap by accepting each notification and holding it for retries and replay on your terms.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Terminal49 webhooks reliably in minutes.