Handle Webhook Example
This Next.js outline shows how to apply a verified v2 delivery to business state. Configure this
route only for an endpoint selected as v2. The route imports the strict parser and verifier from the
complete Signature Verification example; that helper pins
v2, rejects invalid I-JSON, verifies the domain-separated signature and replay window, checks the
event header against the payload, and returns authenticated metadata.
Do not combine v1/v2 negotiation in this route. If an endpoint is configured for v1, use the separate raw-body verifier on the verification page.
Next.js v2 handler outline
// app/api/webhooks/payment/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyWebhookV2Request } from '@/lib/webhook-v2';
type PaymentEvent = {
event: string;
paymentId: string;
referenceId: string;
status: string;
amount: string;
currency: string;
paymentMethod: string;
clientSecret?: string | null;
nextAction?: Record<string, unknown> | null;
expiresAt?: string | null;
};
async function acceptEventAtomically(webhookId: string, event: PaymentEvent): Promise<void> {
// In one database transaction:
// 1. Insert (integration_id, webhookId) into a table with a UNIQUE constraint.
// 2. If the insert conflicts, acknowledge without applying the event twice.
// 3. Otherwise apply a validated, monotonic business-state transition.
// Never perform a separate "already processed?" read before the transaction.
}
export async function POST(request: NextRequest) {
try {
const verified = await verifyWebhookV2Request(request, {
expectedVersion: 'v2',
secret: process.env.INTEGRATION_SECRET_KEY!,
replayWindowMs: 5 * 60 * 1000,
});
const event = verified.payload as PaymentEvent;
validatePaymentEvent(event);
switch (event.event) {
case 'payment.received':
case 'payment.failed':
case 'payment.expired':
case 'payment.refunded':
await acceptEventAtomically(verified.webhookId, event);
break;
default:
return NextResponse.json({ error: 'Unsupported event' }, { status: 400 });
}
return NextResponse.json({ received: true });
} catch (error) {
// Map malformed/missing headers and invalid I-JSON to 400, invalid signatures or stale
// timestamps to 401, and temporary database failures to 500. Do not log secrets or preimages.
return webhookErrorResponse(error);
}
}verifyWebhookV2Request must not return until it has verified this UTF-8 preimage with HMAC-SHA256:
webhook.signature.v2\n<timestamp>\n<X-Webhook-Id>\n<X-Webhook-Event>\n<canonicalPayload>Copy the complete Node.js implementation—including duplicate-name and Unicode validation—from
Signature Verification. Atomically persisting the
authenticated X-Webhook-Id prevents two concurrent deliveries from applying the same transition.
Payload example
Object key order may vary. For this v2 payment.received delivery, nextAction is always omitted.
clientSecret and expiresAt are optional and may be absent or null according to method and
state.
{
"event": "payment.received",
"paymentId": "550e8400-e29b-41d4-a716-446655440000",
"referenceId": "order-12345",
"status": "succeeded",
"amount": "1000.00",
"currency": "THB",
"paymentMethod": "promptpay",
"clientSecret": null,
"expiresAt": "2024-01-01T01:00:00.000Z"
}Operational notes
- Keep the Integration Secret Key server-side and never log it or a complete signature preimage.
- Return
2xxonly after durable acceptance.4xxis terminal;5xx, timeouts, and network errors may be retried. - Do not assume event order. Validate state transitions and make them monotonic.
- Test against the public vector at
/webhook-signature-v2-test-vector.jsonbefore enabling a non-production endpoint.