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

# Webhooks

> Get told when an order is paid, fails or is refunded. Verify the signature, acknowledge fast, and handle repeats.

Webhooks tell your server when an order's payment outcome changes, so you
don't have to poll. They fire for **every order in your organization**, not
only the ones your integration created; use `attribution` to tell them apart.

## Set up an endpoint

Webhook endpoints are managed in the Laces app by an owner or admin. There are
no webhook-management routes on the Partner API.

<Steps>
  <Step title="Open Webhooks">
    **Settings → Developers** (the tile at the bottom of Settings), then the
    **Webhooks** section ("Get told when an order is paid, fails or is
    refunded").

    <Frame caption="Developers, with the Webhooks section">
      <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/app/developers-empty.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=d2895692187600dba0942f55acc75bf3" alt="The Laces Developers screen with the Webhooks section and its Add endpoint action" width="1206" height="1700" data-path="images/app/developers-empty.png" />
    </Frame>
  </Step>

  <Step title="Add an endpoint">
    Tap **Add endpoint**. Enter the **Endpoint URL**, a public `https` address
    such as `https://shop.example.com/laces/webhook`, and choose the
    **Events** to send.

    <Frame caption="The Add endpoint form">
      <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/app/webhooks-add.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=68b3492c7888e7ca136fc3a3879eba29" alt="The Add endpoint form with the URL https://example.com/laces/webhook and the order.paid, order.failed and order.refunded events switched on" width="1206" height="1900" data-path="images/app/webhooks-add.png" />
    </Frame>
  </Step>

  <Step title="Copy the signing secret">
    The **Your signing secret** sheet shows the secret (`LCSWHSEC-…-X`)
    **once**, with a copy button and **Copy all** (an `.env` block with
    `LACES_WEBHOOK_ENDPOINT_ID` and `LACES_WEBHOOK_SIGNING_SECRET`). Store it
    on your server, next to your client secret.

    <Frame caption="The signing secret is shown once (secret value blurred here)">
      <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/app/webhook-secret-reveal.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=2133a250737eff0c571a627a30fd68b5" alt="The Your signing secret sheet showing the endpoint ID and a blurred signing secret, with Copy all and I've saved the secret buttons" width="1206" height="2622" data-path="images/app/webhook-secret-reveal.png" />
    </Frame>

    Your endpoint then appears in the list.

    <Frame caption="A webhook endpoint in the list">
      <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/app/webhooks-list.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=08a0ecb2431823934a1bb335fb960a9b" alt="The Developers screen with a webhook endpoint card showing its endpoint ID, URL, subscribed events and No deliveries yet" width="1206" height="2300" data-path="images/app/webhooks-list.png" />
    </Frame>
  </Step>

  <Step title="Send a test event">
    Open the endpoint and tap **Send test event**. Laces posts a signed `ping`
    to your URL and shows the HTTP status your server returned. Aim for a `2xx`.

    The example below was sent to a placeholder address that does not accept
    webhooks, so the app reports the status it got back (HTTP 405) instead of
    pretending it worked.

    <Frame caption="Send test event reporting the status your endpoint returned">
      <img src="https://mintcdn.com/laces/TvNmP5tvH8tqleQI/images/app/webhook-test-event.png?fit=max&auto=format&n=TvNmP5tvH8tqleQI&q=85&s=272ff74b08a81648f9f90c2ce994c3ca" alt="The Webhook endpoint screen with a message reading Your endpoint answered HTTP 405" width="1206" height="2622" data-path="images/app/webhook-test-event.png" />
    </Frame>
  </Step>
</Steps>

Open an endpoint to see its last delivery status. You can **edit** its URL and
events at any time, and from **Endpoint actions**:

* **Send test event**: see [Test your endpoint](#test-your-endpoint).
* **Roll signing secret**: issues a new secret, shown once, and the old one
  stops signing **immediately** (there is no overlap). Deliveries still queued
  are signed with the new secret at their next attempt, so be ready to accept
  the new secret before you roll, or deliveries fail until you deploy it.
* **Disable endpoint** / **Re-enable**: a disabled endpoint receives nothing.
  Re-enabling resets the failure counter.
* **Delete endpoint**: permanent; Laces stops sending to it.

An organization can have up to **5** webhook endpoints.

**URL rules.** `https` only; no credentials in the URL; no `localhost`,
internal or single-label host names; the name must resolve to **public**
addresses only. Private, loopback, link-local and cloud-metadata addresses are
refused, both when you save the URL and again at send time. **Redirects are not
followed**, so register the final URL.

Unlike allowed domains, a webhook URL can never be `http` or `localhost`. To
receive webhooks on your laptop, see [Test your endpoint](#test-your-endpoint).

## Events

| `type` | Sent when |
| - | - |
| `order.paid` | The payment is confirmed |
| `order.failed` | A payment attempt failed or expired. `data.order.failure_kind` says why. A failure where the charge never reached the payment provider is not sent (nothing was charged, and the buyer can simply try again). |
| `order.refunded` | A full or partial refund completed. `data.order.status` is `refunded` or `partially_refunded`, and `data.refund` is included. Each refund on an order sends its own event. |
| `ping` | Only from **Send test event** |

**Every path that marks an order paid fires `order.paid`**: mobile money
settlements (and card, once it is available), however Laces learns of them,
and free orders confirmed without payment.

## Payload

```json order.paid theme={null}
{
  "id": "LCSWHD-9F3A7C21B4D85E60A1C2D3E4F5061728",
  "type": "order.paid",
  "created": 1791533643,
  "event_id": "q3Zk9mVx",
  "attribution": {
    "channel": "partner_api",
    "api_client_id": "LCSCLI-EXAMPLECLNT2345EXAMP",
    "checkout_session_id": null,
    "partner_reference": "cart-8841"
  },
  "data": {
    "order": {
      "id": "Hk2mQ8vR",
      "order_code": "LC-7K2M9X",
      "event_id": "q3Zk9mVx",
      "status": "paid",
      "currency": "UGX",
      "subtotal": 50000,
      "discount_total": 0,
      "donation_total": 0,
      "laces_fee": 2400,
      "total": 52400,
      "failure_kind": null,
      "ticket_count": 1,
      "attribution": {
        "channel": "partner_api",
        "api_client_id": "LCSCLI-EXAMPLECLNT2345EXAMP",
        "checkout_session_id": null,
        "partner_reference": "cart-8841"
      },
      "hold_expires_at": null,
      "created_at": "2026-10-09T08:14:03.000Z"
    }
  }
}
```

| Field | Meaning |
| - | - |
| `id` | Unique per event. Equal to the `X-Laces-Delivery` header. Constant across retries. |
| `type` | The event type |
| `created` | Unix seconds when the event was queued. Does not change across retries. |
| `event_id` | The Laces event (race) the order belongs to |
| `attribution` | How the order came in. `channel` is `partner_api` (custom UI), `partner_hosted` (hosted or embedded checkout) or `null` (not through the Partner API). `partner_reference` is the value you sent on order creation. |
| `data.order` | The order, without buyer details |
| `data.order.failure_kind` | On `order.failed`: why the attempt failed, one of `provider_not_reached`, `timed_out`, `declined` or `other` (treat unknown values as `other`). `null` on other events. |

For `order.failed`, use `failure_kind` to choose what to tell the buyer:

| `failure_kind` | Meaning | Suggested message |
| - | - | - |
| `timed_out` | The prompt wasn't approved in time, or expired | "The payment wasn't approved in time. Please try again." |
| `declined` | The buyer declined or cancelled, or can't pay (balance, PIN, limit) | "The payment was declined. Check your balance or try another number." |
| `provider_not_reached` | The charge never reached the provider; **nothing was charged**, safe to retry. Normally not sent as a webhook, but handle it. | "We couldn't reach the payment service. You haven't been charged. Please try again." |
| `other` | Anything else | "The payment didn't go through. Please try again." |

For `order.refunded`, `data` also has a `refund` object:

```json theme={null}
"refund": { "id": "rf_8hQ2", "amount": 52400, "status": "succeeded" }
```

The `ping` payload:

```json theme={null}
{
  "id": "LCSWHD-PING-EXAMPLE",
  "type": "ping",
  "created": 1791533643,
  "data": { "message": "This is a test event from Laces." }
}
```

<Note>
  **Webhooks never contain buyer personal data**: no name, email or phone.
  The webhook is a signal to act, plus enough to match your own record through
  `attribution.partner_reference` or `attribution.checkout_session_id`. If you
  need buyer details, read the order with
  [`GET /orders/{orderId}`](/api-reference/v1/orders/get), which needs
  `orders:read`.
</Note>

## Headers

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `User-Agent` | `Laces-Webhooks/1.0` |
| `X-Laces-Signature` | `t=<unix seconds>,v1=<hex HMAC>` |
| `X-Laces-Event` | The event type, for example `order.paid` |
| `X-Laces-Delivery` | The delivery id, the same as the payload `id`. Constant across retries. |

## Verify the signature

Every request is signed with your endpoint's signing secret, with a fresh
timestamp on each attempt:

```text theme={null}
signed_payload = <t> + "." + <raw request body, exactly as received>
v1             = hex( HMAC-SHA256( key = signing secret, message = signed_payload ) )
```

To verify:

1. Parse `t` and every `v1` from `X-Laces-Signature`.
2. Reject if `t` is more than **300 seconds** from your clock (replay
   protection).
3. Compute the HMAC over `` `${t}.${rawBody}` `` using the **whole** secret,
   including the `LCSWHSEC-` prefix and the `-X` suffix, as the key.
4. Compare to each `v1` in **constant time**. Accept if any matches.

<Warning>
  Verify against the **raw bytes** of the body, before any JSON parsing.
  Parsing and re-serialising changes whitespace and key order and breaks the
  signature. In Express that means `express.raw()` on this route, not
  `express.json()`.
</Warning>

<CodeGroup>
  ```js Node (Express) theme={null}
  const crypto = require("crypto");
  const express = require("express");

  function verifyLacesSignature(rawBody, header, secret, toleranceSec = 300) {
    let t = null;
    const sigs = [];
    for (const part of String(header || "").split(",")) {
      const i = part.indexOf("=");
      if (i < 0) continue;
      const k = part.slice(0, i).trim();
      const v = part.slice(i + 1).trim();
      if (k === "t" && /^\d{1,12}$/.test(v)) t = Number(v);
      if (k === "v1") sigs.push(v);
    }
    if (t === null || sigs.length === 0) return false;
    if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;

    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${t}.${rawBody}`, "utf8")
      .digest("hex");
    const exp = Buffer.from(expected, "utf8");
    return sigs.some((s) => {
      const got = Buffer.from(s, "utf8");
      return got.length === exp.length && crypto.timingSafeEqual(got, exp);
    });
  }

  const app = express();

  // express.raw keeps the body as a Buffer. Do NOT use express.json() here.
  app.post("/laces/webhook", express.raw({ type: "application/json" }), (req, res) => {
    const raw = req.body.toString("utf8");
    const ok = verifyLacesSignature(
      raw,
      req.header("X-Laces-Signature"),
      process.env.LACES_WEBHOOK_SECRET,
    );
    if (!ok) return res.status(400).send("bad signature");

    const event = JSON.parse(raw);
    res.status(200).send("ok"); // acknowledge fast...
    handleEventOnce(req.header("X-Laces-Delivery"), event) // ...then do the work
      .catch((err) => logger.error("laces webhook", err));
  });
  ```

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

  def verify_laces_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
      t, sigs = None, []
      for part in (header or "").split(","):
          k, _, v = part.partition("=")
          k, v = k.strip(), v.strip()
          if k == "t" and v.isdigit() and len(v) <= 12:
              t = int(v)
          elif k == "v1":
              sigs.append(v)
      if t is None or not sigs:
          return False
      if abs(time.time() - t) > tolerance:
          return False
      signed = f"{t}.".encode() + raw_body
      expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
      return any(hmac.compare_digest(s, expected) for s in sigs)

  app = Flask(__name__)

  @app.post("/laces/webhook")
  def laces_webhook():
      raw = request.get_data()  # raw bytes, read BEFORE request.json
      if not verify_laces_signature(raw, request.headers.get("X-Laces-Signature", ""),
                                    os.environ["LACES_WEBHOOK_SECRET"]):
          return "bad signature", 400
      event = json.loads(raw)
      enqueue_once(request.headers["X-Laces-Delivery"], event)  # do slow work async
      return "ok", 200
  ```

  ```php PHP theme={null}
  <?php
  function verify_laces_signature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
      $t = null; $sigs = [];
      foreach (explode(',', $header) as $part) {
          $pos = strpos($part, '=');
          if ($pos === false) continue;
          $k = trim(substr($part, 0, $pos));
          $v = trim(substr($part, $pos + 1));
          if ($k === 't' && ctype_digit($v) && strlen($v) <= 12) $t = (int) $v;
          if ($k === 'v1') $sigs[] = $v;
      }
      if ($t === null || !$sigs) return false;
      if (abs(time() - $t) > $tolerance) return false;
      $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
      foreach ($sigs as $s) {
          if (hash_equals($expected, $s)) return true;
      }
      return false;
  }

  $raw = file_get_contents('php://input');   // the raw body, not $_POST
  $ok  = verify_laces_signature($raw, $_SERVER['HTTP_X_LACES_SIGNATURE'] ?? '', getenv('LACES_WEBHOOK_SECRET'));
  if (!$ok) { http_response_code(400); exit('bad signature'); }

  $event = json_decode($raw, true);
  // Record $_SERVER['HTTP_X_LACES_DELIVERY'] and skip it if already processed.
  http_response_code(200);
  echo 'ok';
  ```
</CodeGroup>

## Delivery and retries

* **Acknowledge with any `2xx` within 10 seconds.** Anything else, a timeout,
  or a network error is a failed attempt. The response body is ignored.
* Failed deliveries are retried after **1 minute, 5 minutes, 30 minutes,
  2 hours, 6 hours and 12 hours**: **7 attempts** in total over about
  20.5 hours. Retries are sent by a job that runs every 5 minutes, so one can
  arrive up to 5 minutes after its nominal time.
* Network errors, timeouts, `5xx`, `408` and `429` are retried through the
  whole schedule. A `3xx` (redirects are never followed) or any other `4xx` is
  treated as permanent after **3 attempts**.
* After the last failed attempt the delivery is marked permanently failed and
  not retried again. Reconcile by reading orders or sessions.
* **Auto-disable:** after **50 consecutive failed attempts** on an endpoint,
  Laces switches it off, and deliveries still queued for it are dropped. Fix
  your receiver, then **Re-enable** it in the app (the counter resets). A
  successful delivery also resets the counter.

## Test your endpoint

Payments are real (there is [one environment](/concepts/testing)), so test in
two steps:

1. **Signature and plumbing: Send test event.** In the app, open the endpoint
   and tap **Send test event**. Laces posts a signed `ping` to your URL right
   away and shows the result: "Test delivered (HTTP 200)", or the status your
   server answered, or that it could not reach your endpoint. A `ping` is sent
   once and never retried, and it doesn't count towards auto-disable. Use it
   to prove your signature check and your `2xx` response.
2. **Real events: a test order.** Buy a free or low-price ticket on an
   unlisted test event to receive `order.paid`, then refund it in the app to
   receive `order.refunded`.

To receive webhooks on your laptop, run a public `https` tunnel (for example
ngrok or Cloudflare Tunnel) to your local server and register the tunnel's
`https` URL as the endpoint. Remember to point the endpoint back at your real
server (or delete it) when you're done.

## Handle events idempotently

Delivery is **at least once** and **unordered**:

* The same event can arrive more than once. Store each `X-Laces-Delivery` id
  when you process it, ignore repeats, and make the handler safe to run twice.
* Events can arrive out of order, for example `order.refunded` before a
  retried `order.paid`. Always act on `data.order.status`, never on the order
  in which events arrived.
* An order produces at most one `order.paid`, one `order.failed`, and one
  `order.refunded` per refund, per endpoint. If an order fails twice and then
  succeeds, you see a single `order.failed` and then `order.paid`.

```js theme={null}
async function handleEventOnce(deliveryId, event) {
  // A unique index on delivery_id makes this race-safe across instances.
  const fresh = await db.webhookDeliveries.insertIfAbsent({ delivery_id: deliveryId });
  if (!fresh) return;

  const order = event.data?.order;
  if (!order) return; // ping

  if (order.status === "paid") await fulfil(order.attribution?.partner_reference, order);
  else if (order.status === "refunded" || order.status === "partially_refunded") await markRefunded(order);
  else if (order.status === "failed") await markFailed(order);
}
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.