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

# Escrow webhooks

> Get notified at your callback URL every time an escrow changes state, so you never have to poll.

Instead of polling [List escrow transactions](/api-reference/escrow/list-escrow-transactions),
let Payluk **push** escrow updates to you. Whenever an escrow you manage is
created or changes status, Payluk sends a signed `POST` request to your
registered **callback URL**.

```mermaid theme={null}
flowchart LR
  A["Escrow created / status changes"] --> B["Payluk signs the payload"]
  B --> C["POST to your callback URL<br/>x-payluk-signature"]
  C --> D["You verify the signature<br/>and update your system"]
  D --> E["Respond 2xx"]
```

## Prerequisites

A webhook is sent only when **all** of these hold:

<Check>You've set a **callback URL** for the environment (test or live) in your dashboard business settings.</Check>
<Check>The escrow carries your **`merchantId`**: i.e. it was created through the API under your merchant account.</Check>
<Check>The escrow's seller is one of your **merchant customers** (`merchant_user`).</Check>

If any is missing, no webhook fires and the underlying escrow operation still
succeeds as normal.

<Info>
  Payluk picks the callback URL **and** signing key by the escrow's environment:
  escrows created with a `sk_live_` key post to your **live** callback URL and are
  signed with your **live** secret; `sk_test_` escrows post to your **test**
  callback URL signed with your **test** secret.
</Info>

## Events

The `event` field is `escrow.<status>` (lower-cased), plus `escrow.created` when a
new escrow is generated. Every status transition emits one event:

| Event                  | Fires when                                          | Lifecycle          |
| ---------------------- | --------------------------------------------------- | ------------------ |
| `escrow.created`       | A new escrow link is generated                      | `AWAITING_PAYMENT` |
| `escrow.pending`       | Escrow is created/awaiting funding                  | `AWAITING_PAYMENT` |
| `escrow.ongoing`       | Buyer funded the escrow; funds held                 | `OPENED`           |
| `escrow.completed`     | Buyer confirmed delivery / final milestone released | `CLOSED`           |
| `escrow.claimed`       | Seller claimed funds after the delivery window      | `CLOSED`           |
| `escrow.disputed`      | A party opened a dispute                            | `OPENED`           |
| `escrow.investigating` | Dispute is under review                             | `OPENED`           |
| `escrow.refunded`      | Dispute resolved in the buyer's favour              | `CLOSED`           |

See [Escrow lifecycle](/concepts/escrow-lifecycle) for how these map to `state`
and `status`.

<Note>
  Multi-quantity links emit events for **each escrow** independently; the original
  link and every cloned escrow. Match on `data.id`, not the `paymentToken`. See
  [Multi-quantity escrows](/concepts/multi-quantity-escrows).
</Note>

## The payload

Every webhook body has the same envelope:

```json theme={null}
{
  "event": "escrow.completed",
  "data": {
    "id": "665f1b2c9a1e4d0012ab3c10",
    "amount": 150000,
    "purpose": "MacBook Pro 14\"",
    "description": "Space grey, sealed",
    "whoPays": "both",
    "imageUrl": ["https://cdn.payluk.ng/escrow/abc.png"],
    "fee": 3750,
    "paymentToken": "PY_8AB12C9D3045",
    "sellerId": "665f1b2c9a1e4d0012ab3c01",
    "buyerId": "665f1b2c9a1e4d0012ab3c02",
    "paidAt": "1750593600",
    "status": "COMPLETED",
    "state": "CLOSED",
    "logs": [],
    "channel": "API",
    "dispute": null,
    "category": null,
    "completedAt": "2026-06-22T12:00:00.000Z",
    "approvedClaimBy": null,
    "refundedBy": null,
    "paymentId": "665f1b2c9a1e4d0012ab3c99",
    "maxDelivery": 3,
    "deliveryDetails": null,
    "deliveryTimeline": "days",
    "totalQuantity": 1,
    "settlementType": "STANDARD",
    "milestones": null,
    "createdAt": "2026-06-22T10:15:00.000Z",
    "updatedAt": "2026-06-22T12:00:00.000Z",
    "environment": "live",
    "merchantId": "665f1b2c9a1e4d0012ab3c00"
  },
  "timestamp": "2026-06-22T12:00:00.000Z"
}
```

