Webhooks

Real-time notifications with PayOS

Overview

PayOS uses webhooks to notify your application about events that occur within your payment orchestration process. These notifications are delivered via HTTP POST requests to your specified endpoint, allowing you to automate workflows and keep your systems synchronized with payment activities.

Event Types

PayOS sends different types of webhook events. Transaction event types include:

Event TypeDescription
transaction.completedTransaction was successfully completed
transaction.failedTransaction attempt failed authorisation
transaction.erroredTransaction encountered an error
transaction.cancelledTransaction was cancelled
transaction.expiredTransaction expired

Getting Started with Webhooks

To integrate webhooks into your application, follow these steps:

  1. Create a webhook subscription - Configure the HTTPS URL where you want to receive notifications
  2. Implement signature verification - Verify webhook authenticity using the provided secret
  3. Process webhooks idempotently - Handle webhook events while avoiding duplicate processing
  4. Acknowledge receipt - Return HTTP 200 response for successful webhook receipt

Creating a Webhook Subscription

You can create and configure your webhook subscriptions on the Svix dashboard.

  1. Navigate to the Webhooks section in your Svix dashboard
  2. Click “Add Endpoint”
  3. Enter your HTTPS endpoint URL
  4. Select the events you want to subscribe to
  5. Save your webhook configuration

If you do not yet have a Svix dashboard URL, contact your PayOS account manager.

💡 Tip: If you don’t have an endpoint ready yet, you can use Svix Play to generate a temporary endpoint for testing.

Securing Webhooks

PayOS uses Svix for secure webhook delivery. Each webhook includes a signature that you should verify before processing:

1import { Webhook } from "svix";
2import bodyParser from "body-parser";
3
4const secret = "YOUR_WEBHOOK_SECRET"; // From subscription creation
5
6app.post(
7 "/webhooks",
8 bodyParser.raw({ type: "application/json" }),
9 (req, res) => {
10 const payload = req.body;
11 const headers = req.headers;
12
13 const wh = new Webhook(secret);
14 let msg;
15
16 try {
17 msg = wh.verify(payload, headers);
18 } catch (err) {
19 return res.status(400).json({
20 message: err.toString()
21 });
22 }
23
24 // Process webhook event
25 const event = JSON.parse(payload);
26
27 switch(event.eventType) {
28 case 'transaction.completed':
29 await handleTransactionCompleted(event.payload);
30 break;
31 // Handle other event types
32 }
33
34 res.json({ received: true });
35 }
36);

Manual Webhook Verification

While we recommend using the official Svix libraries for webhook verification, you may need to implement manual verification. Here’s how to verify webhooks manually:

1. Required Headers

Each webhook includes three critical headers:

  • svix-id: Unique identifier for the webhook message
  • svix-timestamp: Timestamp in seconds since epoch
  • svix-signature: Base64 encoded list of signatures (space delimited)

2. Constructing the Signed Content

Concatenate the ID, timestamp, and payload with periods:

1const signedContent = `${svix_id}.${svix_timestamp}.${body}`;

⚠️ Important: Use the raw request body. Any modification (even whitespace) will invalidate the signature.

3. Calculating the Signature

Use HMAC-SHA256 to verify the signature:

1const crypto = require('crypto');
2
3function verifyWebhook(payload, headers, secret) {
4 const svix_id = headers['svix-id'];
5 const svix_timestamp = headers['svix-timestamp'];
6 const svix_signature = headers['svix-signature'];
7
8 // Construct the signed content
9 const signedContent = `${svix_id}.${svix_timestamp}.${payload}`;
10
11 // Extract and decode the secret
12 const secretBytes = Buffer.from(secret.split('_')[1], "base64");
13
14 // Calculate expected signature
15 const expectedSignature = crypto
16 .createHmac('sha256', secretBytes)
17 .update(signedContent)
18 .digest('base64');
19
20 // Get received signatures (can be multiple)
21 const receivedSignatures = svix_signature.split(' ').map(sig => {
22 // Remove version prefix (e.g., "v1,")
23 return sig.split(',')[1];
24 });
25
26 // Verify if any signature matches
27 return receivedSignatures.includes(expectedSignature);
28}

4. Timestamp Verification

Always verify the timestamp to prevent replay attacks:

1function isTimestampValid(timestamp, toleranceInSeconds = 300) {
2 const now = Math.floor(Date.now() / 1000);
3 return Math.abs(now - timestamp) <= toleranceInSeconds;
4}

Example Implementation

Here’s a complete example combining all verification steps:

1function verifyAndProcessWebhook(req, res) {
2 const secret = process.env.WEBHOOK_SECRET;
3 const payload = req.body;
4 const headers = req.headers;
5
6 try {
7 // 1. Verify timestamp
8 if (!isTimestampValid(headers['svix-timestamp'])) {
9 return res.status(400).json({ error: 'Invalid timestamp' });
10 }
11
12 // 2. Verify signature
13 if (!verifyWebhook(payload, headers, secret)) {
14 return res.status(400).json({ error: 'Invalid signature' });
15 }
16
17 // 3. Process webhook
18 const event = JSON.parse(payload);
19 processWebhookEvent(event);
20
21 res.json({ received: true });
22 } catch (err) {
23 res.status(400).json({ error: err.message });
24 }
25}

