Skip to main content

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.

EventFires when
link.createdA link is created
link.updatedA link is updated
link.deletedA link is deleted
link.clickedA short link is visited
link.expiredReserved — not sent yet
link.limit_reachedReserved — not sent yet
domain.verifiedA custom domain passes DNS verification
domain.ssl_readyReserved — not sent yet
subscription.upgradedThe account upgrades tier
subscription.cancelledThe subscription is cancelled
Spelling

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 + pathScopeNotes
POST /api/v1/webhookswebhooks:writeReturns secret once
GET /api/v1/webhookswebhooks:readList; totalDeliveries per hook
GET /api/v1/webhooks/:idwebhooks:readsecretPreview, stats, recentDeliveries (20)
PATCH /api/v1/webhooks/:idwebhooks:writeUpdate url / events / enabled
DELETE /api/v1/webhooks/:idwebhooks:writeRemoves 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:

HeaderValue
Content-Typeapplication/json
X-Webhook-SignatureHex HMAC-SHA256 digest of the raw JSON body (no sha256= prefix)
X-Webhook-EventThe event name (e.g. link.clicked)
X-Webhook-Delivery-IdUnique delivery id (use it for idempotency/logging)
User-AgentLeonAI-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 "")
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.