Webhooks

Last updated: 2026-07-31

This guide covers only merchant-facing webhooks sent by DCE for deposit and withdrawal notifications.

Overview

DCE sends HTTPS POST requests to your configured webhookUrl when a deposit or withdrawal status changes.

Each request includes:

  • Content-Type: application/json
  • X-Webhook-Event: event name
  • X-Webhook-Signature: lowercase hex HMAC-SHA256 signature of the exact raw request body, using your webhookSecret
  • X-Webhook-Id: stable delivery id — identical on every redelivery of the same event

The JSON body also carries the same id as an eventId field. Deduplicate on it: delivery is at-least-once, so the same event can arrive more than once.

Webhook setup

Configure these fields on your merchant profile:

  • webhookUrl
  • webhookSecret
  • webhookEnabled
  • Optional: webhookEvents, webhookTimeout, webhookRetryCount

Events

Deposit events

EventDescription
deposit.confirmedDeposit is confirmed
deposit.failedDeposit failed

Deposits below 1 USD equivalent are fee-exempt and do not trigger a merchant deposit callback.

Withdrawal events

EventDescription
withdrawal.confirmedWithdrawal is confirmed
withdrawal.failedWithdrawal failed

Transaction events

EventDescription
transaction.confirmedLedger transaction confirmed
transaction.failedLedger transaction failed

Pending states (deposit.pending, withdrawal.pending) are tracked internally but do not currently produce outbound merchant webhooks — only confirmed/failed events are delivered. Poll the Deposits/Withdrawals APIs (or the deposit-page status endpoint) for in-flight state.

Signature verification

Verify X-Webhook-Signature against the raw body exactly as received before parsing JSON.

const crypto = require('crypto');

function verifyDceSignature(rawBody, signatureHeader, webhookSecret) {
  const expected = crypto
    .createHmac('sha256', webhookSecret)
    .update(rawBody, 'utf8')
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signatureHeader, 'hex'),
    Buffer.from(expected, 'hex')
  );
}

Delivery and retries

  • Webhooks are produced through a durable outbox: the event is enqueued in the same database transaction as the ledger change, dispatched immediately after commit, and swept by a background worker if the immediate dispatch is lost. Delivery is therefore at-least-once — your consumer must be idempotent.
  • A delivery only counts as acknowledged when your endpoint returns a 2xx status and a JSON body containing { "ok": true }. A 2xx without that acknowledgement is retried.
  • Failed or unacknowledged deliveries (non-2xx, timeout, network error, missing ack) are retried with exponential backoff (1s doubling, capped at 30s) up to webhookRetryCount (default 3).
  • Redeliveries reuse the same eventId / X-Webhook-Id, so deduplicating on eventId makes retries safe.
  • For withdrawals, your own referenceId is also an idempotency key on the API side: resubmitting a withdrawal with an already-used referenceId returns 409 Duplicate referenceId and never creates a second payout.

Sample payloads

The event name travels in the X-Webhook-Event header; the body carries a type field and a stable eventId.

deposit.confirmed

{
  "type": "deposit",
  "depositId": "clx0d3p0s1t000001",
  "amount": "100.00",
  "currency": "USDT",
  "status": "confirmed",
  "identifier": "user123",
  "txHash": "0x7f9fade1c0d57a7af66ab4ead79fade1c0d57a7af66ab4ead7c2c2eb7b11a91385",
  "referenceId": "your-reference-id",
  "address": "TX7uW4mP7cLoBnUYqkNhVpQvNczpxxxxxx",
  "isMerchantDirectDeposit": false,
  "feeCharges": {
    "amount": "0.25",
    "percentage": "0.0025",
    "type": "PERCENTAGE",
    "activationFee": {
      "amount": "0.5",
      "charged": true
    }
  },
  "receivableAmount": "99.25",
  "eventId": "b3a1c2d4-0000-0000-0000-000000000000"
}
  • feeCharges.activationFee reports the one-time address activation fee (0.5) charged on the first confirmed deposit to an address.
  • For merchant wallet top-ups (isMerchantDirectDeposit: true) the feeCharges.type is FIXED_AMOUNT and amount is the flat direct-deposit fee.
  • receivableAmount = amount − total fees (commission + activation fee, or the flat top-up fee).
  • Deposits below 1 USD equivalent are fee-exempt and no callback is sent.

deposit.failed

{
  "type": "deposit",
  "depositId": "clx0d3p0s1t000002",
  "amount": "100.00",
  "currency": "USDT",
  "identifier": "user123",
  "status": "failed",
  "reason": "Deposit failed",
  "txHash": "0x7f9fade1c0d57a7af66ab4ead79fade1c0d57a7af66ab4ead7c2c2eb7b11a91385",
  "address": "TX7uW4mP7cLoBnUYqkNhVpQvNczpxxxxxx",
  "eventId": "b3a1c2d4-0000-0000-0000-000000000001"
}

withdrawal.confirmed

{
  "type": "withdrawal",
  "withdrawalId": "clx0w1thdr4w000001",
  "amount": "200.00",
  "currency": "USDT",
  "status": "confirmed",
  "txHash": "0x7f9fade1c0d57a7af66ab4ead79fade1c0d57a7af66ab4ead7c2c2eb7b11a91385",
  "referenceId": "your-reference-id",
  "address": "TX7uW4mP7cLoBnUYqkNhVpQvNczpxxxxxx",
  "identifier": "your-reference-id",
  "feeCharges": {
    "amount": "1.00",
    "percentage": "0",
    "type": "FIXED_AMOUNT",
    "networkFee": "1.00"
  },
  "receivableAmount": "199.00",
  "eventId": "b3a1c2d4-0000-0000-0000-000000000002"
}
  • referenceId (and identifier) is your merchant referenceId as submitted with the withdrawal — not an internal id — so you can correlate the callback with the request you made.
  • feeCharges is always present. amount is the commission ("0" with type: "NONE" when no commission is charged); networkFee is the per-chain fee charged on top of the withdrawal amount. receivableAmount = amount − commission.

withdrawal.failed

{
  "type": "withdrawal",
  "withdrawalId": "clx0w1thdr4w000002",
  "amount": "200.00",
  "currency": "USDT",
  "status": "failed",
  "reason": "Insufficient funds at provider",
  "txHash": "0x7f9fade1c0d57a7af66ab4ead79fade1c0d57a7af66ab4ead7c2c2eb7b11a91385",
  "referenceId": "your-reference-id",
  "address": "TX7uW4mP7cLoBnUYqkNhVpQvNczpxxxxxx",
  "identifier": "your-reference-id",
  "eventId": "b3a1c2d4-0000-0000-0000-000000000003"
}

After a withdrawal.failed, the reserved funds (amount + fees) are refunded and your referenceId is released, so you may retry the same referenceId with a new submission.


For event-specific business behavior, refer to the Deposits and Withdrawals guides.

Webhooks

The webhooks API allows you to receive real-time notifications about payment events, transaction status changes, and system updates. This guide covers webhook setup, event handling, signature verification, and best practices for reliable webhook processing.

How the system delivers webhooks

There are three different paths; only the first one is the merchant integration surface.

