Verify Webhook Signatures
NextAPI signs every webhook request with HMAC-SHA256 so you can verify that it came from NextAPI and wasn't tampered with. This guide shows you how to implement signature verification correctly.
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.
How signing works
When NextAPI sends a webhook, it:
- Serializes the event payload as JSON
- Computes
HMAC-SHA256(payload, webhook_secret)using your webhook secret - Sets the result as the
x-nextpay-signatureheader (hex-encoded)
Your endpoint verifies by computing the same HMAC and comparing using a timing-safe comparison.
Get your webhook secret
Your webhook secret is set when you create or update a webhook via the API. Store it as an environment variable — never hardcode it.
# 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": ["payment_intent.paid", "payout_request.completed", "payout_request.failed"]
}'
Store the returned secret securely — it won't be shown again.
Verification implementation
- Node.js
- Python
- Go
const crypto = require('crypto');
function verifyWebhookSignature(rawBody, signature, webhookSecret) {
if (!signature || !webhookSecret) return false;
const expected = crypto
.createHmac('sha256', webhookSecret)
.update(rawBody) // rawBody must be the raw bytes, NOT parsed JSON
.digest('hex');
// Use timingSafeEqual to prevent timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
);
} catch {
// Buffers of different lengths will throw — signatures don't match
return false;
}
}
Critical: Pass the raw request body bytes to the HMAC, not the parsed JSON. Parsing and re-serializing can change whitespace or key ordering and cause the signature to not match.
import hmac
import hashlib
def verify_webhook_signature(raw_body: bytes, signature: str, webhook_secret: str) -> bool:
if not signature or not webhook_secret:
return False
expected = hmac.new(
webhook_secret.encode('utf-8'),
raw_body,
hashlib.sha256
).hexdigest()
# hmac.compare_digest is timing-safe
return hmac.compare_digest(signature, expected)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func verifyWebhookSignature(rawBody []byte, signature, webhookSecret string) bool {
if signature == "" || webhookSecret == "" {
return false
}
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expected))
}
Express.js integration
Express parses the request body by default. For webhooks, you need the raw body before JSON parsing:
const express = require('express');
const crypto = require('crypto');
const app = express();
// Register the raw body parser BEFORE any json() middleware for this route
app.post(
'/webhooks/nextapi',
express.raw({ type: 'application/json' }), // raw bytes
(req, res) => {
const signature = req.headers['x-nextpay-signature'];
const webhookSecret = process.env.NEXTAPI_WEBHOOK_SECRET;
if (!verifyWebhookSignature(req.body, signature, webhookSecret)) {
console.error('Webhook signature verification failed');
return res.status(401).json({ error: 'Invalid signature' });
}
// Safe to parse now
const event = JSON.parse(req.body.toString());
// Process the event
handleEvent(event).catch(err => {
console.error('Event handling error:', err);
});
// Always return 200 quickly — process async
res.status(200).send('OK');
}
);
If you apply express.json() globally and then also apply express.raw() to the webhook route, the body may already be parsed. Always register express.raw() before express.json() for your webhook path, or apply express.json() only to non-webhook routes.
FastAPI (Python) integration
from fastapi import FastAPI, Request, HTTPException
import os
app = FastAPI()
@app.post("/webhooks/nextapi")
async def handle_webhook(request: Request):
raw_body = await request.body()
signature = request.headers.get('x-nextpay-signature', '')
webhook_secret = os.environ.get('NEXTAPI_WEBHOOK_SECRET', '')
if not verify_webhook_signature(raw_body, signature, webhook_secret):
raise HTTPException(status_code=401, detail='Invalid signature')
import json
event = json.loads(raw_body)
# Process event asynchronously
await process_event(event)
return {'status': 'ok'}
Handling duplicate deliveries
NextAPI 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.type) {
case 'payment_intent.paid':
await handlePaymentPaid(event.data);
break;
case 'payout_request.completed':
await handlePayoutCompleted(event.data);
break;
case 'payout_request.failed':
await handlePayoutFailed(event.data);
break;
}
processedEventIds.add(event.id);
}
In production, use a database or Redis to persist processed event IDs across restarts.
Replay attack prevention
A replay attack occurs when an attacker captures a legitimate webhook and re-sends it later. Defend against this by rejecting events with timestamps older than a tolerance window:
function isEventFresh(event, toleranceSeconds = 300) {
const eventTime = new Date(event.created_at).getTime();
const now = Date.now();
const age = (now - eventTime) / 1000;
return age <= toleranceSeconds;
}
async function handleEvent(event) {
if (!isEventFresh(event)) {
console.warn(`Rejecting stale event ${event.id}, age: ${event.created_at}`);
return;
}
// ... process event
}
Event structure
All NextAPI webhook events follow this structure:
{
"id": "evt_01HXYZ",
"type": "payment_intent.paid",
"created_at": "2025-11-15T10:35:22Z",
"data": {
"id": "pi_01HABC",
"account_id": "acct_01HDEF",
"amount": 150000,
"external_id": "order-98765",
"status": "paid"
}
}
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/wh_01HXYZ" \
-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 | Body already parsed before verification | Use express.raw() on the webhook route |
| Signature fails intermittently | Re-serialized body differs from original | Always verify against raw bytes |
timingSafeEqual throws | Signature strings have different lengths | Wrap in try/catch, return false |
| Works in dev, fails in prod | Different WEBHOOK_SECRET env var | Check the secret matches what's registered |
Related
- Setup Webhooks — register and configure webhooks
- Webhooks Concept — event types, retry logic, payload structure
- IDs and Idempotency — handling duplicate deliveries