Every delivery to a store endpoint is an HTTPS POST with a JSON body and these headers: X-Tringify-Signature (t=<timestamp>,v1=<signature>), X-Tringify-Timestamp (Unix seconds, the same value as t), X-Tringify-Event (for example orders/create), X-Tringify-Event-ID, X-Tringify-Delivery-ID, X-Tringify-API-Version, Content-Type: application/json and User-Agent: Tringify-Webhooks/1.0.
The signature is the lowercase hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with the endpoint's signing secret exactly as Store Admin shows it: use the 64-character string as the key; do not hex-decode it. Compute it over the raw request bytes before any JSON parsing, compare it in constant time with each v1 value, and reject the request when the timestamp is more than five minutes from your clock. Each attempt is signed afresh, so a retry carries a new timestamp.
The signing secret is shown once, when the endpoint is created and when the secret is rotated. Rotating replaces it immediately; store endpoints have no overlap period, so deploy the new secret right after rotating.
Answer with any 2xx status within 10 seconds. Redirects are not followed and, like any other status, count as a failed attempt. A delivery is attempted at most five times: at once, then after 1 minute, 10 minutes, 1 hour and 6 hours. X-Tringify-Delivery-ID stays the same across attempts; use it to ignore duplicates.
After 15 failed attempts in a row the endpoint is disabled automatically and the store is notified. New events are then held for up to 7 days, at most 5,000 per endpoint, and delivered when the endpoint is enabled again; the endpoint reports how many events could not be held.
Endpoint URLs must use HTTPS and resolve only to public IP addresses. This is checked when the endpoint is saved and again on every delivery.
import crypto from "node:crypto";
// rawBody: the request body exactly as received (a Buffer), before JSON parsing,
// e.g. express.raw({ type: "application/json" }).
export function verifyTringifyWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
const parts = String(headers["x-tringify-signature"] ?? "").split(",").map((part) => part.trim());
const timestamp = parts.find((part) => part.startsWith("t="))?.slice(2) ?? "";
if (!/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
return parts
.filter((part) => part.startsWith("v1="))
.some((part) => {
const given = Buffer.from(part.slice(3), "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}