Skip to main content

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

88Pay integration flow diagram
Flow:
  1. Customer completes payment on 88Pay
  2. 88Pay processes the transaction
  3. 88Pay sends POST request to your webhook URL
  4. Your server validates and processes the notification
  5. Your server responds with 200 OK
  6. Transaction complete!

Setup Your Webhook Endpoint

Requirements

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 typeExample 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

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

FieldTypeDescription
transaction_datestringTransaction timestamp (format: YYYY-MM-DD HH:mm:ss)
transaction_statusstringCurrent status: COMPLETED, REJECTED, PENDING
transaction_idstringInternal 88Pay transaction ID
transaction_amountstringTransaction amount (as string to preserve precision)
transaction_payment_methodstringMethod used: CREDIT_CARD, BANK_TRANSFER, CASH, etc.
transaction_countrystringCountry code (ISO 3166-1 alpha-3): COL, MEX, BRA
transaction_currencystringCurrency code (ISO 4217): COP, USD, MXN
transaction_referencestringYour tracking reference (from original request)
transaction_customer_idstringCustomer ID you provided in original request

Transaction Statuses

StatusMeaningAction Required
COMPLETED✅ Payment successfulFulfill order, send confirmation
REJECTED❌ Payment failed/cancelled/expiredNotify customer, offer retry
PENDING⏳ Awaiting confirmationWait 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

    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');
    });

Testing Webhooks

Local Development

Use tools to expose your local server:

Sandbox Testing

Trigger test webhooks in sandbox:
  1. Create test payment in sandbox
  2. Complete payment with test card 4111111111111111
  3. Webhook sent immediately to your notification_url
  4. Check your server logs for incoming POST request

Retry Logic

How Retries Work

If your endpoint doesn’t respond with 200 OK, 88Pay retries:
AttemptDelayTotal Time
1stImmediate0s
2nd1 minute1m
3rd5 minutes6m
4th15 minutes21m
5th30 minutes51m
6th1 hour1h 51m
7th2 hours3h 51m
8th4 hours7h 51m
9th8 hours15h 51m
10th16 hours31h 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

Never:
    // ❌ BAD: Processing before responding
    app.post('/webhook', async (req, res) => {
      await updateDatabase(req.body);      // Slow!
      await sendEmail(req.body);           // Slow!
      await fulfillOrder(req.body);        // Slow!
      res.status(200).json({ ok: true }); // Too late!
    });
Always:
    // ✅ GOOD: Respond immediately, process async
    app.post('/webhook', async (req, res) => {
      // Respond immediately
      res.status(200).json({ received: true });
      
      // Process async (don't await)
      processWebhookAsync(req.body).catch(err => {
        console.error('Async processing failed:', err);
      });
    });
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:
  1. 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']
      });
    }
  1. 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`
      });
    }
  1. 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:
  1. 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
  1. Check firewall rules:
  • Allow incoming HTTPS (port 443)
  • Whitelist 88Pay IPs (contact support for IP ranges)
  1. Verify SSL certificate:
    openssl s_client -connect yoursite.com:443 -servername yoursite.com
    
    # Should show valid certificate
  1. 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:
  1. Wrong secret key:
    // Verify secret key is 64 hex characters
    if (!/^[0-9a-f]{64}$/i.test(secret)) {
      console.error('Invalid secret key format');
    }
  1. Incorrect base64 decoding:
    // Use proper base64 decoding
    const raw = Buffer.from(tokenB64, 'base64'); // Correct
    
    // Not:
    const raw = atob(tokenB64); // Wrong in Node.js
  1. 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
Debug steps:
  1. 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);
  1. 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);
    }
  1. 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' });
    });
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