1. Outbound webhooks to your URL (merchant integration)

  1. You configure webhookUrl, webhookSecret, webhookEnabled, and optionally webhookEvents, webhookTimeout, and webhookRetryCount on the merchant profile (for example via user/merchant APIs).
  2. When a deposit, withdrawal, or underlying transaction changes state, the event is written to a durable outbox row in the same database transaction as the ledger change. After commit, a dispatcher delivers it (creating a webhookLog row whose id doubles as the on-the-wire eventId) by POSTing JSON to your webhookUrl. A cron sweeper re-dispatches any outbox row a crash left behind, so delivery is at-least-once and consumers must deduplicate by eventId.
  3. The HTTP request includes:
    • Content-Type: application/json
    • X-Webhook-Event — the event name (for example deposit.confirmed, withdrawal.failed)
    • X-Webhook-Id — the stable delivery id (identical on every redelivery; also present in the body as eventId)
    • X-Webhook-Signature — lowercase hex HMAC-SHA256 of the exact raw body bytes, keyed by webhookSecret (same string as JSON.stringify(payload) on the server). Verify using the raw body, not a re-serialized JSON.stringify of a parsed object, so key order cannot break verification.
  4. Subscription filter: if webhookEvents is a non-empty string array, only listed events are sent. If the field is missing or not parseable as a string array, there is no filter. An empty array means nothing is sent.
  5. Acknowledgement & retries: a delivery is final only when your endpoint returns a 2xx status and a JSON body containing "ok": true. Anything else — failed HTTP status, timeout, network error, or a 2xx without the ack — schedules a retry with exponential backoff (1s doubling, capped at 30s) up to webhookRetryCount (default 3). Operators can run the webhook-retries background job to process due retries. Redeliveries reuse the same eventId.

Event names in code (not payout.*): deposit.confirmed | deposit.failed; withdrawal.confirmed | withdrawal.failed; transaction.confirmed | transaction.failed. Pending states (deposit.pending, withdrawal.pending) exist internally but do not currently produce outbound merchant deliveries.

2. Internal ingestion — POST /api/webhook/event

This endpoint is admin-only (admin:write). It accepts callbacks from trusted upstream systems using WEBHOOK_SECRET, x-signature (hex HMAC-SHA256 of the JSON body after recursive alphabetical key sorting), and x-timestamp (Unix ms, ±5 minutes). It is not the URL you publish as a merchant. Successful processing updates core records via WebhookHandler and may trigger outbound deliveries in (1).

3. Chain-provider callbacks

