One2Pays

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 2xx only after durable acceptance. 4xx is 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.json before enabling a non-production endpoint.

On this page