Agent skill

Terminal49 Webhooks Skill

Receive and verify Terminal49 container and shipment tracking webhooks. Use when setting up a Terminal49 webhook endpoint, verifying the X-T49-Webhook-Signature HMAC-SHA256 header, debugging 401s, or handling events like tracking_request.succeeded, container.transport.vessel_arrived and container.updated.

Install this skill

npx skills add hookdeck/webhook-skills --skill 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)

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/, examples/nextjs/, 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.eventSent When
tracking_request.succeededA tracking request was resolved to a shipment and containers
tracking_request.failedA tracking request could not be resolved
container.transport.vessel_arrivedA container's vessel arrived at port
container.transport.vessel_departedA container's vessel departed
container.transport.availableA container is available for pickup
container.updatedA container's attributes changed
shipment.estimated.arrivalA 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. 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

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

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

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

Resources


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 13, 2026

View on GitHub →