Webhooks

Instead of polling, register an HTTPS endpoint and we will POST a signed JSON event to it whenever something happens on your account. Manage your endpoints and signing secrets from the Webhooks page in your dashboard — the secret is shown only once when you create an endpoint.

POSThttps://your-app.com/webhooks/payouts

Each event is delivered as an HTTP POST with a JSON body to every enabled endpoint you have registered. Respond with any 2xx status to acknowledge receipt.

Events
The events we currently emit. More may be added over time, so ignore event types you don't recognise.
EventWhenData
end_user.kyc.updatedAn end-user's KYC status changes (e.g. approved or rejected).endUser: { id, email, name, country, kycStatus }
payment.status.updatedA payment request moves through its lifecycle (e.g. RECEIVED → COMPLETED).payment: { id, transactionId, amount, currency, status, endUserId }
Payload
Every delivery shares the same envelope: id, type, createdAt and an event-specific data object.
end_user.kyc.updated
{
  "id": "evt_5f9b2c1d8e7a4b3c9d0e1f2a3b4c5d6e",
  "type": "end_user.kyc.updated",
  "createdAt": "2026-06-03T10:21:44.812Z",
  "data": {
    "endUser": {
      "id": "clx9p2k3a0000abc",
      "email": "[email protected]",
      "name": "Ada Lovelace",
      "country": "IN",
      "kycStatus": "APPROVED"
    }
  }
}
payment.status.updated
{
  "id": "evt_a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "type": "payment.status.updated",
  "createdAt": "2026-06-03T10:22:03.140Z",
  "data": {
    "payment": {
      "id": "cmpqz8ww70002rzdom9mmux4i",
      "transactionId": "TXN-20260529-1001",
      "amount": "2500.5",
      "currency": "INR",
      "status": "COMPLETED",
      "endUserId": "clx9p2k3a0000abc"
    }
  }
}
Request headers
HeaderDescription
X-Webhook-EventThe event type, e.g. payment.status.updated. Matches the body's type.
X-Webhook-IdUnique id of this delivery attempt's record. Use it to deduplicate.
X-Webhook-SignatureHMAC signature of the body. Format: t=<unix>,v1=<hex>. See Verifying signatures.
Verifying signatures
Confirm each request really came from us before trusting it.

The X-Webhook-Signature header has the form t=<unix>,v1=<hex>. Recompute it and compare:

  1. Read t (a Unix timestamp) and v1 from the header.
  2. Build the signed string `${t}.${rawBody}` using the exact raw request body (do not re-serialise the JSON).
  3. Compute HMAC-SHA256 of that string with your endpoint signing secret (whsec_…).
  4. Compare the hex digest to v1 using a constant-time comparison. Reject the request if they differ.

Optionally reject deliveries whose t is too far from the current time to limit replay attacks. See the panel for complete, copy-paste receiver endpoints in Express, FastAPI, Django and Spring. The signing secret can be re-created by deleting and re-adding the endpoint in the dashboard.

Delivery & retries
BehaviourDetail
SuccessAny 2xx response. Anything else (or a timeout) is treated as a failure.
RetriesUp to 3 attempts per delivery with a short backoff. After the final failure the delivery is marked FAILED.
TimeoutEach attempt waits at most 10 seconds for a response — return 2xx quickly and do heavy work asynchronously.
IdempotencyRetries reuse the same X-Webhook-Id. Deduplicate on it so a redelivered event is processed once.
LogsRecent delivery attempts (status, HTTP code, attempts) are visible on the dashboard Webhooks page.
Receiver endpoint examples
express
import express from "express";
import crypto from "node:crypto";

const app = express();
const SECRET = process.env.WEBHOOK_SECRET; // whsec_...

function verify(rawBody, header) {
  if (!header) return false;
  const { t, v1 } = Object.fromEntries(
    header.split(",").map((p) => p.split("="))
  );
  if (!t || !v1) return false;

  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(v1, "hex");
  // timingSafeEqual throws on length mismatch — guard first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw keeps the exact bytes that were signed (express.json would not).
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  const sig = req.get("X-Webhook-Signature");
  if (!verify(raw, sig)) return res.status(401).send("invalid signature");

  const event = JSON.parse(raw);          // { id, type, createdAt, data }
  // req.get("X-Webhook-Id") is stable across retries — use it to deduplicate.

  switch (event.type) {
    case "end_user.kyc.updated":
      console.log("KYC:", event.data.endUser);
      break;
    case "payment.status.updated":
      console.log("Payment:", event.data.payment);
      break;
  }

  res.status(200).json({ received: true }); // ack fast, do heavy work async
});

app.listen(3000);