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

# Webhooks

> Get sanctions-list changes that affect you pushed to your server

Webhooks let Limitguard push events to your server as signed HTTP `POST` requests, so you do not have to poll.

<Info>
  **Live today: `sanctions.match.new`.** When the OFAC SDN, EU or UN consolidated list changes an entry that matches an entity or wallet you checked, or an entry on your watchlist, Limitguard sends you the same alert `GET /v1/compliance/alerts` shows you. Registrations and pending deliveries are stored, so they survive restarts and deploys.

  `trust.score.changed`, `trust.level.downgrade` and `certificate.expired` can be registered, but nothing sends them yet. Every registration and list response carries `delivered_events`, the subset of your `events` that is actually sent today.
</Info>

<Note>
  Webhooks need an API key (`X-API-Key`). A webhook belongs to the key that registered it: only that key can list, test or delete it, and it receives alerts for that key's checks and watchlist only.
</Note>

## How It Works

When an event occurs, Limitguard sends an HTTP `POST` with a JSON body to every active webhook of yours that subscribed to that event type. Respond with any `2xx` status within 10 seconds to acknowledge it. Anything else (a non-`2xx` status, a redirect, a timeout, a TLS or connection error) counts as a failed attempt and is retried with backoff.

Each request carries an `X-Limitguard-Signature` header: the hex HMAC-SHA256 of the raw request body, keyed with the webhook secret you received at registration. **Always verify it before processing the payload.**

## Setup

