Skip to main content
Webhooks allow you to receive real-time notifications when events occur in your escrow transactions or identity verification sessions. This guide explains how to set up and use webhooks.

Overview

Webhooks are HTTP callbacks that notify your application when escrow, contract, or identity verification events occur. Instead of polling the API, you can receive instant notifications when:
  • Escrow status changes (pending → paid → delivered → completed → cancelled)
  • Escrows are cancelled
  • A contract is signed by the seller or buyer
  • Pre-account KYC status changes or links to a DHMAD user account
  • Any status update occurs

Setting Up Webhooks

Step 1: Create Webhook Endpoint

Create an HTTP endpoint in your application that can receive POST requests:
const express = require('express');
const crypto = require('crypto');
const app = express();

app.use(express.json());

app.post('/webhooks/dhmad', async (req, res) => {
const signature = req.headers['x-webhook-signature'];
const event = req.headers['x-webhook-event'];
const webhookId = req.headers['x-webhook-id'];
const timestamp = req.headers['x-webhook-timestamp'];
const payload = JSON.stringify(req.body);

// Reject replayed events older than 5 minutes
const age = Date.now() - new Date(timestamp).getTime();
if (age > 5 * 60 * 1000) {
  return res.status(400).send('Event too old');
}

// Verify webhook signature (see verification section)
// Process the event (use webhookId for deduplication)
// Return 200 OK quickly

res.status(200).send('OK');
});

app.listen(3000);

Step 2: Configure Webhook in Dashboard

  1. Log into the Developer Dashboard
  2. Navigate to the “Webhooks” section
  3. Click “Create Webhook”
  4. Enter your webhook URL (must be HTTPS in production)
  5. The webhook will automatically subscribe to escrow.status.updated events
You can create a maximum of 2 webhooks per developer account. Choose your endpoints carefully.
In development mode, HTTP URLs are allowed. In production, only HTTPS URLs are accepted.

Webhook Events

You can subscribe to the following events:

escrow.status.updated

Triggered when an escrow status changes (pending → paid → delivered → completed → cancelled)

contract.signed

Triggered when the escrow contract is signed by the seller or the buyer. Each signature triggers one event; data.contract.signedBy indicates who signed (“seller” or “buyer”).

escrow.cancellation.requested

Triggered when the buyer requests cancellation of a paid or delivered escrow. The escrow status does not change yet. Sellers cancel directly on dhmad.tn without this event.

escrow.cancellation.rejected

Triggered when the other party rejects a pending cancellation request. The escrow status remains unchanged.

escrow.cancellation.accepted

Triggered when the other party accepts a cancellation request. The escrow is refunded and its status changes to cancelled. An escrow.status.updated event is also fired.

escrow.updated

Triggered when escrow details are updated: title, amount, estimated delivery time, or contract terms (via the DHMAD dashboard or API). Use this to keep your app in sync when users edit these fields in DHMAD instead of in your app.

escrow.deposit_proof.rejected

Triggered when a guest instant-escrow payment proof is rejected: either an admin rejected it, or DHMAD auto-rejected it because the escrow was cancelled while the proof was still pending. Check data.depositProof.reason and data.escrow.status: if the escrow is still pending, the buyer can usually submit a new proof from the same checkout link; if the escrow is cancelled, treat the payment attempt as closed. Subscribe if you use guest checkout for instant escrows.

escrow.delivery.rejected

Triggered when the buyer rejects the seller’s delivery. The escrow status changes from delivered back to paid so the seller can fix issues and deliver again. Includes data.deliveryRejection.reason when the buyer provided feedback. An escrow.status.updated event (deliveredpaid) is also fired.

escrow.proof.*

Triggered during the Quick Escrow with Proof lifecycle (escrow.proof.required, .submitted, .correction_requested, .accepted, .accepted_by_timeout, .needs_review, .expired). Payload includes proof with phase, deadlines, and time-limited viewUrl on attachments.

identity.verification.updated

Triggered when a pre-account identity verification kyc_status becomes approved or rejected (Didit result, admin review, or sandbox auto-approve). Not sent while Didit returns an ambiguous pending result awaiting admin review.

identity.verification.linked

Triggered when the user registers on DHMAD with the same email and approved KYC is attached to their account. Includes linked_user_id.

Event Payload

All webhook events share the same top-level structure: id, type, timestamp, and data. The data object depends on the event type.

