Gareth Wilson Gareth Wilson

Guide to Terminal49 Webhooks: Features and Best Practices

Published


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_arrived notifications.

Terminal49 webhook features

FeatureDetails
Signaturehex HMAC-SHA256
Signature headerx-t49-webhook-signature
BodyJSON
Event routingBody field data.attributes.event

Events and payloads

EventTriggered WhenCommon Use Cases
tracking_request.succeededA tracking request resolved to a shipment and containersStore the shipment/container IDs
tracking_request.failedA tracking request could not be resolvedSurface the failure to the user
tracking_request.awaiting_manifestTerminal49 is waiting for the carrier manifestShow a pending state
tracking_request.tracking_stoppedTracking of the request stoppedStop expecting updates
container.transport.vessel_loadedA container was loaded on a vesselMilestone timeline
container.transport.vessel_departedA vessel departed with the containerDeparture alerts
container.transport.vessel_arrivedA vessel arrived at portArrival alerts
container.transport.vessel_berthedA vessel berthed at the port of dischargeArrival alerts
container.transport.vessel_dischargedA container was discharged from a vesselTerminal pickup planning
container.transport.availableA container is available for pickupDispatch drayage
container.transport.empty_out / full_inEmpty released / full container gated inOrigin-side milestones
container.transport.transshipment_arrived / _discharged / _loaded / _departedTransshipment stepsRoute tracking
container.transport.feeder_arrived / _discharged / _loaded / _departedFeeder vessel stepsRoute tracking
container.transport.rail_loaded / _departed / _arrived / _unloaded, arrived_at_inland_destinationRail legsInland tracking
container.transport.delivered, empty_in, full_out, not_availableDelivery-side milestonesCompletion, returns
container.transport.estimated.vessel_arrived / vessel_departed / arrived_at_inland_destinationEstimated milestone changedETA updates
container.created, container.updatedA container record was created or changedRe-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_changedLast free day, appointment or terminal changesDemurrage and pickup planning
shipment.estimated.arrivalA shipment's estimated arrival changedETA updates
shipment.routing.filledRouting data was filled inShow the route
document.extracted, document.extraction_failed, link.createdA document was processed or linkedDocument 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:

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 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:

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.

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 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 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.