Skip to content

Webhooks

Webhooks send workspace events to an HTTPS endpoint you host, as they happen. Workspace owners manage endpoints in the app under Settings → Webhooks: add a URL, copy the signing secret (it’s shown once), and events start arriving.

Software can manage endpoints too, with a credential that has the workspace.webhooks:write scope. Reading them needs workspace.webhooks:read, and a personal access token must also belong to a workspace owner. A test key can read endpoints but not add or change them, so use a live key (lim_live_…) or a personal access token; each endpoint still belongs to test or live (below).

Terminal window
curl -X POST \
-H "Authorization: Bearer YOUR_LIVE_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/hooks" }' \
https://api.limitry.com/v1/workspace/webhook-endpoints

The response includes the endpoint’s signing secret, once. The API can also list endpoints, re-enable one that we switched off after repeated failures, send a test event, and list and redeliver deliveries. See the API reference.

AI agents connected to the workspace can read endpoints and delivery history, send test events and redeliver. They can’t add, remove or re-enable endpoints, because that changes where your workspace’s data goes, so a person does it.

Each delivery is an HTTP POST with a JSON body:

{
"id": "evt_5f0c…",
"type": "workspace.member.joined",
"createdAt": "2026-08-20T12:00:00.000Z",
"data": { "userId": "…", "email": "casey@example.com" }
}

and three signature headers:

webhook-id: del_9a1b…
webhook-timestamp: 1755691200
webhook-signature: v1,MEQCIB…

webhook-id identifies the delivery, and stays the same when it’s retried, so use it to ignore duplicates. The id in the body identifies the event. If you have several endpoints, each gets its own delivery of the same event; to process an event once across endpoints, deduplicate by its id. An event can occasionally reach you more than once as separate deliveries, for example when we resend it after an outage. It keeps the same id each time, so deduplicating by id covers that too.

Check every delivery before you trust it: that we sent it, that nobody changed it, and that it’s recent. The SDKs do all three with your endpoint’s secret (whsec_…, shown once when you add the endpoint) and give you the event:

import { verifyWebhook, WebhookVerificationError } from "@limitry/sdk";
// A fetch-style handler (Hono, Workers, Next.js route handlers…).
export async function POST(request: Request) {
try {
// The RAW body: verify before parsing it.
const event = await verifyWebhook(
await request.text(),
request.headers,
process.env.WEBHOOK_SECRET!,
);
// handle event…
return new Response("ok");
} catch (error) {
if (error instanceof WebhookVerificationError) {
return new Response("bad signature", { status: 400 });
}
throw error;
}
}

With a client already made, the same check is client.webhooks.verify(…). The SDKs refuse a delivery older than 5 minutes, so nobody can replay an old one (toleranceSeconds / tolerance_seconds changes it).

We sign the same way as Svix, so a standard Svix library verifies our deliveries too. In any other language, verify by hand: the signature is v1, followed by a Base64 HMAC-SHA256 of `${webhookId}.${timestamp}.${body}`, keyed with the secret after its whsec_ prefix, Base64-decoded. Always verify the raw body, because re-serialising the JSON changes it and breaks the signature. Reject timestamps more than a few minutes old.

Answer with a 2xx within 10 seconds. If processing takes longer, acknowledge first and do the work afterwards, because a timeout counts as a failed delivery.

We retry a failed delivery automatically, with backoff, up to 6 attempts, with the same webhook-id. We retry a 5xx, a 408, a 429, a timeout or a network error. Any other 3xx or 4xx fails the delivery at once, because your endpoint answered and refused it. We don’t follow redirects.

If 20 attempts in a row fail, counting retries, we switch the endpoint off. Once your endpoint is working again, choose Re-enable under Settings → Webhooks, or call POST /v1/workspace/webhook-endpoints/{endpointId}/enable. The endpoint keeps its signing secret, and the count starts again from zero.

Under Settings → Webhooks → Deliveries you can see each delivery’s status and attempts, send a test event (type: "ping"), and redeliver any recorded delivery. A redelivery has a new webhook-id but the same event id, so deduplicating by event still works.

Type Sent when data
limit.threshold_crossed A subject first reaches 80% or 100% of a limit. limitId, name, action, unit, window, subject, environment, threshold, used, amount, windowStart, resetsAt
limit.reached A limit first refuses a subject in a window. limitId, name, action, unit, window, subject, environment, windowStart, resetsAt
workspace.member.joined Someone accepts an invitation. userId, email
ping You send a test from Settings. a test message

name, action, unit and window say what the limit is, so a handler can word its message (“80% of this month’s AI budget”) without looking the limit up.

An endpoint belongs to test or live and receives only that environment’s limit events. limit.reached also says which in environment, "test" or "live". See Usage and reports for an example.

limit.threshold_crossed tells you a subject is close to a limit while there’s still room to act: email the user, show a banner, offer more credits. Limitry sends it the first time a subject’s use reaches 80% of a limit in a window, and again at 100%, which is the last use the limit allows. Limitry refuses the first check after that and sends limit.reached as before.

{
"id": "evt_3c9a…",
"type": "limit.threshold_crossed",
"createdAt": "2026-10-08T09:14:02.000Z",
"data": {
"limitId": "lmt_…",
"name": "AI budget",
"action": "ai-call",
"unit": "cents",
"window": "month",
"subject": { "kind": "user", "id": "usr_123" },
"environment": "live",
"threshold": 80,
"used": 160,
"amount": 200,
"windowStart": "2026-10-01T00:00:00.000Z",
"resetsAt": "2026-11-01T00:00:00.000Z"
}
}

How often each kind of limit warns:

Limit When it warns
Per minute, hour, day, week, month or year Once at 80% and once at 100% in each window.
Rolling (“in any 24 hours”) At 80% and 100%, at most once an hour for each.
Balance (window total) At 20% left (threshold: 80) and when it runs out (threshold: 100). After you add credits, it can warn again (as a new event, with a new id) as the balance runs down. windowStart and resetsAt are null.
Concurrent (“at most N at once”) Never. A concurrent limit fills and empties all the time, so a warning would mean nothing.

A warning reaches your endpoint within about a minute of the check that crossed the line. A reservation counts from the moment you make it. If its commit costs more than it reserved, the extra use can warn too. If you release a reservation that warned, or commit it for less, the warning stands. The next time use reaches the line, it warns again. A rolling limit still warns at most once an hour.

A warning from test carries "environment": "test", so a handler can skip it. If we send the same warning twice, after an outage for example, both deliveries carry the same event id, so deduplicating by id drops the second one.

Limitry only tells you. Whether and how to tell your user is up to your product: see Warn your users before they hit a limit.

Event payloads only ever gain fields, so write parsers that ignore fields they don’t know. GET /v1/workspace/event-types lists every event type and what it means.

An endpoint receives every event unless you choose otherwise. In Settings → Webhooks, pick “Only these” when you add it, or pass eventTypes when you create it through the API:

Terminal window
curl -X POST https://api.limitry.com/v1/workspace/webhook-endpoints \
-H "Authorization: Bearer YOUR_LIVE_API_KEY" -H "content-type: application/json" \
-d '{"url":"https://example.com/hooks","eventTypes":["limit.reached"]}'

A test event (ping) always reaches the endpoint you’re testing.