Verifying webhooks

Outbound webhooks (like checkout_session.succeeded) are signed with HMAC-SHA256 over timestamp.payload, carried in the Transxact-Signature header. Verify this signature before trusting a webhook body — otherwise anything that can reach your endpoint’s URL can forge a fake payment notification.

Set your endpoint’s URL, reveal the signing secret and send a signed webhook.test event from the Webhooks section of your dashboard (Business accounts only). Endpoints must be https://.

Verification also rejects any request where the signed timestamp is more than 5 minutes old. The HMAC signature itself never expires on its own, so without this tolerance window a captured valid webhook call would be replayable indefinitely.

Verifying manually

The Transxact-Signature header carries t=<timestamp>,v1=<hmac>, where timestamp is milliseconds since epoch and hmac is the hex-encoded HMAC-SHA256 of `${timestamp}.${rawPayload}` using your webhook signing secret (from the dashboard’s Webhooks section):

import { timingSafeEqual, createHmac } from "node:crypto";
function verifyWebhookSignature(
rawPayload: string,
header: string,
secret: string,
): boolean {
const parts = Object.fromEntries(
header.split(",").map((part) => part.split("=", 2) as [string, string]),
);
const { t: timestamp, v1: signature } = parts;
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawPayload}`)
.digest("hex");
return (
signature.length === expected.length &&
timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
);
}

Always pass the raw request body, exactly as received — re-serializing JSON before verifying can change byte-for-byte content and produce a signature mismatch even for a genuine webhook. A helper for this will ship in the @transxact/node SDK once it’s published; until then, verify manually as above.

Retries and duplicate events

Every event carries an id (evt_…). Answer with any 2xx status to acknowledge it. Anything else, or no answer within 10 seconds, and we retry the same event, with the same id, after 15 minutes, then 45 minutes, 2, 3, 6 and 12 hours — about a day in all — before giving up.

Delivery is at least once: a retry or a manual resend can reach you more than once, so record the ids you’ve handled and skip repeats. Each retry is signed afresh, so its timestamp is always current.

The Webhooks section of your dashboard lists recent deliveries, whether your endpoint got them, and has a Resend button for sending one again straight away.