Webhooks

Receive webhooks

Get signed notices on your server when a charge is paid or a payout completes.

1Create an endpoint

  1. 1In the dashboard, open Webhooks and click New endpoint.
  2. 2Enter your server's public URL. Use https://.
  3. 3Pick the events. If you pick none, you get all of them.
  4. 4Copy the secret (whsec_…). It's shown only once.

Keep the secret

You need it to verify the signature. If you lose it, rotate it in the dashboard. Rotation is immediate: update your server right away.

Private addresses, localhost and cloud metadata addresses are rejected.

2Receive the event

Each notice is a POST with Content-Type: application/json.

FieldWhat it is
idThe event id. It's the same on every retry. Use it to dedupe.
typeEvent type. See Events.
createdAtWhen it happened, in UTC. It doesn't change between retries.
merchantIdYour merchant account.
dataThe event's own data.

Headers

HeaderValue
TuCapi-Signaturet=<unix>,v1=<hex>. See Signatures.
TuCapi-Event-IdSame as the body's id.
TuCapi-Event-TypeSame as type.
TuCapi-Delivery-IdId of this delivery (event × endpoint). It doesn't change between retries.
TuCapi-AttemptAttempt number: 1, 2, …
TuCapi-Sourcetucapi.
User-Agenttucapi-webhook/2 (+https://tucapi.com).
POST /webhooks/tucapi
Headers
POST /webhooks/tucapi HTTP/1.1Content-Type: application/jsonTuCapi-Signature: t=1726000000,v1=5f1a…9cTuCapi-Event-Id: evt_2b1c9d0e4f5a4a6b8c7d9e0f1a2b3c4dTuCapi-Event-Type: order.paidTuCapi-Delivery-Id: …TuCapi-Attempt: 1TuCapi-Source: tucapiUser-Agent: tucapi-webhook/2 (+https://tucapi.com)
Body
{  "id": "evt_2b1c9d0e4f5a4a6b8c7d9e0f1a2b3c4d",  "type": "order.paid",  "createdAt": "2026-09-15T18:50:31Z",  "merchantId": "3e630ae9-6ee0-4ee7-b40c-1ea853829377",  "data": {    "orderId": "c9227f9b-13f4-4e9c-8fe0-4644e62b83cd",    "merchantOrderRef": "MO-k7x2m9qz-1a2b3c4d",    "amount": "125.00",    "currency": "USD",    "paymentMethod": "PAYPAL",    "netUsdt": "120.33",    "externalRef": "5O190127TN364715T",    "customerEmail": "ana@example.com",    "paidAt": "2026-09-15T18:50:31Z",    "source": "payment_link"  }}

3Verify the signature

Before trusting the event, verify TuCapi-Signature with your secret.

Use the raw body, exactly as received. If you parse it and serialize it again, the signature won't match.

The step-by-step algorithm is in Signatures.

const crypto = require("crypto");​function verifyTuCapi(rawBody, header, secret, toleranceSec = 300) {  const parts = header.split(",").map((p) => p.trim());  const t = parts.find((p) => p.startsWith("t="))?.slice(2);  const v1 = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));  if (!t || v1.length === 0) throw new Error("invalid header");  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) {    throw new Error("outside tolerance window");  }  const expected = crypto    .createHmac("sha256", secret)    .update(`${t}.`)    .update(rawBody)    .digest("hex");  const ok = v1.some(    (sig) =>      sig.length === expected.length &&      crypto.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex")),  );  if (!ok) throw new Error("invalid signature");}

4Answer fast

Answer 2xx within 10 seconds. Anything else counts as a failure and is retried.

  • If your logic is slow, queue the event and process it separately.
  • Dedupe by id: delivery is at least once.
// Express: use the RAW body, not parsed JSONapp.post(  "/webhooks/tucapi",  express.raw({ type: "application/json" }),  (req, res) => {    try {      verifyTuCapi(req.body, req.get("TuCapi-Signature"), process.env.TUCAPI_WEBHOOK_SECRET);    } catch {      return res.sendStatus(400);    }    const event = JSON.parse(req.body);    if (alreadyProcessed(event.id)) return res.sendStatus(200); // dedupe​    if (event.type === "order.paid") {      // order.paid has no charge id; the order does (chargeId).      // Queue it and call GET /v1/orders/{orderId} later.      queuePayment(event.data.orderId);    }    res.sendStatus(200); // answer fast; process later  },);

5Test your endpoint

In the dashboard, click Send test on your endpoint.

You get a webhook.test event signed with your real secret, whatever events you're subscribed to. The result shows up in the delivery log.

webhook.test
JSON
{  "id": "evt_…",  "type": "webhook.test",  "createdAt": "2026-09-15T18:42:07Z",  "merchantId": "3e630ae9-6ee0-4ee7-b40c-1ea853829377",  "data": {    "subscriptionId": "…",    "message": "Prueba de webhook de TuCapi Business"  }}

Best practices

  • Answer 2xx fast and process separately.
  • Dedupe by id.
  • Don't rely on arrival order: a retried event can arrive after a newer one.
  • When in doubt, check the current state with GET /v1/orders/{id} or GET /v1/charges/{id}.
  • If you suspect a leak, rotate the secret.