# Webhooks

[Open in the interactive reference](https://docs.api.lensing.ai/?op=guide%3Awebhooks)

The API also sends events, such as an invoice being issued or a product changing. Subscribe an HTTPS endpoint, and the platform sends it a request whenever such an event happens, so you do not have to poll. Each event is listed on the Reference tab with the endpoints it belongs to, together with the body it delivers.

In practice, a receiver that handles events reliably:

- verifies the signature of every delivery;
- answers 2xx within 30 seconds and does the work afterwards;
- deduplicates on `webhook-id`, because an event can arrive more than once;
- does not rely on the order in which events arrive.

### Subscribing

Manage subscriptions in your tenant, under **API Hub › Connect › Event Subscriptions**. A subscription names the events it wants, all from one API version, and up to two destinations. The **Main** destination receives the events that happen in production. The **Sandbox** destination receives the events that happen in any of your sandboxes. A delivery does not say which sandbox it came from.

A destination is an `https://` URL on a publicly reachable host, and each destination has its own signing secret.

While a destination is disabled, every delivery to it is skipped, including retries of earlier events, and none is made up later. Deleting a destination or its subscription drops its pending retries the same way.

### What you receive

Each event arrives as its own `POST` request, with a JSON body and three signature headers. The body names the subscription and the event: its `uuid`, its `identifier`, when it was emitted, and its `data`. Events carry identifiers, not records, so read the record through its endpoint for its current state. Each event's page on the Reference tab shows its exact `data`.

|  |  |
| --- | --- |
| `webhook-id` | The event's `uuid`, the same value as `event.uuid`. It stays the same on every retry, and on every subscription the event is delivered to. |
| `webhook-timestamp` | When this attempt was made, in Unix seconds. Each retry carries a new one. |
| `webhook-signature` | One or more space-separated signatures, each `v1,<base64>`. Normally one; two while a rolled secret is still valid. |

### Verifying a delivery

Deliveries are signed following the [Standard Webhooks](https://www.standardwebhooks.com) specification, so any of its libraries can verify them. Give the library the destination's secret exactly as the subscription shows it, including the `whsec_` prefix. To verify without a library:

1. Take the body as the raw bytes you received. Parsing the JSON and serializing it again changes the bytes, and the signature no longer matches.
2. Build the signed content: `{webhook-id}.{webhook-timestamp}.{raw body}`.
3. Take the secret without its `whsec_` prefix and base64-decode it. That is the key.
4. Compute the HMAC-SHA256 of the signed content with the key, and base64-encode the result.
5. Accept the delivery if the result equals any of the `v1` signatures in `webhook-signature`, compared in constant time. Checking only the first signature breaks as soon as you roll the secret.
6. Reject a delivery whose timestamp is more than five minutes from your clock, in either direction, so that a recorded delivery cannot be replayed later. Retries are signed afresh, so they pass.

### Answering

Acknowledge a delivery by answering with any **2xx** status within **30 seconds**. The limit covers the whole exchange, your response body included, so a reply that is still arriving when the time is up counts as a failed attempt, whatever its status said. So does anything else: another status, a timeout, a refused connection. Acknowledge first and do the work afterwards: a handler that runs past the limit is retried, and does the work twice.

The response body is not interpreted, but its first 4,096 characters are stored with the delivery, so do not echo sensitive data back. Redirects are followed, but most turn the `POST` into a `GET` without a body, so point the subscription at the final URL.

### Retries

A failed delivery is retried with exponential backoff. The first wait is 30 seconds, and each wait doubles, up to an hour, for up to 30 retries: about a day in all. Each wait varies by up to a quarter either way. A subscription can set its own policy instead: 1 to 99 retries, with exponential or fixed waits of one second to an hour.

After the last retry, the delivery is abandoned and cannot be sent again. Because an event carries identifiers rather than state, catching up is a read: fetch what changed since your last successful delivery through the endpoints. Every attempt, with its outcome and status, is listed under **API Hub › Operate › Event Deliveries**.

### Duplicates and order

Delivery is *at least once*: a retry after a timeout, for example, can bring an event your endpoint has already processed. Deduplicate on `webhook-id` before anything with a side effect, together with the subscription's `uuid` if one endpoint receives several subscriptions. Do not deduplicate on the identifiers in `data`: one record produces many events over its life.

Order is not guaranteed. Two changes in quick succession can arrive either way round, and a read after the first may already show the second. Use `event.emittedAt` where order matters, and take the current state from a read rather than from the sequence of events.

### Rolling the secret

Roll a destination's secret on its subscription, and choose how long the previous secret keeps signing alongside the new one: an hour, a day (the default) or a week. Until then, every delivery carries two signatures, one per secret, so a receiver that still holds the previous secret keeps verifying while you switch it over. Only one previous secret is kept: rolling again ends the earlier overlap at once.

If a secret has leaked, revoke it immediately instead. From that moment, only the new secret signs.

## Examples

**A delivery**

```
POST /your/webhook/endpoint
content-type: application/json
webhook-id: 7a2e4d61-9c03-4f8b-a5d2-6e0b3c9f1a84
webhook-timestamp: 1768469400
webhook-signature: v1,pOGQmcJubx5uB2q7ioL25LmpSsw0Vj3cGqTnrL3axKo=

{"subscription":{"uuid":"3f8b1c92-5d47-4e0a-9b6f-2c1d8e7a4b30","type":"webhook","name":"my-subscription"},"event":{"uuid":"7a2e4d61-9c03-4f8b-a5d2-6e0b3c9f1a84","identifier":"accounting/invoices/created","emittedAt":"2026-01-15T09:30:00.000Z","data":{"id":"9f2c6a10-7d34-4c8e-9b21-5a0f3e6d81b7"}}}
```

**Example secret**

```
whsec_M2Y4YjFjOTI1ZDQ3NGUwYTliNmYyYzFkOGU3YTRiMzA3YTJlNGQ2MTljMDM0ZjhiYTVkMjZlMGIzYzlmMWE4NA==
```

Signs the delivery above, so you can test a verifier against it. Its timestamp is in the past: pass a large tolerance for the test.

**Verify, in Node.js**

```
import { createHmac, timingSafeEqual } from 'node:crypto';

// rawBody: the body exactly as received, before any JSON parsing.
export function verifyDelivery(secret, headers, rawBody, toleranceSeconds = 300) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];
  if (!id || !timestamp || !signatures) {
    return false;
  }
  if (!(Math.abs(Date.now() / 1000 - Number(timestamp)) <= toleranceSeconds)) {
    return false;
  }
  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = createHmac('sha256', key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest();
  return signatures.split(' ').some((entry) => {
    const [version, signature = ''] = entry.split(',');
    const given = Buffer.from(signature, 'base64');
    return version === 'v1' && given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

**Verify, in Python**

```
import base64, hashlib, hmac, time

# raw_body: the body exactly as received, as bytes.
def verify_delivery(secret, headers, raw_body: bytes, tolerance_seconds=300):
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not (msg_id and signatures and timestamp and timestamp.isdigit()):
        return False
    if abs(time.time() - int(timestamp)) > tolerance_seconds:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    return any(
        version == "v1" and hmac.compare_digest(signature, expected)
        for version, _, signature in (
            entry.partition(",") for entry in signatures.split(" ")
        )
    )
```
