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:
// 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 / 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
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
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.
{
"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.
{
"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.
{
"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:
- Secret: Your webhook secret (from dashboard)
- Message: JSON stringified payload
- Algorithm: SHA-256
- Header:
X-Webhook-Signature
Verification Process:
- Get signature from
X-Webhook-Signatureheader - Compute HMAC of request body using your secret
- 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
// ✅ 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
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
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
# 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:
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
- Check endpoint is publicly accessible
- Verify HTTPS is enabled
- Check firewall settings
- Review server logs for errors
- Verify callback_url in checkout
Invalid Signature Errors
- Verify webhook secret is correct
- Check you're using raw request body
- Verify HMAC algorithm (SHA-256)
- Check for timing-safe comparison
Duplicate Events
- Implement idempotency checks
- Use checkout_id + event as unique key
- Store processed events in database