| Field       | Description                                            |
| ----------- | ------------------------------------------------------ |
| `event`     | The event name, e.g. `escrow.completed`.               |
| `data`      | The full escrow object (same shape across all events). |
| `timestamp` | ISO-8601 time the webhook was generated.               |

The `data` object mirrors the escrow you get from the API, including `status`,
`state`, `settlementType`, `milestones`, `totalQuantity`, `environment` and
`merchantId`.

## Verify the signature

Each request carries an **`x-payluk-signature`** header: an **HMAC-SHA512** of the
**raw JSON body**, keyed with your environment's **secret key**. Always verify it
before trusting a payload.

```bash Headers theme={null}
Content-Type: application/json
User-Agent: Payluk-Webhook/1.0
x-payluk-signature: 9f86d081…   # hex HMAC-SHA512 of the body
```

<CodeGroup>
  ```js Node.js (Express) theme={null}
  import crypto from "crypto";
  import express from "express";

  const app = express();

  // Capture the raw body so the signature matches byte-for-byte.
  app.use(express.json({
    verify: (req, _res, buf) => { req.rawBody = buf; },
  }));

  app.post("/webhooks/payluk", (req, res) => {
    const secret = process.env.PAYLUK_SECRET_KEY; // sk_live_… or sk_test_…
    const expected = crypto
      .createHmac("sha512", secret)
      .update(req.rawBody)
      .digest("hex");

    const received = req.headers["x-payluk-signature"];

    const valid =
      received &&
      crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));

    if (!valid) return res.status(401).send("Invalid signature");

    const { event, data } = req.body;
    // …update your records based on event / data.status …

    res.sendStatus(200); // acknowledge quickly
  });
  ```

  ```python Python (Flask) theme={null}
  import hmac, hashlib, os
  from flask import Flask, request, abort

  app = Flask(__name__)

  @app.post("/webhooks/payluk")
  def payluk_webhook():
      secret = os.environ["PAYLUK_SECRET_KEY"].encode()
      expected = hmac.new(secret, request.get_data(), hashlib.sha512).hexdigest()
      received = request.headers.get("x-payluk-signature", "")

      if not hmac.compare_digest(expected, received):
          abort(401)

      payload = request.get_json()
      # …handle payload["event"] / payload["data"] …
      return "", 200
  ```
</CodeGroup>

<Warning>
  Sign against the **exact raw bytes** of the request body. If you re-serialize the
  parsed JSON, key ordering or spacing may differ and the signature won't match.
</Warning>

## Delivery & retries

| Behaviour | Detail                                                                                          |
| --------- | ----------------------------------------------------------------------------------------------- |
| Method    | `POST` with a JSON body.                                                                        |
| Headers   | `Content-Type: application/json`, `User-Agent: Payluk-Webhook/1.0`, `x-payluk-signature`.       |
| Timeout   | Payluk waits up to **10 seconds** for your response.                                            |
| Retries   | Failed attempts are retried up to **3 times** with exponential backoff.                         |
| Delivery  | **Best-effort**: webhook failures are logged but never block or roll back the escrow operation. |

<Tip>
  Acknowledge fast with a `2xx`, then do heavy work asynchronously. If you need
  state that may have arrived out of order, re-fetch the escrow with
  [Verify payment token](/api-reference/escrow/verify-payment-token) using the
  `paymentToken` in the payload.
</Tip>

## Best practices

<AccordionGroup>
  <Accordion title="Always verify the signature">
    Treat any request with a missing or mismatched `x-payluk-signature` as
    untrusted and reject it with `401`. Use a constant-time comparison.
  </Accordion>

  <Accordion title="Be idempotent">
    The same event may be delivered more than once (a retry after a slow `2xx`).
    De-duplicate on `data.id` + `event` (or `data.status`) so reprocessing is a
    no-op.
  </Accordion>

  <Accordion title="Don't assume order">
    Events are sent as state changes happen and may arrive out of order under
    retries. Trust `data.status` / `data.state` as the source of truth rather than
    the sequence of events.
  </Accordion>

  <Accordion title="Separate test and live endpoints">
    Configure distinct callback URLs and verify each with its matching secret
    (`sk_test_` vs `sk_live_`). The `data.environment` field tells you which one a
    payload belongs to.
  </Accordion>
</AccordionGroup>
