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:
- Go to your One2Pays Dashboard
- Navigate to Settings → Webhooks (or Integrations → Webhooks)
- Configure your webhook endpoint URL (must be HTTPS)
- Choose Subscribe to all events or pick specific event families (see below)
- 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 token | Matches |
|---|---|
* | 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.created | Exact 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
| Property | Type | Description |
|---|---|---|
event | string | Event name, such as payment.created or payment.received |
paymentId | string | Platform payment ID |
referenceId | string | Your reference ID |
status | string | Normalized webhook status |
amount | string | Payment amount as decimal string |
currency | string | Currency code |
paymentMethod | string | Payment method, such as promptpay or bank_transfer |
clientSecret | string | Client secret when applicable, otherwise null |
nextAction | object | Payment instructions for the merchant or customer, otherwise null |
expiresAt | string | ISO timestamp when the payment expires, if available |
completedAt | string | ISO timestamp when the payment completed or was canceled |
errorCode | string | Error code for failed events, if available |
errorMessage | string | Error 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 fromnextAction.qrCode.payloaddisplay_bank_transfer_instructions— show bank account details fromnextAction.bankTransferInstructionsuse_payment_app— redirect or embednextAction.paymentAppUrl(hosted payment page)redirect— redirect the customer tonextAction.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