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)
- Select the events you want to receive
- 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.
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 | BroPay 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.completed
Sent when a withdrawal is successfully completed.
{
"event": "withdrawal.completed",
"withdrawalId": "660e8400-e29b-41d4-a716-446655440001",
"status": "completed",
"amount": "1000.00",
"currency": "THB",
"referenceNumber": "payout-12345",
"completedAt": "2024-01-01T00:05:00.000Z"
}withdrawal.failed
Sent when a withdrawal fails.
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.completed':
await handleWithdrawalCompleted(event);
break;
case 'withdrawal.failed':
await handleWithdrawalFailed(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 handleWithdrawalCompleted(withdrawal: any) {
console.log('Withdrawal completed:', withdrawal.withdrawalId);
// Update your system
}
async function handleWithdrawalFailed(withdrawal: any) {
console.log('Withdrawal failed:', withdrawal.withdrawalId);
// Handle failure
}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