One2Pays

Webhook Events

Webhooks allow you to receive real-time notifications when events happen in your One2Pays account. This is especially useful for updating your database when payments are completed or failed.

Tip

Webhooks are the most reliable way to track payment status changes. Don't rely solely on redirect URLs, as users might close their browser before being redirected.

Setting Up Webhooks

Webhook endpoints are configured through your Merchant Dashboard:

  1. Go to your One2Pays Dashboard
  2. Navigate to SettingsWebhooks (or IntegrationsWebhooks)
  3. Configure your webhook endpoint URL (must be HTTPS)
  4. Choose Subscribe to all events or pick specific event families (see below)
  5. Save your webhook configuration

Note

Webhook configuration is managed through the dashboard, not via API endpoints. The API provides provider webhook endpoints for payment providers, not merchant webhook registration.

Event Subscriptions

Each endpoint can subscribe to a subset of events. This is useful when you handle different event families with different services (e.g., one URL for payment events, another for withdrawal events) so each URL only receives traffic it knows how to handle. Leave Subscribe to all events on if you want every event delivered.

Subscription tokenMatches
*Every event (same as "Subscribe to all events")
payment.*All payment.* events
deposit.*All deposit.* events
withdrawal.*All withdrawal.* events
settlement.*All settlement.* events
customer.*All customer.* events
kyc.*All kyc.* events
merchant.*All merchant.* events
payment.createdExact match — single event

Events that don't match any of your endpoint's subscription tokens are skipped for that endpoint. They are still recorded in the Webhook Deliveries view if delivered to another endpoint on the same integration.

Webhook Structure

All webhook events have the following structure:

{
  "event": "payment.created",
  "paymentId": "550e8400-e29b-41d4-a716-446655440000",
  "referenceId": "order-12345",
  "status": "processing",
  "amount": "1000.00",
  "currency": "THB",
  "paymentMethod": "promptpay",
  "clientSecret": null,
  "nextAction": {
    "type": "display_qr_code",
    "paymentAppUrl": "https://pay.example.com/en/pay/token_123",
    "qrCode": {
      "payload": "000201010212...",
      "promptpayId": "0123456789",
      "expiresAt": "2024-01-01T01:00:00.000Z"
    }
  },
  "expiresAt": "2024-01-01T01:00:00.000Z"
}

Event Object Properties

PropertyTypeDescription
eventstringEvent name, such as payment.created or payment.received
paymentIdstringPlatform payment ID
referenceIdstringYour reference ID
statusstringNormalized webhook status
amountstringPayment amount as decimal string
currencystringCurrency code
paymentMethodstringPayment method, such as promptpay or bank_transfer
clientSecretstringClient secret when applicable, otherwise null
nextActionobjectPayment instructions for the merchant or customer, otherwise null
expiresAtstringISO timestamp when the payment expires, if available
completedAtstringISO timestamp when the payment completed or was canceled
errorCodestringError code for failed events, if available
errorMessagestringError message for failed events, if available

Delivery Headers

The HTTP request also includes delivery metadata in headers such as X-Webhook-Event, X-Webhook-Id, X-Webhook-Signature, and X-Webhook-Timestamp.

Event Types

Payment Events

payment.created

Sent when payment instructions are ready. Always includes nextAction with the full payment instructions for the merchant to embed or display.

PromptPay (QR code):

{
  "event": "payment.created",
  "paymentId": "550e8400-e29b-41d4-a716-446655440000",
  "referenceId": "order-12345",
  "status": "processing",
  "amount": "1000.00",
  "currency": "THB",
  "paymentMethod": "promptpay",
  "clientSecret": null,
  "nextAction": {
    "type": "display_qr_code",
    "paymentAppUrl": "https://pay.example.com/en/pay/token_123",
    "qrCode": {
      "payload": "000201010212...",
      "promptpayId": "0123456789",
      "expiresAt": "2024-01-01T01:00:00.000Z"
    }
  },
  "expiresAt": "2024-01-01T01:00:00.000Z"
}

Bank Transfer:

{
  "event": "payment.created",
  "paymentId": "550e8400-e29b-41d4-a716-446655440000",
  "referenceId": "order-12345",
  "status": "processing",
  "amount": "1000.00",
  "currency": "THB",
  "paymentMethod": "bank_transfer",
  "clientSecret": null,
  "nextAction": {
    "type": "display_bank_transfer_instructions",
    "paymentAppUrl": "https://pay.example.com/en/pay/token_123",
    "bankTransferInstructions": {
      "bankCode": "kbank",
      "accountNumber": "1234567890",
      "accountName": "Example Company Ltd.",
      "amount": "1000.00",
      "referenceNumber": "ORDER-12345"
    }
  },
  "expiresAt": "2024-01-01T01:00:00.000Z"
}

