Understanding Payment Webhooks: A Developer's Guide
DGateway is a unified payment and commerce platform for Africa that lets developers accept mobile money (MTN, Airtel) and card payments through a single API. Webhooks are central to how DGateway communicates transaction updates to your application in real time.
If you have ever integrated a payment system, you have encountered webhooks. They are the backbone of asynchronous payment processing — the mechanism that tells your application when something important happens. But webhooks are also one of the most common sources of bugs, data inconsistencies, and late-night debugging sessions.
This guide explains how webhooks work, how DGateway's webhook system is designed, and the best practices that will keep your integration rock-solid. We include complete handler code in multiple languages, signature verification examples, idempotency patterns, local testing with ngrok, and a comparison of webhooks vs polling.
What Are Webhooks?
A webhook is an HTTP POST request sent from one system to another when an event occurs. Instead of your application repeatedly asking "has the payment been completed yet?" (polling), the payment gateway proactively notifies your application the moment something changes.
In the context of payments, the most common webhook events are:
- Payment completed — The customer successfully paid.
- Payment failed — The payment attempt was unsuccessful.
- Payment expired — The customer did not complete the payment within the allowed time window.
- Refund processed — A refund was issued for a previous payment.
- Subscription renewed — A recurring payment was successfully charged.
Without webhooks, you would need to poll the payment API continuously, which is inefficient, slow, and unreliable. Webhooks give you near-real-time notifications with minimal overhead.
Polling vs Webhooks: A Comparison
Before diving deep into webhooks, it is worth understanding why they are preferred over polling.
How Polling Works
Your Server DGateway API
| |
|--- GET /v1/transactions/txn_123 ---->|
|<--- { status: "pending" } -----------|
| |
| (wait 5 seconds) |
| |
|--- GET /v1/transactions/txn_123 ---->|
|<--- { status: "pending" } -----------|
| |
| (wait 5 seconds) |
| |
|--- GET /v1/transactions/txn_123 ---->|
|<--- { status: "pending" } -----------|
| |
| ... repeat 20+ times ... |
| |
|--- GET /v1/transactions/txn_123 ---->|
|<--- { status: "successful" } --------|
How Webhooks Work
Your Server DGateway API
| |
|--- POST /v1/collections ------------>|
|<--- { id: "txn_123", status: "pending" } |
| |
| (do nothing — go handle other requests) |
| |
|<--- POST /webhooks/dgateway ---------|
| { event: "collection.completed", |
| data: { status: "successful" }}|
|--- 200 OK --------------------------->|
Side-by-Side Comparison
| Factor | Polling | Webhooks |
|---|---|---|
| Latency | 5-30 seconds (depends on interval) | Near-instant (~1 second) |
| Server load | High (constant requests) | Low (only on events) |
| API rate limit risk | High | None |
| Reliability | Depends on polling interval | Built-in retries |
| Complexity | Simple to implement | Requires endpoint + verification |
| Cost | Higher (more API calls) | Lower (fewer API calls) |
| Missed events | Possible (if polling stops) | Unlikely (retries + logs) |
| Best for | Simple integrations, status checks | Production payment systems |
Verdict: Use webhooks for production payment processing. Use polling only as a fallback or for manual status checks in admin dashboards.
How DGateway's Webhook System Works
When you create a collection, disbursement, or subscription through the DGateway API, you specify a callback_url. This is the URL where DGateway will send webhook notifications for that transaction.
Here is what happens behind the scenes:
- Event occurs — A transaction changes status (for example, from
pendingtosuccessful). - Payload is constructed — DGateway creates a JSON payload containing the event type and the full transaction data.
- Signature is generated — The payload is signed using HMAC-SHA256 with your webhook secret key. This signature is included in the
X-DGateway-Signatureheader. - HTTP POST is sent — DGateway sends the signed payload to your callback URL.
- Response is evaluated — If your endpoint returns a 2xx status code, the webhook is considered delivered. If it returns anything else (or times out), DGateway schedules a retry.
Webhook Payload Structure
Every webhook from DGateway follows a consistent structure:
{
"event": "collection.completed",
"timestamp": "2026-03-24T14:30:00Z",
"data": {
"id": "txn_abc123",
"status": "successful",
"amount": 50000,
"currency": "UGX",
"provider": "mtn",
"phone_number": "256771234567",
"description": "Order #1234",
"metadata": {
"order_id": "order_1234",
"customer_email": "customer@example.com"
},
"created_at": "2026-03-24T14:29:50Z",
"completed_at": "2026-03-24T14:30:00Z"
}
}The event field tells you what happened. The data field contains the full transaction record, including any metadata you attached when creating the transaction.
Event Types
DGateway sends webhooks for the following events:
| Event | Description | When It Fires |
|---|---|---|
collection.completed | A collection was successfully completed | Customer paid via MoMo/card |
collection.failed | A collection attempt failed | Wrong PIN, insufficient funds, etc. |
collection.expired | A collection request expired without payment | Customer did not confirm in time |
disbursement.completed | A disbursement was sent successfully | Money sent to recipient |
disbursement.failed | A disbursement attempt failed | Invalid phone, provider error |
subscription.renewed | A subscription payment was charged | Recurring billing cycle |
subscription.cancelled | A subscription was cancelled | Customer or merchant cancelled |
refund.processed | A refund was issued | Refund completed |
Webhook Signature Verification
Never process a webhook without verifying its signature. The signature ensures that the webhook actually came from DGateway and has not been tampered with in transit.
DGateway signs every webhook payload using HMAC-SHA256 with your webhook secret key. The signature is included in the X-DGateway-Signature header.
How Signature Verification Works
1. DGateway takes the raw JSON payload (as a string)
2. Creates HMAC-SHA256 hash using your webhook secret
3. Sends the hex-encoded hash in the X-DGateway-Signature header
4. Your server repeats the same process and compares the results
Node.js / TypeScript
import crypto from "crypto";
function verifyWebhookSignature(
rawBody: string, // The raw request body as a string
signature: string, // From X-DGateway-Signature header
secret: string, // Your webhook secret
): boolean {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
// Use timingSafeEqual to prevent timing attacks
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
// Usage in Next.js App Router
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const signature = req.headers.get("x-dgateway-signature") || "";
if (
!verifyWebhookSignature(
rawBody,
signature,
process.env.DGATEWAY_WEBHOOK_SECRET!,
)
) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
// Safe to process
const payload = JSON.parse(rawBody);
// ...
}Go
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func VerifySignature(payload []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
expected := hex.EncodeToString(mac.Sum(nil))
// hmac.Equal is constant-time comparison (safe against timing attacks)
return hmac.Equal([]byte(signature), []byte(expected))
}
// Usage in Gin handler
func WebhookHandler(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(400, gin.H{"error": "Bad request"})
return
}
signature := c.GetHeader("X-DGateway-Signature")
secret := os.Getenv("DGATEWAY_WEBHOOK_SECRET")
if !VerifySignature(body, signature, secret) {
c.JSON(401, gin.H{"error": "Invalid signature"})
return
}
// Safe to process
var payload WebhookPayload
json.Unmarshal(body, &payload)
// ...
c.JSON(200, gin.H{"received": true})
}Python
import hmac
import hashlib
def verify_webhook_signature(
raw_body: bytes, # The raw request body
signature: str, # From X-DGateway-Signature header
secret: str # Your webhook secret
) -> bool:
expected = hmac.new(
secret.encode("utf-8"),
raw_body,
hashlib.sha256
).hexdigest()
# compare_digest is constant-time (safe against timing attacks)
return hmac.compare_digest(signature, expected)
# Usage in Flask
@app.route("/webhooks/dgateway", methods=["POST"])
def handle_webhook():
signature = request.headers.get("X-DGateway-Signature", "")
secret = os.getenv("DGATEWAY_WEBHOOK_SECRET")
if not verify_webhook_signature(request.data, signature, secret):
return jsonify({"error": "Invalid signature"}), 401
payload = request.get_json()
# Safe to process...
return jsonify({"received": True}), 200PHP
function verifyWebhookSignature(
string $rawBody,
string $signature,
string $secret
): bool {
$expected = hash_hmac('sha256', $rawBody, $secret);
return hash_equals($expected, $signature);
}
// Usage
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_DGATEWAY_SIGNATURE'] ?? '';
$secret = getenv('DGATEWAY_WEBHOOK_SECRET');
if (!verifyWebhookSignature($rawBody, $signature, $secret)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
$payload = json_decode($rawBody, true);
// Safe to process...Common Mistakes in Signature Verification
| Mistake | Why It Is Wrong | Fix |
|---|---|---|
| Parsing JSON before verifying | Changes whitespace/ordering of the payload | Use raw body string/bytes |
Using === string comparison | Vulnerable to timing attacks | Use timing-safe comparison |
| Using API key instead of webhook secret | Different keys for different purposes | Use the webhook secret specifically |
| Not reading the raw body | Frameworks may auto-parse the body | Access raw body before parsing |
Complete Webhook Handlers
Next.js (App Router) — Production-Ready
// app/api/webhooks/dgateway/route.ts
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { prisma } from "@/lib/prisma";
function verifySignature(
payload: string,
signature: string,
secret: string,
): boolean {
const expected = crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
export async function POST(req: NextRequest) {
// Step 1: Read raw body
const rawBody = await req.text();
const signature = req.headers.get("x-dgateway-signature") || "";
// Step 2: Verify signature
if (
!verifySignature(rawBody, signature, process.env.DGATEWAY_WEBHOOK_SECRET!)
) {
console.error("Webhook signature verification failed");
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const { event, data, timestamp } = JSON.parse(rawBody);
// Step 3: Idempotency check
const existingLog = await prisma.webhookLog.findUnique({
where: { transactionId_event: { transactionId: data.id, event } },
});
if (existingLog) {
console.log(`Duplicate webhook: ${event} for ${data.id}`);
return NextResponse.json({ received: true, duplicate: true });
}
// Step 4: Log the webhook (before processing)
await prisma.webhookLog.create({
data: {
transactionId: data.id,
event,
payload: JSON.parse(rawBody),
receivedAt: new Date(),
},
});
// Step 5: Process based on event type
try {
switch (event) {
case "collection.completed":
await handleCollectionCompleted(data);
break;
case "collection.failed":
await handleCollectionFailed(data);
break;
case "collection.expired":
await handleCollectionExpired(data);
break;
case "disbursement.completed":
await handleDisbursementCompleted(data);
break;
case "subscription.renewed":
await handleSubscriptionRenewed(data);
break;
default:
console.log(`Unhandled event: ${event}`);
}
} catch (error) {
console.error(`Error processing webhook ${event}:`, error);
// Still return 200 — we logged the event and can reprocess later
}
// Step 6: Return 200 immediately
return NextResponse.json({ received: true });
}
async function handleCollectionCompleted(data: any) {
if (data.status !== "successful") return;
const orderId = data.metadata?.order_id;
if (!orderId) return;
// Update order status
await prisma.order.update({
where: { id: orderId },
data: {
status: "paid",
paidAt: new Date(data.completed_at),
transactionId: data.id,
paymentProvider: data.provider,
amountPaid: data.amount,
currency: data.currency,
},
});
// Send confirmation email (async — do not block the webhook)
await sendOrderConfirmationEmail(orderId);
}
async function handleCollectionFailed(data: any) {
const orderId = data.metadata?.order_id;
if (!orderId) return;
await prisma.order.update({
where: { id: orderId },
data: { status: "payment_failed" },
});
}
async function handleCollectionExpired(data: any) {
const orderId = data.metadata?.order_id;
if (!orderId) return;
await prisma.order.update({
where: { id: orderId },
data: { status: "expired" },
});
}Express.js — Production-Ready
// routes/webhooks.ts
import express from "express";
import crypto from "crypto";
import { db } from "../lib/db";
import { Queue } from "bullmq";
const router = express.Router();
const webhookQueue = new Queue("webhook-processing");
// Important: Use express.raw() to get the raw body for signature verification
router.post(
"/dgateway",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.headers["x-dgateway-signature"] as string;
const rawBody = req.body.toString();
// Verify signature
const expected = crypto
.createHmac("sha256", process.env.DGATEWAY_WEBHOOK_SECRET!)
.update(rawBody)
.digest("hex");
if (
!crypto.timingSafeEqual(
Buffer.from(signature || ""),
Buffer.from(expected),
)
) {
return res.status(401).json({ error: "Invalid signature" });
}
const payload = JSON.parse(rawBody);
// Check for duplicates
const existing = await db.webhookLog.findFirst({
where: { transactionId: payload.data.id, event: payload.event },
});
if (existing) {
return res.status(200).json({ received: true, duplicate: true });
}
// Log immediately
await db.webhookLog.create({
data: {
transactionId: payload.data.id,
event: payload.event,
payload: payload,
},
});
// Queue for async processing (return 200 fast)
await webhookQueue.add("process-webhook", payload);
res.status(200).json({ received: true });
},
);
export default router;Go (Gin) — Production-Ready
package handlers
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type WebhookPayload struct {
Event string `json:"event"`
Timestamp time.Time `json:"timestamp"`
Data json.RawMessage `json:"data"`
}
type TransactionData struct {
ID string `json:"id"`
Status string `json:"status"`
Amount int `json:"amount"`
Currency string `json:"currency"`
Provider string `json:"provider"`
PhoneNumber string `json:"phone_number"`
Description string `json:"description"`
Metadata map[string]string `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at"`
}
type WebhookLog struct {
ID uint `gorm:"primaryKey"`
TransactionID string `gorm:"uniqueIndex:idx_txn_event"`
Event string `gorm:"uniqueIndex:idx_txn_event"`
Payload json.RawMessage `gorm:"type:jsonb"`
ProcessedAt *time.Time
CreatedAt time.Time
}
type WebhookHandler struct {
db *gorm.DB
}
func NewWebhookHandler(db *gorm.DB) *WebhookHandler {
return &WebhookHandler{db: db}
}
func (h *WebhookHandler) HandleWebhook(c *gin.Context) {
// Read raw body
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot read body"})
return
}
// Verify signature
signature := c.GetHeader("X-DGateway-Signature")
secret := os.Getenv("DGATEWAY_WEBHOOK_SECRET")
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(signature), []byte(expected)) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"})
return
}
// Parse payload
var payload WebhookPayload
if err := json.Unmarshal(body, &payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"})
return
}
var txnData TransactionData
json.Unmarshal(payload.Data, &txnData)
// Idempotency check
var existingLog WebhookLog
result := h.db.Where("transaction_id = ? AND event = ?", txnData.ID, payload.Event).First(&existingLog)
if result.Error == nil {
c.JSON(http.StatusOK, gin.H{"received": true, "duplicate": true})
return
}
// Log the webhook
h.db.Create(&WebhookLog{
TransactionID: txnData.ID,
Event: payload.Event,
Payload: body,
})
// Process the event
switch payload.Event {
case "collection.completed":
h.handleCollectionCompleted(txnData)
case "collection.failed":
h.handleCollectionFailed(txnData)
case "disbursement.completed":
h.handleDisbursementCompleted(txnData)
default:
log.Printf("Unhandled webhook event: %s", payload.Event)
}
c.JSON(http.StatusOK, gin.H{"received": true})
}
func (h *WebhookHandler) handleCollectionCompleted(data TransactionData) {
if data.Status != "successful" {
return
}
orderID := data.Metadata["order_id"]
if orderID == "" {
return
}
h.db.Model(&Order{}).Where("id = ?", orderID).Updates(map[string]interface{}{
"status": "paid",
"paid_at": data.CompletedAt,
"transaction_id": data.ID,
"payment_provider": data.Provider,
"amount_paid": data.Amount,
"currency": data.Currency,
})
}Idempotency: Handling Duplicate Webhooks
Webhooks can be delivered more than once. Network issues, retries, or edge cases in distributed systems can result in duplicate deliveries. Your handler must be able to process the same webhook multiple times without causing problems.
Why Duplicates Happen
DGateway Your Server
| |
|--- POST webhook (attempt 1) -->|
| | (processes successfully)
| |--- 200 OK (but network drops before DGateway receives it)
| |
| (DGateway thinks delivery failed)
| |
|--- POST webhook (attempt 2) -->|
| | (same event, second delivery)
|<--- 200 OK --------------------|
Database-Backed Idempotency
The most reliable approach is to use a database table to track processed webhooks:
-- Webhook log table (PostgreSQL)
CREATE TABLE webhook_logs (
id SERIAL PRIMARY KEY,
transaction_id VARCHAR(255) NOT NULL,
event VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
processed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(transaction_id, event) -- Prevents duplicate processing
);// Check-and-insert pattern with a unique constraint
async function processWebhookIdempotently(
event: string,
data: TransactionData,
) {
try {
// This will throw if the unique constraint is violated (duplicate)
await prisma.webhookLog.create({
data: {
transactionId: data.id,
event: event,
payload: data as any,
},
});
} catch (error: any) {
if (error.code === "P2002") {
// Unique constraint violation — this is a duplicate
console.log(`Duplicate webhook: ${event} for ${data.id}, skipping`);
return;
}
throw error; // Re-throw unexpected errors
}
// First time seeing this event — process it
switch (event) {
case "collection.completed":
await fulfillOrder(data.metadata.order_id);
break;
// ... other events
}
// Mark as processed
await prisma.webhookLog.update({
where: { transactionId_event: { transactionId: data.id, event } },
data: { processedAt: new Date() },
});
}Redis-Based Idempotency (For High Throughput)
If you process thousands of webhooks per minute, a Redis-based check can be faster:
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
async function isWebhookProcessed(
transactionId: string,
event: string,
): Promise<boolean> {
const key = `webhook:${transactionId}:${event}`;
// SET with NX (only set if not exists) and EX (expire after 7 days)
const result = await redis.set(key, "1", "EX", 7 * 24 * 60 * 60, "NX");
// Returns "OK" if the key was set (first time), null if it already existed (duplicate)
return result === null;
}
// Usage
if (await isWebhookProcessed(data.id, event)) {
console.log("Duplicate webhook, skipping");
return;
}
// Process the webhook...Retry Logic and Exponential Backoff
Networks are unreliable. Servers go down. Deployments cause brief outages. DGateway's retry system ensures you do not miss critical events.
If your webhook endpoint fails to return a 2xx response, DGateway retries the delivery using an exponential backoff schedule:
| Attempt | Delay After Previous | Total Time Elapsed | What To Expect |
|---|---|---|---|
| Initial | Immediate | 0 | First delivery attempt |
| Retry 1 | 1 minute | 1 minute | Quick retry for transient errors |
| Retry 2 | 5 minutes | 6 minutes | Server probably restarting |
| Retry 3 | 30 minutes | 36 minutes | More serious issue |
| Retry 4 | 2 hours | 2 hours 36 minutes | Extended outage |
| Retry 5 | 12 hours | 14 hours 36 minutes | Final attempt |
After five failed retries, the webhook is marked as failed. You can view failed webhooks in your dashboard and manually trigger a re-delivery.
This schedule means that even if your server is down for a few hours, you will still receive the webhook when it comes back online.
What Counts as a Failed Delivery?
| Response | Outcome |
|---|---|
200, 201, 202, 204 | Delivered successfully |
301, 302 (redirect) | Followed, then evaluated |
400 | Failed (will retry) |
401, 403 | Failed (will retry — check your signature verification) |
404 | Failed (will retry — check your endpoint URL) |
500, 502, 503 | Failed (will retry — server error) |
| Timeout (>30 seconds) | Failed (will retry — your handler is too slow) |
| Connection refused | Failed (will retry — server is down) |
Designing for Retries
Your webhook handler should be designed to handle retries gracefully:
- Return 200 quickly — Do heavy processing asynchronously.
- Be idempotent — The same webhook delivered twice should not cause duplicate side effects.
- Log everything — Store the raw payload for debugging.
- Do not rely on ordering — Webhooks may arrive out of order (e.g.,
failedbeforecompletedif retries are involved).
Respond Quickly: Async Processing Pattern
Your webhook endpoint should return a 200 response as fast as possible. Do not perform heavy processing — like sending emails, updating external systems, or running long database queries — inside the webhook handler.
Instead, receive the webhook, validate it, store the raw event, and return 200. Then process the event asynchronously using a background job or queue.
Pattern: Queue-Based Processing
// Webhook handler — fast, returns 200 immediately
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const signature = req.headers.get("x-dgateway-signature") || "";
if (
!verifySignature(rawBody, signature, process.env.DGATEWAY_WEBHOOK_SECRET!)
) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const payload = JSON.parse(rawBody);
// Store the raw event
await prisma.webhookLog.create({
data: {
transactionId: payload.data.id,
event: payload.event,
payload: payload,
status: "received",
},
});
// Queue for async processing
await webhookQueue.add("process", {
logId: payload.data.id,
event: payload.event,
data: payload.data,
});
// Return 200 immediately — total time: ~50ms
return NextResponse.json({ received: true });
}
// Background worker — processes the event asynchronously
webhookQueue.process("process", async (job) => {
const { logId, event, data } = job.data;
try {
switch (event) {
case "collection.completed":
await fulfillOrder(data.metadata.order_id);
await sendConfirmationEmail(data.metadata.customer_email);
await updateAnalytics(data);
break;
// ... other events
}
// Mark as processed
await prisma.webhookLog.update({
where: { transactionId: logId },
data: { status: "processed", processedAt: new Date() },
});
} catch (error) {
// Mark as failed — can retry manually from dashboard
await prisma.webhookLog.update({
where: { transactionId: logId },
data: { status: "processing_failed", error: error.message },
});
}
});Testing Webhooks Locally with ngrok
During development, your local server is not accessible from the internet. DGateway cannot deliver webhooks to http://localhost:3000. You need a tunnel.
Step 1: Install ngrok
# macOS
brew install ngrok
# Windows
choco install ngrok
# Or download from https://ngrok.com/downloadStep 2: Start Your Local Server
cd my-app
npm run dev
# Server running on http://localhost:3000Step 3: Start ngrok
ngrok http 3000ngrok will output a public URL:
Forwarding https://a1b2c3d4.ngrok.io -> http://localhost:3000
Step 4: Use the ngrok URL as Your Callback
curl -X POST https://api.dgateway.io/v1/collections \
-H "Authorization: Bearer dg_test_sk_your_key" \
-H "Content-Type: application/json" \
-d '{
"amount": 5000,
"currency": "UGX",
"phone_number": "256771000001",
"provider": "mtn",
"description": "Test with ngrok",
"callback_url": "https://a1b2c3d4.ngrok.io/api/webhooks/dgateway"
}'Step 5: Monitor in ngrok Dashboard
ngrok provides a local dashboard at http://localhost:4040 where you can:
- See all incoming requests
- Inspect request headers and body
- Replay requests for debugging
- See response codes from your server
Alternative: DGateway Dashboard
You can also test webhooks directly from the DGateway dashboard:
- Go to a completed transaction.
- Click "Re-deliver webhook."
- DGateway sends the webhook again to your configured callback URL.
Best Practices Summary
Always Verify Signatures
Never process a webhook without verifying its signature. Use timing-safe comparison to prevent timing attacks.
Make Your Handler Idempotent
Use a database table or Redis to track processed webhook events. The combination of transaction_id + event should be unique.
Respond Quickly
Return 200 within a few hundred milliseconds. Queue heavy processing for background workers.
Use HTTPS
Always use an HTTPS endpoint for your webhook URL. This encrypts the webhook payload in transit and prevents man-in-the-middle attacks. DGateway will not send webhooks to HTTP endpoints in production.
Log Everything
Store the raw webhook payload alongside your processed data. If something goes wrong, having the original payload makes debugging dramatically easier. Include the timestamp, event type, and the full data object.
Handle All Event Types
Even if you only care about collection.completed right now, log all events. Your future self will thank you when you need to debug a failed payment or add subscription support.
Do Not Rely on Webhook Order
In rare cases, webhooks may arrive out of order. A collection.failed webhook might arrive after a collection.completed if the first delivery was retried. Always check the status field in the data, not just the event type.
Monitoring and Debugging
DGateway provides several tools for monitoring your webhook health:
- Webhook logs — View every webhook sent to your endpoint, including the payload, response code, and delivery time.
- Retry history — See which webhooks were retried and whether they eventually succeeded.
- Manual re-delivery — Trigger a webhook re-delivery from the dashboard for any past event.
- Provider health — Check if a payment provider is having issues that might delay webhooks.
Debugging Checklist
| Symptom | Likely Cause | Fix |
|---|---|---|
| Webhook never arrives | Wrong callback URL | Check URL in request or dashboard settings |
| 401 response in logs | Signature verification failing | Check webhook secret, use raw body |
| 500 response in logs | Handler code error | Check server logs, add error handling |
| Timeout in logs | Handler too slow | Move heavy processing to background queue |
| Duplicate processing | Missing idempotency check | Add unique constraint on transaction_id + event |
| Missing webhooks | Server was down during delivery | Check retry history, manually re-deliver |
If you are experiencing webhook issues, start by checking the webhook logs in your dashboard. Nine times out of ten, the problem is a misconfigured endpoint URL, a signature verification bug, or a server that returned a 500 error.
What is Next
Webhooks are the foundation of reliable payment integration. By understanding how DGateway's webhook system works — and following the best practices outlined here — you can build integrations that handle payments accurately, gracefully recover from failures, and never miss a transaction.
Here is your action plan:
- Set up your webhook endpoint with signature verification.
- Add idempotency using a database table or Redis.
- Implement async processing with a background queue.
- Test locally using ngrok.
- Monitor using the DGateway dashboard webhook logs.
- Handle all event types even if you only need a few right now.
Get the fundamentals right, and your payment integration will be one of the most reliable parts of your entire system.