Skip to main content

Verify Webhook Signatures

NextAPI delivers webhooks through Svix, a dedicated webhook delivery platform. Signature verification is handled by the official Svix SDK — you do not need to implement HMAC manually.

Always verify signatures before processing webhook events. Processing unverified webhooks can allow attackers to inject fake events (fake payment confirmations, fake payout completions) into your system.

Install the Svix SDK

npm install svix       # Node.js
pip install svix # Python

For Go, import github.com/svix/svix-webhooks/go.

How signing works

Svix signs every webhook delivery using three request headers:

HeaderDescription
svix-idUnique message ID for this delivery
svix-timestampUnix timestamp (seconds) of the delivery
svix-signatureSpace-separated list of v1,<base64-hmac> signatures

The HMAC is computed over {svix-id}.{svix-timestamp}.{rawBody} using SHA-256. The Svix SDK handles all of this automatically, including enforcing a 5-minute tolerance window against replay attacks.

Get your webhook secret

Your webhook secret is returned when you create a webhook via the API. It is in whsec_<base64> format.

# Create a webhook and note the secret in the response
curl -u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \
-X POST "https://api.partners.nextpay.world/v2/webhooks" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.com/webhooks/nextapi",
"events": ["v2.payment_intent.succeeded", "payout_request.processed", "payout.failed"]
}'

The response includes the secret:

{
"id": "...",
"url": "https://your-server.com/webhooks/nextapi",
"secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
}

Store this as NEXTAPI_WEBHOOK_SECRET in your environment — it won't be shown again.

Verification implementation

import { Webhook } from 'svix';

export function verifyWebhook(rawBody, headers, secret) {
const wh = new Webhook(secret);
// throws WebhookVerificationError on failure
return wh.verify(rawBody.toString(), {
'svix-id': headers['svix-id'],
'svix-timestamp': headers['svix-timestamp'],
'svix-signature': headers['svix-signature'],
});
}

Express.js integration

Express parses the request body by default. For webhooks, you need the raw body before JSON parsing:

import express from 'express';
import { Webhook, WebhookVerificationError } from 'svix';

const app = express();

app.post(
'/webhooks/nextapi',
express.raw({ type: 'application/json' }),
(req, res) => {
const secret = process.env.NEXTAPI_WEBHOOK_SECRET;
const wh = new Webhook(secret);

let event;
try {
event = wh.verify(req.body.toString(), {
'svix-id': req.headers['svix-id'],
'svix-timestamp': req.headers['svix-timestamp'],
'svix-signature': req.headers['svix-signature'],
});
} catch (err) {
console.error('Webhook verification failed:', err.message);
return res.status(401).json({ error: 'Invalid signature' });
}

handleEvent(event).catch(console.error);
res.status(200).send('OK');
}
);

FastAPI (Python) integration

from fastapi import FastAPI, Request, HTTPException
from svix.webhooks import Webhook, WebhookVerificationError
import os

app = FastAPI()

@app.post("/webhooks/nextapi")
async def handle_webhook(request: Request):
raw_body = await request.body()
secret = os.environ['NEXTAPI_WEBHOOK_SECRET']
wh = Webhook(secret)

try:
event = wh.verify(raw_body, {
'svix-id': request.headers.get('svix-id', ''),
'svix-timestamp': request.headers.get('svix-timestamp', ''),
'svix-signature': request.headers.get('svix-signature', ''),
})
except WebhookVerificationError:
raise HTTPException(status_code=401, detail='Invalid signature')

await process_event(event)
return {'status': 'ok'}

Handling duplicate deliveries

Svix may deliver the same event more than once (network retries). Implement idempotent event handling by tracking processed event IDs:

const processedEventIds = new Set(); // Use Redis or a database in production

async function handleEvent(event) {
if (processedEventIds.has(event.id)) {
console.log(`Event ${event.id} already processed, skipping`);
return;
}

// Process the event
switch (event.event || event.type) {
case 'v2.payment_intent.succeeded':
// v2-prefixed events use event.event and event.payload
await handlePaymentSucceeded(event.payload);
break;
case 'payout_request.processed':
await handlePayoutProcessed(event.data);
break;
case 'payout.failed':
await handlePayoutFailed(event.data);
break;
}

processedEventIds.add(event.id);
}

Payload shape depends on event type. Events with the v2. prefix use event.event and event.payload. Older-style events (payout events, etc.) use event.type and event.data. See the Webhooks concept page for a full table.

In production, use a database or Redis to persist processed event IDs across restarts.

Replay attack prevention

Svix enforces a 5-minute timestamp tolerance automatically. Events older than 5 minutes are rejected by wh.verify() — no additional timestamp check is needed.

Event structure

NextAPI webhook events come in two shapes depending on the event category:

// v2-prefixed events (payment instrument, payment intent)
{
"event": "v2.payment_intent.succeeded",
"payload": {
"id": "...",
"account_id": "...",
"amount": 150000,
"external_id": "order-98765",
"status": "succeeded"
}
}
// Payout events
{
"type": "payout_request.processed",
"data": {
"id": "...",
"source_account_id": "...",
"amount_cents": 50000,
"status": "completed"
}
}

See the Webhooks concept page for the full event type list and payload shapes.

Testing your implementation

Use the sandbox to send test events. Register a local endpoint with a tunneling tool:

# Using ngrok (or similar)
ngrok http 3000

# Then update your webhook URL
curl -u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \
-X PATCH "https://api.partners.nextpay.world/v2/webhooks/YOUR_WEBHOOK_ID" \
-H "Content-Type: application/json" \
-d '{"url": "https://abc123.ngrok.io/webhooks/nextapi"}'

Trigger a test payment in sandbox and verify your signature check passes before any real transactions.

Common issues

ProblemCauseFix
Signature always failsWrong header names — Svix requires svix-id, svix-timestamp, svix-signature, not x-nextpay-signatureUpdate your header extraction to use the three Svix headers
Signature failsBody already parsed before verificationUse express.raw() on the webhook route; pass raw bytes to wh.verify()
WebhookVerificationError in prodSecret mismatch or event older than 5 minutesCheck NEXTAPI_WEBHOOK_SECRET matches the registered secret; confirm your server clock is accurate
Works in dev, fails in prodDifferent NEXTAPI_WEBHOOK_SECRET env varVerify the secret matches what the API returned when the webhook was created