# Verify and Test (/webhooks/receiving)



Verify every webhook before using its payload, then send a test to confirm your receiver works end to end.

## Before you start [#before-you-start]

* A [saved destination](/webhooks/configuration#save-a-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 [#receive-a-webhook]

<div className="fd-steps [&_h3]:fd-step">
  ### Read the raw body [#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.

  ```http
  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 [#check-the-signature]

  Compute an HMAC-SHA256 over the timestamp, a period, and the raw body, then compare it with `v1` in constant time.

  ```js title="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](/webhooks/configuration#configuration-changes), try the new secret first, then the old one, until the queue has drained.

  ### Store and acknowledge [#store-and-acknowledge]

  Accept the event only after it is safely stored, so a failure on your side triggers a retry.

  ```js title="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.
</div>

## Send a test webhook [#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.

```json
{
  "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."
  }
}
```

<Accordions type="multiple">
  <Accordion title="What a test covers" id="what-a-test-covers">
    * One attempt with a 10-second deadline and no automatic retries.
    * It checks delivery and signature handling, not payment or onboarding payloads. Test those with sandbox activity.
    * A failed delivery shows as a test result, not as an error in the Platform.
  </Accordion>

  <Accordion title="Test limits" id="test-limits">
    * Queued deliveries and manual tests share 2 concurrent requests per organization.
    * Manual tests also allow 10 starts per rolling 60 seconds per organization, shared by all users.
    * Over either limit, the test returns `429` with `Retry-After` seconds and sends nothing.
    * Admitted tests count even if they fail or are canceled. Recreating the destination does not reset the count.
  </Accordion>
</Accordions>

## Local and automation receivers [#local-and-automation-receivers]

Point a webhook at a development machine or a no-code tool when your production server is not ready.

<Accordions type="multiple">
  <Accordion title="Test a local receiver" id="test-a-local-receiver">
    * Save a public HTTPS tunnel URL as your destination; localhost URLs and private IP addresses are rejected.
    * Keep signature verification on, and make sure the tunnel forwards the raw body and Tapaya headers unchanged.
    * When the tunnel URL changes, save the destination again. Keep the old tunnel up until events queued to it have drained.
  </Accordion>

  <Accordion title="Connect an automation tool" id="connect-an-automation-tool">
    * The entry point must need no custom authentication headers, keep the original body, and answer within 10 seconds.
    * It must check the raw-body HMAC itself, or sit behind a verification adapter that does.
    * Once the tool answers `2xx`, delivery counts as successful. Later workflow failures and duplicates are the tool's to handle; they do not trigger another delivery.
  </Accordion>
</Accordions>

## Handle errors [#handle-errors]

<Accordions type="multiple">
  <Accordion title="Test results" id="test-results">
    | Result            | Meaning                                                                                          |
    | ----------------- | ------------------------------------------------------------------------------------------------ |
    | `delivered: true` | The receiver returned HTTP `2xx`.                                                                |
    | `http_error`      | The receiver returned a non-`2xx` status.                                                        |
    | `transport_error` | The destination could not be reached, including TLS, DNS, or public-network validation failures. |
    | `timeout`         | The receiver did not acknowledge within 10 seconds.                                              |
  </Accordion>

  <Accordion title="Signature checks" id="signature-checks">
    | Symptom            | Check                                                                                                        |
    | ------------------ | ------------------------------------------------------------------------------------------------------------ |
    | Signature mismatch | Use the full secret and the original bytes. Reformatted JSON, or the API token as a key, fails verification. |
    | Expired signature  | Synchronize the receiver clock, and verify against the request header, not `createdAt`.                      |
  </Accordion>

  <Accordion title="Delivery" id="delivery">
    | Symptom                               | Check                                                                                                                                   |
    | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
    | URL rejected or connection blocked    | Use public HTTPS on port 443 without redirects, credentials, or a fragment.                                                             |
    | No webhook event                      | Check the organization, the enabled state, and the selected event types. Past changes are not backfilled.                               |
    | Duplicate or older event              | Deduplicate by `id`, and reconcile against current state instead of assuming order. See the [delivery rules](/webhooks#delivery-rules). |
    | Test works but payment handling fails | `webhook.test` has a different `data` shape. Route by `type` and test with real sandbox events.                                         |
  </Accordion>
</Accordions>
