> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dhmad.tn/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Set up real-time notifications for escrow, contract, and identity verification events

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:

<CodeGroup>
  ```javascript Express.js theme={null}
  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);

  ```

  ```python Flask theme={null}
  from flask import Flask, request, jsonify
  import hmac
  import hashlib
  from datetime import datetime, timezone, timedelta

  app = Flask(__name__)

  @app.route('/webhooks/dhmad', methods=['POST'])
  def webhook():
      signature = request.headers.get('X-Webhook-Signature')
      event = request.headers.get('X-Webhook-Event')
      webhook_id = request.headers.get('X-Webhook-Id')
      timestamp = request.headers.get('X-Webhook-Timestamp')
      payload = request.get_json()

      # Reject replayed events older than 5 minutes
      event_time = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
      if datetime.now(timezone.utc) - event_time > timedelta(minutes=5):
          return jsonify({'error': 'Event too old'}), 400

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

      return jsonify({'status': 'ok'}), 200

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

  ```php Laravel theme={null}
  <?php

  namespace App\Http\Controllers;

  use Illuminate\Http\Request;
  use Carbon\Carbon;

  class WebhookController extends Controller
  {
      public function handle(Request $request)
      {
          $signature = $request->header('X-Webhook-Signature');
          $event = $request->header('X-Webhook-Event');
          $webhookId = $request->header('X-Webhook-Id');
          $timestamp = $request->header('X-Webhook-Timestamp');
          $payload = $request->all();

          // Reject replayed events older than 5 minutes
          if (Carbon::parse($timestamp)->diffInMinutes(now()) > 5) {
              return response()->json(['error' => 'Event too old'], 400);
          }

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

          return response()->json(['status' => 'ok'], 200);
      }
  }
  ```
</CodeGroup>

### Step 2: Configure Webhook in Dashboard

1. Log into the [Developer Dashboard](https://developer.dhmad.tn/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

<Warning>
  You can create a maximum of 2 webhooks per developer account. Choose your
  endpoints carefully.
</Warning>

<Info>
  In development mode, HTTP URLs are allowed. In production, only HTTPS URLs are
  accepted.
</Info>

## Webhook Events

You can subscribe to the following events:

<CardGroup cols={1}>
  <Card title="escrow.status.updated" icon="refresh">
    Triggered when an escrow status changes (pending → paid → delivered →
    completed → cancelled)
  </Card>

  <Card title="contract.signed" icon="file-signature">
    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").
  </Card>

  <Card title="escrow.cancellation.requested" icon="xmark">
    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.
  </Card>

  <Card title="escrow.cancellation.rejected" icon="ban">
    Triggered when the other party rejects a pending cancellation request.
    The escrow status remains unchanged.
  </Card>

  <Card title="escrow.cancellation.accepted" icon="check">
    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.
  </Card>

  <Card title="escrow.updated" icon="pencil">
    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.
  </Card>

  <Card title="escrow.deposit_proof.rejected" icon="ban">
    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.
  </Card>

  <Card title="escrow.delivery.rejected" icon="rotate-left">
    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 (`delivered` → `paid`)
    is also fired.
  </Card>

  <Card title="escrow.proof.*" icon="file-image">
    Triggered during the [Quick Escrow with Proof](/guides/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.
  </Card>

  <Card title="identity.verification.updated" icon="id-card">
    Triggered when a [pre-account identity verification](/guides/kyc-for-marketplaces)
    `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.
  </Card>

  <Card title="identity.verification.linked" icon="link">
    Triggered when the user registers on DHMAD with the same email and approved
    KYC is attached to their account. Includes `linked_user_id`.
  </Card>
</CardGroup>

## 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

