Each endpoint has a signing secret starting with whsec_. Find it under Developers → Webhooks → Reveal signing secret. Every delivery carries an OrderCard-Signature header:
OrderCard-Signature: t=1792250401,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdv1 is the HMAC-SHA256, keyed with the secret, of the timestamp, a dot, and the raw request body. To verify a delivery:
- Read the raw body before any JSON parsing; re-encoding changes the bytes.
- Split the header into
tandv1. - Compute
HMAC-SHA256(secret, t + "." + body)as lowercase hex. - Compare it with
v1using a constant-time comparison. - Reject the delivery if
tis more than five minutes old, so a captured request cannot be replayed later.
PHP
function ordercardWebhookIsValid(string $body, string $header, string $secret, int $tolerance = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
$parts[$key] = $value;
}
if (! ctype_digit($parts['t'] ?? '') || abs(time() - (int) $parts['t']) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $parts['t'].'.'.$body, $secret);
return hash_equals($expected, $parts['v1'] ?? '');
}
// Laravel: $request->getContent() is the raw body.
$valid = ordercardWebhookIsValid($request->getContent(), (string) $request->header('OrderCard-Signature'), config('services.ordercard.webhook_secret'));Node.js
import crypto from "node:crypto";
export function ordercardWebhookIsValid(rawBody, header, secret, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map((part) => part.trim().split("=", 2)));
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > tolerance) {
return false;
}
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const received = Buffer.from(parts.v1 ?? "", "hex");
return received.length === 32 && crypto.timingSafeEqual(Buffer.from(expected, "hex"), received);
}
// Express: express.raw({ type: "application/json" }) keeps the body as a Buffer.Rotating the secret
Choose Rotate secret on the endpoint. The new secret applies to the next delivery and the old one stops working at once, so update your server first if you cannot afford rejected deliveries; failed ones are retried and can be resent from the dashboard.