<Steps>
  <Step title="Register your endpoint">
    Send `POST /v1/webhooks` with your HTTPS URL and the event types you want.

    <CodeGroup>
      ```bash curl theme={null}
      curl -X POST https://api.limitguard.ai/v1/webhooks \
        -H "X-API-Key: lg_live_xxxxxxxxxxxxxxxxxxxx" \
        -H "Content-Type: application/json" \
        -d '{
          "url": "https://your-app.com/webhooks/limitguard",
          "events": ["sanctions.match.new"]
        }'
      ```

      ```python Python theme={null}
      import httpx

      response = httpx.post(
          "https://api.limitguard.ai/v1/webhooks",
          headers={"X-API-Key": "lg_live_xxxxxxxxxxxxxxxxxxxx"},
          json={
              "url": "https://your-app.com/webhooks/limitguard",
              "events": ["sanctions.match.new"],
          },
      )
      webhook = response.json()
      print(webhook["id"])      # 3f6c2a9e-...
      print(webhook["secret"])  # 64 hex characters: store it securely now
      ```

      ```javascript JavaScript theme={null}
      const response = await fetch("https://api.limitguard.ai/v1/webhooks", {
        method: "POST",
        headers: {
          "X-API-Key": "lg_live_xxxxxxxxxxxxxxxxxxxx",
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          url: "https://your-app.com/webhooks/limitguard",
          events: ["sanctions.match.new"],
        }),
      });
      const webhook = await response.json();
      console.log(webhook.id);     // 3f6c2a9e-...
      console.log(webhook.secret); // 64 hex characters: store it securely now
      ```
    </CodeGroup>

    ```json Response (201 Created) theme={null}
    {
      "id": "3f6c2a9e-8b41-4d7e-9a53-2c1f0e7b6d48",
      "url": "https://your-app.com/webhooks/limitguard",
      "events": ["sanctions.match.new"],
      "secret": "9f2c...64 hex characters...a71e",
      "active": true,
      "created_at": "2026-09-24T10:00:00.412907Z",
      "delivered_events": ["sanctions.match.new"]
    }
    ```

    The URL must use `https://`, its host must resolve, and it must not resolve to a private, loopback, link-local, multicast, reserved or cloud-metadata address (IPv4 or IPv6). The same address check runs again before every delivery, since DNS can change after registration. A URL that fails any of these is refused with `422`.

    <Warning>
      The `secret` is returned **once only**, in this response, and is never shown again (it is stored encrypted). Put it in a secret manager or an environment variable that is never committed. If you lose it, delete the webhook and register a new one.
    </Warning>
  </Step>

  <Step title="Verify the signature">
    Compute the HMAC-SHA256 of the **raw request body** (the exact bytes you received, before any JSON parsing), keyed with your secret string, and compare its hex digest with `X-Limitguard-Signature` in constant time. The header is the bare hex digest, with no `sha256=` prefix.

    <CodeGroup>
      ```python Python theme={null}
      import hashlib
      import hmac

      def verify_signature(payload: bytes, signature_header: str, secret: str) -> bool:
          expected = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
          return hmac.compare_digest(expected, signature_header)


      # FastAPI example
      import os
      from fastapi import FastAPI, HTTPException, Request

      app = FastAPI()
      WEBHOOK_SECRET = os.environ["LIMITGUARD_WEBHOOK_SECRET"]

      @app.post("/webhooks/limitguard")
      async def handle_webhook(request: Request):
          payload = await request.body()
          signature = request.headers.get("X-Limitguard-Signature", "")
          if not verify_signature(payload, signature, WEBHOOK_SECRET):
              raise HTTPException(status_code=401, detail="Invalid signature")

          event = await request.json()
          await queue_for_processing(event)  # respond fast, work in the background
          return {"received": True}
      ```

      ```javascript JavaScript theme={null}
      import crypto from "crypto";
      import express from "express";

      const WEBHOOK_SECRET = process.env.LIMITGUARD_WEBHOOK_SECRET;

      function verifySignature(payload, signatureHeader, secret) {
        const expected = crypto.createHmac("sha256", secret).update(payload).digest("hex");
        const a = Buffer.from(expected);
        const b = Buffer.from(signatureHeader || "");
        // timingSafeEqual throws on a length mismatch, so check that first
        return a.length === b.length && crypto.timingSafeEqual(a, b);
      }

      const app = express();

      app.post(
        "/webhooks/limitguard",
        // Keep the raw body: re-serialised JSON will not match the signature
        express.raw({ type: "application/json" }),
        (req, res) => {
          const signature = req.headers["x-limitguard-signature"];
          if (!verifySignature(req.body, signature, WEBHOOK_SECRET)) {
            return res.status(401).json({ error: "Invalid signature" });
          }
          const event = JSON.parse(req.body);
          res.json({ received: true }); // acknowledge first
          processEvent(event).catch(console.error);
        }
      );
      ```
    </CodeGroup>

    <Warning>
      Compare signatures in constant time (`hmac.compare_digest` in Python, `crypto.timingSafeEqual` in Node.js). A plain `==` or `===` comparison leaks timing information.
    </Warning>
  </Step>

  <Step title="Handle the event">
    Every delivery has the same envelope. Dispatch on `event`:

    ```json Delivery body theme={null}
    {
      "created_at": "2026-09-24T10:00:03.114233+00:00",
      "data": {
        "action_items": [
          "Stop payouts to and freeze pending transfers with wallet 0x1da58215...",
          "Re-screen counterparties linked to this wallet",
          "File the hit with your compliance officer and keep this alert as evidence"
        ],
        "advice": [
          {
            "action": "Stop payouts to and freeze pending transfers with wallet 0x1da58215...",
            "why": "Added to OFAC SDN on 2026-09-23 under programme CYBER2",
            "expected_result": "Avoids a sanctions breach on further transfers"
          },
          {
            "action": "Re-screen counterparties linked to this wallet",
            "why": "Entities paying or paid by a listed wallet may be exposed too",
            "expected_result": "Finds indirect exposure before the next transfer"
          },
          {
            "action": "File the hit with your compliance officer and keep this alert as evidence",
            "why": "Sanctions screening decisions must be documented",
            "expected_result": "An auditable record of when you learned of the listing"
          }
        ],
        "affected_sectors": ["financial_services"],
        "as_of": "2026-09-23",
        "content_hash": "4e1f...",
        "created_at": "2026-09-24T10:00:02Z",
        "effective_date": "2026-09-23",
        "entry_id": "54321",
        "event_type": "designated",
        "id": "b7d0e6a4-2f18-4c55-8e0b-1a9c3d7f5e21",
        "is_relevant_to_trust_scoring": true,
        "jurisdiction": "us",
        "list_name": "OFAC SDN",
        "match": {"basis": "checked", "kind": "crypto_address", "score": 1.0},
        "relevance_score": 1.0,
        "severity": "critical",
        "source_id": "ofac_sdn",
        "source_name": "OFAC SDN",
        "source_url": "https://sanctionslist.ofac.treas.gov/Home/SdnList",
        "summary": "OFAC SDN: designated entity Example Exchange Ltd (CYBER2)"
      },
      "event": "sanctions.match.new",
      "event_id": "9c41e2d07b5a4f86a3e1d2c4b6f80a17"
    }
    ```

    | Field | Description |
    | - | - |
    | `event` | The event type, e.g. `sanctions.match.new`. A test delivery uses `test`. |
    | `event_id` | Stable ID of the event. A retry of the same event carries the same `event_id`: use it to de-duplicate. |
    | `created_at` | When the delivery was queued (ISO 8601, UTC). The same on every retry, so it is not a freshness check. |
    | `data` | The event payload. For `sanctions.match.new`, your alert exactly as `GET /v1/compliance/alerts` returns it: never another customer's data, never more than that endpoint shows you. |

    The body is JSON with its keys sorted. Always parse it rather than relying on key order or formatting, and verify the signature against the raw bytes before parsing.

    <Warning>
      **`sanctions.match.new` is sent for every list change that touches you, not only new listings.** Read `data.event_type`:

      | `data.event_type` | Meaning | `data.severity` |
      | - | - | - |
      | `designated` | The entry was added to the list | `critical` |
      | `amended` | The listing changed (names, identifiers, programmes and so on) | `high` |
      | `delisted` | The entry was removed from the list | `informational` |

      `data.match.basis` says why the alert is yours: `checked` (an entity or wallet you checked) or `watchlist` (an entry on your watchlist). `data.match.kind` is how it matched (`crypto_address`, `registration`, `name_exact` or `name_tokens`) and `data.match.score` how strongly.
    </Warning>

    <CodeGroup>
      ```python Python theme={null}
      async def process_event(event: dict):
          if event["event"] != "sanctions.match.new":
              return  # test deliveries and future event types: acknowledge and ignore

          alert = event["data"]
          if alert["event_type"] == "designated":
              await freeze_and_escalate(alert)
          elif alert["event_type"] == "amended":
              await rescreen(alert)
          elif alert["event_type"] == "delisted":
              await review_restrictions(alert)
      ```

      ```javascript JavaScript theme={null}
      async function processEvent(event) {
        if (event.event !== "sanctions.match.new") return; // test and future types

        const alert = event.data;
        switch (alert.event_type) {
          case "designated":
            await freezeAndEscalate(alert);
            break;
          case "amended":
            await rescreen(alert);
            break;
          case "delisted":
            await reviewRestrictions(alert);
            break;
        }
      }
      ```
    </CodeGroup>
  </Step>