```json theme={null}
{
  "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.

```json theme={null}
{
  "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.

```json theme={null}
{
  "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).

```json theme={null}
{
  "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`).

```json theme={null}
{
  "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.

```json theme={null}
{
  "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](/guides/quick-escrow-with-proof).

| Event                               | When                                                  |
| ----------------------------------- | ----------------------------------------------------- |
| `escrow.proof.required`             | Buyer funded; seller must upload proof                |
| `escrow.proof.submitted`            | Seller uploaded; buyer should review                  |
| `escrow.proof.correction_requested` | Buyer requested better proof                          |
| `escrow.proof.accepted`             | Buyer accepted; delivery unlocked                     |
| `escrow.proof.accepted_by_timeout`  | Review deadline passed; delivery unlocked             |
| `escrow.proof.needs_review`         | Escalated to DHMAD after max correction rounds        |
| `escrow.proof.expired`              | No 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](/api-reference/escrows/get-escrow-proof)**.

```json theme={null}
{
  "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](/guides/kyc-for-marketplaces).

### identity.verification.updated

Sent when `kyc_status` becomes `approved` or `rejected`.

```json theme={null}
{
  "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"
  }
}
```

<ParamField response="data.status_source" type="string">
  How the status was set: `didit`, `admin_review`, or `sandbox`
</ParamField>

### identity.verification.linked

Sent when the user creates a DHMAD account and KYC is attached.

```json theme={null}
{
  "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

<ParamField response="id" type="string">
  Unique identifier for this webhook event
</ParamField>

<ParamField response="type" type="string">
  Event type: "escrow\.status.updated", "contract.signed",
  "escrow\.cancellation.requested", "escrow\.cancellation.rejected",
  "escrow\.cancellation.accepted", "escrow\.delivery.rejected",
  "escrow\.updated", "escrow\.deposit\_proof.rejected",
  "escrow\.proof.required", "escrow\.proof.submitted", "escrow\.proof.correction\_requested",
  "escrow\.proof.accepted", "escrow\.proof.accepted\_by\_timeout", "escrow\.proof.needs\_review",
  "escrow\.proof.expired",
  "identity.verification.updated", or "identity.verification.linked"
</ParamField>

<ParamField response="timestamp" type="string">
  ISO 8601 timestamp when the event occurred
</ParamField>

<ParamField response="data.escrow.id" type="string">
  Escrow ID
</ParamField>

<ParamField response="data.escrow.status" type="string">
  Escrow status (pending, paid, delivered, completed, cancelled). For
  contract.signed, oldStatus is not present.
</ParamField>

<ParamField response="data.escrow.oldStatus" type="string">
  Previous escrow status (escrow\.status.updated only)
</ParamField>

<ParamField response="data.contract" type="object">
  Present for contract.signed only. Contains contract id, escrowId, signedBy
  ("seller" or "buyer"), signature timestamps, and isFullySigned.
</ParamField>

<ParamField response="data.contract.signedBy" type="string">
  Who signed in this event: "seller" or "buyer"
</ParamField>

<ParamField response="data.cancellationRequest" type="object">
  Present for escrow\.cancellation.\* events only. Contains requestedBy, reason,
  status ("pending", "accepted", or "rejected"), requestedAt, and optionally
  acceptedBy or rejectedBy.
</ParamField>

<ParamField response="data.cancellationRequest.status" type="string">
  Cancellation request status: "pending", "accepted", or "rejected"
</ParamField>

<ParamField response="data.cancellationRequest.reason" type="string">
  The reason provided for the cancellation request
</ParamField>

## Webhook Headers

Each webhook request includes the following headers:

<ParamField response="X-Webhook-Signature" type="string">
  HMAC SHA256 signature of the payload. Use this to verify the webhook is from
  DHMAD.
</ParamField>

<ParamField response="X-Webhook-Event" type="string">
  The event type (e.g., "escrow\.status.updated", "contract.signed")
</ParamField>

<ParamField response="X-Webhook-Id" type="string">
  Unique identifier (UUID) for this delivery. Use this to detect and discard duplicate deliveries.
</ParamField>

<ParamField response="X-Webhook-Timestamp" type="string">
  ISO 8601 timestamp of when the event was dispatched. Reject events with timestamps older than 5 minutes to prevent replay attacks.
</ParamField>

<ParamField response="Content-Type" type="string">
  Always "application/json"
</ParamField>

<Warning>
  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.
</Warning>

## 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).

<Warning>
  Store your webhook secret securely. Never commit it to version control or
  expose it publicly.
</Warning>

### Signature Verification Examples

<CodeGroup>
  ```javascript Node.js theme={null}
  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');
  });

  ```

  ```python Python theme={null}
  import hmac
  import hashlib
  import json

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

      return hmac.compare_digest(signature, expected_signature)

  # In your webhook handler
  @app.route('/webhooks/dhmad', methods=['POST'])
  def webhook():
      signature = request.headers.get('X-Webhook-Signature')
      secret = os.getenv('DHMAD_WEBHOOK_SECRET')
      payload = request.get_json()

      if not verify_webhook_signature(payload, signature, secret):
          return jsonify({'error': 'Invalid signature'}), 401

      # Process webhook
      print('Webhook verified:', payload)
      return jsonify({'status': 'ok'}), 200
  ```

  ```php PHP theme={null}
  <?php

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

  // In your webhook handler
  public function handle(Request $request) {
      $signature = $request->header('X-Webhook-Signature');
      $secret = env('DHMAD_WEBHOOK_SECRET');
      $payload = $request->all();

      if (!verifyWebhookSignature($payload, $signature, $secret)) {
          return response()->json(['error' => 'Invalid signature'], 401);
      }

      // Process webhook
      Log::info('Webhook verified:', $payload);
      return response()->json(['status' => 'ok'], 200);
  }
  ```
</CodeGroup>

## Best Practices

<CardGroup cols={2}>
  <Card title="Verify Signatures" icon="shield-check">
    Always verify webhook signatures before processing. This ensures the request
    is from DHMAD and hasn't been tampered with.
  </Card>

  <Card title="Respond Quickly" icon="bolt">
    Return 200 OK within 5 seconds. Process the webhook asynchronously if
    needed.
  </Card>

  <Card title="Idempotency" icon="refresh">
    Handle duplicate events gracefully. Use the event `id` to track processed
    events.
  </Card>

  <Card title="Error Handling" icon="exclamation-triangle">
    Log errors and implement retry logic for failed processing. Don't fail the
    webhook response.
  </Card>

  <Card title="HTTPS Only" icon="lock">
    Use HTTPS endpoints in production. HTTP is only allowed in development.
  </Card>

  <Card title="Monitor Failures" icon="chart-line">
    Track webhook delivery failures in your dashboard and set up alerts.
  </Card>
</CardGroup>

## 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

<Warning>
  If a webhook fails repeatedly, consider checking your endpoint and fixing any
  issues. High failure counts may indicate problems with your webhook handler.
</Warning>

## Example: Complete Webhook Handler

Here's a complete example of a webhook handler with signature verification and async processing:

<CodeGroup>
  ```javascript Node.js theme={null}
  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');
  });

  ```

  ```python Python theme={null}
  from flask import Flask, request, jsonify
  import hmac
  import hashlib
  import json
  import os
  from celery import Celery

  app = Flask(__name__)
  celery = Celery('webhooks', broker='redis://localhost:6379/0')

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

      return hmac.compare_digest(signature, expected_signature)

  @app.route('/webhooks/dhmad', methods=['POST'])
  def webhook():
      signature = request.headers.get('X-Webhook-Signature')
      event = request.headers.get('X-Webhook-Event')
      secret = os.getenv('DHMAD_WEBHOOK_SECRET')
      payload = request.get_json()

      # Verify signature
      if not verify_webhook_signature(payload, signature, secret):
          return jsonify({'error': 'Invalid signature'}), 401

      # Return 200 OK immediately
      response = jsonify({'received': True})

      # Process webhook asynchronously
      process_webhook.delay(event, payload)

      return response, 200

  @celery.task
  def process_webhook(event, payload):
      if event == 'escrow.status.updated':
          escrow = payload['data']['escrow']
          print(f"Escrow {escrow['id']} status changed: {escrow['oldStatus']} → {escrow['status']}")
          # Update your database, send notifications, trigger other actions
      elif event == 'contract.signed':
          contract = payload['data']['contract']
          escrow = payload['data']['escrow']
          print(f"Contract {contract['id']} signed by {contract['signedBy']} for escrow {escrow['id']}")
          # Update your database, notify when both parties have signed (contract['isFullySigned'])

  if __name__ == '__main__':
      app.run(port=3000)
  ```
</CodeGroup>

## Testing Webhooks

### Using ngrok (Local Development)

To test webhooks locally, use ngrok to expose your local server:

```bash theme={null}
# 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.

***

<Info>
  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.
</Info>

<Warning>
  Never expose your webhook secret. Treat it like a password and store it
  securely using environment variables or secret management services.
</Warning>
