Webhooks

Webhooks

Signed, thin events. Verify, dedupe, then fetch the object.

JSON
{
  "object": "event",
  "id": "event_01K5X8R9M2WKXT4NVQ7BDZJ3PC",
  "type": "transfer.status.payout_settled",
  "linked_id": "transfer_01K5X8QT8D4RFMJ2NVWXZ9QBCH",
  "linked_object": "transfer",
  "created": 1789048800,
  "livemode": true
}

Events say what changed. GET the linked_id for the current state.

Event types#

TypeFires when
transfer.status.*A transfer changes status
beneficiary.status.*A beneficiary changes status
deposit.status.completedFunds received
payout.status.*A payout changes status
payout.payment_advice.availablePayment advice ready

Headers#

Header
Webhook-IdUnique per event. Dedupe on it
Webhook-TimestampUnix seconds
Webhook-Signaturev1,<base64> — may hold several, space-separated

Always verify the signature.

Verify signatures#

HMAC-SHA256 over the raw body. Four lines of logic.

  1. Build {Webhook-Id}.{Webhook-Timestamp}.{raw body}.
  2. HMAC-SHA256 it with your webhook secret. Base64-encode.
  3. Strip v1, from each space-separated entry in Webhook-Signature. Accept if any matches.
  4. Reject timestamps older than 5 minutes.
Node.js
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.SUPERPAY_WEBHOOK_SECRET;

// express.raw — NOT express.json. The signature covers the exact bytes.
app.post('/webhooks/superpay', express.raw({ type: 'application/json' }), (req, res) => {
  const id = req.header('Webhook-Id');
  const timestamp = req.header('Webhook-Timestamp');

  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(400);

  const expected = Buffer.from(
    crypto
      .createHmac('sha256', SECRET)
      .update(`${id}.${timestamp}.${req.body}`)
      .digest('base64')
  );

  const valid = (req.header('Webhook-Signature') || '')
    .split(' ')
    .map(s => Buffer.from(s.replace(/^v1,/, '')))
    .some(sig => sig.length === expected.length && crypto.timingSafeEqual(sig, expected));

  if (!valid) return res.sendStatus(400);

  res.sendStatus(200);                 // ack first
  enqueue(id, JSON.parse(req.body));   // dedupe on id, process async
});

Common mistakes#

MistakeFix
Signing re-serialised JSONUse the raw request bytes. #1 cause of failures
Base64-decoding the secretUse the secret verbatim as the HMAC key
Comparing with ===Use a constant-time compare
Processing before respondingReturn 2xx, then work from a queue
Trusting arrival orderFetch the object; statuses only move forward

Delivery#

  • At least once. Duplicates happen — dedupe on Webhook-Id.
  • Out of order — never infer state from arrival order; fetch the object.
  • Return 2xx fast. Process in a queue.
  • Retries with backoff for 24 hours. Endpoints failing for days are disabled.
  • Missed a window? Backfill from GET /v1/events.

Manage endpoints#

POST/v1/webhook_endpoints
GET/v1/webhook_endpoints
DELETE/v1/webhook_endpoints/:id

Deposits, payouts & events#

Read-only views of each leg. All lists are paginated.

Deposits#

Inbound funding legs.

GET/v1/deposits
GET/v1/deposits/:id

Payouts#

Outbound INR legs, with UTR and payment advice.

GET/v1/payouts
GET/v1/payouts/:id

Balance#

Balance by compartment.

GET/v1/balance

Events#

Every event, kept 30 days.

GET/v1/events
GET/v1/events/:id

Webhook endpoint down? Page through /v1/events from your last processed id and replay.