escrow.status.updated

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "type": "escrow.status.updated",
  "timestamp": "2024-01-15T10:30:00Z",
  "data": {
    "escrow": {
      "id": "507f1f77bcf86cd799439011",
      "title": "Web Development Service",
      "amount": 1000.0,
      "escrowFee": 50.0,
      "status": "paid",
      "oldStatus": "pending",
      "seller": {
        "_id": "507f1f77bcf86cd799439013",
        "email": "seller@example.com",
        "firstName": "John",
        "lastName": "Doe"
      },
      "buyer": {
        "_id": "507f1f77bcf86cd799439014",
        "email": "buyer@example.com",
        "firstName": "Jane",
        "lastName": "Smith"
      },
      "estimatedDeliveryDays": 7,
      "createdAt": "2024-01-15T10:00:00Z",
      "updatedAt": "2024-01-15T10:30:00Z"
    }
  }
}

escrow.updated

Sent when the escrow’s title, amount, estimated delivery days, or contract terms are updated (via the dashboard or API). The payload includes which fields changed and the current escrow snapshot; when contract terms were updated, data.contract is also present.
{
  "id": "550e8400-e29b-41d4-a716-446655440002",
  "type": "escrow.updated",
  "timestamp": "2024-01-15T12:00:00Z",
  "data": {
    "updatedFields": ["amount", "estimatedDeliveryDays"],
    "escrow": {
      "id": "507f1f77bcf86cd799439011",
      "title": "Web Development Service",
      "amount": 1200.0,
      "escrowFee": 60.0,
      "status": "pending",
      "seller": {
        "_id": "507f1f77bcf86cd799439013",
        "email": "seller@example.com",
        "firstName": "John",
        "lastName": "Doe"
      },
      "buyer": {
        "_id": "507f1f77bcf86cd799439014",
        "email": "buyer@example.com",
        "firstName": "Jane",
        "lastName": "Smith"
      },
      "estimatedDeliveryDays": 14,
      "createdAt": "2024-01-15T10:00:00Z",
      "updatedAt": "2024-01-15T12:00:00Z"
    }
  }
}
When contract terms are updated, data also includes a contract object with id, terms, and optional language. The escrow object has the same shape as in the example above.

escrow.deposit_proof.rejected

Sent when a guest instant-escrow payment proof is rejected—by an admin (optional reason in depositProof.reason) or automatically when the escrow is cancelled while a proof was still pending (reason explains that the escrow was cancelled before review). Always inspect data.escrow.status: only when the escrow remains pending should you expect the buyer to upload another proof from the same checkout session.
{
  "id": "550e8400-e29b-41d4-a716-446655440099",
  "type": "escrow.deposit_proof.rejected",
  "timestamp": "2024-01-15T14:00:00Z",
  "data": {
    "escrow": {
      "id": "507f1f77bcf86cd799439011",
      "title": "Instant purchase",
      "amount": 50.0,
      "escrowFee": 0.0,
      "status": "pending",
      "seller": { "_id": "507f1f77bcf86cd799439013", "email": "seller@example.com" },
      "buyer": null,
      "estimatedDeliveryDays": 1,
      "createdAt": "2024-01-15T10:00:00Z",
      "updatedAt": "2024-01-15T10:00:00Z"
    },
    "depositProof": {
      "id": "507f1f77bcf86cd799439099",
      "buyerEmail": "buyer@example.com",
      "reason": "Amount mismatch",
      "amount": 50.0,
      "method": "flouci"
    }
  }
}

contract.signed

Sent when the seller or the buyer signs the contract (one event per signature).
{
  "id": "550e8400-e29b-41d4-a716-446655440001",
  "type": "contract.signed",
  "timestamp": "2024-01-15T11:00:00Z",
  "data": {
    "contract": {
      "id": "507f1f77bcf86cd799439012",
      "escrowId": "507f1f77bcf86cd799439011",
      "signedBy": "seller",
      "sellerSignature": { "signedAt": "2024-01-15T11:00:00Z" },
      "buyerSignature": null,
      "isFullySigned": false,
      "signedAt": "2024-01-15T11:00:00Z"
    },
    "escrow": {
      "id": "507f1f77bcf86cd799439011",
      "title": "Web Development Service",
      "amount": 1000.0,
      "escrowFee": 50.0,
      "status": "pending",
      "seller": {
        "_id": "507f1f77bcf86cd799439013",
        "email": "seller@example.com",
        "firstName": "John",
        "lastName": "Doe"
      },
      "buyer": {
        "_id": "507f1f77bcf86cd799439014",
        "email": "buyer@example.com",
        "firstName": "Jane",
        "lastName": "Smith"
      },
      "estimatedDeliveryDays": 7,
      "createdAt": "2024-01-15T10:00:00Z",
      "updatedAt": "2024-01-15T11:00:00Z"
    }
  }
}