</Steps>

## Event Types

These are the event types you can register. Only `sanctions.match.new` is sent today; the others are accepted so an integration can subscribe ahead of time, and `delivered_events` tells you which of yours are live.

| Event | Sent today | Meaning |
| - | - | - |
| `sanctions.match.new` | Yes | A sanctions-list change (designation, amendment or delisting) matches an entity or wallet you checked, or your watchlist |
| `trust.score.changed` | Not yet | A monitored entity's trust score changed |
| `trust.level.downgrade` | Not yet | A monitored entity's `trust_level` moved to a lower band |
| `certificate.expired` | Not yet | A certificate passed its `expires_at` |

`events` must list at least one of these names; there is no wildcard. An unknown name is refused with `422`.

## Delivery Headers

| Header | Example | Description |
| - | - | - |
| `Content-Type` | `application/json` | Always JSON |
| `X-Limitguard-Signature` | `a3f8c1...` (64 hex characters) | HMAC-SHA256 of the raw body, keyed with your secret |
| `X-Limitguard-Event` | `sanctions.match.new` | The event type (same as `event` in the body) |
| `X-Limitguard-Event-Id` | `9c41e2d07b5a...` | Same as `event_id` in the body; unchanged across retries |

There is no timestamp header. Protect against replays by storing the `event_id`s you have processed and ignoring repeats.

## Retries

A failed attempt is retried with exponential backoff (doubling from 30 seconds, plus up to 20% random jitter), up to 6 attempts per event:

| Attempt | Delay after the previous attempt | About this long after the first |
| - | - | - |
| 1 | Within seconds of the alert | 0 |
| 2 | 30 s | 30 s |
| 3 | 1 min | 1.5 min |
| 4 | 2 min | 3.5 min |
| 5 | 4 min | 7.5 min |
| 6 (last) | 8 min | 15.5 min |

After the 6th failed attempt that event is marked failed and is not retried again. Other events keep being delivered.

Limitguard waits up to **10 seconds** for your response and does not follow redirects. Respond `2xx` immediately and do the work asynchronously.

<Warning>
  **Deactivation.** When 10 events in a row fail all their attempts for the same webhook, that webhook is deactivated: its queued deliveries are dropped and it no longer appears in `GET /v1/webhooks`. Any successful delivery resets the count. To resume, fix your endpoint and register the webhook again; you will get a new secret. You can still poll `GET /v1/compliance/alerts` for anything you missed.
</Warning>

## Managing Webhooks

### List your webhooks

`GET /v1/webhooks` returns your active webhooks as a JSON array. Secrets are never included.

<CodeGroup>
  ```bash curl theme={null}
  curl https://api.limitguard.ai/v1/webhooks \
    -H "X-API-Key: lg_live_xxxxxxxxxxxxxxxxxxxx"
  ```

  ```python Python theme={null}
  response = httpx.get(
      "https://api.limitguard.ai/v1/webhooks",
      headers={"X-API-Key": "lg_live_xxxxxxxxxxxxxxxxxxxx"},
  )
  for wh in response.json():
      print(wh["id"], wh["url"], wh["delivered_events"])
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch("https://api.limitguard.ai/v1/webhooks", {
    headers: { "X-API-Key": "lg_live_xxxxxxxxxxxxxxxxxxxx" },
  });
  const webhooks = await response.json();
  webhooks.forEach((wh) => console.log(wh.id, wh.url, wh.delivered_events));
  ```
