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

HeaderDescription
svix-idUnique message id (stable across retries of the same delivery)
svix-timestampUnix time in seconds when the message was sent
svix-signatureOne 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

The 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 svix
import { 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):

FieldValue
Secretwhsec_plJ3nmyCDGBKInavdOK15jsl
Payload{"event_type":"ping","data":{"success":true}}
Message IDmsg_loFOjxBNrRLzqYUf
Timestamp1731705121
Expectedv1,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:

  1. GET /api/webhook/{webhookId}/secret → export WEBHOOK_SECRET
  2. Run a small listener that verifies each POST (see the Inflow APIs repo scripts/webhook-listener.ts for a reference implementation)
  3. ngrok http <port>

Security checklist

  • Verify signature on every request
  • Use raw body for verification
  • Enforce timestamp tolerance
  • Return 401 (or 400) on failure — do not process the payload
  • Still return 200 quickly on success; process async when possible
  • Use svix-id for idempotency (best practices)

Related