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 |
The event is subscription.cancelled (double l).
Create a webhook
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
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:
{
"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:
// 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)
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)
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
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 "")
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
2xxresponse counts as delivered, anything else is retried. - Delivery logs record
eventType,status(pending/delivered/failed),attempts,responseCode, and a truncatedresponseBody. Read them viaGET /api/v1/webhooks/:id, which returns aggregatestats(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 — the
link.clickeddata mirrors click events - Custom domains — sources of
domain.verified/domain.ssl_ready - Billing — sources of
subscription.upgraded/subscription.cancelled