You created a provisioning job and got a 202 back: an engine ID, and status: "in_progress". The AI agent is now crawling your sources and applying brand voices, glossary items, and instructions to that engine in the background. The work could take a moment or a while, depending on how many links it has to crawl. You could hold a live WebSocket open and watch it work – but you'd rather not keep a connection open just to learn the agent is done and find out what it built.
That is what the webhook does. When you pass a callbackUrl while creating the job, Lingo POSTs the terminal result to that URL the moment the job ends – told when the engine is ready, with the inventory of what got built. A job that finishes arrives as provisioning.completed with the summary of every record the AI created. A job that fails arrives as provisioning.failed with the reason. Either way, your setup flow is told, without asking.
This page covers the two payloads and how to handle them. The delivery is signed and retried – that machinery is shared with localization and lives on the webhook signature verification page, linked at each point you'll need it.
On this page
- How delivery works
- The completed payload
- The failed payload
- Handling a webhook
- When delivery is the wrong tool
How delivery works#
A provisioning job ends exactly once. The instant it reaches a terminal state – every source crawled and analyzed, or the run abandoned – its result is delivered to your callbackUrl as a single POST. A localization group fans out into one job per target locale, each delivering its own callback; a provisioning job is one job, so it is one delivery.
Set the destination with callbackUrl when you create the job. Two payload shapes cross the wire, distinguished by their type field: provisioning.completed and provisioning.failed. Both name the jobId and engineId they belong to, so a single handler can route on type and update the right record.
HTTPS only
callbackUrl must use HTTPS. An HTTP URL is rejected when you create the job – the webhook is signed, and a signed payload over plaintext defeats the point.
Handle unknown event types gracefully
Today the wire carries provisioning.completed and provisioning.failed. Treat the set as open – branch on the types you know and ignore the rest, so a future event type can't break a deployed handler.
The completed payload#
When the job finishes, the payload carries the summary – the same inventory you would get by reading the job, pushed to you instead of polled. It names every brand voice, glossary item, and instruction the AI created on your engine, and lists any per-item failures it hit along the way.
{
"type": "provisioning.completed",
"jobId": "pjb_A1b2C3d4E5f6G7h8",
"engineId": "eng_X1y2Z3a4B5c6D7e8",
"summary": {
"brandVoices": { "count": 3, "ids": ["bv_A1b2C3d4", "bv_B2c3D4e5", "bv_C3d4E5f6"] },
"glossaryItems": { "count": 12, "ids": ["gi_A1b2C3d4", "..."] },
"instructions": { "count": 5, "ids": ["ins_A1b2C3d4", "..."] },
"errors": []
}
}| Field | Description |
|---|---|
type | provisioning.completed |
jobId | The provisioning job that finished (pjb_ prefix) |
engineId | The engine it configured (eng_ prefix) |
summary | What the AI created on the engine – counts and IDs per component, plus per-item failures in errors |
The summary is the same object the job carries, and its field-by-field meaning – what each component is, how items map to locales, what lands in errors – is documented once on What the AI extracts. Here it is enough to know the completed payload hands you the IDs of everything the agent built, so your handler can record them or surface them in your dashboard without re-fetching the job.
A non-empty errors array still arrives as completed.
Per-item failures do not fail the job. If a single source would not crawl or one record could not be created, it lands in summary.errors and the rest are still applied to the engine – and the payload is still provisioning.completed, not provisioning.failed. The completed event means the job ran to the end; read errors to see what to fix. A provisioning.failed payload is sent when the run produced no usable engine at all.
The failed payload#
A provisioning job fails when the run produces nothing to work with – for example, every source fails to crawl, so the agent has no content to analyze. When that happens, you are still told. The payload type is provisioning.failed, and it carries an error string in place of the summary:
{
"type": "provisioning.failed",
"jobId": "pjb_A1b2C3d4E5f6G7h8",
"engineId": "eng_X1y2Z3a4B5c6D7e8",
"error": "All sources failed to crawl. No content available for analysis."
}| Field | Description |
|---|---|
type | provisioning.failed |
jobId | The provisioning job that failed |
engineId | The engine that was created but left unconfigured |
error | Human-readable reason the job could not complete |
Here is the part a skeptical reader is right to ask about: if the job failed, did I lose the engine too? You did not. The engineId in this payload is the same engine you received in the 202 – it still exists, created the moment you made the call, just without the configuration the failed run would have added. A failure costs you the extraction, never the engine. Adjust what you submitted and try again, or configure the engine by hand from the dashboard. When a job fails on crawling, the sources are usually the reason – Source types covers what makes a source worth pointing at.
Handling a webhook#
A skeptical reader's first thought here is the right one: my handler does real work – a database write, a notification, a dashboard refresh – so won't that hold the connection open long enough to time the webhook out?
It would, so don't make Lingo wait for it. Return 200 first, then process. Acknowledge receipt, then do the real work after the response is sent. The full delivery contract – why you acknowledge first, and the retry schedule that follows if you don't – is on the signature and delivery page; the handler below shows the shape it takes for a provisioning payload.
app.post("/webhooks/provisioning", verifyWebhook, async (req, res) => {
// Acknowledge first - the job ends once, so this fires once.
res.status(200).send("ok");
const { type, jobId, engineId } = req.body;
if (type === "provisioning.completed") {
const { summary } = req.body;
await db.engines.update({
where: { engineId },
data: {
status: "ready",
brandVoiceCount: summary.brandVoices.count,
glossaryCount: summary.glossaryItems.count,
instructionCount: summary.instructions.count,
},
});
}
if (type === "provisioning.failed") {
console.error(`Provisioning failed: ${jobId} (${engineId})`, req.body.error);
await db.engines.update({
where: { engineId },
data: { status: "needs_configuration" },
});
}
});The verifyWebhook middleware is the one piece this page doesn't define. Every delivery is signed following the Standard Webhooks spec – three headers, an HMAC over the raw body, a whsec_ secret minted the first time you submit a job with a callback. Provisioning and localization callbacks use that scheme unchanged, so it lives once on webhook signature verification. Wire the middleware in before you trust a payload – an unverified body is an unauthenticated one.
Verify before you trust the body
Your endpoint is a public URL; anyone can POST to it. Verify the signature against the raw request body before acting on any payload – before you mark an engine ready or store the IDs it claims to have created. The how – the headers, the HMAC, the whsec_ secret – is on the signature verification page.
When delivery is the wrong tool#
The webhook is a push convenience, not the system of record. Two cases call for something else, and both are one link away.
If your endpoint was down when the result was delivered, the platform retries on the same schedule every Lingo webhook uses – and the result is not trapped in the callback. The records the AI created are the engine's actual configuration; the completed summary is a report of work that already happened on a real engine, not the only copy of it. So a stretch of downtime costs you a notification, never the engine. The retry schedule itself is on the signature and delivery page.
And if what you want is live progress while the engine configures – a crawling-then-configuring status in a UI, rather than a single callback to your server when it ends – that is the provisioning job WebSocket, not the webhook. It streams a snapshot on connect and progress events as the run advances, and you can connect at any point, even after the job has finished.