escrow.cancellation.requested

Sent when the buyer requests cancellation. The same payload structure applies to escrow.cancellation.rejected and escrow.cancellation.accepted (with the corresponding type and cancellationRequest.status).
{
  "id": "550e8400-e29b-41d4-a716-446655440002",
  "type": "escrow.cancellation.requested",
  "timestamp": "2024-01-16T09:00:00Z",
  "data": {
    "escrow": {
      "id": "507f1f77bcf86cd799439011",
      "title": "Web Development Service",
      "amount": 1000.0,
      "escrowFee": 50.0,
      "status": "delivered",
      "seller": {
        "_id": "507f1f77bcf86cd799439013",
        "email": "seller@example.com",
        "firstName": "John",
        "lastName": "Doe"
      },
      "buyer": {
        "_id": "507f1f77bcf86cd799439014",
        "email": "buyer@example.com",
        "firstName": "Jane",
        "lastName": "Smith"
      },
      "estimatedDeliveryDays": 7,
      "createdAt": "2024-01-15T10:00:00Z",
      "updatedAt": "2024-01-16T09:00:00Z"
    },
    "cancellationRequest": {
      "requestedBy": {
        "_id": "507f1f77bcf86cd799439014",
        "firstName": "Jane",
        "lastName": "Smith"
      },
      "reason": "Service not as described",
      "status": "pending",
      "requestedAt": "2024-01-16T09:00:00Z"
    }
  }
}

escrow.delivery.rejected

Sent when the buyer rejects delivery. The escrow moves from delivered back to paid. Subscribe to this event to read the buyer’s optional feedback in data.deliveryRejection.reason. An escrow.status.updated event is also emitted.
{
  "id": "550e8400-e29b-41d4-a716-446655440003",
  "type": "escrow.delivery.rejected",
  "timestamp": "2024-01-20T16:30:00Z",
  "data": {
    "escrow": {
      "id": "507f1f77bcf86cd799439011",
      "title": "Web Development Service",
      "amount": 1000.0,
      "currency": "TND",
      "amountTnd": 1000.0,
      "escrowFee": 50.0,
      "status": "paid",
      "oldStatus": "delivered",
      "seller": {
        "_id": "507f1f77bcf86cd799439013",
        "email": "seller@example.com",
        "firstName": "John",
        "lastName": "Doe"
      },
      "buyer": {
        "_id": "507f1f77bcf86cd799439014",
        "email": "buyer@example.com",
        "firstName": "Jane",
        "lastName": "Smith"
      },
      "estimatedDeliveryDays": 7,
      "createdAt": "2024-01-15T10:00:00Z",
      "updatedAt": "2024-01-20T16:30:00Z"
    },
    "deliveryRejection": {
      "reason": "One item was missing from the shipment.",
      "rejectedAt": "2024-01-20T16:30:00Z",
      "previousDeliveredAt": "2024-01-20T14:00:00Z",
      "rejectedBy": "507f1f77bcf86cd799439014"
    }
  }
}
If the buyer did not provide a reason, deliveryRejection.reason is omitted from the payload.

Quick Escrow with Proof events

