One2Pays

Webhooks

Webhooks let you receive real-time notifications about payment, withdrawal, deposit, settlement, customer, KYC, and merchant events. Instead of polling the API, One2Pays sends an HTTP POST to each subscribed endpoint.

How webhooks work

  1. Configure an HTTPS endpoint and event subscriptions in the Merchant Dashboard.
  2. One2Pays sends matching events with X-Webhook-Event and X-Webhook-Id metadata headers.
  3. v1 signing uses the frozen X-BroPay-Signature and X-BroPay-Timestamp names. v2 uses vendor-neutral X-Webhook-Signature, X-Webhook-Timestamp, and X-Webhook-Signature-Version: v2.
  4. Configure each receiver for its endpoint's expected signature version. Verify the delivery, enforce a replay window, deduplicate only with authenticated metadata, and then update your system.

Event names

Common payment events are payment.created, payment.updated, payment.received, payment.completed, payment.failed, payment.expired, and payment.refunded.

Withdrawal events include withdrawal.created, withdrawal.processing, withdrawal.completed, withdrawal.paid, withdrawal.failed, withdrawal.cancelled, and withdrawal.expired. Other families include deposit.*, settlement.*, customer.*, kyc.*, and merchant.updated. See Webhook Events for the catalog and subscription tokens.

Registering an endpoint

  1. Open SettingsWebhooks (or IntegrationsWebhooks) in the dashboard.
  2. Add a publicly reachable HTTPS URL.
  3. Choose *, an event family such as payment.*, or an exact event.
  4. Save the endpoint.

The signing key is the Integration Secret Key (integration API secret) for the integration that owns the endpoint. Creating an endpoint does not generate a new per-endpoint secret. Store the key server-side in a secret manager.

Payload

Payloads are JSON objects with an event field. Payment payloads include paymentId, referenceId, status, amount, currency, and paymentMethod; method/state-specific fields may be optional. Object key order is not significant and can vary between deliveries.

For signature v2, payment.received omits nextAction because payment instructions are no longer actionable after success. The legacy v1 payload can still include it. A v2 delivery has this shape:

{
  "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"
}

clientSecret is a string or null; expiresAt is an optional ISO timestamp. Optional fields can be absent or null according to method/state. For events where nextAction remains available, such as payment.created, inspect nextAction.type before reading nested values.

Verify signatures

Pin the expected algorithm in each endpoint receiver. Do not negotiate security from the incoming version header alone:

  • A v1-configured receiver requires no version header. Capture the exact raw body before parsing and verify ${timestamp}.${rawBody} with HMAC-SHA256.

  • A v2-configured receiver requires X-Webhook-Signature-Version: v2. Strictly parse I-JSON, RFC 8785/JCS-canonicalize the entire payload, and verify this UTF-8 message with HMAC-SHA256:

    webhook.signature.v2\n<timestamp>\n<X-Webhook-Id>\n<X-Webhook-Event>\n<canonicalPayload>

    The v2 signature authenticates the timestamp, webhook ID, event type, and every payload value.

The signature is always sha256=<lowercase hex>. Use a constant-time comparison after validating that both decoded values have equal length. A standard json.dumps/sorted-key serializer is not JCS; use an RFC 8785 implementation. See Signature Verification for exact JCS rules, complete Node.js/Python v2 examples, a v1 raw-body example, and the executable vector at /webhook-signature-v2-test-vector.json.

Replay protection

The platform does not reject old outbound timestamps automatically. Enforce your own replay window and process a v2 X-Webhook-Id only after its signature verifies. For v1, the ID header is not signed; derive durable idempotency from signed payload business fields and a monotonic state transition.

Header reference

VersionSignature headerTimestamp headerVersion header
v1X-BroPay-SignatureX-BroPay-TimestampOmitted
v2X-Webhook-SignatureX-Webhook-TimestampX-Webhook-Signature-Version: v2

Both versions also include X-Webhook-Event, X-Webhook-Id, and Content-Type: application/json.

Complete handler outline

export async function POST(request: Request) {
  // Configure verifyWebhookRequest for this endpoint's expected version.
  // For v2 it returns the authenticated ID/event metadata with the parsed payload.
  const verified = await verifyWebhookRequest(request, { expectedVersion: 'v2' });
  await acceptEventAtomically(verified.deliveryId, verified.payload);
  return new Response('OK');
}

Return 2xx after durable acceptance. Return 5xx for a retriable server-side failure; a 4xx response is terminal for that delivery attempt. Do not assume event order; make transitions idempotent and monotonic where appropriate.

Retries and testing

Server errors (5xx), timeouts, and network failures may cause retries according to endpoint settings. Client errors (4xx) are terminal for that delivery attempt and are not retried. Use the dashboard delivery view to inspect attempts. Test against the public executable v2 vector, then send a nested payment.received event through a non-production endpoint. A local HTTPS tunnel such as ngrok can expose a development server; never put a production Integration Secret Key in a local test project.

Security checklist

  1. Use HTTPS.
  2. Verify v1/v2 signatures before processing.
  3. Enforce a replay window; for v2, atomically deduplicate the authenticated X-Webhook-Id.
  4. Validate event-specific payloads and treat unknown fields as forward-compatible.
  5. Return 2xx only after durable acceptance; log failures without logging secrets.

On this page