🔒 Security Note: Always use constant-time string comparison when comparing signatures to prevent timing attacks.

Webhook Payload Schema

PayOS webhooks are delivered via Svix with the following structure:

Webhook Payload Fields

FieldTypePresenceDescription
eventTypestringRequiredThe type of event that occurred
payloadobjectRequiredTransaction data (see table below)
eventIdstringOptionalUnique identifier for this event
timestampstring (ISO 8601)RequiredWhen the event occurred

Transaction Response Fields

The payload field contains transaction data with these fields:

FieldTypePresenceDescription
transactionIdstringRequiredUnique identifier for the transaction
statusstringRequiredTransaction status: COMPLETED/FAILED/ERRORED/CANCELLED/EXPIRED
messagestringRequiredHuman-readable status message
merchantReferencestringOptionalMerchant’s reference for this transaction
sessionIdstringOptionalSession identifier from checkout
processorReferencestringOptionalPayment processor’s reference
financialTransactionReferencestringOptionalFinancial institution’s reference
currentAttemptIdstringOptionalCurrent payment attempt identifier
identifiers.sessionIdstringOptionalSession ID in identifiers object
identifiers.merchantReferencestringOptionalMerchant reference in identifiers object
paymentAttributes.paymentMethodstringOptionalPayment method used
paymentAttributes.customerEmailstringOptionalCustomer’s email address
authStateobjectOptionalAuthentication state details (see table below)

Auth State Fields

When present, the authState object contains these fields:

FieldTypePresenceDescription
statestringRequiredAuthentication state: completed/failed/error/cancelled/expired
transitionedAtstring (ISO 8601)RequiredWhen the transaction reached this state
messagestringOptionalHuman-readable state message
codestringOptionalPayOS error or failure code
connectorFailureCodestringOptionalPayment processor’s failure code
connectorFailureMessagestringOptionalPayment processor’s failure message
authMethodTypestringOptionalAuthentication method type: redirect/fields/pendingApproval
redirect.urlstringOptionalURL for redirect authentication method type
fields.fieldsobjectOptionalFields for fields authentication method type

Example Webhook Payload

Here’s an example of a complete webhook payload for a successful transaction:

1{
2 "eventType": "transaction.completed",
3 "eventId": "evt_1234567890",
4 "timestamp": "2025-07-21T10:30:00Z",
5 "payload": {
6 "transactionId": "PAY_123",
7 "status": "COMPLETED",
8 "message": "Transaction completed successfully",
9 "merchantReference": "ORDER_123",
10 "sessionId": "SESSION_123",
11 "identifiers": {
12 "sessionId": "SESSION_123",
13 "merchantReference": "ORDER_123"
14 },
15 "paymentAttributes": {
16 "paymentMethod": "card",
17 "customerEmail": "customer@example.com"
18 },
19 "processorReference": "PROC_123",
20 "financialTransactionReference": "FIN_123",
21 "currentAttemptId": "ATTEMPT_123",
22 "authState": {
23 "state": "completed",
24 "transitionedAt": "2025-07-21T10:30:00Z",
25 "message": "Transaction completed successfully"
26 }
27 }
28}

Best Practices

  1. Verify Signatures

    • Always verify webhook signatures using your webhook secret
    • Reject requests with invalid signatures
  2. Handle Events Idempotently

    • Store processed webhook IDs to prevent duplicate processing
    • Use event IDs for deduplication
  3. Implement Proper Error Handling

    • Return 2xx status codes for successful receipt
    • Return 4xx for invalid requests
    • Return 5xx for processing errors
  4. Monitor Webhook Health

    • Track failed deliveries in the PayOS dashboard
    • Set up alerts for repeated failures

Testing Webhooks

For development and testing:

  1. Use the Svix dashboard to view webhook delivery history
  2. Test signature verification with sample payloads
  3. Use tools like Svix Play to inspect webhook content

Webhook Retries

If your endpoint returns a non-200 response code or is unavailable, PayOS will retry the webhook delivery with the following backoff schedule:

  • 1st retry: 5 minutes
  • 2nd retry: 15 minutes
  • 3rd retry: 30 minutes
  • 4th retry: 1 hour
  • 5th retry: 2 hours
  • Final retry: 4 hours

IP Allowlisting

If your infrastructure requires IP allowlisting, you’ll need to whitelist Svix’s IP ranges. The current list can be found in the Svix documentation.

Webhook Security Checklist

✅ Use HTTPS endpoints only
✅ Verify webhook signatures
✅ Store webhook secrets securely
✅ Process events idempotently
✅ Monitor failed deliveries
✅ Implement proper error handling