Tapaya

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.

verify-tapaya-webhook.mjs
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.

server.mjs
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 2xx for an event you already stored. Retries keep the same id and body, with a fresh timestamp and signature.
  • Return a non-2xx response 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.

  1. Open API Keys for your organization and save the destination.
  2. Use the webhook test action.
  3. 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.

Handle errors

Missing something on this page?Request a feature. We attach this page so the team sees the context.