Subscribe to these when you create escrows with fulfillmentPolicy.type: "purchase_proof_required". See the Quick Escrow with Proof guide.
EventWhen
escrow.proof.requiredBuyer funded; seller must upload proof
escrow.proof.submittedSeller uploaded; buyer should review
escrow.proof.correction_requestedBuyer requested better proof
escrow.proof.acceptedBuyer accepted; delivery unlocked
escrow.proof.accepted_by_timeoutReview deadline passed; delivery unlocked
escrow.proof.needs_reviewEscalated to DHMAD after max correction rounds
escrow.proof.expiredNo proof in time; escrow cancelled and buyer refunded
Proof payloads include a proof object with phase, deadlines, submissions (with time-limited viewUrl per attachment), latestSubmission, and checkoutActions for the next hosted step. You can also poll GET /api/v1/escrows/:id/proof.
{
  "id": "550e8400-e29b-41d4-a716-446655440004",
  "type": "escrow.proof.submitted",
  "timestamp": "2026-06-19T14:00:00.000Z",
  "data": {
    "escrow": {
      "id": "507f1f77bcf86cd799439011",
      "title": "iPhone 15 from Dubai",
      "status": "paid",
      "mode": "quick"
    },
    "proof": {
      "phase": "submitted_waiting_buyer_review",
      "correctionRounds": 0,
      "uploadDeadline": "2026-06-20T10:00:00.000Z",
      "reviewDeadline": "2026-06-21T10:00:00.000Z",
      "latestSubmission": {
        "submittedAt": "2026-06-19T14:00:00.000Z",
        "attachments": [
          {
            "mimeType": "image/jpeg",
            "viewUrl": "https://cdn.dhmad.tn/escrow-proof/..."
          }
        ]
      },
      "checkoutActions": [
        {
          "action": "review_proof",
          "targetUserEmail": "buyer@example.com",
          "description": "Create a checkout session with this action and redirect the buyer to DHMAD..."
        }
      ]
    }
  }
}

Identity verification events

Subscribe to these when you use pre-account KYC.

identity.verification.updated

Sent when kyc_status becomes approved or rejected.
{
  "id": "550e8400-e29b-41d4-a716-446655440001",
  "type": "identity.verification.updated",
  "timestamp": "2026-06-06T14:30:00.000Z",
  "data": {
    "id": "6655abc1234567890abcdef12",
    "external_user_id": "seller_42",
    "email": "seller@example.com",
    "kyc_status": "approved",
    "previous_kyc_status": "pending",
    "needs_admin_review": false,
    "linked_user_id": null,
    "status_source": "didit",
    "claim_status": "pending",
    "updated_at": "2026-06-06T14:30:00.000Z"
  }
}

identity.verification.linked

Sent when the user creates a DHMAD account and KYC is attached.
{
  "id": "550e8400-e29b-41d4-a716-446655440002",
  "type": "identity.verification.linked",
  "timestamp": "2026-06-10T09:00:00.000Z",
  "data": {
    "id": "6655abc1234567890abcdef12",
    "external_user_id": "seller_42",
    "email": "seller@example.com",
    "kyc_status": "approved",
    "linked_user_id": "6655user1234567890abcdef",
    "linked_at": "2026-06-10T09:00:00.000Z"
  }
}

Payload Fields

Webhook Headers

Each webhook request includes the following headers:
To protect against replay attacks, always validate X-Webhook-Timestamp — reject any delivery where the timestamp is more than 5 minutes in the past. Track X-Webhook-Id values to discard duplicate deliveries.

Verifying Webhook Signatures

Always verify webhook signatures to ensure requests are from DHMAD and haven’t been tampered with.

Getting Your Webhook Secret

The webhook secret is generated automatically when you create a webhook. The secret is only shown once at creation and again if you regenerate it. It is never returned by the list or get endpoints. Copy and store the secret immediately when it is displayed. If you lose it, use the “Regenerate Secret” action in your dashboard to get a new one (the old secret will stop working).
Store your webhook secret securely. Never commit it to version control or expose it publicly.

Signature Verification Examples

const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(payload))
.digest('hex');

return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}

// In your webhook handler
app.post('/webhooks/dhmad', express.json(), (req, res) => {
const signature = req.headers['x-webhook-signature'];
const secret = process.env.DHMAD_WEBHOOK_SECRET;

if (!verifyWebhookSignature(req.body, signature, secret)) {
return res.status(401).send('Invalid signature');
}

// Process webhook
console.log('Webhook verified:', req.body);
res.status(200).send('OK');
});

Best Practices

Verify Signatures

Always verify webhook signatures before processing. This ensures the request is from DHMAD and hasn’t been tampered with.

Respond Quickly

Return 200 OK within 5 seconds. Process the webhook asynchronously if needed.

Idempotency

Handle duplicate events gracefully. Use the event id to track processed events.

Error Handling

Log errors and implement retry logic for failed processing. Don’t fail the webhook response.

HTTPS Only

Use HTTPS endpoints in production. HTTP is only allowed in development.

Monitor Failures

Track webhook delivery failures in your dashboard and set up alerts.

Webhook Delivery

Retry Logic

