Skip to content

Webhooks

Polling is expensive and slow. Subscribe an endpoint to the events you need instead, and let Cargofollow call you the moment something changes.

  1. Create an endpoint with POST /v1/webhook-endpoints (scope webhooks:manage) and name the events you want. The whsec_ secret comes back once, in that response — store it immediately.
  2. On arrival, verify the freightapi-signature header against the raw body, before you use the content for anything.
  3. Answer within ten seconds with a 2xx. Heavy work belongs behind a queue.
  4. Deduplicate on freightapi-event-id: an event can arrive more than once.

events holds individual names from the event catalogue, a family wildcard or *:

Value Matches
shipment.delivered Only that one event
shipment.* Every shipment. event, but not signature.completed
* Everything, including events added later

Pick * only if your handler really skips every unknown type instead of crashing. New event types appear without notice.

The url must be https and publicly routable. Loopback, RFC 1918, CGNAT, link-local and names that only resolve internally return 422 with a field error on url.

Every delivery carries three headers:

Header What is in it
freightapi-signature t=<unix>,v1=<hex hmac_sha256(secret, "<t>.<body>")>
freightapi-event-id The evt_ id; on a replay the same as the first time
freightapi-delivery-id This attempt; unique per delivery

The signed string is <t>.<raw body>. Use the bytes as they arrive. Parse the JSON and re-serialise it and key order and whitespace change, and the signature no longer matches — this is by far the most common mistake.

Reject a timestamp that is more than five minutes off your own clock; that is what stops a replay attack.

All four are tested against the same vector as the SDK (apps/site/examples/webhook-signature/), so what is shown here is literally what gets verified.

verify.mjs
// Verify a Cargofollow webhook signature in Node (>= 18), with nothing but the standard library.
//
// The header is `freightapi-signature: t=<unix>,v1=<hex>`, where the hex is
// HMAC-SHA256(secret, "<t>.<raw body>"). Sign the bytes exactly as they arrived: re-serialising the
// parsed JSON reorders keys and changes whitespace, and the signature will not match.
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SECONDS = 300
export function verifySignature({ secret, header, body, now = Math.floor(Date.now() / 1000) }) {
const parsed = parseHeader(header)
if (parsed === null) return false
if (Math.abs(now - parsed.timestamp) > TOLERANCE_SECONDS) return false
const expected = createHmac('sha256', secret).update(`${parsed.timestamp}.${body}`).digest()
// Every v1 element is tried: during a secret rotation an in-flight delivery still carries the old
// signature, and a receiver that only looks at the first one would drop it.
return parsed.signatures.some((candidate) => {
const given = Buffer.from(candidate, 'hex')
return given.length === expected.length && timingSafeEqual(given, expected)
})
}
function parseHeader(header) {
if (typeof header !== 'string') return null
let timestamp
const signatures = []
for (const element of header.split(',')) {
const [key, value] = element.trim().split('=', 2)
if (key === 't' && /^\d+$/.test(value ?? '')) timestamp = Number(value)
else if (key === 'v1' && /^[0-9a-f]{64}$/.test(value ?? '')) signatures.push(value)
}
if (timestamp === undefined || signatures.length === 0) return null
return { timestamp, signatures }
}
// An Express handler: take the raw body, verify, then parse. Never the other way round.
//
// app.post('/webhooks/freightapi', express.raw({ type: 'application/json' }), (req, res) => {
// const body = req.body.toString('utf8')
// if (!verifySignature({ secret: process.env.FREIGHTAPI_WEBHOOK_SECRET,
// header: req.get('freightapi-signature'), body })) {
// return res.sendStatus(400)
// }
// res.sendStatus(202) // acknowledge first, do the work on a queue
// enqueue(JSON.parse(body))
// })

In TypeScript you do not have to write this yourself: parseEvent from @freightapi/sdk/webhooks verifies and parses in one step, and throws when the check fails.

import { parseEvent } from '@freightapi/sdk/webhooks'
const event = await parseEvent({
secret: process.env.FREIGHTAPI_WEBHOOK_SECRET,
header: request.headers,
body: await request.text(),
})

To check your own implementation, use this one:

secret whsec_01J8Z3K2Q4R5S6T7V8W9X0Y1Z2
timestamp 1789718400
body {"id":"evt_01J8Z3K2Q4R5S6T7V8W9X0Y1Z2","type":"shipment.issued","created_at":"2026-09-14T10:00:00.000Z","mode":"test","data":{"shipment_id":"shp_01J8Z3K2Q4R5S6T7V8W9X0Y1Z2","status":"issued"}}
v1 a364566072946da94ad9f4418765c0ef2cba776e478d1b7ff63ce3aac69884fd

One attempt lasts at most ten seconds and follows no redirects. Only a 2xx counts as success; anything else — a 3xx included — is a failure and earns another attempt.

Attempt After the previous one
2 1 minute
3 5 minutes
4 15 minutes
5 1 hour
6 3 hours
7 6 hours
8 12 hours

After the eighth attempt the delivery becomes failed and a message goes to the dead-letter queue. Endpoints are never disabled automatically, and a failed delivery does not itself produce an event — so look at the delivery log actively.

Every attempt is signed again. A secret rotation therefore applies from the very next attempt.

Route What for
GET /v1/webhook-deliveries The log: newest first, cursor-paginated, filters endpoint_id, status, event_type
GET /v1/webhook-deliveries/{id} Plus the sent headers, the sent body and the receiver’s answer (truncated at 4 KB)
POST /v1/webhook-deliveries/{id}/retry Queues the same event again
POST /v1/webhook-endpoints/{id}/test Sends a ping, even when nothing has happened yet
POST /v1/webhook-endpoints/{id}/rotate-secret Returns a new whsec_

Delivery statuses: pending (an attempt is scheduled, see next_attempt_at), succeeded, failed (all attempts used) and dead (stranded in the dead-letter queue).

A replay is a new delivery with its own freightapi-delivery-id, but with the same freightapi-event-id and the same body (201 with Location). It is allowed as soon as the delivery is no longer pending; an inactive endpoint returns 409.

The ping from POST /v1/webhook-endpoints/{id}/test goes synchronously and without retries, but does appear as a one-attempt delivery in the same log.

  • Verify first, parse second. Unverified JSON should never reach your application logic.
  • Acknowledge fast, work later. Put the event on a queue and answer 202 straight away. A handler that waits on your database is a handler that will time out.
  • Be idempotent. Deduplicate on freightapi-event-id. On a retry or a replay you get the same event again, with the same content.
  • Do not rely on order. Deliveries can overtake each other. Use created_at and, where the chain matters, the seq from GET /v1/shipments/{id}/events.
  • Ignore what you do not know. Unknown type values and unknown fields in data may be skipped, but they must not make your handler fail.
  • Look at mode. One endpoint can receive both sandbox and live events if you create it in both modes; mode says which it is.
  • Fall back to the feed. If your receiver is down for a while, GET /v1/events (the last 90 days) is a more reliable catch-up than hundreds of replays.