← Back to blog
engineering21 min read

Understanding Payment Webhooks: A Developer's Guide

A deep dive into how payment webhooks work, how DGateway's webhook system is designed, and best practices for handling webhook events reliably.

Understanding Payment Webhooks: A Developer's Guide

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

FactorPollingWebhooks
Latency5-30 seconds (depends on interval)Near-instant (~1 second)
Server loadHigh (constant requests)Low (only on events)
API rate limit riskHighNone
ReliabilityDepends on polling intervalBuilt-in retries
ComplexitySimple to implementRequires endpoint + verification
CostHigher (more API calls)Lower (fewer API calls)
Missed eventsPossible (if polling stops)Unlikely (retries + logs)
Best forSimple integrations, status checksProduction 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:

  1. Event occurs — A transaction changes status (for example, from pending to successful).
  2. Payload is constructed — DGateway creates a JSON payload containing the event type and the full transaction data.
  3. Signature is generated — The payload is signed using HMAC-SHA256 with your webhook secret key. This signature is included in the X-DGateway-Signature header.
  4. HTTP POST is sent — DGateway sends the signed payload to your callback URL.
  5. 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:

EventDescriptionWhen It Fires
collection.completedA collection was successfully completedCustomer paid via MoMo/card
collection.failedA collection attempt failedWrong PIN, insufficient funds, etc.
collection.expiredA collection request expired without paymentCustomer did not confirm in time
disbursement.completedA disbursement was sent successfullyMoney sent to recipient
disbursement.failedA disbursement attempt failedInvalid phone, provider error
subscription.renewedA subscription payment was chargedRecurring billing cycle
subscription.cancelledA subscription was cancelledCustomer or merchant cancelled
refund.processedA refund was issuedRefund 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}), 200

PHP

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

MistakeWhy It Is WrongFix
Parsing JSON before verifyingChanges whitespace/ordering of the payloadUse raw body string/bytes
Using === string comparisonVulnerable to timing attacksUse timing-safe comparison
Using API key instead of webhook secretDifferent keys for different purposesUse the webhook secret specifically
Not reading the raw bodyFrameworks may auto-parse the bodyAccess 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:

AttemptDelay After PreviousTotal Time ElapsedWhat To Expect
InitialImmediate0First delivery attempt
Retry 11 minute1 minuteQuick retry for transient errors
Retry 25 minutes6 minutesServer probably restarting
Retry 330 minutes36 minutesMore serious issue
Retry 42 hours2 hours 36 minutesExtended outage
Retry 512 hours14 hours 36 minutesFinal 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?

ResponseOutcome
200, 201, 202, 204Delivered successfully
301, 302 (redirect)Followed, then evaluated
400Failed (will retry)
401, 403Failed (will retry — check your signature verification)
404Failed (will retry — check your endpoint URL)
500, 502, 503Failed (will retry — server error)
Timeout (>30 seconds)Failed (will retry — your handler is too slow)
Connection refusedFailed (will retry — server is down)

Designing for Retries

Your webhook handler should be designed to handle retries gracefully:

  1. Return 200 quickly — Do heavy processing asynchronously.
  2. Be idempotent — The same webhook delivered twice should not cause duplicate side effects.
  3. Log everything — Store the raw payload for debugging.
  4. Do not rely on ordering — Webhooks may arrive out of order (e.g., failed before completed if 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/download

Step 2: Start Your Local Server

cd my-app
npm run dev
# Server running on http://localhost:3000

Step 3: Start ngrok

ngrok http 3000

ngrok 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:

  1. Go to a completed transaction.
  2. Click "Re-deliver webhook."
  3. 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

SymptomLikely CauseFix
Webhook never arrivesWrong callback URLCheck URL in request or dashboard settings
401 response in logsSignature verification failingCheck webhook secret, use raw body
500 response in logsHandler code errorCheck server logs, add error handling
Timeout in logsHandler too slowMove heavy processing to background queue
Duplicate processingMissing idempotency checkAdd unique constraint on transaction_id + event
Missing webhooksServer was down during deliveryCheck 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:

  1. Set up your webhook endpoint with signature verification.
  2. Add idempotency using a database table or Redis.
  3. Implement async processing with a background queue.
  4. Test locally using ngrok.
  5. Monitor using the DGateway dashboard webhook logs.
  6. 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.