If your endpoint doesn’t return a 200 OK status within 10 seconds, DHMAD will retry the webhook:
  • Retries: Up to 3 attempts
  • Backoff: Exponential backoff (1s, 2s, 4s)
  • Client Errors (4xx): Not retried (fix your endpoint)
  • Server Errors (5xx): Retried

Delivery Status

You can monitor webhook delivery status in your dashboard:
  • Last Triggered: Timestamp of last webhook delivery
  • Last Response Status: HTTP status code from your endpoint
  • Failure Count: Number of consecutive failures
If a webhook fails repeatedly, consider checking your endpoint and fixing any issues. High failure counts may indicate problems with your webhook handler.

Example: Complete Webhook Handler

Here’s a complete example of a webhook handler with signature verification and async processing:
const express = require('express');
const crypto = require('crypto');
const { Queue } = require('bullmq');

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

const webhookQueue = new Queue('webhooks', {
connection: { host: 'localhost', port: 6379 }
});

function verifyWebhookSignature(payload, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(payload))
.digest('hex');

return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}

app.post('/webhooks/dhmad', async (req, res) => {
const signature = req.headers['x-webhook-signature'];
const event = req.headers['x-webhook-event'];
const secret = process.env.DHMAD_WEBHOOK_SECRET;

// Verify signature
if (!verifyWebhookSignature(req.body, signature, secret)) {
return res.status(401).json({ error: 'Invalid signature' });
}

// Return 200 OK immediately
res.status(200).json({ received: true });

// Process webhook asynchronously
await webhookQueue.add('process-webhook', {
event,
payload: req.body,
receivedAt: new Date().toISOString()
});
});

// Worker to process webhooks
const worker = new Worker('webhooks', async (job) => {
const { event, payload } = job.data;

if (event === 'escrow.status.updated') {
const { escrow } = payload.data;
console.log(`Escrow ${escrow.id} status changed: ${escrow.oldStatus}${escrow.status}`);
// Update your database, send notifications, trigger other actions
} else if (event === 'contract.signed') {
const { contract, escrow } = payload.data;
console.log(`Contract ${contract.id} signed by ${contract.signedBy} for escrow ${escrow.id}`);
// Update your database, notify when both parties have signed (contract.isFullySigned)
}
});

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

Testing Webhooks

Using ngrok (Local Development)

To test webhooks locally, use ngrok to expose your local server:
# Install ngrok
npm install -g ngrok

# Start your webhook server
node server.js

# In another terminal, expose it
ngrok http 3000

# Use the ngrok URL in your webhook configuration
# Example: https://abc123.ngrok.io/webhooks/dhmad

Testing Checklist

  • Webhook endpoint returns 200 OK
  • Signature verification works correctly
  • Event processing handles all status transitions
  • Duplicate events are handled idempotently
  • Errors are logged but don’t fail the response
  • Webhook works in production (HTTPS)

Troubleshooting

Webhook Not Receiving Events

  1. Check Webhook Status: Ensure the webhook is active in your dashboard
  2. Verify URL: Make sure the URL is correct and accessible
  3. Check HTTPS: In production, ensure your endpoint uses HTTPS
  4. Review Logs: Check your server logs for incoming requests
  5. Test Endpoint: Manually POST to your endpoint to verify it works

Invalid Signature Errors

  1. Verify Secret: Ensure you’re using the correct webhook secret
  2. Check Payload Format: Make sure you’re stringifying the payload correctly
  3. Header Name: Verify you’re reading X-Webhook-Signature (case-sensitive)

High Failure Count

  1. Response Time: Ensure your endpoint responds within 10 seconds
  2. Status Code: Return 200 OK for successful processing
  3. Error Handling: Don’t throw errors that cause 500 responses
  4. Network Issues: Check for firewall or network problems

Webhook Management

Viewing Webhooks

In your dashboard, you can:
  • View all your webhooks
  • See delivery status and failure counts
  • Check last triggered timestamp
  • View last response status

Updating Webhooks

You can update webhook URLs and toggle active/inactive status from the dashboard.

Deleting Webhooks

Delete webhooks you no longer need. Remember, you can only have 2 webhooks maximum.
Webhooks are delivered for escrows where your associated user account is the seller or buyer, or for escrows created through your developer account via the API (tracked by createdByDeveloper). This means if you create escrows with a sellerEmail, you’ll still receive webhook notifications for those escrows.
Never expose your webhook secret. Treat it like a password and store it securely using environment variables or secret management services.