Pre-release — contact your Fortary account team for access.
Every delivery is signed with your webhook’s secret (whsec_…). Verifying the signature proves the request came from Fortary and that the body was not modified in transit. Always verify before trusting a payload.
| Header | Example | Description |
|---|
x-fortary-webhook-id | whs_… | The webhook receiving the event |
x-fortary-event-id | evt_… | The event being delivered — your deduplication key |
x-fortary-delivery-id | del_… | This delivery; a redelivery gets a new id |
x-fortary-delivery-attempt | 3 | Attempt counter within this delivery, starting at 1 |
x-fortary-signature | t=1718027000,v1=5f8c… | Timestamp + HMAC signature (scheme below) |
Delivery metadata travels in headers, never in the body — the body of every retry is byte-identical.
The signature scheme
x-fortary-signature: t=1718027000,v1=a1b2c3…
The signature is HMAC-SHA256 over the string "{t}.{body}" — the unix timestamp, a literal ., then the raw request body — keyed with your whsec_… secret, hex-encoded. Reject deliveries whose timestamp differs from your clock by more than 5 minutes. The format matches Stripe’s scheme, so existing Stripe-style verification middleware typically works with the header name swapped.
During the 24-hour grace window after a secret rotation, the header carries two v1 entries — one per secret. Verify against each and accept if any matches.
Verification steps
- Read the raw request body — the exact bytes received, before any JSON parsing.
- Parse the header into the timestamp (
t=) and every v1= signature.
- Reject if
|now − t| is greater than 5 minutes.
- Compute
HMAC-SHA256(secret, "{t}.{body}").
- Compare against each
v1 value using a constant-time comparison. Accept if any matches.
If verification fails, respond with a 4xx and do not process the payload.
Sample code (Node.js / TypeScript)
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export interface VerifyWebhookSignatureParams {
/** Your signing secret (whsec_…). */
secret: string;
/** The x-fortary-signature header value. */
signature: string;
/** The exact request body bytes, before any JSON parsing. */
rawBody: string;
/** Override for testing; defaults to the current time. */
nowSeconds?: number;
}
export const verifyWebhookSignature = ({
secret,
signature,
rawBody,
nowSeconds,
}: VerifyWebhookSignatureParams): boolean => {
const parts = signature.split(",");
const timestampPart = parts.find((part) => part.startsWith("t="));
const signatureParts = parts.filter((part) => part.startsWith("v1="));
if (timestampPart === undefined || signatureParts.length === 0) return false;
const timestamp = Number(timestampPart.slice(2));
if (!Number.isInteger(timestamp)) return false;
const now = nowSeconds ?? Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
return signatureParts.some((part) => {
const candidate = Buffer.from(part.slice(3), "hex");
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
});
};
Verify the raw body. The most common integration bug is parsing the JSON and re-serializing it before computing the HMAC — key order or whitespace changes and the signature no longer matches. Configure your framework to give you the unmodified body bytes for this route (for example express.raw(), or Fastify’s addContentTypeParser with the raw buffer).