Webhook Setup
Configure webhooks to receive real-time notifications when documents are processed.
Subscribe to real-time events from your document and agent workflows. Get notified instantly when documents are processed, validated, or when agent runs change state, with secure, reliable webhook delivery.
Everything on this page can also be driven programmatically: see the Webhook Management API for the v1 routes, and limai webhooks for the same operations from a terminal.
Setting Up Webhooks
- Navigate to Events Page -- Go to your project and click "Events" in the left sidebar
- Add Webhook Subscription -- Click "Add Webhook" and enter your HTTPS endpoint URL
- Select Event Types -- Choose which events you want to receive from the Documents, Classification, Splitting and Agents groups (see Event Types)
- Configure Routes -- Select which deployments, classifiers, splitters and/or agents the subscription listens to (see Routing Families)
- Verify Your Endpoint -- Click "Verify URL" to confirm you control the endpoint and activate the subscription
Routing Families
Events belong to one of four routing families, and each family is routed differently:
| Family | Events | Routed by |
|---|---|---|
| Document | DOCUMENT_EXTRACTED, DOCUMENT_REVIEWED, DOCUMENT_EXTRACTION_FAILED, DOCUMENT_VALIDATED, DOCUMENT_VALIDATION_STARTED, DOCUMENT_VALIDATION_FAILED | Deployment routes -- the deployments the subscription listens to |
| Classification | DOCUMENT_CLASSIFIED, DOCUMENT_CLASSIFICATION_FAILED | Classifier routes -- the classifiers the subscription listens to |
| Splitting | DOCUMENT_SPLIT, DOCUMENT_SPLIT_FAILED | Splitter routes -- the splitters the subscription listens to |
| Agent | AGENT_RUN_* (all four) | Agent routes -- the agents the subscription listens to |
Document events are matched to a subscription by the deployment that processed the document. Agent events are matched by the agent that produced the run.
Classification and splitting events are matched by the classifier or splitter, not by deployment. That is the whole point: a document that did not route, or a split that failed, has no deployment to key off. Keying those events on a deployment is why classification failures were silently undeliverable before this release.
Because of this, a subscription is only valid when it has at least one route of each family it subscribes to:
- at least one deployment route for any document event
- at least one classifier route for any classification event
- at least one splitter route for any split event
- at least one agent route for any agent event
A single subscription can mix families as long as it has the routes each one requires.
Global classifiers
A classifier can be global -- shared across every project in your organization. A global classifier can be routed from any project in that organization, and it shows a Global badge in the route picker. Classifiers from another organization are rejected.
URL Verification
Before activating your webhook subscription, LimAI verifies that you control the endpoint by sending a verification challenge.
- You click "Verify URL" for your webhook subscription
- LimAI sends a
VERIFICATIONevent to your endpoint with achallengevalue - Your endpoint must respond with the challenge value in the response body
- The subscription is marked as verified and activated
HMAC Signature Verification
All webhook events are signed with HMAC-SHA256 for authenticity verification. Each webhook subscription gets a unique secret key, and every request includes an X-Webhook-Signature header with the format sha256=<hex_digest>.
Always verify the signature before processing events.
const crypto = require("crypto")
const express = require("express")
function verifyWebhookSignature(payload, signature, secretKey) {
const expectedSignature = crypto
.createHmac("sha256", secretKey)
.update(payload)
.digest("hex")
const receivedSignature = signature.replace("sha256=", "")
return crypto.timingSafeEqual(
Buffer.from(expectedSignature, "hex"),
Buffer.from(receivedSignature, "hex")
)
}
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["x-webhook-signature"]
const payload = req.body.toString()
if (!verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).send("Invalid signature")
}
const event = JSON.parse(payload)
if (event.eventType === "VERIFICATION") {
return res.json({ challenge: event.challenge })
}
switch (event.eventType) {
case "DOCUMENT_EXTRACTED":
handleDocumentExtracted(event)
break
case "DOCUMENT_REVIEWED":
handleDocumentReviewed(event)
break
case "DOCUMENT_EXTRACTION_FAILED":
case "DOCUMENT_CLASSIFICATION_FAILED":
handleDocumentFailure(event)
break
case "DOCUMENT_VALIDATED":
handleDocumentValidated(event)
break
case "DOCUMENT_VALIDATION_STARTED":
handleValidationStarted(event)
break
case "DOCUMENT_VALIDATION_FAILED":
handleValidationFailed(event)
break
case "AGENT_RUN_STARTED":
case "AGENT_RUN_COMPLETED":
case "AGENT_RUN_FAILED":
case "AGENT_RUN_WAITING_HUMAN":
handleAgentRunEvent(event)
break
}
res.status(200).send("OK")
})Retry Policy
Your endpoint must answer with a 2xx status code. Anything else is a failed delivery.
- Maximum attempts: 5, the first one included
- Backoff: exponential, starting at 10 seconds
- Terminal failures: a 4xx response, or a URL that is blocked as unsafe, is never retried -- the delivery goes straight to the dead-letter queue
A 4xx is treated as a decision, not an outage: your endpoint rejected the payload, and sending it four more times will not change that. A 5xx or a transport error is treated as an outage and retried.
Delivery Statuses
Every attempt updates the delivery's record, and each delivery ends up in one of five states. They are what you filter on in GET /deliveries and in limai webhooks deliveries --status.
The log keeps one row per delivery -- per (eventId, subscription) -- not one per attempt. Each attempt overwrites that row's status, attempt count, response code, response body, error type and duration, so what you can inspect is always the latest attempt; the response of an earlier one is gone once the next attempt runs, and retryCount is the only trace that it happened.
| Status | Meaning |
|---|---|
success | Your endpoint answered 2xx. Terminal. |
pending | Queued, not attempted yet. |
retrying | Attempted at least once, failed, and a further attempt is scheduled. |
failed | No further automatic attempt -- either the failure was terminal, or all 5 attempts were spent. Retryable by hand. |
dead_lettered | The job reached the dead-letter queue. Retryable by hand while the payload is still retained. |
failed and dead_lettered are the same delivery seen from two sides: the worker marks its last attempt failed, then the dead-letter hook marks the job dead_lettered.
How far back a retry reaches
A manual retry replays the queued job's retained payload, not a copy of the event in the database. The delivery log therefore outlives the ability to retry from it -- a row can read failed long after its payload is gone.
Both failed and dead-lettered deliveries are retained for about 7 days, and only the most recent few thousand of each are kept, so a high-volume project loses reach sooner than 7 days. Past that, retry answers payload_unavailable and the work has to be re-driven from your own side.
What the Delivery Log Does Not Tell You
Three things about the log are easy to misread.
The log starts on 2026-08-26. Per-attempt delivery recording shipped that day, and only events enqueued from then on are written down. An empty log on a subscription that has been live for months does not mean nothing was delivered -- it means nothing was recorded. Treat the log as a record of recent delivery, not as history.
VERIFICATION pings are never stored. The verification challenge is sent outside the delivery queue, so a successful verify leaves no row. A subscription that has been verified but not yet triggered legitimately shows an empty log.
retryCount does not restart when you retry by hand. A re-queued delivery keeps its attempt counter, so one that had already spent its five attempts gets a single further attempt and then dead-letters again rather than starting a fresh set of five. Retrying a delivery whose endpoint is still broken buys you one attempt, not five.
Event Types
Documents (routed by deployment route):
| Event | Description |
|---|---|
DOCUMENT_EXTRACTED | Fires when a document is successfully extracted |
DOCUMENT_REVIEWED | Fires when all rows in a document are accepted |
DOCUMENT_EXTRACTION_FAILED | Fires when document extraction fails |
DOCUMENT_VALIDATED | Fires when all validations for a document settle |
DOCUMENT_VALIDATION_STARTED | Fires when a validation attempt is dispatched for a document |
DOCUMENT_VALIDATION_FAILED | Fires when a validation attempt cannot complete |
Classification (routed by classifier route):
| Event | Description |
|---|---|
DOCUMENT_CLASSIFIED | Fires when a classifier reaches a decision -- routed or unclassified, both on this event |
DOCUMENT_CLASSIFICATION_FAILED | Fires when a classification run breaks before reaching a decision |
Splitting (routed by splitter route):
| Event | Description |
|---|---|
DOCUMENT_SPLIT | Fires when a splitter finishes, carrying every segment and its file id |
DOCUMENT_SPLIT_FAILED | Fires when a split cannot complete |
Agents (routed by agent route):
| Event | Description |
|---|---|
AGENT_RUN_STARTED | Fires when an agent run starts |
AGENT_RUN_COMPLETED | Fires when an agent run completes successfully |
AGENT_RUN_FAILED | Fires when an agent run terminates with an error |
AGENT_RUN_WAITING_HUMAN | Fires when an agent run pauses for human input |
Retrieving Data from Webhooks
Webhook payloads contain metadata (file ID, schema ID, status) but not the actual extracted data. Use the get-file-data endpoint with the fileId from the webhook payload to retrieve the processed content.
Best Practices
- Always verify HMAC signatures before processing events
- Respond with 2xx status codes quickly to avoid timeouts
- Implement idempotency using the unique
eventIdto prevent duplicate processing - Process events asynchronously to avoid blocking the response
- Use HTTPS endpoints to protect data in transit
- Store webhook secret keys in environment variables