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.
In vier stappen
Section titled “In vier stappen”- Maak een endpoint met
POST /v1/webhook-endpoints(scopewebhooks:manage) en geef de gebeurtenissen op die je wilt ontvangen. Hetwhsec_-secret komt één keer terug, in die respons — bewaar het meteen. - Verifieer bij binnenkomst de
freightapi-signature-header tegen de ruwe body, vóór je de inhoud ergens voor gebruikt. - Antwoord binnen tien seconden met een
2xx. Zwaar werk hoort achter een queue. - Dedupliceer op
freightapi-event-id: een gebeurtenis kan meer dan één keer aankomen.
Waarop je je abonneert
Section titled “Waarop je je abonneert”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.
De handtekening verifiëren
Section titled “De handtekening verifiëren”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.
Voorbeelden
Section titled “Voorbeelden”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 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))// })// Verify a Cargofollow webhook signature in .NET (>= 8), no NuGet packages.//// 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.
using System;using System.Collections.Generic;using System.Globalization;using System.Security.Cryptography;using System.Text;
namespace FreightApi.Webhooks;
public static class WebhookSignature{ private const int ToleranceSeconds = 300;
public static bool Verify(string secret, string? header, string body, DateTimeOffset? now = null) { if (!TryParseHeader(header, out long timestamp, out List<string> signatures)) { return false; }
long unixNow = (now ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds(); if (Math.Abs(unixNow - timestamp) > ToleranceSeconds) { return false; }
string message = timestamp.ToString(CultureInfo.InvariantCulture) + "." + body; byte[] expected = HMACSHA256.HashData( Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(message));
// 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. foreach (string candidate in signatures) { byte[] given = Convert.FromHexString(candidate); if (CryptographicOperations.FixedTimeEquals(given, expected)) { return true; } }
return false; }
private static bool TryParseHeader(string? header, out long timestamp, out List<string> signatures) { timestamp = 0; signatures = new List<string>(); if (string.IsNullOrEmpty(header)) { return false; }
bool haveTimestamp = false; foreach (string element in header.Split(',')) { string[] parts = element.Trim().Split('=', 2); if (parts.Length != 2) { continue; }
if (parts[0] == "t" && long.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out long parsed)) { timestamp = parsed; haveTimestamp = true; } else if (parts[0] == "v1" && parts[1].Length == 64 && IsLowerHex(parts[1])) { signatures.Add(parts[1]); } }
return haveTimestamp && signatures.Count > 0; }
private static bool IsLowerHex(string value) { foreach (char c in value) { bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); if (!hex) { return false; } }
return true; }}
// An ASP.NET Core minimal API: read the raw body, verify, then deserialise. Never the other way// round.//// app.MapPost("/webhooks/freightapi", async (HttpRequest request) =>// {// using var reader = new StreamReader(request.Body);// string body = await reader.ReadToEndAsync();// string? header = request.Headers["freightapi-signature"];// if (!WebhookSignature.Verify(secret, header, body))// {// return Results.BadRequest();// }//// await queue.EnqueueAsync(body); // acknowledge fast, do the work elsewhere// return Results.Accepted();// });<?php/** * Verify a Cargofollow webhook signature in PHP (>= 8.0), no dependencies. * * 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. */
const FREIGHTAPI_TOLERANCE_SECONDS = 300;
/** @return array{timestamp:int, signatures:string[]}|null */function freightapi_parse_signature_header(?string $header): ?array{ if ($header === null) { return null; } $timestamp = null; $signatures = []; foreach (explode(',', $header) as $element) { $parts = explode('=', trim($element), 2); if (count($parts) !== 2) { continue; } [$key, $value] = $parts; if ($key === 't' && preg_match('/^\d+$/', $value) === 1) { $timestamp = (int) $value; } elseif ($key === 'v1' && preg_match('/^[0-9a-f]{64}$/', $value) === 1) { $signatures[] = $value; } } if ($timestamp === null || $signatures === []) { return null; }
return ['timestamp' => $timestamp, 'signatures' => $signatures];}
function freightapi_verify_signature( string $secret, ?string $header, string $body, ?int $now = null): bool { $parsed = freightapi_parse_signature_header($header); if ($parsed === null) { return false; }
$now ??= time(); if (abs($now - $parsed['timestamp']) > FREIGHTAPI_TOLERANCE_SECONDS) { return false; }
$expected = hash_hmac('sha256', $parsed['timestamp'] . '.' . $body, $secret); // 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. foreach ($parsed['signatures'] as $candidate) { if (hash_equals($expected, $candidate)) { return true; } }
return false;}
// A plain handler: take the raw body, verify, then decode. Never the other way round.//// $body = file_get_contents('php://input');// $header = $_SERVER['HTTP_FREIGHTAPI_SIGNATURE'] ?? null;// if (!freightapi_verify_signature(getenv('FREIGHTAPI_WEBHOOK_SECRET'), $header, $body)) {// http_response_code(400);// exit;// }// http_response_code(202); // acknowledge fast// enqueue(json_decode($body, true)); // do the work elsewhere"""Verify a Cargofollow webhook signature in Python (>= 3.8), standard library only.
The header is ``freightapi-signature: t=<unix>,v1=<hex>``, where the hex isHMAC-SHA256(secret, "<t>.<raw body>"). Sign the bytes exactly as they arrived: re-serialising theparsed JSON reorders keys and changes whitespace, and the signature will not match."""
import hashlibimport hmacimport reimport time
TOLERANCE_SECONDS = 300
_SIGNATURE = re.compile(r"^[0-9a-f]{64}$")
def parse_header(header): """Return ``(timestamp, signatures)`` or ``None`` when the header is unusable.""" if not isinstance(header, str): return None timestamp = None signatures = [] for element in header.split(","): key, _, value = element.strip().partition("=") if key == "t" and value.isdigit(): timestamp = int(value) elif key == "v1" and _SIGNATURE.match(value): signatures.append(value) if timestamp is None or not signatures: return None return timestamp, signatures
def verify_signature(secret, header, body, now=None): """True when ``body`` was signed with ``secret`` inside the tolerance window.""" parsed = parse_header(header) if parsed is None: return False timestamp, signatures = parsed
if now is None: now = int(time.time()) if abs(now - timestamp) > TOLERANCE_SECONDS: return False
message = "{}.{}".format(timestamp, body).encode("utf-8") expected = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest() # 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 any(hmac.compare_digest(candidate, expected) for candidate in signatures)
# A Flask handler: take the raw body, verify, then parse. Never the other way round.## @app.post("/webhooks/freightapi")# def freightapi_webhook():# body = request.get_data(as_text=True)# if not verify_signature(os.environ["FREIGHTAPI_WEBHOOK_SECRET"],# request.headers.get("freightapi-signature"), body):# return "", 400# enqueue(json.loads(body)) # acknowledge fast, do the work elsewhere# return "", 202In 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(),})De testvector
Section titled “De testvector”Wil je je eigen implementatie controleren, gebruik dan deze:
secret whsec_01J8Z3K2Q4R5S6T7V8W9X0Y1Z2timestamp 1789718400body {"id":"evt_01J8Z3K2Q4R5S6T7V8W9X0Y1Z2","type":"shipment.issued","created_at":"2026-09-14T10:00:00.000Z","mode":"test","data":{"shipment_id":"shp_01J8Z3K2Q4R5S6T7V8W9X0Y1Z2","status":"issued"}}v1 a364566072946da94ad9f4418765c0ef2cba776e478d1b7ff63ce3aac69884fdRetries
Section titled “Retries”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.
Leveringen bekijken en opnieuw afspelen
Section titled “Leveringen bekijken en opnieuw afspelen”| 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.
Praktijkregels
Section titled “Praktijkregels”- 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_aten, waar het om de keten gaat, desequitGET /v1/shipments/{id}/events. - Negeer wat je niet kent. Onbekende
type-waarden en onbekende velden indatamag 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;modezegt 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.