# Webhooks

Source: https://developers.leonai.io/docs/guides/webhooks/

Webhooks push events to your endpoint as they happen. Every delivery is signed with
**HMAC-SHA256** so you can verify authenticity. Managing webhooks requires the
`webhooks:read` / `webhooks:write` scope (or a JWT). Webhooks are available on **Pro and
above** — the Free tier has a webhook quota of `0`.

## Event types

You can subscribe to ten event types (exact strings). Seven are sent today; `link.expired`, `link.limit_reached` and `domain.ssl_ready` are reserved: the API accepts them in a subscription, but nothing sends them yet.

| Event                    | Fires when                              |
| ------------------------ | --------------------------------------- |
| `link.created`           | A link is created                       |
| `link.updated`           | A link is updated                       |
| `link.deleted`           | A link is deleted                       |
| `link.clicked`           | A short link is visited                 |
| `link.expired`           | Reserved — not sent yet                 |
| `link.limit_reached`     | Reserved — not sent yet                 |
| `domain.verified`        | A custom domain passes DNS verification |
| `domain.ssl_ready`       | Reserved — not sent yet                 |
| `subscription.upgraded`  | The account upgrades tier               |
| `subscription.cancelled` | The subscription is cancelled           |

:::note[Spelling]
The event is `subscription.cancelled` (double `l`).
:::

## Create a webhook

```ts
const { data: webhook } = await leonai.webhooks.create({
  url: 'https://example.com/hooks/leonai',
  events: ['link.created', 'link.clicked'],
  enabled: true, // default true
});
// webhook.secret is returned ONLY here — store it securely.
```

`REST: POST /api/v1/webhooks` → `201`. The signing `secret` (a 64-character hex string)
is returned **once**, at creation, and never again.

## Manage webhooks

```ts
await leonai.webhooks.list(); // no secrets; includes totalDeliveries per hook
await leonai.webhooks.get(webhook.id); // secretPreview + delivery stats + last 20 deliveries
await leonai.webhooks.update(webhook.id, { enabled: false });
await leonai.webhooks.delete(webhook.id);
```

| Method + path                 | Scope            | Notes                                             |
| ----------------------------- | ---------------- | ------------------------------------------------- |
| `POST /api/v1/webhooks`       | `webhooks:write` | Returns `secret` once                             |
| `GET /api/v1/webhooks`        | `webhooks:read`  | List; `totalDeliveries` per hook                  |
| `GET /api/v1/webhooks/:id`    | `webhooks:read`  | `secretPreview`, `stats`, `recentDeliveries` (20) |
| `PATCH /api/v1/webhooks/:id`  | `webhooks:write` | Update `url` / `events` / `enabled`               |
| `DELETE /api/v1/webhooks/:id` | `webhooks:write` | Removes the hook and its delivery logs            |

There is no "test webhook" endpoint — trigger a real event (for example, create a link)
to see a delivery.

## Delivery format

Each delivery is an HTTP `POST` with these headers:

| Header                  | Value                                                             |
| ----------------------- | ----------------------------------------------------------------- |
| `Content-Type`          | `application/json`                                                |
| `X-Webhook-Signature`   | Hex HMAC-SHA256 digest of the raw JSON body (no `sha256=` prefix) |
| `X-Webhook-Event`       | The event name (e.g. `link.clicked`)                              |
| `X-Webhook-Delivery-Id` | Unique delivery id (use it for idempotency/logging)               |
| `User-Agent`            | `LeonAI-Webhooks/1.0`                                             |

The JSON body envelope is always:

```json
{
  "event": "link.clicked",
  "timestamp": "2026-07-10T12:00:00.000Z",
  "data": {}
}
```

Only `event`, `timestamp`, and `data` appear in the body. The delivery id is in the
`X-Webhook-Delivery-Id` header, not the body.

## Payload per event

The `data` object for each event:

