Verify and Test
Verify the raw request body, handle duplicates, and test your receiver safely.
Verify every webhook before using its payload, then send a test to confirm your receiver works end to end.
Before you start
- A saved destination and its signing secret,
twhsec_.... Do not use your Server Secret Token to verify webhooks. - A receiver route that can read the raw request body before any JSON parser changes it.
- A store with a unique constraint on the event
id, so duplicates are safe.
Receive a webhook
Read the raw body
Tapaya sends a JSON POST with these headers. Keep the body as the original bytes; a parsed and re-serialized body no longer matches the signature.
Content-Type: application/json
X-Tapaya-Event-Id: 019a0000-0000-7000-8000-000000000001
X-Tapaya-Signature: t=1793361600,v1=<lowercase hexadecimal HMAC-SHA256>- With Express, use
express.raw({ type: "application/json" })on this route, before a global JSON parser.
Check the signature
Compute an HMAC-SHA256 over the timestamp, a period, and the raw body, then compare it with v1 in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyTapayaWebhook(rawBody, signatureHeader, secret, now = Date.now()) {
if (!Buffer.isBuffer(rawBody) || typeof signatureHeader !== "string") {
throw new Error("Missing raw body or signature");
}
if (typeof secret !== "string" || !secret.startsWith("twhsec_")) {
throw new Error("Missing signing secret");
}
const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(signatureHeader);
if (!match) throw new Error("Malformed signature");
const timestamp = Number(match[1]);
if (!Number.isSafeInteger(timestamp) || Math.abs(now / 1000 - timestamp) > 300) {
throw new Error("Expired signature");
}
const expected = createHmac("sha256", Buffer.from(secret, "utf8"))
.update(`${match[1]}.`, "utf8")
.update(rawBody)
.digest();
const received = Buffer.from(match[2], "hex");
if (!timingSafeEqual(expected, received)) throw new Error("Invalid signature");
return JSON.parse(rawBody.toString("utf8"));
}- Use the full
twhsec_...secret as a UTF-8 key. Do not decode it or remove its prefix. - Reject timestamps more than five minutes away from your clock, and keep the clock synchronized.
- While you rotate the signing secret, try the new secret first, then the old one, until the queue has drained.
Store and acknowledge
Accept the event only after it is safely stored, so a failure on your side triggers a retry.
import express from "express";
import { verifyTapayaWebhook } from "./verify-tapaya-webhook.mjs";
import { saveEventOnce } from "./events.mjs"; // your store, unique on event id
const app = express();
app.post(
"/webhooks/tapaya",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
event = verifyTapayaWebhook(
req.body,
req.get("X-Tapaya-Signature"),
process.env.TAPAYA_WEBHOOK_SECRET,
);
} catch {
return res.sendStatus(400);
}
if (event.organizationId !== process.env.TAPAYA_ORGANIZATION_ID) {
return res.sendStatus(400);
}
await saveEventOnce(event); // skips an id it already stored
res.sendStatus(204);
},
);- Return
2xxfor an event you already stored. Retries keep the sameidand body, with a fresh timestamp and signature. - Return a non-
2xxresponse when you cannot store a new event, so delivery retries. - Process stored events from your own queue. Delivery order is not guaranteed, so reconcile older snapshots against current state.
Send a test webhook
The Platform's test action sends one signed webhook.test request to the saved URL. It works even when webhooks are disabled or the type is not in your subscription, and it creates no payment, refund, or merchant.
- Open API Keys for your organization and save the destination.
- Use the webhook test action.
- Check the result, duration, and sent JSON shown in the Platform.
{
"id": "019a0000-0000-7000-8000-000000000010",
"type": "webhook.test",
"apiVersion": "1",
"organizationId": "019a0000-0000-7000-8000-000000000002",
"merchantId": null,
"createdAt": "2026-10-30T12:00:00.000000Z",
"test": true,
"data": {
"message": "This is a test webhook from Tapaya. No payment was created."
}
}Local and automation receivers
Point a webhook at a development machine or a no-code tool when your production server is not ready.