Verifying Webhook Signatures
Every Inflow webhook is signed by Svix. Reject any request that fails verification before you parse or act on the body.
Official reference: Verifying Webhooks Manually
Get your signing secret
The secret is returned when you create a webhook:
{
"webhookId": "webhook_abc123",
"webhookUrl": "https://yourserver.com/webhooks/inflow",
"secret": "whsec_…"
}Retrieve it later (same marketplace or merchant account):
curl https://api.inflowpay.xyz/api/webhook/{webhookId}/secret \
-H "X-Inflow-Api-Key: inflow_prod_your_key"{
"webhookId": "webhook_abc123",
"secret": "whsec_…"
}Store the secret in a secrets manager or environment variable. Never expose it in client-side code or public repos.
Required headers
| Header | Description |
|---|---|
svix-id | Unique message id (stable across retries of the same delivery) |
svix-timestamp | Unix time in seconds when the message was sent |
svix-signature | One or more signatures, space-separated, e.g. v1,<base64> |
Verification steps
1. Read the raw body
Use the exact bytes received. In Express, use express.raw({ type: 'application/json' }) on the webhook route — not express.json() before verification.
Parsing JSON and calling JSON.stringify again often breaks verification.
2. Build signed content
Concatenate with dots (.):
{svix-id}.{svix-timestamp}.{rawBody}3. Compute HMAC-SHA256
- Secret format:
whsec_<base64> - HMAC key: base64-decode the part after
whsec_ - Digest: HMAC-SHA256 of signed content, then base64-encode the result
4. Compare to svix-signature
svix-signatureThe header lists entries like v1,abc123=. Strip the v1, prefix from each entry and check that one matches your digest. Use constant-time comparison.
5. Check timestamp
Reject if svix-timestamp is too far from your server clock (e.g. more than 300 seconds). This limits replay attacks.
Node.js — Svix SDK (recommended)
npm install sviximport { Webhook } from 'svix';
import express from 'express';
const app = express();
const wh = new Webhook(process.env.INFLOW_WEBHOOK_SECRET);
app.post(
'/webhooks/inflow',
express.raw({ type: 'application/json' }),
(req, res) => {
const payload = req.body.toString('utf8');
wh.verify(payload, {
'svix-id': req.headers['svix-id'],
'svix-timestamp': req.headers['svix-timestamp'],
'svix-signature': req.headers['svix-signature'],
});
res.status(200).json({ received: true });
const { data } = JSON.parse(payload);
for (const event of data) {
processEvent(event).catch(console.error);
}
},
);Node.js — manual verification
import crypto from 'crypto';
function verifyInflowWebhook(req, secret, rawBody) {
const svixId = req.headers['svix-id'];
const svixTimestamp = req.headers['svix-timestamp'];
const svixSignature = req.headers['svix-signature'];
const signedContent = `${svixId}.${svixTimestamp}.${rawBody}`;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = crypto
.createHmac('sha256', key)
.update(signedContent)
.digest('base64');
const signatures = svixSignature.split(' ').map((p) => p.split(',')[1]);
const ok = signatures.some((sig) => {
try {
return crypto.timingSafeEqual(
Buffer.from(sig),
Buffer.from(expected),
);
} catch {
return false;
}
});
if (!ok) throw new Error('Invalid signature');
const tolerance = 300;
const ts = Number.parseInt(svixTimestamp, 10);
if (Math.abs(Math.floor(Date.now() / 1000) - ts) > tolerance) {
throw new Error('Timestamp outside tolerance');
}
}Test vectors (Svix docs)
Use this to validate a manual implementation (source):
| Field | Value |
|---|---|
| Secret | whsec_plJ3nmyCDGBKInavdOK15jsl |
| Payload | {"event_type":"ping","data":{"success":true}} |
| Message ID | msg_loFOjxBNrRLzqYUf |
| Timestamp | 1731705121 |
| Expected | v1,rAvfW3dJ/X/qxhsaXPOyyCGmRKsaKWcsNccKXlIktD0= |
This example may fail the timestamp check unless you widen tolerance for testing.
Connect payloads
Connect events use the same signing rules. The body may look like:
{
"data": [
{
"eventType": "connect.payment.authorized",
"payload": {
"object": "connect_event",
"eventType": "payment.authorized",
"subMerchant": { "id": "usr_…", "merchantName": "…" },
"data": { }
}
}
]
}Verify the entire raw POST body, then route on data[].eventType (connect.*).
Local testing with ngrok
During development you can point a sandbox or dev webhook URL at ngrok and verify signatures locally:
GET /api/webhook/{webhookId}/secret→ exportWEBHOOK_SECRET- Run a small listener that verifies each POST (see the Inflow APIs repo
scripts/webhook-listener.tsfor a reference implementation) ngrok http <port>
Security checklist
- Verify signature on every request
- Use raw body for verification
- Enforce timestamp tolerance
- Return
401(or400) on failure — do not process the payload - Still return
200quickly on success; process async when possible - Use
svix-idfor idempotency (best practices)
Related
Updated about 1 hour ago