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:
| Header | Description |
|---|---|
svix-id | Unique message ID for this delivery |
svix-timestamp | Unix timestamp (seconds) of the delivery |
svix-signature | Space-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
- Node.js
- Python
- Go
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'],
});
}
from svix.webhooks import Webhook, WebhookVerificationError
def verify_webhook(raw_body: bytes, headers: dict, secret: str):
wh = Webhook(secret)
# raises WebhookVerificationError on failure
return wh.verify(raw_body, {
'svix-id': headers.get('svix-id', ''),
'svix-timestamp': headers.get('svix-timestamp', ''),
'svix-signature': headers.get('svix-signature', ''),
})
import "github.com/svix/svix-webhooks/go"
func verifyWebhook(rawBody []byte, headers http.Header, secret string) (interface{}, error) {
wh, err := svix.NewWebhook(secret)
if err != nil {
return nil, err
}
return wh.Verify(rawBody, headers)
}
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 useevent.eventandevent.payload. Older-style events (payout events, etc.) useevent.typeandevent.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
| Problem | Cause | Fix |
|---|---|---|
| Signature always fails | Wrong header names — Svix requires svix-id, svix-timestamp, svix-signature, not x-nextpay-signature | Update your header extraction to use the three Svix headers |
| Signature fails | Body already parsed before verification | Use express.raw() on the webhook route; pass raw bytes to wh.verify() |
WebhookVerificationError in prod | Secret mismatch or event older than 5 minutes | Check NEXTAPI_WEBHOOK_SECRET matches the registered secret; confirm your server clock is accurate |
| Works in dev, fails in prod | Different NEXTAPI_WEBHOOK_SECRET env var | Verify the secret matches what the API returned when the webhook was created |
Related
- Setup Webhooks — register and configure webhooks
- Webhooks Concept — event types, retry logic, payload structure
- IDs and Idempotency — handling duplicate deliveries