</CodeGroup>

```json Response theme={null}
[
  {
    "id": "3f6c2a9e-8b41-4d7e-9a53-2c1f0e7b6d48",
    "url": "https://your-app.com/webhooks/limitguard",
    "events": ["sanctions.match.new"],
    "active": true,
    "created_at": "2026-09-24T10:00:00.412907Z",
    "delivered_events": ["sanctions.match.new"]
  }
]
```

### Change a webhook

There is no update endpoint. To change the URL or the events, register the new configuration and then delete the old webhook. Registering the same URL twice is allowed and creates two separate webhooks, each receiving its own copy of every event.

### Delete a webhook

```bash curl theme={null}
curl -X DELETE https://api.limitguard.ai/v1/webhooks/3f6c2a9e-8b41-4d7e-9a53-2c1f0e7b6d48 \
  -H "X-API-Key: lg_live_xxxxxxxxxxxxxxxxxxxx"
```

Returns `204 No Content`. The webhook and its delivery history are removed and nothing more is sent to it. An unknown ID, or one registered by a different API key, returns `404`.

## Testing

`POST /v1/webhooks/{webhook_id}/test` sends one signed test delivery to your endpoint right away. It takes no request body, makes a single attempt with no retries, and reports the result:

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.limitguard.ai/v1/webhooks/3f6c2a9e-8b41-4d7e-9a53-2c1f0e7b6d48/test \
    -H "X-API-Key: lg_live_xxxxxxxxxxxxxxxxxxxx"
  ```

  ```python Python theme={null}
  response = httpx.post(
      "https://api.limitguard.ai/v1/webhooks/3f6c2a9e-8b41-4d7e-9a53-2c1f0e7b6d48/test",
      headers={"X-API-Key": "lg_live_xxxxxxxxxxxxxxxxxxxx"},
  )
  print(response.json())
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch(
    "https://api.limitguard.ai/v1/webhooks/3f6c2a9e-8b41-4d7e-9a53-2c1f0e7b6d48/test",
    { method: "POST", headers: { "X-API-Key": "lg_live_xxxxxxxxxxxxxxxxxxxx" } }
  );
  console.log(await response.json());
  ```
</CodeGroup>

```json Response theme={null}
{
  "delivery_id": "c2a8f0d1-5e7b-4a39-b6c4-8d1e2f3a4b5c",
  "success": true,
  "status_code": 200,
  "attempts": 1
}
```

`status_code` is what your endpoint answered, or `null` if it could not be reached. Your endpoint receives this body, signed with your real secret, so the test exercises your whole verification path:

```json Test delivery body theme={null}
{
  "created_at": "2026-09-24T10:05:00.281554+00:00",
  "data": {"message": "Webhook test delivery from Limitguard.ai"},
  "event": "test",
  "event_id": "e8b1c7a2-4d6f-4e3a-9b8c-0f1d2e3a4b5c"
}
```

<Tip>
  Call the test endpoint from your deployment pipeline to confirm your handler is reachable and verifying signatures before real alerts depend on it.
</Tip>

## Best Practices

### Respond immediately, process asynchronously

Return `2xx` within 10 seconds, before doing any real work, and hand the event to a background queue (Celery, BullMQ, SQS and so on). A slow handler turns into timeouts, retries and, eventually, deactivation.

### De-duplicate on `event_id`

The same event can arrive more than once, for example when your `2xx` response is lost and the attempt is retried. Record each `event_id` together with its side effects, in one transaction, and skip any you have seen:

```python Python theme={null}
async def process_event(event: dict):
    async with db.transaction():
        if not await db.insert_if_absent("processed_events", event["event_id"]):
            return  # already handled
        await apply_business_logic(event)
```

### Ignore what you do not recognise

New event types and new `data` fields will be added. Acknowledge unknown `event` values with `2xx` instead of failing, or the retries will count towards deactivation.

### Serve a valid certificate

Deliveries verify your TLS certificate. A self-signed or expired certificate is not refused at registration, but every delivery to it fails.

## Next Steps

<CardGroup cols={2}>
  <Card title="API Reference" icon="code" href="/api-reference/introduction">
    All endpoints, including the four webhook endpoints
  </Card>

  <Card title="Rate Limits" icon="gauge" href="/guides/rate-limits">
    The per-minute and daily limits your API key has
  </Card>

  <Card title="Error Handling" icon="triangle-exclamation" href="/guides/errors">
    The error catalog, including webhook registration errors
  </Card>

  <Card title="Sandbox" icon="flask" href="/sandbox">
    Try the API without spending anything
  </Card>
</CardGroup>


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