Use nextAction.type to determine which payment instructions to display:

  • display_qr_code — render the PromptPay QR code from nextAction.qrCode.payload
  • display_bank_transfer_instructions — show bank account details from nextAction.bankTransferInstructions
  • use_payment_app — redirect or embed nextAction.paymentAppUrl (hosted payment page)
  • redirect — redirect the customer to nextAction.redirectUrl

payment.received

Sent when a payment is successfully completed.

{
  "event": "payment.received",
  "paymentId": "550e8400-e29b-41d4-a716-446655440000",
  "referenceId": "order-12345",
  "status": "succeeded",
  "amount": "1000.00",
  "currency": "THB",
  "paymentMethod": "promptpay",
  "completedAt": "2024-01-01T00:05:00.000Z"
}

payment.failed

Sent when a payment fails.

{
  "event": "payment.failed",
  "paymentId": "550e8400-e29b-41d4-a716-446655440000",
  "referenceId": "order-12345",
  "status": "failed",
  "amount": "1000.00",
  "currency": "THB",
  "paymentMethod": "promptpay",
  "errorCode": "PAYMENT_FAILED",
  "errorMessage": "Payment could not be completed"
}

payment.updated

Sent when a payment is updated but not yet completed.

payment.expired

Sent when a payment expires before completion.

payment.refunded

Sent when a payment is refunded.

Withdrawal Events

withdrawal.paid

Sent when a withdrawal is successfully paid out to the destination bank account. The funds have left your wallet and reached the recipient.

{
  "event": "withdrawal.paid",
  "id": "b34028d2-345f-4c63-b3c4-dfc40d91feee",
  "withdrawalId": "payout-12345",
  "referenceId": "payout-12345",
  "status": "paid",
  "amount": "1000.00",
  "currency": "THB",
  "feeAmount": "15.00",
  "netAmount": "985.00",
  "createdAt": "2024-01-01T00:00:00.000Z",
  "paidAt": "2024-01-01T00:05:00.000Z"
}

Legacy wallet-withdrawal integrations may still receive withdrawal.completed with a slightly different shape. New integrations should listen for withdrawal.paid.

withdrawal.failed

Sent when a withdrawal fails. Any funds that were deducted from your wallet are automatically credited back before this event fires.

{
  "event": "withdrawal.failed",
  "id": "b34028d2-345f-4c63-b3c4-dfc40d91feee",
  "withdrawalId": "payout-12345",
  "referenceId": "payout-12345",
  "status": "failed",
  "amount": "1000.00",
  "currency": "THB",
  "feeAmount": "15.00",
  "netAmount": "985.00",
  "createdAt": "2024-01-01T00:00:00.000Z",
  "errorCode": "BANK_REJECTED",
  "errorMessage": "Destination account name mismatch"
}

withdrawal.cancelled

Sent when a withdrawal is cancelled (either by you or by the provider). Any funds that were deducted from your wallet are automatically credited back before this event fires.

{
  "event": "withdrawal.cancelled",
  "id": "b34028d2-345f-4c63-b3c4-dfc40d91feee",
  "withdrawalId": "payout-12345",
  "referenceId": "payout-12345",
  "status": "canceled",
  "amount": "1000.00",
  "currency": "THB",
  "feeAmount": "15.00",
  "netAmount": "985.00",
  "createdAt": "2024-01-01T00:00:00.000Z",
  "canceledAt": "2024-01-01T00:03:00.000Z"
}

withdrawal.expired

Sent when a withdrawal expires.

withdrawal.created

Sent when a withdrawal is created.

Handling Webhooks

Basic Webhook Handler

import crypto from 'crypto';

const WEBHOOK_SECRET = process.env.PAYMENT_WEBHOOK_SECRET!;

// Verify webhook signature
function verifySignature(
  payload: string,
  signature: string,
  timestamp: string,
  secret: string
): boolean {
  // Extract signature hash (format: sha256=<hash>)
  const signatureHash = signature.replace(/^sha256=/, '');

  // Parse timestamp (in milliseconds)
  const timestampMs = parseInt(timestamp, 10);
  if (isNaN(timestampMs) || timestampMs <= 0) {
    return false;
  }

  // Create signed payload: timestamp + "." + payload
  const signedPayload = `${timestampMs}.${payload}`;

  // Compute expected signature
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  // Compare signatures using timing-safe comparison
  return crypto.timingSafeEqual(
    Buffer.from(signatureHash, 'hex'),
    Buffer.from(expectedSignature, 'hex')
  );
}