```jsonc
// link.created
{ "id": "link_...", "shortCode": "spring-sale", "longUrl": "https://example.com", "title": "Spring Sale", "createdAt": "2026-07-10T12:00:00.000Z" }

// link.updated
{ "id": "link_...", "shortCode": "spring-sale", "longUrl": "https://example.com", "title": "Spring Sale", "updatedAt": "2026-07-10T12:05:00.000Z" }

// link.deleted
{ "id": "link_...", "shortCode": "spring-sale", "deletedAt": "2026-07-10T12:10:00.000Z" }

// link.clicked  (human clicks only; country/browser/device/referrer when known)
{ "id": "link_...", "shortCode": "spring-sale", "clickedAt": "2026-07-10T12:00:00.000Z", "country": "US", "browser": "Chrome", "device": "mobile", "referrer": "https://twitter.com" }

// link.expired  (reserved, not sent yet)
{ "id": "link_...", "shortCode": "spring-sale", "expiredAt": "2026-08-01T00:00:00.000Z" }

// link.limit_reached  (reserved, not sent yet)
{ "limitType": "clicks", "current": 5000, "limit": 5000, "timestamp": "2026-07-10T12:00:00.000Z" }

// domain.verified
{ "id": "dom_...", "domain": "go.example.com", "verifiedAt": "2026-07-10T12:00:00.000Z" }

// domain.ssl_ready  (reserved, not sent yet)
{ "id": "dom_...", "domain": "go.example.com", "sslEnabled": true, "readyAt": "2026-07-10T12:00:00.000Z" }

// subscription.upgraded
{ "fromTier": "pro", "toTier": "business" }

// subscription.cancelled
{ "tier": "business" }
```

## Verifying signatures

The signature is the hex HMAC-SHA256 digest of the **raw request body**, keyed with your
webhook `secret`. Always verify against the raw bytes you received — never a
re-serialized JSON object, since whitespace and key order would change the digest.

### SDK helper (TypeScript, not published yet)

```ts
import { verifyWebhookSignature } from '@leonai/sdk';

app.post('/hooks/leonai', async (req, res) => {
  const valid = await verifyWebhookSignature(
    req.rawBody, // raw string/bytes, NOT re-serialized JSON
    req.headers['x-webhook-signature'],
    process.env.LEONAI_WEBHOOK_SECRET!
  );
  if (!valid) return res.status(401).end();

  const payload = JSON.parse(req.rawBody); // { event, timestamp, data }
  // ... handle payload.event ...
  res.status(200).end();
});
```

### Node (no SDK)

```js
const crypto = require('crypto');

function verify(rawBody, signature, secret) {
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signature || '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

### Python

```python
import hashlib
import hmac

def verify(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")
```

:::tip[Capture the raw body]
Most frameworks parse JSON before your handler runs. Retain the raw bytes — for example
Express: `express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } })`, or in
Flask read `request.get_data()` before touching `request.json`.
:::

## Retries & delivery logs

- **5 attempts** with exponential backoff, base delay 1s (`1s, 2s, 4s, 8s, 16s`).
- Each delivery has a **10-second** HTTP timeout; a `2xx` response counts as delivered,
  anything else is retried.
- Delivery logs record `eventType`, `status` (`pending` / `delivered` / `failed`),
  `attempts`, `responseCode`, and a truncated `responseBody`. Read them via
  `GET /api/v1/webhooks/:id`, which returns aggregate `stats`
  (`total`, `delivered`, `failed`, `pending`, `successRate`) plus the last 20 deliveries.

Use `X-Webhook-Delivery-Id` to make your handler idempotent so retries are safe.

## Related

- [Analytics & conversions](https://developers.leonai.io/docs/guides/analytics/) — the `link.clicked` data mirrors click events
- [Custom domains](https://developers.leonai.io/docs/guides/domains/) — sources of `domain.verified` / `domain.ssl_ready`
- [Billing](https://developers.leonai.io/docs/guides/billing/) — sources of `subscription.upgraded` / `subscription.cancelled`
