Limai Docs
API ReferenceWebhooks

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

  1. Navigate to Events Page -- Go to your project and click "Events" in the left sidebar
  2. Add Webhook Subscription -- Click "Add Webhook" and enter your HTTPS endpoint URL
  3. Select Event Types -- Choose which events you want to receive from the Documents, Classification, Splitting and Agents groups (see Event Types)
  4. Configure Routes -- Select which deployments, classifiers, splitters and/or agents the subscription listens to (see Routing Families)
  5. 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:

FamilyEventsRouted by
DocumentDOCUMENT_EXTRACTED, DOCUMENT_REVIEWED, DOCUMENT_EXTRACTION_FAILED, DOCUMENT_VALIDATED, DOCUMENT_VALIDATION_STARTED, DOCUMENT_VALIDATION_FAILEDDeployment routes -- the deployments the subscription listens to
ClassificationDOCUMENT_CLASSIFIED, DOCUMENT_CLASSIFICATION_FAILEDClassifier routes -- the classifiers the subscription listens to
SplittingDOCUMENT_SPLIT, DOCUMENT_SPLIT_FAILEDSplitter routes -- the splitters the subscription listens to
AgentAGENT_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.

  1. You click "Verify URL" for your webhook subscription
  2. LimAI sends a VERIFICATION event to your endpoint with a challenge value
  3. Your endpoint must respond with the challenge value in the response body
  4. 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.

StatusMeaning
successYour endpoint answered 2xx. Terminal.
pendingQueued, not attempted yet.
retryingAttempted at least once, failed, and a further attempt is scheduled.
failedNo further automatic attempt -- either the failure was terminal, or all 5 attempts were spent. Retryable by hand.
dead_letteredThe 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):

EventDescription
DOCUMENT_EXTRACTEDFires when a document is successfully extracted
DOCUMENT_REVIEWEDFires when all rows in a document are accepted
DOCUMENT_EXTRACTION_FAILEDFires when document extraction fails
DOCUMENT_VALIDATEDFires when all validations for a document settle
DOCUMENT_VALIDATION_STARTEDFires when a validation attempt is dispatched for a document
DOCUMENT_VALIDATION_FAILEDFires when a validation attempt cannot complete

Classification (routed by classifier route):

EventDescription
DOCUMENT_CLASSIFIEDFires when a classifier reaches a decision -- routed or unclassified, both on this event
DOCUMENT_CLASSIFICATION_FAILEDFires when a classification run breaks before reaching a decision

Splitting (routed by splitter route):

EventDescription
DOCUMENT_SPLITFires when a splitter finishes, carrying every segment and its file id
DOCUMENT_SPLIT_FAILEDFires when a split cannot complete

Agents (routed by agent route):

EventDescription
AGENT_RUN_STARTEDFires when an agent run starts
AGENT_RUN_COMPLETEDFires when an agent run completes successfully
AGENT_RUN_FAILEDFires when an agent run terminates with an error
AGENT_RUN_WAITING_HUMANFires 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 eventId to 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

On this page