Additional routes under /api/webhook/* (for example chain adapters) handle provider-specific payloads and signatures. They are internal plumbing, not the merchant webhook contract.

Overview

Outbound webhooks (merchant integration): DCE POSTs JSON to your configured webhookUrl when deposits, withdrawals, or ledger transactions change state. The canonical description (headers, X-Webhook-Signature, retries, subscription filters) is in How the system delivers webhooks at the top of this page.

Inbound operator route: POST /api/webhook/event is for admin/system ingestion only (signed with WEBHOOK_SECRET). It is not the URL you publish as a merchant.

Settlement notifications: Outbound webhook event names in code are deposit.*, withdrawal.*, and transaction.*. Do not assume settlement.* events are emitted on the same merchant webhook channel unless your account team explicitly confirms it—use the Settlements API for settlement state when in doubt.

Webhook setup (merchant)

Configure webhookUrl, webhookSecret, webhookEnabled, and optionally webhookEvents, webhookTimeout, and webhookRetryCount on the merchant profile (via user/merchant APIs).

Your HTTPS endpoint should:

  1. Accept POST requests with Content-Type: application/json
  2. Return 2xx with a JSON body containing { "ok": true } after the event is safely accepted (move heavy work async if needed) — a 2xx without that acknowledgement is treated as undelivered and retried
  3. Verify X-Webhook-Signature (hex HMAC-SHA256 of the raw body with webhookSecret)
  4. Implement idempotency keyed on the payload's eventId (delivery is at-least-once)

Outbound event names (current code)

Event typeDescription
deposit.confirmedDeposit confirmed
deposit.failedDeposit failed
withdrawal.confirmedWithdrawal confirmed
withdrawal.failedWithdrawal failed
transaction.confirmedLedger transaction confirmed
transaction.failedLedger transaction failed

The event name travels in the X-Webhook-Event header. Payloads carry a type field (deposit | withdrawal | transaction), a status field, a stable eventId, plus business fields (see the sample payloads earlier on this page). Do not rely on a generic { data, metadata } envelope unless you normalize it yourself—integrate against the header event name and the documented fields.

Internal: POST /api/webhook/event

Purpose: ingest upstream/system events for processing by WebhookHandler (operator tooling; not the merchant callback URL).

Auth: API key with admin:write, plus server WEBHOOK_SECRET.

Headers:

  • Content-Type: application/json
  • x-signature — hex HMAC-SHA256 of the canonical JSON (parsed then recursively key-sorted—see server verification)
  • x-timestamp — Unix time in milliseconds (±5 minutes)

Success response (typical):

{
  "success": true,
  "message": "Webhook processed successfully"
}

Signature Verification

Merchant outbound deliveries (X-Webhook-Signature)

DCE signs the exact raw JSON body bytes sent in the webhook POST using your webhookSecret. The signature is lowercase hex HMAC-SHA256 and is sent in the X-Webhook-Event / X-Webhook-Signature headers (see How the system delivers webhooks).

Important: verify the signature against the raw HTTP body string captured before JSON parsing. Re-stringifying JSON.parse output can break verification due to key ordering differences.

JavaScript (Express + raw body):

const crypto = require('crypto');

function verifyDceSignature(rawBodyString, signatureHeader, secret) {
  const expected = crypto.createHmac('sha256', secret).update(rawBodyString, 'utf8').digest('hex');
  return crypto.timingSafeEqual(Buffer.from(signatureHeader, 'hex'), Buffer.from(expected, 'hex'));
}

// Capture raw JSON body, then parse JSON only after verification
app.post(
  '/webhooks/dce',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const raw = req.body.toString('utf8');
    const signature = req.headers['x-webhook-signature'];
    if (!signature || !verifyDceSignature(raw, signature, process.env.DCE_WEBHOOK_SECRET)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }
    const event = JSON.parse(raw);
    processWebhook(event);
    return res.status(200).json({ ok: true });
  }
);

Python:

import hmac
import hashlib
import json

def verify_webhook_signature(payload, signature, secret):
    expected_signature = hmac.new(
        secret.encode('utf-8'),
        payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()
    
    return hmac.compare_digest(signature, expected_signature)

# Flask webhook handler
@app.route('/webhooks/dce', methods=['POST'])
def webhook_handler():
    signature = request.headers.get('X-Webhook-Signature')
    payload = request.get_data(as_text=True)
    
    if not verify_webhook_signature(payload, signature, os.environ['WEBHOOK_SECRET']):
        return jsonify({'error': 'Invalid signature'}), 401
    
    process_webhook(request.json)
    # Acknowledge with { "ok": true } — required, or the delivery is retried
    return jsonify({'ok': True})

PHP:

<?php
function verifyWebhookSignature($payload, $signature, $secret) {
    $expectedSignature = hash_hmac('sha256', $payload, $secret);
    return hash_equals($signature, $expectedSignature);
}

// Slim/Laravel webhook handler
$app->post('/webhooks/dce', function (Request $request, Response $response) {
    $signature = $request->getHeaderLine('X-Webhook-Signature');
    $payload = $request->getBody()->getContents();
    
    if (!verifyWebhookSignature($payload, $signature, $_ENV['WEBHOOK_SECRET'])) {
        $response->getBody()->write(json_encode(['error' => 'Invalid signature']));
        return $response->withStatus(401);
    }
    
    // Process webhook
    $event = json_decode($payload, true);
    processWebhook($event);
    
    // Acknowledge with { "ok": true } — required, or the delivery is retried
    $response->getBody()->write(json_encode(['ok' => true]));
    return $response;
});
?>

Java:

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;

public class WebhookVerifier {
    public static boolean verifyWebhookSignature(String payload, String signature, String secret) {
        try {
            Mac sha256Hmac = Mac.getInstance("HmacSHA256");
            SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(), "HmacSHA256");
            sha256Hmac.init(secretKey);
            
            byte[] hash = sha256Hmac.doFinal(payload.getBytes());
            StringBuilder expectedSignature = new StringBuilder();
            for (byte b : hash) {
                expectedSignature.append(String.format("%02x", b));
            }
            
            return signature.equals(expectedSignature.toString());
        } catch (Exception e) {
            return false;
        }
    }
}

// Spring Boot webhook handler
@PostMapping("/webhooks/dce")
public ResponseEntity<Map<String, Object>> webhook(
        @RequestBody Map<String, Object> event,
        @RequestHeader("X-Webhook-Signature") String signature,
        @RequestBody String rawPayload) {
    
    if (!WebhookVerifier.verifyWebhookSignature(rawPayload, signature, System.getenv("WEBHOOK_SECRET"))) {
        return ResponseEntity.status(401)
            .body(Map.of("error", "Invalid signature"));
    }
    
    // Process webhook
    processWebhook(event);
    
    // Acknowledge with { "ok": true } — required, or the delivery is retried
            return ResponseEntity.ok(Map.of("ok", true));
}

C#:

using System.Security.Cryptography;
using System.Text;

public class WebhookVerifier
{
    public static bool VerifyWebhookSignature(string payload, string signature, string secret)
    {
        using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)))
        {
            var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
            var expectedSignature = Convert.ToHexString(hash).ToLower();
            return signature.Equals(expectedSignature, StringComparison.OrdinalIgnoreCase);
        }
    }
}

// ASP.NET Core webhook handler
[HttpPost("dce")]
public IActionResult Webhook()
{
    var signature = Request.Headers["X-Webhook-Signature"].FirstOrDefault();
    var payload = Request.Body.ToString();
    
    if (!WebhookVerifier.VerifyWebhookSignature(payload, signature, 
        Environment.GetEnvironmentVariable("WEBHOOK_SECRET")))
    {
        return Unauthorized(new { error = "Invalid signature" });
    }
    
    // Process webhook
    var eventData = JsonConvert.DeserializeObject<dynamic>(payload);
    ProcessWebhook(eventData);
    
    // Acknowledge with { "ok": true } — required, or the delivery is retried
            return Ok(new { ok = true });
}

Event Processing

Fee Charges in Webhook Payloads

For deposit.confirmed and withdrawal.confirmed events, the webhook payload includes a feeCharges object that provides detailed information about the fees charged to the merchant:

{
  "feeCharges": {
    "amount": "0.25",
    "percentage": "0.0025",
    "type": "PERCENTAGE",
    "activationFee": {
      "amount": "0.5",
      "charged": true
    }
  },
  "receivableAmount": "99.25"
}

Fee Charges Object Properties

PropertyTypeDescription
amountstringThe commission amount charged (in the transaction currency, e.g. USDT)
percentagestringThe decimal rate used for calculation (e.g., "0.0025" for 0.25%); "0" for flat fees
typestringThe charge type: PERCENTAGE, FIXED_AMOUNT, or HYBRID
activationFeeobjectDeposit events only: the one-time address activation fee (amount, charged)

On withdrawal.confirmed, feeCharges is present only when a withdrawal commission was charged, and contains amount, percentage, and type (no activationFee).

Additional Fields

PropertyTypeDescription
receivableAmountstringThe final amount after fee deduction. Deposits: amount − (commission + activation fee), or amount − flat fee for merchant top-ups. Withdrawals: amount − commission
isMerchantDirectDepositbooleanDeposit events only: true when the deposit is a merchant wallet top-up (flat fee instead of commission + activation fee)

Charge Types

  • PERCENTAGE: Fee calculated as a percentage of the transaction amount
  • FIXED_AMOUNT: Fixed fee amount regardless of transaction size
  • HYBRID: Combination of percentage and fixed amount

Example Fee Calculations

Percentage-based (5%):

{
  "amount": "100.00",
  "feeCharges": {
    "amount": "5.00",
    "percentage": "0.05",
    "type": "PERCENTAGE"
  },
  "receivableAmount": "95.00"
}

Fixed amount (2.50 USDT):

{
  "amount": "100.00", 
  "feeCharges": {
    "amount": "2.50",
    "percentage": "0.00",
    "type": "FIXED_AMOUNT"
  },
  "receivableAmount": "97.50"
}

Hybrid (2% + 1.00 USDT):

{
  "amount": "100.00",
  "feeCharges": {
    "amount": "3.00",
    "percentage": "0.02", 
    "type": "HYBRID"
  },
  "receivableAmount": "97.00"
}

Deposit Events

deposit.confirmed (X-Webhook-Event: deposit.confirmed)

{
  "type": "deposit",
  "depositId": "clx0d3p0s1t000001",
  "amount": "100.00",
  "currency": "USDT",
  "status": "confirmed",
  "identifier": "user123",
  "txHash": "0x7f9fade1c0d57a7af66ab4ead79fade1c0d57a7af66ab4ead7c2c2eb7b11a91385",
  "referenceId": "your-reference-id",
  "address": "TX7uW4mP7cLoBnUYqkNhVpQvNczpxxxxxx",
  "isMerchantDirectDeposit": false,
  "feeCharges": {
    "amount": "0.25",
    "percentage": "0.0025",
    "type": "PERCENTAGE",
    "activationFee": {
      "amount": "0.5",
      "charged": true
    }
  },
  "receivableAmount": "99.25",
  "eventId": "b3a1c2d4-0000-0000-0000-000000000000"
}

Deposits below 1 USD equivalent are fee-exempt and do not produce a deposit.confirmed delivery.

deposit.failed (X-Webhook-Event: deposit.failed)

{
  "type": "deposit",
  "depositId": "clx0d3p0s1t000002",
  "amount": "100.00",
  "currency": "USDT",
  "identifier": "user123",
  "status": "failed",
  "reason": "Deposit failed",
  "txHash": "0x7f9fade1c0d57a7af66ab4ead79fade1c0d57a7af66ab4ead7c2c2eb7b11a91385",
  "address": "TX7uW4mP7cLoBnUYqkNhVpQvNczpxxxxxx",
  "eventId": "b3a1c2d4-0000-0000-0000-000000000001"
}

Withdrawal Events

withdrawal.confirmed (X-Webhook-Event: withdrawal.confirmed)

{
  "type": "withdrawal",
  "withdrawalId": "clx0w1thdr4w000001",
  "amount": "200.00",
  "currency": "USDT",
  "status": "confirmed",
  "txHash": "0x7f9fade1c0d57a7af66ab4ead79fade1c0d57a7af66ab4ead7c2c2eb7b11a91385",
  "referenceId": "your-reference-id",
  "address": "TX7uW4mP7cLoBnUYqkNhVpQvNczpxxxxxx",
  "identifier": "your-reference-id",
  "feeCharges": {
    "amount": "1.00",
    "percentage": "0",
    "type": "FIXED_AMOUNT"
  },
  "receivableAmount": "199.00",
  "eventId": "b3a1c2d4-0000-0000-0000-000000000002"
}

referenceId and identifier carry your merchant referenceId (as submitted to POST /api/withdrawals), not an internal id — correlate the callback with your original request on it.

withdrawal.failed (X-Webhook-Event: withdrawal.failed)

{
  "type": "withdrawal",
  "withdrawalId": "clx0w1thdr4w000002",
  "amount": "200.00",
  "currency": "USDT",
  "status": "failed",
  "reason": "Withdrawal failed",
  "txHash": "0x7f9fade1c0d57a7af66ab4ead79fade1c0d57a7af66ab4ead7c2c2eb7b11a91385",
  "referenceId": "your-reference-id",
  "address": "TX7uW4mP7cLoBnUYqkNhVpQvNczpxxxxxx",
  "identifier": "your-reference-id",
  "eventId": "b3a1c2d4-0000-0000-0000-000000000003"
}

On failure, the reserved balance (amount + commission + network fee) is refunded and your referenceId is released for reuse in a fresh submission.

Webhook Processing

Idempotency

Delivery is at-least-once, so implement idempotency to prevent duplicate processing. Every payload carries a stable eventId (also sent as the X-Webhook-Id header) that is identical on every redelivery of the same event — deduplicate on it:

class WebhookProcessor {
  constructor() {
    this.processedEvents = new Set();
  }
  
  async processWebhook(eventName, event) {
    const eventId = event.eventId; // stable across redeliveries
    
    // Check if already processed
    if (this.processedEvents.has(eventId)) {
      console.log(`Event already processed: ${eventId}`);
      return { status: 'already_processed' };
    }
    
    // Process the event
    await this.handleEvent(eventName, event);
    
    // Mark as processed
    this.processedEvents.add(eventId);
    
    return { status: 'processed' };
  }
  
  async handleEvent(eventName, event) {
    // eventName comes from the X-Webhook-Event header
    switch (eventName) {
      case 'deposit.confirmed':
        await this.handleDepositConfirmed(event);
        break;
      case 'deposit.failed':
        await this.handleDepositFailed(event);
        break;
      case 'withdrawal.confirmed':
        await this.handleWithdrawalConfirmed(event);
        break;
      case 'withdrawal.failed':
        await this.handleWithdrawalFailed(event);
        break;
      default:
        console.log(`Unknown event type: ${eventName}`);
    }
  }
}

Database Integration

class WebhookDatabase {
  async logWebhookEvent(event, status) {
    return await prisma.webhookLog.create({
      data: {
        eventType: event.event,
        payload: event,
        status: status,
        merchantId: event.metadata?.merchantId || 'system',
        transactionId: event.metadata?.transactionId
      }
    });
  }
  
  async updateTransactionStatus(transactionId, status, metadata = {}) {
    return await prisma.transaction.update({
      where: { id: transactionId },
      data: {
        status,
        updatedAt: new Date(),
        metadata: {
          ...metadata,
          webhookProcessedAt: new Date().toISOString()
        }
      }
    });
  }
}

Error Handling

Retry Logic

Implement exponential backoff for failed webhook processing:

class WebhookRetryHandler {
  async processWithRetry(event, maxRetries = 3) {
    let attempt = 0;
    
    while (attempt < maxRetries) {
      try {
        return await this.processWebhook(event);
      } catch (error) {
        attempt++;
        
        if (attempt >= maxRetries) {
          console.error(`Webhook processing failed after ${maxRetries} attempts:`, error);
          throw error;
        }
        
        // Exponential backoff
        const delay = Math.pow(2, attempt) * 1000;
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }
}

Error Response Handling

app.post('/webhooks/dce', async (req, res) => {
  try {
    // Verify signature
    if (!verifySignature(req)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }
    
    // Process webhook
    await webhookProcessor.processWebhook(req.body);
    
    // Acknowledge with { ok: true } — required, or the delivery is retried
    res.json({ ok: true });
  } catch (error) {
    console.error('Webhook processing error:', error);
    
    // Anything without { ok: true } is retried — return an error status so
    // the redelivery (same eventId) can be processed once the issue is fixed
    res.status(500).json({ 
      status: 'error',
      error: error.message 
    });
  }
});

Webhook Retry Strategies and Failure Handling

Understanding Webhook Delivery Failures

Webhook delivery can fail for various reasons:

  • Network Issues: Temporary connectivity problems, DNS resolution failures
  • Server Errors: 5xx HTTP status codes from your endpoint
  • Timeout Issues: Slow processing causing connection timeouts
  • Rate Limiting: Too many requests hitting your endpoint
  • Authentication Failures: Invalid signatures or missing headers
  • Payload Issues: Malformed JSON or oversized payloads

Exponential Backoff Implementation

1. Basic Exponential Backoff

class ExponentialBackoffRetry {
  constructor(options = {}) {
    this.maxRetries = options.maxRetries || 5;
    this.baseDelay = options.baseDelay || 1000; // 1 second
    this.maxDelay = options.maxDelay || 30000; // 30 seconds
    this.jitter = options.jitter || 0.1; // 10% jitter
  }

  async execute(operation) {
    let lastError;
    
    for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
      try {
        return await operation();
      } catch (error) {
        lastError = error;
        
        if (attempt === this.maxRetries) {
          throw new Error(`Operation failed after ${this.maxRetries} attempts: ${error.message}`);
        }
        
        const delay = this.calculateDelay(attempt);
        console.log(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`);
        
        await this.sleep(delay);
      }
    }
    
    throw lastError;
  }

  calculateDelay(attempt) {
    // Exponential backoff with jitter
    const exponentialDelay = Math.min(
      this.baseDelay * Math.pow(2, attempt),
      this.maxDelay
    );
    
    const jitterAmount = exponentialDelay * this.jitter;
    const jitter = (Math.random() - 0.5) * jitterAmount;
    
    return Math.floor(exponentialDelay + jitter);
  }

  sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

// Usage
const retryHandler = new ExponentialBackoffRetry({
  maxRetries: 5,
  baseDelay: 1000,
  maxDelay: 30000
});

try {
  const result = await retryHandler.execute(async () => {
    return await processWebhook(event);
  });
  console.log('Webhook processed successfully');
} catch (error) {
  console.error('Webhook processing failed:', error);
}

2. Advanced Retry with Circuit Breaker

class CircuitBreaker {
  constructor(options = {}) {
    this.failureThreshold = options.failureThreshold || 5;
    this.recoveryTimeout = options.recoveryTimeout || 60000; // 1 minute
    this.failures = 0;
    this.lastFailureTime = null;
    this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
  }

  async execute(operation) {
    if (this.state === 'OPEN') {
      if (Date.now() - this.lastFailureTime > this.recoveryTimeout) {
        this.state = 'HALF_OPEN';
      } else {
        throw new Error('Circuit breaker is OPEN');
      }
    }

    try {
      const result = await operation();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }

  onSuccess() {
    this.failures = 0;
    this.state = 'CLOSED';
  }

  onFailure() {
    this.failures++;
    this.lastFailureTime = Date.now();
    
    if (this.failures >= this.failureThreshold) {
      this.state = 'OPEN';
    }
  }
}

class AdvancedWebhookRetry {
  constructor() {
    this.retryHandler = new ExponentialBackoffRetry();
    this.circuitBreaker = new CircuitBreaker();
  }

  async processWebhook(event) {
    return await this.circuitBreaker.execute(async () => {
      return await this.retryHandler.execute(async () => {
        return await this.actualWebhookProcessing(event);
      });
    });
  }

  async actualWebhookProcessing(event) {
    // Your actual webhook processing logic
    console.log('Processing webhook:', event.event);
    // ... processing logic
  }
}

Dead Letter Queue Implementation

1. Database-Based Dead Letter Queue

class DeadLetterQueue {
  constructor(prisma) {
    this.prisma = prisma;
  }

  async addToDeadLetter(event, error, attemptCount) {
    return await this.prisma.deadLetterQueue.create({
      data: {
        eventType: event.event,
        payload: event,
        error: error.message,
        stackTrace: error.stack,
        attemptCount,
        correlationId: event.correlationId,
        createdAt: new Date(),
        status: 'pending'
      }
    });
  }

  async processDeadLetterQueue() {
    const failedEvents = await this.prisma.deadLetterQueue.findMany({
      where: {
        status: 'pending',
        createdAt: {
          gte: new Date(Date.now() - 24 * 60 * 60 * 1000) // Last 24 hours
        }
      },
      orderBy: {
        createdAt: 'asc'
      }
    });

    for (const failedEvent of failedEvents) {
      try {
        await this.retryFailedEvent(failedEvent);
        
        // Mark as processed
        await this.prisma.deadLetterQueue.update({
          where: { id: failedEvent.id },
          data: { status: 'processed' }
        });
      } catch (error) {
        console.error('Failed to process dead letter:', error);
        
        // Mark as permanently failed after max attempts
        if (failedEvent.attemptCount >= 10) {
          await this.prisma.deadLetterQueue.update({
            where: { id: failedEvent.id },
            data: { status: 'permanently_failed' }
          });
        }
      }
    }
  }

  async retryFailedEvent(failedEvent) {
    const retryHandler = new ExponentialBackoffRetry({
      maxRetries: 3,
      baseDelay: 5000 // 5 seconds
    });

    return await retryHandler.execute(async () => {
      return await this.processWebhook(failedEvent.payload);
    });
  }
}

2. Redis-Based Dead Letter Queue

class RedisDeadLetterQueue {
  constructor(redis) {
    this.redis = redis;
    this.queueKey = 'webhook:dead_letter_queue';
    this.processingKey = 'webhook:processing_queue';
  }

  async addToDeadLetter(event, error, attemptCount) {
    const deadLetterItem = {
      id: `dlq_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
      event,
      error: error.message,
      attemptCount,
      timestamp: Date.now(),
      correlationId: event.correlationId
    };

    await this.redis.lpush(this.queueKey, JSON.stringify(deadLetterItem));
    
    // Set TTL for automatic cleanup (7 days)
    await this.redis.expire(this.queueKey, 7 * 24 * 60 * 60);
    
    console.log(`Added to dead letter queue: ${deadLetterItem.id}`);
  }

  async processDeadLetterQueue() {
    while (true) {
      const item = await this.redis.brpop(this.queueKey, 1);
      
      if (!item) continue;
      
      const deadLetterItem = JSON.parse(item[1]);
      
      try {
        await this.retryFailedEvent(deadLetterItem);
        console.log(`Successfully processed dead letter: ${deadLetterItem.id}`);
      } catch (error) {
        console.error(`Failed to process dead letter ${deadLetterItem.id}:`, error);
        
        // Re-add to queue if under max attempts
        if (deadLetterItem.attemptCount < 10) {
          deadLetterItem.attemptCount++;
          await this.redis.lpush(this.queueKey, JSON.stringify(deadLetterItem));
        } else {
          console.log(`Permanently failed dead letter: ${deadLetterItem.id}`);
        }
      }
    }
  }

  async retryFailedEvent(deadLetterItem) {
    const retryHandler = new ExponentialBackoffRetry({
      maxRetries: 3,
      baseDelay: 5000
    });

    return await retryHandler.execute(async () => {
      return await this.processWebhook(deadLetterItem.event);
    });
  }
}

Comprehensive Webhook Handler with Retry and DLQ

class ComprehensiveWebhookHandler {
  constructor(options = {}) {
    this.retryHandler = new ExponentialBackoffRetry(options.retry);
    this.deadLetterQueue = new DeadLetterQueue(options.prisma);
    this.metrics = {
      totalEvents: 0,
      successfulEvents: 0,
      failedEvents: 0,
      deadLetterEvents: 0
    };
  }

  async handleWebhook(event) {
    this.metrics.totalEvents++;
    
    try {
      // Attempt to process with retry
      const result = await this.retryHandler.execute(async () => {
        return await this.processWebhook(event);
      });
      
      this.metrics.successfulEvents++;
      return result;
      
    } catch (error) {
      this.metrics.failedEvents++;
      
      // Add to dead letter queue
      await this.deadLetterQueue.addToDeadLetter(
        event, 
        error, 
        this.retryHandler.maxRetries
      );
      
      this.metrics.deadLetterEvents++;
      
      // Log for monitoring
      await this.logFailure(event, error);
      
      throw error;
    }
  }

  async processWebhook(event) {
    // Your webhook processing logic
    console.log(`Processing webhook: ${event.event}`);
    
    // Simulate processing time
    await new Promise(resolve => setTimeout(resolve, 100));
    
    // Simulate potential failure
    if (Math.random() < 0.1) { // 10% failure rate for testing
      throw new Error('Simulated processing failure');
    }
    
    return { status: 'processed', event: event.event };
  }

  async logFailure(event, error) {
    const logEntry = {
      timestamp: new Date().toISOString(),
      event: event.event,
      correlationId: event.correlationId,
      error: error.message,
      stackTrace: error.stack
    };
    
    // Log to monitoring system
    console.error('Webhook failure:', logEntry);
    
    // Send to monitoring service
    await this.sendToMonitoring(logEntry);
  }

  async sendToMonitoring(logEntry) {
    // Send to your monitoring service (DataDog, New Relic, etc.)
    console.log('Sending to monitoring:', logEntry);
  }

  getMetrics() {
    return {
      ...this.metrics,
      successRate: this.metrics.totalEvents > 0 
        ? (this.metrics.successfulEvents / this.metrics.totalEvents) * 100 
        : 0
    };
  }
}

// Usage
const handler = new ComprehensiveWebhookHandler({
  retry: {
    maxRetries: 5,
    baseDelay: 1000,
    maxDelay: 30000
  },
  prisma: prisma
});

// Process webhook
try {
  const result = await handler.handleWebhook(webhookEvent);
  console.log('Webhook processed:', result);
} catch (error) {
  console.error('Webhook failed:', error);
}

// Monitor metrics
setInterval(() => {
  const metrics = handler.getMetrics();
  console.log('Webhook metrics:', metrics);
}, 60000); // Log metrics every minute

Monitoring and Alerting

1. Webhook Health Monitoring

class WebhookHealthMonitor {
  constructor() {
    this.alerts = [];
    this.thresholds = {
      failureRate: 0.05, // 5% failure rate
      avgProcessingTime: 5000, // 5 seconds
      deadLetterQueueSize: 100
    };
  }

  async checkHealth(metrics) {
    const alerts = [];
    
    // Check failure rate
    if (metrics.successRate < (1 - this.thresholds.failureRate) * 100) {
      alerts.push({
        severity: 'critical',
        message: `High webhook failure rate: ${(100 - metrics.successRate).toFixed(2)}%`,
        metric: 'failure_rate',
        value: 100 - metrics.successRate
      });
    }
    
    // Check dead letter queue size
    if (metrics.deadLetterEvents > this.thresholds.deadLetterQueueSize) {
      alerts.push({
        severity: 'warning',
        message: `Large dead letter queue: ${metrics.deadLetterEvents} events`,
        metric: 'dead_letter_queue_size',
        value: metrics.deadLetterEvents
      });
    }
    
    return alerts;
  }

  async sendAlert(alert) {
    // Send to your alerting system (PagerDuty, Slack, etc.)
    console.log(`ALERT [${alert.severity.toUpperCase()}]: ${alert.message}`);
    
    // Store alert
    this.alerts.push({
      ...alert,
      timestamp: new Date().toISOString()
    });
  }
}

2. Real-time Metrics Dashboard

class WebhookMetricsDashboard {
  constructor() {
    this.metrics = {
      hourly: new Map(),
      daily: new Map()
    };
  }

  recordEvent(event, processingTime, success) {
    const hour = new Date().toISOString().slice(0, 13) + ':00:00.000Z';
    const day = new Date().toISOString().slice(0, 10);
    
    // Update hourly metrics
    if (!this.metrics.hourly.has(hour)) {
      this.metrics.hourly.set(hour, {
        total: 0,
        successful: 0,
        failed: 0,
        avgProcessingTime: 0,
        events: []
      });
    }
    
    const hourlyMetric = this.metrics.hourly.get(hour);
    hourlyMetric.total++;
    hourlyMetric.avgProcessingTime = 
      (hourlyMetric.avgProcessingTime + processingTime) / 2;
    
    if (success) {
      hourlyMetric.successful++;
    } else {
      hourlyMetric.failed++;
    }
    
    hourlyMetric.events.push({
      event: event.event,
      processingTime,
      success,
      timestamp: new Date().toISOString()
    });
    
    // Keep only last 100 events per hour
    if (hourlyMetric.events.length > 100) {
      hourlyMetric.events = hourlyMetric.events.slice(-100);
    }
  }

  getMetrics(timeframe = 'hourly') {
    const metrics = timeframe === 'hourly' ? this.metrics.hourly : this.metrics.daily;
    const result = [];
    
    for (const [timestamp, data] of metrics) {
      result.push({
        timestamp,
        total: data.total,
        successful: data.successful,
        failed: data.failed,
        successRate: data.total > 0 ? (data.successful / data.total) * 100 : 0,
        avgProcessingTime: data.avgProcessingTime
      });
    }
    
    return result.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
  }
}

Best Practices for Webhook Reliability

1. Idempotency Implementation

class IdempotentWebhookProcessor {
  constructor(prisma) {
    this.prisma = prisma;
  }

  async processWebhook(event) {
    const eventId = this.generateEventId(event);
    
    // Check if already processed
    const existing = await this.prisma.webhookLog.findFirst({
      where: { eventId }
    });
    
    if (existing) {
      console.log(`Event already processed: ${eventId}`);
      return { status: 'already_processed', eventId };
    }
    
    // Process webhook
    const result = await this.actualProcessing(event);
    
    // Log successful processing
    await this.prisma.webhookLog.create({
      data: {
        eventId,
        eventType: event.event,
        payload: event,
        status: 'processed',
        processedAt: new Date()
      }
    });
    
    return { status: 'processed', eventId, result };
  }

  generateEventId(event) {
    // DCE payloads carry a stable `eventId` (also in the X-Webhook-Id header)
    // that is identical on every redelivery — always prefer it.
    if (event.eventId) {
      return event.eventId;
    }

    // Fallback for payloads from other sources
    const components = [
      event.type,
      event.status,
      event.txHash
    ];
    
    return require('crypto')
      .createHash('sha256')
      .update(components.join('|'))
      .digest('hex');
  }
}

2. Rate Limiting and Throttling

class WebhookRateLimiter {
  constructor(options = {}) {
    this.maxRequestsPerSecond = options.maxRequestsPerSecond || 10;
    this.maxRequestsPerMinute = options.maxRequestsPerMinute || 100;
    this.requests = [];
  }

  async throttle(operation) {
    await this.waitForRateLimit();
    
    this.requests.push(Date.now());
    
    // Clean old requests
    this.requests = this.requests.filter(
      time => Date.now() - time < 60000 // Keep last minute
    );
    
    return await operation();
  }

  async waitForRateLimit() {
    const now = Date.now();
    const requestsLastSecond = this.requests.filter(
      time => now - time < 1000
    ).length;
    
    const requestsLastMinute = this.requests.length;
    
    if (requestsLastSecond >= this.maxRequestsPerSecond) {
      const delay = 1000 - (now - this.requests[0]);
      if (delay > 0) {
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
    
    if (requestsLastMinute >= this.maxRequestsPerMinute) {
      const delay = 60000 - (now - this.requests[0]);
      if (delay > 0) {
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }
}

Configuration Examples

1. Environment-Specific Configuration

// config/webhook-config.js
const webhookConfig = {
  development: {
    retry: {
      maxRetries: 3,
      baseDelay: 1000,
      maxDelay: 10000
    },
    deadLetterQueue: {
      maxRetries: 5,
      cleanupAfterDays: 1
    },
    monitoring: {
      enabled: false
    }
  },
  
  staging: {
    retry: {
      maxRetries: 5,
      baseDelay: 2000,
      maxDelay: 30000
    },
    deadLetterQueue: {
      maxRetries: 10,
      cleanupAfterDays: 3
    },
    monitoring: {
      enabled: true,
      alertThreshold: 0.1 // 10% failure rate
    }
  },
  
  production: {
    retry: {
      maxRetries: 7,
      baseDelay: 3000,
      maxDelay: 60000
    },
    deadLetterQueue: {
      maxRetries: 15,
      cleanupAfterDays: 7
    },
    monitoring: {
      enabled: true,
      alertThreshold: 0.05 // 5% failure rate
    }
  }
};

module.exports = webhookConfig[process.env.NODE_ENV || 'development'];

2. Docker Compose for Webhook Testing

# docker-compose.webhook-test.yml
version: '3.8'

services:
  webhook-server:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=development
      - WEBHOOK_SECRET=test_secret
      - DATABASE_URL=postgresql://user:pass@db:5432/webhook_test
    depends_on:
      - db
      - redis

  db:
    image: postgres:13
    environment:
      POSTGRES_DB: webhook_test
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

  redis:
    image: redis:6-alpine
    ports:
      - "6379:6379"

  webhook-tester:
    build: .
    command: npm run test:webhooks
    environment:
      - WEBHOOK_URL=http://webhook-server:3000/webhooks/dce
      - WEBHOOK_SECRET=test_secret
    depends_on:
      - webhook-server

volumes:
  postgres_data:

This comprehensive webhook retry and failure handling system provides:

  • Exponential backoff with jitter for retry attempts
  • Circuit breaker pattern to prevent cascading failures
  • Dead letter queue for permanently failed events
  • Comprehensive monitoring and alerting
  • Idempotency to prevent duplicate processing
  • Rate limiting to prevent overwhelming your endpoint
  • Environment-specific configuration for different deployment stages

The system ensures your webhook processing is robust, reliable, and maintainable across all environments.

Testing Webhooks

Local Testing

Use ngrok to test webhooks locally:

# Install ngrok
npm install -g ngrok

# Start your local server
npm start

# Expose local server
ngrok http 3000

# Use the ngrok URL as your webhook endpoint
# https://abc123.ngrok.io/webhooks/dce

Webhook Testing Tools

class WebhookTester {
  async testWebhookEndpoint(url, event) {
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Webhook-Signature': this.generateSignature(event)
      },
      body: JSON.stringify(event)
    });
    
    return {
      status: response.status,
      body: await response.json()
    };
  }
  
  generateSignature(payload) {
    const crypto = require('crypto');
    return crypto
      .createHmac('sha256', process.env.WEBHOOK_SECRET)
      .update(JSON.stringify(payload), 'utf8')
      .digest('hex');
  }
  
  createTestEvent(eventName, data = {}) {
    const [type, status] = eventName.split('.');
    return {
      type,
      status,
      eventId: `test-${Date.now()}`,
      ...data
    };
  }
}

// Usage
const tester = new WebhookTester();
const testEvent = tester.createTestEvent('deposit.confirmed', {
  depositId: 'test_deposit_id',
  txHash: 'test_tx_hash',
  amount: '100.00',
  currency: 'USDT',
  address: 'TX7uW4mP7cLoBnUYqkNhVpQvNczpxxxxxx'
});

const result = await tester.testWebhookEndpoint('https://your-domain.com/webhooks/dce', testEvent);
console.log(result);

Best Practices

1. Security

  • Always verify signatures before processing webhooks
  • Use HTTPS for webhook endpoints
  • Implement rate limiting to prevent abuse
  • Validate payload structure before processing

2. Reliability

class ReliableWebhookHandler {
  constructor() {
    this.queue = [];
    this.processing = false;
  }
  
  async addToQueue(event) {
    this.queue.push(event);
    
    if (!this.processing) {
      this.processing = true;
      await this.processQueue();
    }
  }
  
  async processQueue() {
    while (this.queue.length > 0) {
      const event = this.queue.shift();
      
      try {
        await this.processWebhook(event);
      } catch (error) {
        console.error('Failed to process webhook:', error);
        // Add back to queue for retry
        this.queue.unshift(event);
        await new Promise(resolve => setTimeout(resolve, 5000));
      }
    }
    
    this.processing = false;
  }
}

3. Monitoring

class WebhookMonitor {
  constructor() {
    this.metrics = {
      totalEvents: 0,
      successfulEvents: 0,
      failedEvents: 0,
      averageProcessingTime: 0
    };
  }
  
  async trackEvent(event, processingTime) {
    this.metrics.totalEvents++;
    this.metrics.averageProcessingTime = 
      (this.metrics.averageProcessingTime + processingTime) / 2;
    
    // Log to monitoring service
    await this.logToMonitoring({
      event: event.event,
      processingTime,
      timestamp: new Date().toISOString()
    });
  }
  
  async logToMonitoring(data) {
    // Send to monitoring service (e.g., DataDog, New Relic)
    console.log('Webhook metric:', data);
  }
}

4. Logging

class WebhookLogger {
  async logWebhook(event, result) {
    const logEntry = {
      timestamp: new Date().toISOString(),
      event: event.event,
      correlationId: event.correlationId,
      status: result.status,
      processingTime: result.processingTime,
      error: result.error
    };
    
    // Log to database
    await this.saveToDatabase(logEntry);
    
    // Log to external service
    await this.sendToLogService(logEntry);
  }
}

Integration Examples

Express.js Webhook Handler

const express = require('express');
const crypto = require('crypto');

const app = express();
app.use(express.json());

class WebhookHandler {
  constructor() {
    this.processor = new WebhookProcessor();
    this.logger = new WebhookLogger();
  }
  
  async handleWebhook(req, res) {
    const startTime = Date.now();
    
    try {
      // Verify signature
      if (!this.verifySignature(req)) {
        return res.status(401).json({ error: 'Invalid signature' });
      }
      
      // Process webhook
      const result = await this.processor.processWebhook(req.body);
      
      // Log success
      await this.logger.logWebhook(req.body, {
        status: 'success',
        processingTime: Date.now() - startTime
      });
      
      // Acknowledge with { ok: true } — required, or the delivery is retried
      res.json({ ok: true });
    } catch (error) {
      // Log error
      await this.logger.logWebhook(req.body, {
        status: 'error',
        processingTime: Date.now() - startTime,
        error: error.message
      });
      
      res.status(500).json({ 
        status: 'error',
        error: error.message 
      });
    }
  }
  
  verifySignature(req) {
    const signature = req.headers['x-webhook-signature'];
    const payload = JSON.stringify(req.body);
    
    const expectedSignature = crypto
      .createHmac('sha256', process.env.WEBHOOK_SECRET)
      .update(payload, 'utf8')
      .digest('hex');
    
    return crypto.timingSafeEqual(
      Buffer.from(signature, 'hex'),
      Buffer.from(expectedSignature, 'hex')
    );
  }
}

const handler = new WebhookHandler();
app.post('/webhooks/dce', (req, res) => handler.handleWebhook(req, res));

Python Flask Webhook Handler

from flask import Flask, request, jsonify
import hmac
import hashlib
import json
import os
from datetime import datetime

app = Flask(__name__)

class WebhookHandler:
    def __init__(self):
        self.secret = os.environ['WEBHOOK_SECRET']
    
    def verify_signature(self, payload, signature):
        expected_signature = hmac.new(
            self.secret.encode('utf-8'),
            payload.encode('utf-8'),
            hashlib.sha256
        ).hexdigest()
        
        return hmac.compare_digest(signature, expected_signature)
    
    def process_webhook(self, event):
        # Event name from the X-Webhook-Event header, or derived from the body
        event_type = f"{event.get('type')}.{event.get('status')}"
        
        if event_type == 'deposit.confirmed':
            return self.handle_deposit_confirmed(event)
        elif event_type == 'withdrawal.confirmed':
            return self.handle_withdrawal_confirmed(event)
        else:
            print(f"Unknown event type: {event_type}")
            return {'status': 'ignored'}
    
    def handle_deposit_confirmed(self, event):
        # Update user balance
        # Update transaction status
        # Send notification
        return {'status': 'processed'}
    
    def handle_withdrawal_confirmed(self, event):
        # Update withdrawal status
        # Send confirmation email
        return {'status': 'processed'}

handler = WebhookHandler()

@app.route('/webhooks/dce', methods=['POST'])
def webhook_endpoint():
    try:
        # Verify signature
        signature = request.headers.get('X-Webhook-Signature')
        payload = request.get_data(as_text=True)
        
        if not handler.verify_signature(payload, signature):
            return jsonify({'error': 'Invalid signature'}), 401
        
        # Process webhook
        event = request.json
        result = handler.process_webhook(event)
        
        # Acknowledge with { "ok": true } — required, or the delivery is retried
        return jsonify({'ok': True})
    except Exception as e:
        print(f"Webhook processing error: {e}")
        return jsonify({'status': 'error', 'error': str(e)}), 500

if __name__ == '__main__':
    app.run(debug=True)

Node.js (Express):

const express = require('express');
const crypto = require('crypto');
require('dotenv').config();

const app = express();
app.use(express.json());

function verifyWebhookSignature(payload, signature, secret) {
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(payload, 'utf8')
    .digest('hex');
  
  return crypto.timingSafeEqual(
    Buffer.from(signature, 'hex'),
    Buffer.from(expectedSignature, 'hex')
  );
}

function processWebhook(event) {
  // The event name arrives in the X-Webhook-Event header; it can also be
  // derived from the body as `${event.type}.${event.status}`.
  const eventName = `${event.type}.${event.status}`;
  console.log('Processing webhook:', eventName);
  
  switch (eventName) {
    case 'deposit.confirmed':
      console.log('Deposit confirmed:', event.txHash);
      // Update database, send confirmation email, etc.
      break;
    case 'deposit.failed':
      console.log('Deposit failed:', event.reason);
      // Handle failed deposit
      break;
    case 'withdrawal.confirmed':
      console.log('Withdrawal confirmed:', event.txHash);
      // Update withdrawal status
      break;
    case 'withdrawal.failed':
      console.log('Withdrawal failed:', event.reason);
      // Handle failed withdrawal
      break;
    default:
      console.log('Unknown event type:', eventName);
  }
}

app.post('/webhooks/dce', (req, res) => {
  try {
    const signature = req.headers['x-webhook-signature'];
    const payload = JSON.stringify(req.body);
    
    if (!verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }
    
    processWebhook(req.body);
    // Acknowledge with { ok: true } — required, or the delivery is retried
    res.json({ ok: true });
  } catch (error) {
    console.error('Webhook error:', error);
    res.status(500).json({ error: 'Internal server error' });
  }
});

app.listen(3000, () => {
  console.log('Webhook server running on port 3000');
});

Python (Flask):

from flask import Flask, request, jsonify
import hmac
import hashlib
import os
from dotenv import load_dotenv

load_dotenv()
app = Flask(__name__)

def verify_webhook_signature(payload, signature, secret):
    expected_signature = hmac.new(
        secret.encode('utf-8'),
        payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()
    
    return hmac.compare_digest(signature, expected_signature)

def process_webhook(event):
    # Event name comes from the X-Webhook-Event header, or derive it from the body
    event_name = f"{event['type']}.{event['status']}"
    print(f"Processing webhook: {event_name}")
    
    if event_name == 'deposit.confirmed':
        print(f"Deposit confirmed: {event['txHash']}")
        # Update database, send confirmation email, etc.
    elif event_name == 'deposit.failed':
        print(f"Deposit failed: {event['reason']}")
        # Handle failed deposit
    elif event_name == 'withdrawal.confirmed':
        print(f"Withdrawal confirmed: {event['txHash']}")
        # Update withdrawal status
    elif event_name == 'withdrawal.failed':
        print(f"Withdrawal failed: {event['reason']}")
        # Handle failed withdrawal
    else:
        print(f"Unknown event type: {event_name}")

@app.route('/webhooks/dce', methods=['POST'])
def webhook():
    try:
        signature = request.headers.get('X-Webhook-Signature')
        payload = request.get_data(as_text=True)
        
        if not verify_webhook_signature(payload, signature, os.getenv('WEBHOOK_SECRET')):
            return jsonify({'error': 'Invalid signature'}), 401
        
        process_webhook(request.json)
        # Acknowledge with { "ok": true } — required, or the delivery is retried
        return jsonify({'ok': True})
    except Exception as e:
        print('Webhook error:', e)
        return jsonify({'error': 'Internal server error'}), 500

if __name__ == '__main__':
    app.run(port=3000)

PHP (Laravel/Slim):

<?php
require 'vendor/autoload.php';

use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory;

$app = AppFactory::create();
$app->addBodyParsingMiddleware();

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

function processWebhook($event) {
    // Event name comes from the X-Webhook-Event header, or derive it from the body
    $eventName = $event['type'] . '.' . $event['status'];
    echo "Processing webhook: " . $eventName . "\n";
    
    switch ($eventName) {
        case 'deposit.confirmed':
            echo "Deposit confirmed: " . $event['txHash'] . "\n";
            // Update database, send confirmation email, etc.
            break;
        case 'deposit.failed':
            echo "Deposit failed: " . $event['reason'] . "\n";
            // Handle failed deposit
            break;
        case 'withdrawal.confirmed':
            echo "Withdrawal confirmed: " . $event['txHash'] . "\n";
            // Update withdrawal status
            break;
        case 'withdrawal.failed':
            echo "Withdrawal failed: " . $event['reason'] . "\n";
            // Handle failed withdrawal
            break;
        default:
            echo "Unknown event type: " . $eventName . "\n";
    }
}

$app->post('/webhooks/dce', function (Request $request, Response $response) {
    try {
        $signature = $request->getHeaderLine('X-Webhook-Signature');
        $payload = $request->getBody()->getContents();
        
        if (!verifyWebhookSignature($payload, $signature, $_ENV['WEBHOOK_SECRET'])) {
            $response->getBody()->write(json_encode(['error' => 'Invalid signature']));
            return $response->withStatus(401);
        }
        
        $event = json_decode($payload, true);
        processWebhook($event);
        
        // Acknowledge with { "ok": true } — required, or the delivery is retried
    $response->getBody()->write(json_encode(['ok' => true]));
        return $response;
    } catch (Exception $e) {
        echo "Webhook error: " . $e->getMessage() . "\n";
        $response->getBody()->write(json_encode(['error' => 'Internal server error']));
        return $response->withStatus(500);
    }
});

$app->run();
?>

Java (Spring Boot):

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.Map;

@SpringBootApplication
@RestController
public class WebhookController {
    
    private static final String WEBHOOK_SECRET = System.getenv("WEBHOOK_SECRET");
    
    private boolean verifyWebhookSignature(String payload, String signature) {
        try {
            Mac sha256Hmac = Mac.getInstance("HmacSHA256");
            SecretKeySpec secretKey = new SecretKeySpec(WEBHOOK_SECRET.getBytes(), "HmacSHA256");
            sha256Hmac.init(secretKey);
            
            byte[] hash = sha256Hmac.doFinal(payload.getBytes());
            StringBuilder expectedSignature = new StringBuilder();
            for (byte b : hash) {
                expectedSignature.append(String.format("%02x", b));
            }
            
            return signature.equals(expectedSignature.toString());
        } catch (Exception e) {
            return false;
        }
    }
    
    private void processWebhook(Map<String, Object> event) {
        // Event name from the X-Webhook-Event header, or derived from the body
        String eventType = event.get("type") + "." + event.get("status");
        System.out.println("Processing webhook: " + eventType);
        
        switch (eventType) {
            case "deposit.confirmed":
                System.out.println("Deposit confirmed: " + event.get("txHash"));
                // Update database, send confirmation email, etc.
                break;
            case "deposit.failed":
                System.out.println("Deposit failed: " + event.get("reason"));
                // Handle failed deposit
                break;
            case "withdrawal.confirmed":
                System.out.println("Withdrawal confirmed: " + event.get("txHash"));
                // Update withdrawal status
                break;
            case "withdrawal.failed":
                System.out.println("Withdrawal failed: " + event.get("reason"));
                // Handle failed withdrawal
                break;
            default:
                System.out.println("Unknown event type: " + eventType);
        }
    }
    
    @PostMapping("/webhooks/dce")
    public ResponseEntity<Map<String, Object>> webhook(
            @RequestBody Map<String, Object> event,
            @RequestHeader("X-Webhook-Signature") String signature,
            @RequestBody String rawPayload) {
        
        try {
            if (!verifyWebhookSignature(rawPayload, signature)) {
                return ResponseEntity.status(401)
                    .body(Map.of("error", "Invalid signature"));
            }
            
            processWebhook(event);
            // Acknowledge with { "ok": true } — required, or the delivery is retried
            return ResponseEntity.ok(Map.of("ok", true));
        } catch (Exception e) {
            System.err.println("Webhook error: " + e.getMessage());
            return ResponseEntity.status(500)
                .body(Map.of("error", "Internal server error"));
        }
    }
    
    public static void main(String[] args) {
        SpringApplication.run(WebhookController.class, args);
    }
}

C# (.NET):

using Microsoft.AspNetCore.Mvc;
using System.Security.Cryptography;
using System.Text;
using Newtonsoft.Json;

[ApiController]
[Route("[controller]")]
public class WebhookController : ControllerBase
{
    private static readonly string webhookSecret = Environment.GetEnvironmentVariable("WEBHOOK_SECRET");
    
    private bool VerifyWebhookSignature(string payload, string signature)
    {
        using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(webhookSecret)))
        {
            var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
            var expectedSignature = Convert.ToHexString(hash).ToLower();
            return signature.Equals(expectedSignature, StringComparison.OrdinalIgnoreCase);
        }
    }
    
    private void ProcessWebhook(dynamic eventData)
    {
        // Event name from the X-Webhook-Event header, or derived from the body
        string eventType = (string)eventData.type + "." + (string)eventData.status;
        Console.WriteLine($"Processing webhook: {eventType}");
        
        switch (eventType)
        {
            case "deposit.confirmed":
                Console.WriteLine($"Deposit confirmed: {eventData.txHash}");
                // Update database, send confirmation email, etc.
                break;
            case "deposit.failed":
                Console.WriteLine($"Deposit failed: {eventData.reason}");
                // Handle failed deposit
                break;
            case "withdrawal.confirmed":
                Console.WriteLine($"Withdrawal confirmed: {eventData.txHash}");
                // Update withdrawal status
                break;
            case "withdrawal.failed":
                Console.WriteLine($"Withdrawal failed: {eventData.reason}");
                // Handle failed withdrawal
                break;
            default:
                Console.WriteLine($"Unknown event type: {eventType}");
                break;
        }
    }
    
    [HttpPost("dce")]
    public IActionResult Webhook()
    {
        try
        {
            var signature = Request.Headers["X-Webhook-Signature"].FirstOrDefault();
            var payload = Request.Body.ToString();
            
            if (!VerifyWebhookSignature(payload, signature))
            {
                return Unauthorized(new { error = "Invalid signature" });
            }
            
            var eventData = JsonConvert.DeserializeObject<dynamic>(payload);
            ProcessWebhook(eventData);
            
            // Acknowledge with { "ok": true } — required, or the delivery is retried
            return Ok(new { ok = true });
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Webhook error: {ex.Message}");
            return StatusCode(500, new { error = "Internal server error" });
        }
    }
}

For more information about specific event types, see the Deposits and Withdrawals documentation.


Did this page help you?