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.
Managing endpoints through the API
Section titled “Managing endpoints through the API”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).
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-endpointsThe 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.
A delivery
Section titled “A delivery”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: 1755691200webhook-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.
Verifying signatures
Section titled “Verifying signatures”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; }}import os
from flask import Flask, requestfrom limitry import WebhookVerificationError, verify_webhook
app = Flask(__name__)
@app.post("/webhooks")def webhooks(): try: # The RAW body: verify before parsing it. event = verify_webhook( request.get_data(), request.headers, os.environ["WEBHOOK_SECRET"] ) except WebhookVerificationError: return "bad signature", 400 # handle event… return "ok"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.
Respond quickly, process later
Section titled “Respond quickly, process later”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.
Retries and failures
Section titled “Retries and failures”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.
Events
Section titled “Events”| 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.
Warnings before a limit
Section titled “Warnings before a limit”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.
Choosing events
Section titled “Choosing events”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:
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.