Guide to Terminal49 Webhooks: Features and Best Practices
Terminal49 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_ 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_ | 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_ | Terminal49 is waiting for the carrier manifest | Show a pending state |
tracking_ | Tracking of the request stopped | Stop expecting updates |
container.transport.vessel_ | A container was loaded on a vessel | Milestone timeline |
container.transport.vessel_ | A vessel departed with the container | Departure alerts |
container.transport.vessel_ | A vessel arrived at port | Arrival alerts |
container.transport.vessel_ | A vessel berthed at the port of discharge | Arrival alerts |
container.transport.vessel_ | A container was discharged from a vessel | Terminal pickup planning |
container.transport.available | A container is available for pickup | Dispatch drayage |
container.transport.empty_ / full_in | Empty released / full container gated in | Origin-side milestones |
container.transport.transshipment_ / _discharged / _loaded / _departed | Transshipment steps | Route tracking |
container.transport.feeder_ / _discharged / _loaded / _departed | Feeder vessel steps | Route tracking |
container.transport.rail_ / _departed / _arrived / _unloaded, arrived_ | Rail legs | Inland tracking |
container.transport.delivered, empty_in, full_out, not_available | Delivery-side milestones | Completion, returns |
container.transport.estimated.vessel_ / vessel_departed / arrived_ | Estimated milestone changed | ETA updates |
container.created, container.updated | A container record was created or changed | Re-fetch container state |
container.pickup_ (and _line, _rail, _terminal variants), container.pickup_, container.delivery_, container.detention_, container.pod_ | 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_, link.created | A document was processed or linked | Document workflows |
container.pickup_ 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_ is available on the Rail plan only, and the arrived_ events only fire for shipments with an inland rail leg.
Event Payload Structure
data.idis unique per notification and is the idempotency key.data.typeis alwayswebhook_notification.data.attributeshasevent,delivery_statusandcreated_at.data.relationships.reference_points at the record that caused the notification, with types such asobject transport_event,container_updated_event,estimated_eventortracking_request.includedholds that record plus the related shipment and container, each with its ownid,typeandattributes.
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:
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
- Set
urlto your endpoint, e.g.https://example.com/webhooks/terminal49. - Pick the events (see overview.md for the full list).
- Make sure the endpoint answers with
200,201,202or204. Any other success code, such as207, 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:
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_ 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.
Node.js verification
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.
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 verifies Terminal49 notifications, acknowledges with a
200and 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_and re-fetch shipment and container state from the REST API.notifications - 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
200for 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
includedlookups bytyperather 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_ 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 closes that gap by accepting each notification and holding it for retries and replay on your terms.
Get started with Hookdeck for free and handle Terminal49 webhooks reliably in minutes.