Skip to content

Webhooks

Receive real-time notifications when payment events occur using secure HMAC-signed webhooks.

Overview

Webhooks deliver instant notifications for payment events to your server. They're more efficient than polling and provide real-time updates.


Setting Up Webhooks

1. Create Webhook Endpoint

Set up an HTTPS endpoint on your server to receive webhook notifications:

javascript
// Node.js / Express
const express = require('express');
const crypto = require('crypto');
const app = express();

app.use(express.json());

app.post('/webhooks/payment', (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const webhookSecret = process.env.RACH_WEBHOOK_SECRET;
  
  // Verify HMAC signature
  if (!verifySignature(req.body, signature, webhookSecret)) {
    return res.status(401).send('Invalid signature');
  }
  
  const event = req.body;
  handlePaymentEvent(event);
  
  // Always respond with 200 to acknowledge receipt
  res.sendStatus(200);
});

function verifySignature(payload, signature, secret) {
  const hmac = crypto.createHmac('sha256', secret);
  hmac.update(JSON.stringify(payload));
  const computed = hmac.digest('hex');
  
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(computed)
  );
}

function handlePaymentEvent(event) {
  console.log('Received event:', event.event);
  
  switch (event.event) {
    case 'payment.detected':
      console.log('Payment detected, awaiting confirmations');
      break;
    case 'payment.confirmed':
      console.log('Payment confirmed, fulfill order');
      fulfillOrder(event.reference);
      break;
    case 'payment.expired':
      console.log('Payment expired');
      break;
  }
}

app.listen(3000);
python
# Python / Flask
from flask import Flask, request, jsonify
import hmac
import hashlib
import json
import os

app = Flask(__name__)

@app.route('/webhooks/payment', methods=['POST'])
def payment_webhook():
    signature = request.headers.get('X-Webhook-Signature')
    webhook_secret = os.getenv('RACH_WEBHOOK_SECRET').encode()
    
    # Verify HMAC signature
    payload = request.get_data()
    computed = hmac.new(webhook_secret, payload, hashlib.sha256).hexdigest()
    
    if not hmac.compare_digest(signature, computed):
        return 'Invalid signature', 401
    
    event = request.json
    handle_payment_event(event)
    
    return '', 200

def handle_payment_event(event):
    event_type = event['event']
    
    if event_type == 'payment.detected':
        print('Payment detected, awaiting confirmations')
    elif event_type == 'payment.confirmed':
        print('Payment confirmed, fulfill order')
        fulfill_order(event['reference'])
    elif event_type == 'payment.expired':
        print('Payment expired')

if __name__ == '__main__':
    app.run(port=3000)
go
// Go
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "io/ioutil"
    "log"
    "net/http"
    "os"
)

type WebhookEvent struct {
    Event      string `json:"event"`
    CheckoutID string `json:"checkout_id"`
    Reference  string `json:"reference"`
    Amount     string `json:"amount"`
    TxnHash    string `json:"txn_hash"`
}

func paymentWebhookHandler(w http.ResponseWriter, r *http.Request) {
    signature := r.Header.Get("X-Webhook-Signature")
    webhookSecret := os.Getenv("RACH_WEBHOOK_SECRET")
    
    body, _ := ioutil.ReadAll(r.Body)
    
    // Verify HMAC signature
    if !verifySignature(body, signature, webhookSecret) {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }
    
    var event WebhookEvent
    json.Unmarshal(body, &event)
    
    handlePaymentEvent(event)
    
    w.WriteHeader(http.StatusOK)
}

func verifySignature(payload []byte, signature, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(payload)
    computed := hex.EncodeToString(mac.Sum(nil))
    
    return hmac.Equal([]byte(signature), []byte(computed))
}

func handlePaymentEvent(event WebhookEvent) {
    switch event.Event {
    case "payment.detected":
        log.Println("Payment detected")
    case "payment.confirmed":
        log.Println("Payment confirmed")
        fulfillOrder(event.Reference)
    case "payment.expired":
        log.Println("Payment expired")
    }
}

func main() {
    http.HandleFunc("/webhooks/payment", paymentWebhookHandler)
    http.ListenAndServe(":3000", nil)
}
php
// PHP
<?php

function verifyWebhookSignature($payload, $signature, $secret) {
    $computed = hash_hmac('sha256', $payload, $secret);
    return hash_equals($signature, $computed);
}

function handlePaymentWebhook() {
    $signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
    $webhookSecret = getenv('RACH_WEBHOOK_SECRET');
    
    $payload = file_get_contents('php://input');
    
    if (!verifyWebhookSignature($payload, $signature, $webhookSecret)) {
        http_response_code(401);
        die('Invalid signature');
    }
    
    $event = json_decode($payload, true);
    
    switch ($event['event']) {
        case 'payment.detected':
            error_log('Payment detected');
            break;
        case 'payment.confirmed':
            error_log('Payment confirmed');
            fulfillOrder($event['reference']);
            break;
        case 'payment.expired':
            error_log('Payment expired');
            break;
    }
    
    http_response_code(200);
}

// Route: POST /webhooks/payment
handlePaymentWebhook();
?>

Event Types

payment.detected

Sent when a payment transaction is first detected on the blockchain.

json
{
  "event": "payment.detected",
  "checkout_id": "checkout_xyz789",
  "reference": "ORDER-12345",
  "amount": "100.00",
  "currency": "USDT",
  "network": "BSC",
  "txn_hash": "0x1234abc...",
  "confirmations": 0,
  "timestamp": "2025-12-22T04:35:00Z"
}

