Webhooks
Polling is expensive and slow. Subscribe an endpoint to the events you need instead, and let Cargofollow call you the moment something changes.
In four steps
Section titled “In four steps”- Create an endpoint with
POST /v1/webhook-endpoints(scopewebhooks:manage) and name the events you want. Thewhsec_secret comes back once, in that response — store it immediately. - On arrival, verify the
freightapi-signatureheader against the raw body, before you use the content for anything. - Answer within ten seconds with a
2xx. Heavy work belongs behind a queue. - Deduplicate on
freightapi-event-id: an event can arrive more than once.
What you subscribe to
Section titled “What you subscribe to”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.
Verifying the signature
Section titled “Verifying the signature”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.
Examples
Section titled “Examples”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 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 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(),})The test vector
Section titled “The test vector”To check your own implementation, use this one:
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”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.
Inspecting and replaying deliveries
Section titled “Inspecting and replaying deliveries”| 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.
Rules of thumb
Section titled “Rules of thumb”- Verify first, parse second. Unverified JSON should never reach your application logic.
- Acknowledge fast, work later. Put the event on a queue and answer
202straight 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_atand, where the chain matters, theseqfromGET /v1/shipments/{id}/events. - Ignore what you do not know. Unknown
typevalues and unknown fields indatamay 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;modesays 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.