Ga naar inhoud

Webhooks

Pollen is duur en traag. Abonneer in plaats daarvan een endpoint op de gebeurtenissen die je nodig hebt, en laat Cargofollow je bellen zodra er iets verandert.

  1. Maak een endpoint met POST /v1/webhook-endpoints (scope webhooks:manage) en geef de gebeurtenissen op die je wilt ontvangen. Het whsec_-secret komt één keer terug, in die respons — bewaar het meteen.
  2. Verifieer bij binnenkomst de freightapi-signature-header tegen de ruwe body, vóór je de inhoud ergens voor gebruikt.
  3. Antwoord binnen tien seconden met een 2xx. Zwaar werk hoort achter een queue.
  4. Dedupliceer op freightapi-event-id: een gebeurtenis kan meer dan één keer aankomen.

events bevat losse namen uit de gebeurtenissencatalogus, een familie-wildcard of *:

Waarde Matcht
shipment.delivered Alleen die ene gebeurtenis
shipment.* Elke shipment.-gebeurtenis, maar niet signature.completed
* Alles, inclusief gebeurtenissen die later worden toegevoegd

Kies * alleen als je handler echt elke onbekende type overslaat in plaats van te crashen. Nieuwe gebeurtenistypes verschijnen zonder aankondiging.

De url moet https zijn en publiek routeerbaar. Loopback, RFC 1918, CGNAT, link-local en namen die alleen intern resolven geven 422 met een veldfout op url.

Elke levering draagt drie headers:

Header Wat erin staat
freightapi-signature t=<unix>,v1=<hex hmac_sha256(secret, "<t>.<body>")>
freightapi-event-id Het evt_-id; bij een replay hetzelfde als de eerste keer
freightapi-delivery-id Deze poging; uniek per levering

De ondertekende string is <t>.<ruwe body>. Gebruik de bytes zoals ze binnenkomen. Parse je de JSON en serialiseer je hem opnieuw, dan veranderen sleutelvolgorde en witruimte en klopt de handtekening niet meer — dit is verreweg de meest gemaakte fout.

Verwerp een tijdstempel die meer dan vijf minuten afwijkt van je eigen klok; dat is wat een replay-aanval tegenhoudt.

Alle vier zijn getest tegen dezelfde vector als de SDK (apps/site/examples/webhook-signature/), dus wat hier staat is letterlijk wat er geverifieerd wordt.

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 hoef je dit niet zelf te schrijven: parseEvent uit @freightapi/sdk/webhooks verifieert en parseert in één stap, en gooit als de controle faalt.

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

Wil je je eigen implementatie controleren, gebruik dan deze:

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

Eén poging duurt maximaal tien seconden en volgt geen redirects. Alleen een 2xx telt als succes; alles anders — ook een 3xx — is een mislukking en levert een nieuwe poging op.

Poging Na de vorige
2 1 minuut
3 5 minuten
4 15 minuten
5 1 uur
6 3 uur
7 6 uur
8 12 uur

Na de achtste poging krijgt de levering de status failed en gaat er een bericht naar de dead-letter-queue. Endpoints worden nooit automatisch uitgeschakeld, en een mislukte levering levert zelf geen gebeurtenis op — kijk dus actief in het leveringslog.

Elke poging wordt opnieuw ondertekend. Een rotatie van het secret geldt daardoor meteen voor de volgende poging.

Route Waarvoor
GET /v1/webhook-deliveries Het log: nieuwste eerst, cursor-gepagineerd, filters endpoint_id, status, event_type
GET /v1/webhook-deliveries/{id} Daarbovenop de verstuurde headers, de verstuurde body en het antwoord van de ontvanger (afgekapt op 4 KB)
POST /v1/webhook-deliveries/{id}/retry Zet dezelfde gebeurtenis opnieuw in de wachtrij
POST /v1/webhook-endpoints/{id}/test Stuurt een ping, ook als er nog niets is gebeurd
POST /v1/webhook-endpoints/{id}/rotate-secret Geeft een nieuw whsec_ terug

Statussen van een levering: pending (een poging staat gepland, zie next_attempt_at), succeeded, failed (alle pogingen op) en dead (gestrand in de dead-letter-queue).

Een replay is een nieuwe levering met een eigen freightapi-delivery-id, maar met hetzelfde freightapi-event-id en dezelfde body (201 met Location). Dat kan zodra de levering niet meer pending is; een inactief endpoint geeft 409.

De ping van POST /v1/webhook-endpoints/{id}/test gaat synchroon en zonder retries, maar staat wel als levering met één poging in hetzelfde log.

  • Verifieer eerst, parse daarna. Ongeverifieerde JSON hoort nooit in je applicatielogica.
  • Bevestig snel, werk later. Zet de gebeurtenis op een queue en antwoord meteen 202. Een handler die op je database wacht, is een handler die gaat timeouten.
  • Wees idempotent. Dedupliceer op freightapi-event-id. Bij een retry of een replay krijg je dezelfde gebeurtenis nog een keer, met dezelfde inhoud.
  • Reken niet op volgorde. Leveringen kunnen elkaar inhalen. Gebruik created_at en, waar het om de keten gaat, de seq uit GET /v1/shipments/{id}/events.
  • Negeer wat je niet kent. Onbekende type-waarden en onbekende velden in data mag je overslaan, maar ze mogen je handler niet laten falen.
  • Kijk naar mode. Eén endpoint kan zowel sandbox- als live-gebeurtenissen ontvangen als je het in beide modi aanmaakt; mode zegt welke het is.
  • Val terug op de feed. Ligt je ontvanger een tijd plat, dan is GET /v1/events (de laatste 90 dagen) een betrouwbaarder inhaalslag dan honderden replays.