payment.confirmed

Sent when the payment reaches required confirmations and merchant balance is credited.

json
{
  "event": "payment.confirmed",
  "checkout_id": "checkout_xyz789",
  "reference": "ORDER-12345",
  "amount": "100.00",
  "currency": "USDT",
  "network": "BSC",
  "txn_hash": "0x1234abc...",
  "confirmations": 12,
  "merchant_balance_credited": true,
  "timestamp": "2025-12-22T04:36:00Z"
}

payment.expired

Sent when a checkout expires without payment.

json
{
  "event": "payment.expired",
  "checkout_id": "checkout_xyz789",
  "reference": "ORDER-12345",
  "expired_at": "2025-12-22T05:00:00Z"
}

Signature Verification

CRITICAL: Always verify webhook signatures to prevent fraud.

HMAC-SHA256 Verification

Rach signs all webhooks with HMAC-SHA256:

  1. Secret: Your webhook secret (from dashboard)
  2. Message: JSON stringified payload
  3. Algorithm: SHA-256
  4. Header: X-Webhook-Signature

Verification Process:

  1. Get signature from X-Webhook-Signature header
  2. Compute HMAC of request body using your secret
  3. Compare signatures using timing-safe comparison

Security Warning

Never skip signature verification! Attackers can send fake webhooks to your endpoint without verification.


Retry Logic

If your endpoint doesn't respond with 2xx status, Rach will retry:

Retry Schedule:

  • Attempt 1: Immediate
  • Attempt 2: After 30 seconds
  • Attempt 3: After 60 seconds (2^1 × 30s)
  • Attempt 4: After 120 seconds (2^2 × 30s)
  • Attempt 5: After 240 seconds (2^3 × 30s)

Maximum retries: 5 attempts over ~8 minutes


Best Practices

1. Respond Quickly

javascript
// ✅ Good: Respond immediately, process async
app.post('/webhooks/payment', async (req, res) => {
  // Verify signature
  if (!verifySignature(req.body, req.headers['x-webhook-signature'])) {
    return res.status(401).send('Invalid signature');
  }
  
  // Respond immediately
  res.sendStatus(200);
  
  // Process asynchronously
  processWebhookAsync(req.body);
});

// ❌ Bad: Long processing before response
app.post('/webhooks/payment', async (req, res) => {
  await verifySignature(req.body);
  await updateDatabase(req.body);
  await sendEmail(req.body);
  await fulfillOrder(req.body);  // This takes too long!
  res.sendStatus(200);  // Rach might have already timed out
});

2. Handle Idempotency

javascript
async function processWebhook(event) {
  const { checkout_id, event: eventType } = event;
  
  // Check if already processed
  const existing = await db.webhookEvents.findOne({ checkout_id, event: eventType });
  if (existing) {
    console.log('Event already processed, skipping');
    return;
  }
  
  // Process event
  await handleEvent(event);
  
  // Mark as processed
  await db.webhookEvents.create({ checkout_id, event: eventType, processed_at: new Date() });
}

3. Log Everything

javascript
app.post('/webhooks/payment', async (req, res) => {
  const event = req.body;
  
  // Log receipt
  logger.info('Webhook received', {
    event: event.event,
    checkout_id: event.checkout_id,
    reference: event.reference
  });
  
  try {
    await processWebhook(event);
    logger.info('Webhook processed successfully');
  } catch (error) {
    logger.error('Webhook processing failed', { error: error.message, event });
  }
  
  res.sendStatus(200);
});

Testing Webhooks Locally

Using ngrok

bash
# 1. Start your local server
node server.js
# Server running on http://localhost:3000

# 2. Expose with ngrok
ngrok http 3000
# Forwarding: https://abc123.ngrok.io -> http://localhost:3000

# 3. Use ngrok URL in checkout
{
  "callback_url": "https://abc123.ngrok.io/webhooks/payment"
}

Manual Testing

Send test webhook to your endpoint:

bash
curl -X POST http://localhost:3000/webhooks/payment \
  -H "X-Webhook-Signature: test_signature" \
  -H "Content-Type: application/json" \
  -d '{
    "event": "payment.confirmed",
    "checkout_id": "checkout_test123",
    "reference": "ORDER-TEST",
    "amount": "100.00",
    "txn_hash": "0xtest"
  }'

Webhook Security Checklist

` tip Security Checklist

  • ✅ Always verify HMAC signature
  • ✅ Use HTTPS endpoints only
  • ✅ Implement rate limiting
  • ✅ Validate payload structure
  • ✅ Use timing-safe comparison for signatures
  • ✅ Log all webhook attempts
  • ✅ Handle replay attacks (idempotency)
  • ✅ Set webhook secret in environment variables
  • ✅ Rotate webhook secrets periodically `

Troubleshooting

Webhook Not Received

  1. Check endpoint is publicly accessible
  2. Verify HTTPS is enabled
  3. Check firewall settings
  4. Review server logs for errors
  5. Verify callback_url in checkout

Invalid Signature Errors

  1. Verify webhook secret is correct
  2. Check you're using raw request body
  3. Verify HMAC algorithm (SHA-256)
  4. Check for timing-safe comparison

Duplicate Events

  1. Implement idempotency checks
  2. Use checkout_id + event as unique key
  3. Store processed events in database

Next Steps

Built with ❤️ by Rach Finance