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/jsonX-Webhook-Event: event nameX-Webhook-Signature: lowercase hex HMAC-SHA256 signature of the exact raw request body, using yourwebhookSecretX-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:
webhookUrlwebhookSecretwebhookEnabled- Optional:
webhookEvents,webhookTimeout,webhookRetryCount
Events
Deposit events
| Event | Description |
|---|---|
deposit.confirmed | Deposit is confirmed |
deposit.failed | Deposit failed |
Deposits below 1 USD equivalent are fee-exempt and do not trigger a merchant deposit callback.
Withdrawal events
| Event | Description |
|---|---|
withdrawal.confirmed | Withdrawal is confirmed |
withdrawal.failed | Withdrawal failed |
Transaction events
| Event | Description |
|---|---|
transaction.confirmed | Ledger transaction confirmed |
transaction.failed | Ledger 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
2xxstatus and a JSON body containing{ "ok": true }. A2xxwithout 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(default3). - Redeliveries reuse the same
eventId/X-Webhook-Id, so deduplicating oneventIdmakes retries safe. - For withdrawals, your own
referenceIdis also an idempotency key on the API side: resubmitting a withdrawal with an already-usedreferenceIdreturns409 Duplicate referenceIdand 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
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.activationFeereports the one-time address activation fee (0.5) charged on the first confirmed deposit to an address.- For merchant wallet top-ups (
isMerchantDirectDeposit: true) thefeeCharges.typeisFIXED_AMOUNTandamountis 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
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
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(andidentifier) is your merchantreferenceIdas submitted with the withdrawal — not an internal id — so you can correlate the callback with the request you made.feeChargesis always present.amountis the commission ("0"withtype: "NONE"when no commission is charged);networkFeeis the per-chain fee charged on top of the withdrawal amount.receivableAmount=amount− commission.
withdrawal.failed
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)
- You configure
webhookUrl,webhookSecret,webhookEnabled, and optionallywebhookEvents,webhookTimeout, andwebhookRetryCounton the merchant profile (for example via user/merchant APIs). - 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
webhookLogrow whose id doubles as the on-the-wireeventId) by POSTing JSON to yourwebhookUrl. A cron sweeper re-dispatches any outbox row a crash left behind, so delivery is at-least-once and consumers must deduplicate byeventId. - The HTTP request includes:
Content-Type: application/jsonX-Webhook-Event— the event name (for exampledeposit.confirmed,withdrawal.failed)X-Webhook-Id— the stable delivery id (identical on every redelivery; also present in the body aseventId)X-Webhook-Signature— lowercase hex HMAC-SHA256 of the exact raw body bytes, keyed bywebhookSecret(same string asJSON.stringify(payload)on the server). Verify using the raw body, not a re-serializedJSON.stringifyof a parsed object, so key order cannot break verification.
- Subscription filter: if
webhookEventsis 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. - 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 towebhookRetryCount(default 3). Operators can run thewebhook-retriesbackground job to process due retries. Redeliveries reuse the sameeventId.
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
POST /api/webhook/eventThis 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:
- Accept POST requests with
Content-Type: application/json - Return
2xxwith 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 - Verify
X-Webhook-Signature(hex HMAC-SHA256 of the raw body withwebhookSecret) - Implement idempotency keyed on the payload's
eventId(delivery is at-least-once)
Outbound event names (current code)
| Event type | Description |
|---|---|
deposit.confirmed | Deposit confirmed |
deposit.failed | Deposit failed |
withdrawal.confirmed | Withdrawal confirmed |
withdrawal.failed | Withdrawal failed |
transaction.confirmed | Ledger transaction confirmed |
transaction.failed | Ledger 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
POST /api/webhook/eventPurpose: 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/jsonx-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)
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
| Property | Type | Description |
|---|---|---|
amount | string | The commission amount charged (in the transaction currency, e.g. USDT) |
percentage | string | The decimal rate used for calculation (e.g., "0.0025" for 0.25%); "0" for flat fees |
type | string | The charge type: PERCENTAGE, FIXED_AMOUNT, or HYBRID |
activationFee | object | Deposit 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
| Property | Type | Description |
|---|---|---|
receivableAmount | string | The final amount after fee deduction. Deposits: amount − (commission + activation fee), or amount − flat fee for merchant top-ups. Withdrawals: amount − commission |
isMerchantDirectDeposit | boolean | Deposit 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)
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)
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)
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)
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 minuteMonitoring 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/dceWebhook 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.
Updated 14 days ago

