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#
| Type | Fires when |
|---|---|
transfer.status.* | A transfer changes status |
beneficiary.status.* | A beneficiary changes status |
deposit.status.completed | Funds received |
payout.status.* | A payout changes status |
payout.payment_advice.available | Payment advice ready |
Headers#
| Header | |
|---|---|
Webhook-Id | Unique per event. Dedupe on it |
Webhook-Timestamp | Unix seconds |
Webhook-Signature | v1,<base64> — may hold several, space-separated |
Always verify the signature.
Verify signatures#
HMAC-SHA256 over the raw body. Four lines of logic.
- Build
{Webhook-Id}.{Webhook-Timestamp}.{raw body}. - HMAC-SHA256 it with your webhook secret. Base64-encode.
- Strip
v1,from each space-separated entry inWebhook-Signature. Accept if any matches. - 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
});Python
import base64, hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["SUPERPAY_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/superpay")
def superpay_webhook():
wid = request.headers["Webhook-Id"]
ts = request.headers["Webhook-Timestamp"]
if abs(time.time() - int(ts)) > 300:
abort(400)
signed = f"{wid}.{ts}.".encode() + request.get_data() # raw bytes
expected = base64.b64encode(hmac.new(SECRET, signed, hashlib.sha256).digest()).decode()
sigs = [s.removeprefix("v1,") for s in request.headers.get("Webhook-Signature", "").split()]
if not any(hmac.compare_digest(s, expected) for s in sigs):
abort(400)
enqueue(wid, request.get_json()) # dedupe on wid, process async
return "", 200Common mistakes#
| Mistake | Fix |
|---|---|
| Signing re-serialised JSON | Use the raw request bytes. #1 cause of failures |
| Base64-decoding the secret | Use the secret verbatim as the HMAC key |
Comparing with === | Use a constant-time compare |
| Processing before responding | Return 2xx, then work from a queue |
| Trusting arrival order | Fetch 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
2xxfast. 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_endpointsGET
/v1/webhook_endpointsDELETE
/v1/webhook_endpoints/:idDeposits, payouts & events#
Read-only views of each leg. All lists are paginated.
Deposits#
Inbound funding legs.
GET
/v1/depositsGET
/v1/deposits/:idPayouts#
Outbound INR legs, with UTR and payment advice.
GET
/v1/payoutsGET
/v1/payouts/:idBalance#
Balance by compartment.
GET
/v1/balanceEvents#
Every event, kept 30 days.
GET
/v1/eventsGET
/v1/events/:idWebhook endpoint down? Page through /v1/events from your last processed id and replay.