Overview
When you use webhookMode: true in payment requests, payment results are delivered to your configured webhook endpoints instead of being returned in the HTTP response. This page covers how to receive, verify, and process these webhook payloads.
Event Types
Event Description Trigger payment.completedPayment succeeded Terminal returns SUCCESS status payment.failedPayment declined or error Terminal returns FAILED status payment.cancelledUser cancelled payment Terminal returns CANCELLED status payment.timeoutTerminal didn’t respond 90-second timeout reached
Webhook Payload Structure
All webhook payloads follow this structure:
{
"eventType" : "payment.completed" ,
"eventId" : "evt_01HQ3K4M5N6P7R8S9T0UVWXYZ" ,
"timestamp" : "2024-01-15T10:37:30.000Z" ,
"data" : {
"transactionId" : "TXN-20240115-001" ,
"status" : "SUCCESS" ,
"amount" : "99.99" ,
"currency" : "USD" ,
"paymentMethod" : "CARD" ,
"authorizationCode" : "AUTH123456" ,
"receiptData" : "..." ,
"terminalId" : "TERM-001" ,
"metadata" : {
"orderId" : "ORD-12345"
}
}
}
Payload Fields
The type of event that triggered this webhook. Values: payment.completed, payment.failed, payment.cancelled, payment.timeout
Unique identifier for this event. Use this for idempotency checks. Example: "evt_01HQ3K4M5N6P7R8S9T0UVWXYZ"
ISO 8601 timestamp of when the event occurred.
Event-specific payload data containing the payment result. Your transaction identifier from the original payment request.
Payment status: SUCCESS, FAILED, CANCELLED, or TIMEOUT.
Authorization code from payment processor (success only).
Error code if payment failed.
Human-readable error message if payment failed.
Opaque receipt data from terminal.
Terminal that processed the payment.
Your custom metadata from the original payment request.
Event Payload Examples
payment.completed
{
"eventType" : "payment.completed" ,
"eventId" : "evt_01HQ3K4M5N6P7R8S9T0UVWXYZ" ,
"timestamp" : "2024-01-15T10:37:30.000Z" ,
"data" : {
"transactionId" : "TXN-20240115-001" ,
"status" : "SUCCESS" ,
"amount" : "99.99" ,
"currency" : "USD" ,
"paymentMethod" : "CARD" ,
"authorizationCode" : "AUTH123456" ,
"receiptData" : "..." ,
"terminalId" : "TERM-001" ,
"metadata" : {
"orderId" : "ORD-12345"
}
}
}
payment.failed
{
"eventType" : "payment.failed" ,
"eventId" : "evt_01HQ3K5N6P7R8S9T0UVWXYZA" ,
"timestamp" : "2024-01-15T10:38:00.000Z" ,
"data" : {
"transactionId" : "TXN-20240115-002" ,
"status" : "FAILED" ,
"amount" : "150.00" ,
"currency" : "USD" ,
"paymentMethod" : "CARD" ,
"errorCode" : "INSUFFICIENT_FUNDS" ,
"errorMessage" : "Card declined due to insufficient funds" ,
"terminalId" : "TERM-001" ,
"metadata" : {
"orderId" : "ORD-12346"
}
}
}
payment.cancelled
{
"eventType" : "payment.cancelled" ,
"eventId" : "evt_01HQ3K6P7R8S9T0UVWXYZAB" ,
"timestamp" : "2024-01-15T10:39:00.000Z" ,
"data" : {
"transactionId" : "TXN-20240115-003" ,
"status" : "CANCELLED" ,
"amount" : "75.00" ,
"currency" : "USD" ,
"paymentMethod" : "CARD" ,
"terminalId" : "TERM-001" ,
"metadata" : {
"orderId" : "ORD-12347"
}
}
}
payment.timeout
{
"eventType" : "payment.timeout" ,
"eventId" : "evt_01HQ3K7R8S9T0UVWXYZABC" ,
"timestamp" : "2024-01-15T10:40:30.000Z" ,
"data" : {
"transactionId" : "TXN-20240115-004" ,
"status" : "TIMEOUT" ,
"amount" : "200.00" ,
"currency" : "USD" ,
"paymentMethod" : "CARD" ,
"terminalId" : "TERM-001" ,
"metadata" : {
"orderId" : "ORD-12348"
}
}
}
Signature Verification
All webhook requests include headers for signature verification. You must verify signatures to ensure webhooks are authentic and haven’t been tampered with.
Header Description webhook-idUnique identifier for this webhook delivery webhook-timestampUnix timestamp (seconds) when the webhook was sent webhook-signatureHMAC-SHA256 signature in format v1,<base64-signature>
Verification Algorithm
Extract headers: webhook-id, webhook-timestamp, webhook-signature
Check timestamp is within 5-minute tolerance window
Construct signed content: ${webhook-id}.${webhook-timestamp}.${raw-body}
Compute HMAC-SHA256 using your webhook secret (base64-decoded)
Compare computed signature with provided signature
Always verify signatures before processing webhooks. Reject requests with invalid or expired signatures.
Node.js Example
const crypto = require ( 'crypto' );
function verifyWebhookSignature ( payload , headers , secret ) {
const webhookId = headers [ 'webhook-id' ];
const webhookTimestamp = headers [ 'webhook-timestamp' ];
const webhookSignature = headers [ 'webhook-signature' ];
if ( ! webhookId || ! webhookTimestamp || ! webhookSignature ) {
throw new Error ( 'Missing required webhook headers' );
}
// Check timestamp is within 5-minute tolerance
const tolerance = 5 * 60 ; // 5 minutes in seconds
const now = Math . floor ( Date . now () / 1000 );
const timestamp = parseInt ( webhookTimestamp , 10 );
if ( Math . abs ( now - timestamp ) > tolerance ) {
throw new Error ( 'Webhook timestamp outside tolerance window' );
}
// Construct signed content
const signedContent = ` ${ webhookId } . ${ webhookTimestamp } . ${ payload } ` ;
// Compute expected signature
const secretBytes = Buffer . from ( secret , 'base64' );
const expectedSignature = crypto
. createHmac ( 'sha256' , secretBytes )
. update ( signedContent )
. digest ( 'base64' );
// Parse and compare signatures (format: "v1,<signature> v1,<signature>")
const signatures = webhookSignature . split ( ' ' );
for ( const sig of signatures ) {
const [ version , hash ] = sig . split ( ',' );
if ( version === 'v1' && hash === expectedSignature ) {
return true ;
}
}
throw new Error ( 'Invalid webhook signature' );
}
// Express.js example
app . post ( '/webhooks/payments' , express . raw ({ type: 'application/json' }), ( req , res ) => {
const payload = req . body . toString ();
try {
verifyWebhookSignature ( payload , req . headers , process . env . WEBHOOK_SECRET );
const event = JSON . parse ( payload );
console . log ( 'Received event:' , event . eventType , event . eventId );
// Process the webhook
switch ( event . eventType ) {
case 'payment.completed' :
handlePaymentCompleted ( event . data );
break ;
case 'payment.failed' :
handlePaymentFailed ( event . data );
break ;
case 'payment.cancelled' :
handlePaymentCancelled ( event . data );
break ;
case 'payment.timeout' :
handlePaymentTimeout ( event . data );
break ;
}
res . status ( 200 ). send ( 'OK' );
} catch ( error ) {
console . error ( 'Webhook verification failed:' , error . message );
res . status ( 401 ). send ( 'Invalid signature' );
}
});
Python Example
import hmac
import hashlib
import base64
import time
import json
from flask import Flask, request, abort
app = Flask( __name__ )
def verify_webhook_signature ( payload : str , headers : dict , secret : str ) -> bool :
webhook_id = headers.get( 'webhook-id' )
webhook_timestamp = headers.get( 'webhook-timestamp' )
webhook_signature = headers.get( 'webhook-signature' )
if not all ([webhook_id, webhook_timestamp, webhook_signature]):
raise ValueError ( 'Missing required webhook headers' )
# Check timestamp is within 5-minute tolerance
tolerance = 5 * 60 # 5 minutes in seconds
now = int (time.time())
timestamp = int (webhook_timestamp)
if abs (now - timestamp) > tolerance:
raise ValueError ( 'Webhook timestamp outside tolerance window' )
# Construct signed content
signed_content = f ' { webhook_id } . { webhook_timestamp } . { payload } '
# Compute expected signature
secret_bytes = base64.b64decode(secret)
expected_signature = base64.b64encode(
hmac.new(secret_bytes, signed_content.encode(), hashlib.sha256).digest()
).decode()
# Parse and compare signatures (format: "v1,<signature> v1,<signature>")
signatures = webhook_signature.split( ' ' )
for sig in signatures:
parts = sig.split( ',' )
if len (parts) == 2 :
version, sig_hash = parts
if version == 'v1' and sig_hash == expected_signature:
return True
raise ValueError ( 'Invalid webhook signature' )
@app.route ( '/webhooks/payments' , methods = [ 'POST' ])
def handle_webhook ():
payload = request.get_data( as_text = True )
try :
verify_webhook_signature(payload, request.headers, WEBHOOK_SECRET )
event = json.loads(payload)
print ( f "Received event: { event[ 'eventType' ] } { event[ 'eventId' ] } " )
# Process the webhook
if event[ 'eventType' ] == 'payment.completed' :
handle_payment_completed(event[ 'data' ])
elif event[ 'eventType' ] == 'payment.failed' :
handle_payment_failed(event[ 'data' ])
elif event[ 'eventType' ] == 'payment.cancelled' :
handle_payment_cancelled(event[ 'data' ])
elif event[ 'eventType' ] == 'payment.timeout' :
handle_payment_timeout(event[ 'data' ])
return 'OK' , 200
except ValueError as e:
print ( f 'Webhook verification failed: { e } ' )
abort( 401 )
Response Requirements
Your webhook endpoint must respond according to these requirements:
Requirement Value Response time Within 15 seconds Success status Any 2xx status code Retry on failure Up to 3 retries with exponential backoff
If your endpoint doesn’t respond with a 2xx status within 15 seconds, the webhook is considered failed and will be retried.
Retry Schedule
Attempt Delay after failure 1st retry 30 seconds 2nd retry 2 minutes 3rd retry 10 minutes
After all retries are exhausted, the webhook delivery is marked as failed. You can query the transaction status using GET /v1/transactions/{transactionId} to reconcile any missed webhooks.
Best Practices
Use the eventId field to ensure you don’t process the same event twice. Store processed event IDs and skip duplicates: const processedEvents = new Set ();
function handleWebhook ( event ) {
if ( processedEvents . has ( event . eventId )) {
console . log ( 'Duplicate event, skipping:' , event . eventId );
return ;
}
processedEvents . add ( event . eventId );
// Process the event...
}
Respond to webhooks immediately, then process asynchronously: app . post ( '/webhooks/payments' , ( req , res ) => {
// Verify signature first
verifyWebhookSignature ( req . body , req . headers , secret );
// Queue for async processing
eventQueue . push ( JSON . parse ( req . body ));
// Respond immediately
res . status ( 200 ). send ( 'OK' );
});
Subscribe to all relevant event types and handle each appropriately: switch ( event . eventType ) {
case 'payment.completed' :
await updateOrderStatus ( event . data . transactionId , 'paid' );
await sendReceipt ( event . data );
break ;
case 'payment.failed' :
await updateOrderStatus ( event . data . transactionId , 'failed' );
await notifyCustomer ( event . data . errorMessage );
break ;
case 'payment.cancelled' :
await updateOrderStatus ( event . data . transactionId , 'cancelled' );
break ;
case 'payment.timeout' :
// Check transaction status to reconcile
const txn = await getTransaction ( event . data . transactionId );
await handleTimeoutReconciliation ( txn );
break ;
}
Always verify webhook signatures
Use HTTPS endpoints only
Validate the webhook-timestamp is recent
Consider IP allowlisting if available
Troubleshooting
Signature verification fails
Common causes:
Using the wrong webhook secret
Modifying the payload before verification (e.g., parsing JSON first)
Clock skew exceeding 5-minute tolerance
Decoding the secret incorrectly (must be base64-decoded)
Solutions:
Verify you’re using the secret from endpoint creation
Use the raw request body for signature verification
Ensure your server clock is synchronized with NTP
Base64-decode the secret before computing HMAC
Check:
Endpoint URL is publicly accessible
Endpoint is not returning errors
Firewall allows incoming HTTPS connections
Endpoint status is active (not disabled)
You’re subscribed to the relevant event types
Cause: Retries due to slow responses or 5xx errors.Solution: Implement idempotency using eventId. Store processed event IDs and skip duplicates.
Next Steps