export async function POST(request: Request) {
  const signature = request.headers.get('x-webhook-signature');
  const timestamp = request.headers.get('x-webhook-timestamp');
  const body = await request.text();

  // Verify signature
  if (!signature || !timestamp || !verifySignature(body, signature, timestamp, WEBHOOK_SECRET)) {
    return new Response('Invalid signature', { status: 401 });
  }

  const event = JSON.parse(body);

  // Handle the event
  switch (event.event) {
    case 'payment.created':
      await handlePaymentCreated(event);
      break;
    case 'payment.received':
      await handlePaymentReceived(event);
      break;
    case 'payment.failed':
      await handlePaymentFailed(event);
      break;
    case 'withdrawal.paid':
      await handleWithdrawalPaid(event);
      break;
    case 'withdrawal.failed':
      await handleWithdrawalFailed(event);
      break;
    case 'withdrawal.cancelled':
      await handleWithdrawalCancelled(event);
      break;
    default:
      console.log(`Unhandled event type: ${event.event}`);
  }

  return new Response('OK', { status: 200 });
}

async function handlePaymentCreated(payment: any) {
  console.log('Payment created:', payment.paymentId);

  if (payment.nextAction?.type === 'display_bank_transfer_instructions') {
    // Render QR or bank transfer instructions in your own UI
    await savePaymentInstructions(payment.referenceId, payment.nextAction);
  }

  if (payment.nextAction?.type === 'use_payment_app') {
    await saveHostedPaymentUrl(payment.referenceId, payment.nextAction.paymentAppUrl);
  }
}

async function handlePaymentReceived(payment: any) {
  console.log('Payment received:', payment.paymentId);

  // Update your database
  await updateOrderStatus(payment.referenceId, 'paid');
}

async function handlePaymentFailed(payment: any) {
  console.log('Payment failed:', payment.paymentId);

  // Update your database
  await updateOrderStatus(payment.referenceId, 'failed');
}

async function handleWithdrawalPaid(withdrawal: any) {
  console.log('Withdrawal paid:', withdrawal.referenceId, '-', withdrawal.netAmount);
  // Money has left your wallet and reached the recipient
}

async function handleWithdrawalFailed(withdrawal: any) {
  console.log('Withdrawal failed:', withdrawal.referenceId, '-', withdrawal.errorMessage);
  // Funds have already been credited back to your wallet
}

async function handleWithdrawalCancelled(withdrawal: any) {
  console.log('Withdrawal cancelled:', withdrawal.referenceId);
  // Funds have already been credited back to your wallet
}

Best Practices

Important

Always verify webhook signatures to ensure the request is from One2Pays and hasn't been tampered with.

1. Verify Signatures

Always verify the webhook signature before processing the event. See Signature Verification for details.

2. Handle Idempotency

Webhooks may be delivered more than once. Use the delivery ID from the X-Webhook-Id header to ensure you only process each delivery once.

const processedEvents = new Set();

export async function POST(request: Request) {
  // ... signature verification ...

  const webhookId = request.headers.get('x-webhook-id');
  const event = JSON.parse(body);

  if (!webhookId) {
    return new Response('Missing webhook id', { status: 400 });
  }

  if (processedEvents.has(webhookId)) {
    console.log('Event already processed:', webhookId);
    return new Response('OK', { status: 200 });
  }

  // Process the event
  await handleEvent(event);

  // Mark as processed
  processedEvents.add(webhookId);

  return new Response('OK', { status: 200 });
}

3. Return 200 Status Code

Always return a 200 status code for successfully processed webhooks. Non-2xx responses will trigger retries.

4. Process Quickly

Webhook endpoints should respond within 10 seconds. For longer processing, acknowledge the webhook immediately and process asynchronously.

5. Handle Failures Gracefully

If processing fails, return a 500 status code so One2Pays will retry the webhook.

Webhook Retries

If your endpoint returns a non-2xx status code or times out, One2Pays will retry the webhook:

  • Retry Schedule: 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours, 24 hours
  • Maximum Retries: 6 attempts over 3 days
  • Timeout: 10 seconds per attempt

Testing Webhooks

Using ngrok for Local Development

# Install ngrok
npm install -g ngrok

# Expose your local server
ngrok http 3000

# Use the HTTPS URL in your webhook settings
# https://abc123.ngrok.io/webhook

On this page