What Are Webhooks?
Webhooks are server-to-server HTTP notifications that 88Pay sends to your application when transaction events occur. They’re the only reliable way to know when payments succeed, fail, or change status.
Critical: Never rely solely on return_url redirects to confirm payments. Customers can close browsers, lose connectivity, or be redirected incorrectly. Always use webhooks.
Why Webhooks?
Real-Time Updates Get instant notifications when transactions complete
Secure & Reliable Server-to-server communication, impossible to manipulate
Automatic Retries 88Pay retries up to 10 times if your endpoint is temporarily down
How Webhooks Work
Flow:
Customer completes payment on 88Pay
88Pay processes the transaction
88Pay sends POST request to your webhook URL
Your server validates and processes the notification
Your server responds with 200 OK
Transaction complete!
Setup Your Webhook Endpoint
Requirements
Technical Requirements
Infrastructure Tips
Your webhook endpoint MUST: ✅ Be publicly accessible (no localhost) ✅ Use HTTPS (SSL certificate required) ✅ Respond within 5 seconds ✅ Return 200 OK status code ✅ Accept POST requests ✅ Handle application/json content type Example valid URLs: https://api.yoursite.com/webhooks/88pay
https://yoursite.com/api/payment-notifications
https://webhooks.myapp.io/88pay-events
Invalid URLs: http://yoursite.com/webhook ❌ Not HTTPS
http://localhost:3000/webhook ❌ Not publicly accessible
https://192.168.1.100/webhook ❌ Private IP
Recommendations:
Use a dedicated endpoint:
POST https://api.yoursite.com/webhooks/88pay
Not: https://yoursite.com/process-payment (too generic)
Enable logging: Log all incoming webhooks for debugging
Implement idempotency: Handle duplicate notifications gracefully
Queue processing: Respond with 200 immediately, process async
Monitor failures: Set up alerts for webhook processing errors
Webhook Payload
Request Details
Method: POST
Headers:
Content-Type : application/json
Authorization : Bearer {ENCRYPTED_TOKEN}
Payload Structure
{
"transaction_date" : "2025-01-17 14:30:45" ,
"transaction_status" : "COMPLETED" ,
"transaction_id" : "sess_4e91a5c1-b7f2-4c64-91b1-3d204a5738b4" ,
"transaction_amount" : "50000" ,
"transaction_payment_method" : "CREDIT_CARD" ,
"transaction_country" : "COL" ,
"transaction_currency" : "COP" ,
"transaction_reference" : "IP9BD7CJES6" ,
"transaction_customer_id" : "user_abc123"
}
Field Descriptions
Field Type Description transaction_datestring Transaction timestamp (format: YYYY-MM-DD HH:mm:ss) transaction_statusstring Current status: COMPLETED, REJECTED, PENDING transaction_idstring Internal 88Pay transaction ID transaction_amountstring Transaction amount (as string to preserve precision) transaction_payment_methodstring Method used: CREDIT_CARD, BANK_TRANSFER, CASH, etc. transaction_countrystring Country code (ISO 3166-1 alpha-3): COL, MEX, BRA transaction_currencystring Currency code (ISO 4217): COP, USD, MXN transaction_referencestring Your tracking reference (from original request)transaction_customer_idstring Customer ID you provided in original request
Transaction Statuses
Status Meaning Action Required COMPLETED✅ Payment successful Fulfill order, send confirmation REJECTED❌ Payment failed/cancelled/expired Notify customer, offer retry PENDING⏳ Awaiting confirmation Wait for next webhook (common for bank transfers)
Some payment methods (bank transfers, cash) may send PENDING status first, then COMPLETED or REJECTED later.
Webhook Security
Token Encryption
88Pay encrypts webhook data using AES-256-GCM to ensure authenticity. You must decrypt and verify the token in the Authorization header.
Encryption Scheme:
Algorithm: AES-256-GCM
Key: Your 64-character merchant secret key (HEX format)
IV: 12 bytes
Auth Tag: 16 bytes
Token Structure: base64( IV(12) || CIPHERTEXT || TAG(16) )
Decrypted Token Payload
{
"version" : 1 ,
"transaction_id" : "sess_4e91a5c1-b7f2-4c64-91b1-3d204a5738b4" ,
"transaction_amount" : "50000" ,
"ts" : 1737130245000
}
Validation checklist:
✅ transaction_id matches payload
✅ transaction_amount matches payload
✅ ts (timestamp) is recent (within 5 minutes)
Implementation Examples
Node.js (Express)
Python (Flask)
PHP
import express from 'express' ;
import crypto from 'crypto' ;
const app = express ();
app . use ( express . json ());
// Your 64-char merchant secret key (HEX)
const MERCHANT_SECRET = process . env . EIGHTY_EIGHT_PAY_SECRET ;
/**
* Decrypt webhook token
*/
function decryptToken ( secretHex , tokenB64 ) {
if ( ! secretHex || secretHex . length !== 64 ) {
throw new Error ( 'Invalid secret key format' );
}
const key = Buffer . from ( secretHex , 'hex' );
const raw = Buffer . from ( tokenB64 , 'base64' );
if ( raw . length < 29 ) { // 12 (IV) + 16 (TAG) + 1 (min data)
throw new Error ( 'Invalid token length' );
}
const iv = raw . subarray ( 0 , 12 );
const tag = raw . subarray ( raw . length - 16 );
const ciphertext = raw . subarray ( 12 , raw . length - 16 );
const decipher = crypto . createDecipheriv ( 'aes-256-gcm' , key , iv );
decipher . setAuthTag ( tag );
const plaintext = Buffer . concat ([
decipher . update ( ciphertext ),
decipher . final ()
]);
return JSON . parse ( plaintext . toString ( 'utf8' ));
}
/**
* Webhook endpoint
*/
app . post ( '/webhooks/88pay' , async ( req , res ) => {
try {
// 1. Extract token from Authorization header
const authHeader = req . headers . authorization ;
if ( ! authHeader || ! authHeader . startsWith ( 'Bearer ' )) {
return res . status ( 401 ). json ({ error: 'Missing authorization' });
}
const token = authHeader . substring ( 7 ); // Remove "Bearer "
// 2. Decrypt and verify token
const decrypted = decryptToken ( MERCHANT_SECRET , token );
// 3. Verify token matches payload
if ( decrypted . transaction_id !== req . body . transaction_id ) {
return res . status ( 400 ). json ({ error: 'Transaction ID mismatch' });
}
if ( decrypted . transaction_amount !== req . body . transaction_amount ) {
return res . status ( 400 ). json ({ error: 'Amount mismatch' });
}
// 4. Verify timestamp (within 5 minutes)
const now = Date . now ();
const tokenAge = now - decrypted . ts ;
if ( tokenAge > 300000 ) { // 5 minutes
return res . status ( 400 ). json ({ error: 'Token expired' });
}
// 5. Process webhook (async, respond immediately)
processWebhookAsync ( req . body );
// 6. ALWAYS respond with 200 OK
res . status ( 200 ). json ({ received: true });
} catch ( error ) {
console . error ( 'Webhook processing error:' , error );
// Still return 200 to prevent retries for invalid requests
// Log the error for investigation
res . status ( 200 ). json ({ received: true , error: error . message });
}
});
/**
* Process webhook asynchronously
*/
async function processWebhookAsync ( webhook ) {
const {
transaction_status ,
transaction_reference ,
transaction_amount ,
transaction_customer_id
} = webhook ;
// Check if already processed (idempotency)
const existing = await db . webhooks . findOne ({
reference: transaction_reference
});
if ( existing && existing . processed ) {
console . log ( 'Webhook already processed:' , transaction_reference );
return ;
}
// Save webhook to database
await db . webhooks . create ({
reference: transaction_reference ,
status: transaction_status ,
amount: transaction_amount ,
customer_id: transaction_customer_id ,
received_at: new Date (),
processed: false
});
// Handle status
switch ( transaction_status ) {
case 'COMPLETED' :
await handlePaymentSuccess ( transaction_reference );
break ;
case 'REJECTED' :
await handlePaymentFailure ( transaction_reference );
break ;
case 'PENDING' :
await handlePaymentPending ( transaction_reference );
break ;
default :
console . warn ( 'Unknown status:' , transaction_status );
}
// Mark as processed
await db . webhooks . update (
{ reference: transaction_reference },
{ processed: true , processed_at: new Date () }
);
}
async function handlePaymentSuccess ( reference ) {
// Update order status
await db . orders . update (
{ payment_reference: reference },
{
status: 'paid' ,
paid_at: new Date ()
}
);
// Send confirmation email
await sendEmail ({
to: order . customer_email ,
subject: 'Payment Confirmed' ,
template: 'payment-success'
});
// Trigger fulfillment
await fulfillOrder ( reference );
console . log ( 'Payment completed:' , reference );
}
async function handlePaymentFailure ( reference ) {
await db . orders . update (
{ payment_reference: reference },
{ status: 'payment_failed' }
);
await sendEmail ({
to: order . customer_email ,
subject: 'Payment Failed' ,
template: 'payment-failed'
});
console . log ( 'Payment failed:' , reference );
}
async function handlePaymentPending ( reference ) {
await db . orders . update (
{ payment_reference: reference },
{ status: 'payment_pending' }
);
console . log ( 'Payment pending:' , reference );
}
app . listen ( 3000 , () => {
console . log ( 'Webhook server running on port 3000' );
});
from flask import Flask, request, jsonify
import base64
import json
import time
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
app = Flask( __name__ )
# Your 64-char merchant secret key (HEX)
MERCHANT_SECRET = os.getenv( "EIGHTY_EIGHT_PAY_SECRET" )
def decrypt_token ( secret_hex : str , token_b64 : str ) -> dict :
"""Decrypt webhook token using AES-256-GCM"""
key = bytes .fromhex(secret_hex)
raw = base64.b64decode(token_b64)
iv = raw[: 12 ]
tag = raw[ - 16 :]
ciphertext = raw[ 12 : - 16 ]
aesgcm = AESGCM(key)
plaintext = aesgcm.decrypt(iv, ciphertext + tag, None )
return json.loads(plaintext.decode( 'utf-8' ))
@app.route ( '/webhooks/88pay' , methods = [ 'POST' ])
def webhook_handler ():
try :
# 1. Extract token
auth_header = request.headers.get( 'Authorization' , '' )
if not auth_header.startswith( 'Bearer ' ):
return jsonify({ 'error' : 'Missing authorization' }), 401
token = auth_header[ 7 :] # Remove "Bearer "
# 2. Decrypt and verify
decrypted = decrypt_token( MERCHANT_SECRET , token)
webhook_data = request.json
# 3. Verify payload
if decrypted[ 'transaction_id' ] != webhook_data[ 'transaction_id' ]:
return jsonify({ 'error' : 'Transaction ID mismatch' }), 400
if str (decrypted[ 'transaction_amount' ]) != webhook_data[ 'transaction_amount' ]:
return jsonify({ 'error' : 'Amount mismatch' }), 400
# 4. Verify timestamp (within 5 minutes)
now = int (time.time() * 1000 )
token_age = now - decrypted[ 'ts' ]
if token_age > 300000 : # 5 minutes
return jsonify({ 'error' : 'Token expired' }), 400
# 5. Process async
process_webhook_async(webhook_data)
# 6. Always respond 200 OK
return jsonify({ 'received' : True }), 200
except Exception as e:
print ( f 'Webhook error: { e } ' )
# Still return 200 to prevent retries
return jsonify({ 'received' : True , 'error' : str (e)}), 200
def process_webhook_async ( webhook ):
"""Process webhook asynchronously"""
status = webhook[ 'transaction_status' ]
reference = webhook[ 'transaction_reference' ]
# Check idempotency
existing = db.webhooks.find_one({ 'reference' : reference})
if existing and existing.get( 'processed' ):
print ( f 'Webhook already processed: { reference } ' )
return
# Save webhook
db.webhooks.insert_one({
'reference' : reference,
'status' : status,
'amount' : webhook[ 'transaction_amount' ],
'received_at' : datetime.now(),
'processed' : False
})
# Handle status
if status == 'COMPLETED' :
handle_payment_success(reference)
elif status == 'REJECTED' :
handle_payment_failure(reference)
elif status == 'PENDING' :
handle_payment_pending(reference)
# Mark processed
db.webhooks.update_one(
{ 'reference' : reference},
{ '$set' : { 'processed' : True , 'processed_at' : datetime.now()}}
)
if __name__ == '__main__' :
app.run( port = 3000 )
<? php
// webhook.php
// Your 64-char merchant secret key (HEX)
$merchantSecret = getenv ( 'EIGHTY_EIGHT_PAY_SECRET' );
/**
* Decrypt webhook token
*/
function decryptToken ( $secretHex , $tokenB64 ) {
$key = hex2bin ( $secretHex );
$raw = base64_decode ( $tokenB64 , true );
$iv = substr ( $raw , 0 , 12 );
$tag = substr ( $raw , - 16 );
$ciphertext = substr ( $raw , 12 , - 16 );
$plaintext = openssl_decrypt (
$ciphertext ,
'aes-256-gcm' ,
$key ,
OPENSSL_RAW_DATA ,
$iv ,
$tag
);
if ( $plaintext === false ) {
throw new Exception ( 'Decryption failed' );
}
return json_decode ( $plaintext , true );
}
// Get Authorization header
$headers = getallheaders ();
$authHeader = $headers [ 'Authorization' ] ?? '' ;
if ( ! str_starts_with ( $authHeader , 'Bearer ' )) {
http_response_code ( 401 );
echo json_encode ([ 'error' => 'Missing authorization' ]);
exit ;
}
$token = substr ( $authHeader , 7 ); // Remove "Bearer "
// Get webhook payload
$payload = json_decode ( file_get_contents ( 'php://input' ), true );
try {
// Decrypt and verify token
$decrypted = decryptToken ( $merchantSecret , $token );
// Verify payload matches token
if ( $decrypted [ 'transaction_id' ] !== $payload [ 'transaction_id' ]) {
throw new Exception ( 'Transaction ID mismatch' );
}
if ( $decrypted [ 'transaction_amount' ] != $payload [ 'transaction_amount' ]) {
throw new Exception ( 'Amount mismatch' );
}
// Verify timestamp (within 5 minutes)
$now = round ( microtime ( true ) * 1000 );
$tokenAge = $now - $decrypted [ 'ts' ];
if ( $tokenAge > 300000 ) {
throw new Exception ( 'Token expired' );
}
// Process webhook (async recommended)
processWebhook ( $payload );
// Always respond 200 OK
http_response_code ( 200 );
echo json_encode ([ 'received' => true ]);
} catch ( Exception $e ) {
error_log ( 'Webhook error: ' . $e -> getMessage ());
// Still return 200 to prevent retries
http_response_code ( 200 );
echo json_encode ([
'received' => true ,
'error' => $e -> getMessage ()
]);
}
function processWebhook ( $webhook ) {
global $db ;
$status = $webhook [ 'transaction_status' ];
$reference = $webhook [ 'transaction_reference' ];
// Check idempotency
$existing = $db -> webhooks -> findOne ([ 'reference' => $reference ]);
if ( $existing && $existing [ 'processed' ]) {
error_log ( "Webhook already processed: $reference " );
return ;
}
// Save webhook
$db -> webhooks -> insertOne ([
'reference' => $reference ,
'status' => $status ,
'amount' => $webhook [ 'transaction_amount' ],
'received_at' => new DateTime (),
'processed' => false
]);
// Handle status
switch ( $status ) {
case 'COMPLETED' :
handlePaymentSuccess ( $reference );
break ;
case 'REJECTED' :
handlePaymentFailure ( $reference );
break ;
case 'PENDING' :
handlePaymentPending ( $reference );
break ;
}
// Mark processed
$db -> webhooks -> updateOne (
[ 'reference' => $reference ],
[ '$set' => [
'processed' => true ,
'processed_at' => new DateTime ()
]]
);
}
?>
Testing Webhooks
Local Development
Use tools to expose your local server:
ngrok (Recommended)
localtunnel
webhook.site
# Install ngrok
npm install -g ngrok
# Expose local server
ngrok http 3000
# Use the HTTPS URL provided:
# https://abc123.ngrok.io/webhooks/88pay
Advantages:
Free tier available
Persistent URLs (paid)
Request inspection UI
# Install
npm install -g localtunnel
# Expose port 3000
lt --port 3000 --subdomain myapp
# Use: https://myapp.loca.lt/webhooks/88pay
Visit webhook.site Advantages:
No installation needed
Instantly see payloads
Perfect for testing
Limitations:
Can’t process webhooks
Only for debugging
Sandbox Testing
Trigger test webhooks in sandbox:
Create test payment in sandbox
Complete payment with test card 4111111111111111
Webhook sent immediately to your notification_url
Check your server logs for incoming POST request
Retry Logic
How Retries Work
If your endpoint doesn’t respond with 200 OK, 88Pay retries:
Attempt Delay Total Time 1st Immediate 0s 2nd 1 minute 1m 3rd 5 minutes 6m 4th 15 minutes 21m 5th 30 minutes 51m 6th 1 hour 1h 51m 7th 2 hours 3h 51m 8th 4 hours 7h 51m 9th 8 hours 15h 51m 10th 16 hours 31h 51m
After 10 failed attempts:
Webhook marked as undeliverable
Email sent to your registered email
No further retries
Important: Always respond with 200 OK immediately, even if processing fails. Queue the webhook for async processing to avoid blocking the response.
Best Practices
Respond Immediately with 200 OK
Handle duplicate webhooks gracefully: async function processWebhook ( webhook ) {
const reference = webhook . transaction_reference ;
// Check if already processed
const existing = await db . webhooks . findOne ({
reference: reference ,
processed: true
});
if ( existing ) {
console . log ( 'Webhook already processed:' , reference );
return ; // Skip processing
}
// Save webhook
await db . webhooks . create ({
reference: reference ,
payload: webhook ,
processed: false ,
received_at: new Date ()
});
// Process...
await handleWebhook ( webhook );
// Mark as processed
await db . webhooks . update (
{ reference: reference },
{ processed: true , processed_at: new Date () }
);
}
Always decrypt and verify the authorization token: function verifyWebhook ( authHeader , payload ) {
// 1. Decrypt token
const token = authHeader . replace ( 'Bearer ' , '' );
const decrypted = decryptToken ( MERCHANT_SECRET , token );
// 2. Verify transaction_id matches
if ( decrypted . transaction_id !== payload . transaction_id ) {
throw new Error ( 'Transaction ID mismatch' );
}
// 3. Verify amount matches
if ( decrypted . transaction_amount !== payload . transaction_amount ) {
throw new Error ( 'Amount mismatch' );
}
// 4. Verify timestamp is recent (within 5 minutes)
const now = Date . now ();
const age = now - decrypted . ts ;
if ( age > 300000 ) {
throw new Error ( 'Token expired' );
}
return true ;
}
Comprehensive logging for debugging: app . post ( '/webhook' , async ( req , res ) => {
const webhookId = generateId ();
// Log incoming webhook
logger . info ( 'Webhook received' , {
id: webhookId ,
reference: req . body . transaction_reference ,
status: req . body . transaction_status ,
amount: req . body . transaction_amount ,
headers: req . headers ,
timestamp: new Date ()
});
try {
// Verify and process
verifyWebhook ( req . headers . authorization , req . body );
logger . info ( 'Webhook verified' , { id: webhookId });
await processWebhookAsync ( req . body );
logger . info ( 'Webhook processed' , { id: webhookId });
res . status ( 200 ). json ({ received: true });
} catch ( error ) {
logger . error ( 'Webhook error' , {
id: webhookId ,
error: error . message ,
stack: error . stack
});
// Still return 200
res . status ( 200 ). json ({ received: true , error: error . message });
}
});
What to log:
✅ Webhook ID (for correlation)
✅ Transaction reference
✅ Status
✅ Amount
✅ Timestamp
❌ Never log: Authorization token, customer data
Set up monitoring for:
Webhook failures:
if ( error ) {
// Alert if webhook processing fails
await alerting . send ({
severity: 'error' ,
title: '88Pay Webhook Failed' ,
message: `Failed to process webhook ${ reference } : ${ error . message } ` ,
tags: [ 'webhooks' , '88pay' , 'payments' ]
});
}
Slow processing:
const start = Date . now ();
await processWebhook ( data );
const duration = Date . now () - start ;
if ( duration > 3000 ) {
// Alert if processing takes >3s
await alerting . send ({
severity: 'warning' ,
title: 'Slow Webhook Processing' ,
message: `Webhook took ${ duration } ms to process`
});
}
Unexpected statuses:
const validStatuses = [ 'COMPLETED' , 'REJECTED' , 'PENDING' ];
if ( ! validStatuses . includes ( webhook . transaction_status )) {
await alerting . send ({
severity: 'warning' ,
title: 'Unknown Webhook Status' ,
message: `Received unknown status: ${ webhook . transaction_status } `
});
}
Troubleshooting
Checklist:
Verify URL is publicly accessible:
curl -X POST https://yoursite.com/webhooks/88pay \
-H "Content-Type: application/json" \
-d '{"test": true}'
# Should return 200 OK
Check firewall rules:
Allow incoming HTTPS (port 443)
Whitelist 88Pay IPs (contact support for IP ranges)
Verify SSL certificate:
openssl s_client -connect yoursite.com:443 -servername yoursite.com
# Should show valid certificate
Test with webhook.site:
Use https://webhook.site/your-unique-id as notification_url
Create test payment
Check if webhook arrives at webhook.site
If yes: issue is with your endpoint
If no: contact support
Common causes:
Wrong secret key:
// Verify secret key is 64 hex characters
if ( ! / ^ [ 0-9a-f ] {64} $ / i . test ( secret )) {
console . error ( 'Invalid secret key format' );
}
Incorrect base64 decoding:
// Use proper base64 decoding
const raw = Buffer . from ( tokenB64 , 'base64' ); // Correct
// Not:
const raw = atob ( tokenB64 ); // Wrong in Node.js
Missing auth tag:
// Ensure you extract all parts correctly
const iv = raw . subarray ( 0 , 12 ); // First 12 bytes
const tag = raw . subarray ( raw . length - 16 ); // Last 16 bytes
const ct = raw . subarray ( 12 , raw . length - 16 ); // Middle
Webhook processed but order not updated
Debug steps:
Check database logs:
// Add detailed logging
console . log ( 'Updating order:' , {
reference: webhook . transaction_reference ,
status: webhook . transaction_status ,
timestamp: new Date ()
});
const result = await db . orders . update (
{ payment_reference: reference },
{ status: 'paid' }
);
console . log ( 'Update result:' , result );
Verify reference matching:
// Ensure reference format matches
const order = await db . orders . findOne ({
payment_reference: webhook . transaction_reference
});
if ( ! order ) {
console . error ( 'Order not found:' , webhook . transaction_reference );
}
Check for race conditions:
// Use database transactions
await db . transaction ( async ( trx ) => {
const order = await trx . orders
. findOne ({ reference })
. forUpdate (); // Lock row
if ( order . status === 'paid' ) {
return ; // Already processed
}
await trx . orders . update ({ reference }, { status: 'paid' });
});
Receiving duplicate webhooks
This is normal behavior: 88Pay may send the same webhook multiple times if:
Your server was temporarily down
Response took >5 seconds
Network issues occurred
Solution: Implement idempotency async function processWebhook ( webhook ) {
const reference = webhook . transaction_reference ;
// Atomic check-and-set
const result = await db . webhooks . updateOne (
{
reference: reference ,
processed: false
},
{
$set: {
processed: true ,
processed_at: new Date ()
}
}
);
if ( result . modifiedCount === 0 ) {
console . log ( 'Already processed:' , reference );
return ; // Skip
}
// Process webhook...
}
Security Checklist
Before going live, verify:
Webhook endpoint uses HTTPS (not HTTP)
Authorization token is decrypted and verified
Timestamp validation implemented (5-minute window)
Transaction ID and amount verified against token
Idempotency implemented (duplicate handling)
Responds with 200 OK within 5 seconds
Processing happens asynchronously
All webhooks logged (except tokens/sensitive data)
Monitoring and alerting configured
Tested with sandbox environment
Next Steps