Skip to main content

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:

  1. Serializes the event payload as JSON
  2. Computes HMAC-SHA256(payload, webhook_secret) using your webhook secret
  3. Sets the result as the x-nextpay-signature header (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

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.

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');
}
);
Don't use express.json() for the webhook route

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

ProblemCauseFix
Signature always failsBody already parsed before verificationUse express.raw() on the webhook route
Signature fails intermittentlyRe-serialized body differs from originalAlways verify against raw bytes
timingSafeEqual throwsSignature strings have different lengthsWrap in try/catch, return false
Works in dev, fails in prodDifferent WEBHOOK_SECRET env varCheck the secret matches what's registered