Mistral AI Webhooks and Async Job Delivery
The request was accepted. The answer comes back later, over a connection you did not open, to a handler that has no memory of asking — and the most common failure is that nothing goes wrong anywhere and the result never lands.
📡 Monitor your APIs — know when they go down before your users do
Better Stack checks uptime every 30 seconds with instant Slack, email & SMS alerts. Free tier available.
Affiliate link — we may earn a commission at no extra cost to you
There are three ways an answer gets back to you from api.mistral.ai, and almost everything written about integrating with an AI API covers only the first two. A synchronous call blocks until the answer is ready. A stream keeps one connection open and hands you the answer in pieces. The third shape is different in kind: you hand over the work, you get an identifier, the connection closes, and the result arrives later — through a callback to a URL you published, or through a job object you go back and read.
That third shape is not exotic. It is where batch pricing lives, where anything that runs longer than a request timeout lives, and where the largest jobs in most production systems live. It is also the shape with the fewest guarantees, because the moment delivery is decoupled from the request, every assumption that made the synchronous path easy stops holding. There is no response to inspect. There is no exception to catch. The characteristic production failure is not an error — it is a job that finished successfully and a database that never heard about it.
This guide is about that gap. It is not about streaming, which is an in-band transport problem with the connection still open (see the streaming guide below); it is not about provider outages, which are a failover problem. It is about the delivery layer: what an asynchronous result actually promises, how to build a receiver that survives duplicates and silence, and why the poll you thought the webhook replaced is the only thing making the system reliable.
What an Async Delivery Actually Guarantees
Every webhook system in the industry converges on roughly the same contract, and it is weaker than most teams assume when they write the first handler. Read this table as the set of things you must handle yourself, because none of them will be handled for you.
| Property | What you actually get | What that forces you to build |
|---|---|---|
| Delivery | At-least-once, if it is delivered at all | Assume duplicates are normal traffic, not incidents. Dedupe on the event id before any side effect. |
| Ordering | None across separate deliveries | Never require a started event before a completed one. Upsert, and let the newer state win. |
| Retries | On any non-2xx, or on a slow response | Return 200 in milliseconds. A 40-second handler is indistinguishable from a dead one. |
| Authenticity | Signature over the raw body, if offered | Verify before parsing, in constant time, with a timestamp window to block replays. |
| Completeness | Job-level, not record-level | Reconcile submitted / succeeded / failed counts. A terminal job can contain failed rows. |
| Endpoint reachability | Not the sender's problem | A callback URL behind a stalled deploy silently drops events. The sweep is the only recovery. |
The row that surprises people is the last one. When a synchronous call fails because your service is broken, you find out immediately, from the same process that made the call. When a callback fails because your receiver is unreachable, the failure happens entirely on the sender’s side of the wire. It exhausts its retries against a URL that is not answering, gives up, and the only record of the event is in a log you cannot read. Your system’s view of that incident is that a quiet afternoon happened.
The Receiver: Four Steps, In This Order
A webhook endpoint is a public, unauthenticated, attacker-reachable URL that performs privileged writes. It deserves more care than an internal route and usually gets less. The whole of the receiver should be four steps, and the ordering carries most of the correctness.
// The receiver does four things and nothing else.
export async function POST(req: Request) {
const raw = await req.text(); // 1. read the body ONCE, unparsed
const sig = req.headers.get('x-signature');
const ts = req.headers.get('x-timestamp');
// 2. verify before parsing. An unverified body is attacker-controlled input.
if (!ts || Math.abs(Date.now() - Number(ts) * 1000) > 5 * 60_000) {
return new Response('stale', { status: 400 }); // replay window
}
if (!sig || !timingSafeEqual(sign(ts + '.' + raw), sig)) {
return new Response('bad signature', { status: 401 });
}
const event = JSON.parse(raw);
// 3. dedupe on the provider's event id, not on the job id.
// Re-delivery of the same event is normal, not an error.
const fresh = await db.insertIfAbsent('webhook_events', {
event_id: event.id, job_id: event.job_id, received_at: new Date(), raw,
});
if (!fresh) return new Response('ok', { status: 200 }); // already handled
// 4. hand off and acknowledge. No model calls, no downloads, no fan-out here.
await queue.publish('provider.job.completed', { event_id: event.id });
return new Response('ok', { status: 200 });
}Read the raw body once and verify against those exact bytes. Any framework that parses JSON for you and re-serialises it before you compute the signature will produce a mismatch on whitespace or key order, and the usual fix applied under time pressure — disabling verification “temporarily” — is how endpoints end up permanently accepting forged completions from anyone who guesses the URL.
Compare in constant time. A byte-by-byte early-return comparison on a signature is a genuine oracle, and this is a rare case where the textbook attack applies to ordinary application code. Include a timestamp in the signed material and reject anything outside a few minutes, so a captured valid delivery cannot be replayed a thousand times a week later.
Then acknowledge fast. The most common self-inflicted outage on this path is a handler that does the real work before returning 200: the sender times out, retries, and now two copies of an expensive job are running against a database that was already the reason the first one was slow. Enqueue and return.
Polling Is Not the Fallback. It Is the Source of Truth.
This is the load-bearing idea of the whole guide, and it inverts how most teams think about the two mechanisms. The webhook is a latency optimisation: it tells you sooner. The scheduled sweep over non-terminal jobs is what makes the system correct, because it is the only component that can notice the absence of an event. Nothing else in your architecture is capable of reacting to a message that was never sent.
// The sweep is what actually makes delivery reliable. Run it on a schedule.
// Webhooks make results FAST. This makes them CERTAIN.
async function reconcileOpenJobs() {
const stale = await db.query(
`SELECT job_id, submitted_at FROM async_jobs
WHERE state NOT IN ('succeeded','failed','cancelled','expired')
AND submitted_at < now() - interval '15 minutes'`
);
for (const job of stale) {
const remote = await provider.jobs.retrieve(job.job_id); // poll = source of truth
if (isTerminal(remote.status)) {
// The callback never arrived, or arrived and was dropped. Same handler,
// same idempotency key -- so a late webhook after this is a no-op.
await handleCompletion(job.job_id, remote, { source: 'reconciler' });
metrics.increment('async.recovered_by_sweep');
} else if (ageMinutes(job) > MAX_JOB_AGE) {
// Not terminal and far past the expected window: this is an incident,
// not a slow job. Page rather than keep polling forever.
alert.fire('async_job_stuck', { job_id: job.job_id, age: ageMinutes(job) });
}
}
}Route both paths into one handleCompletion, keyed for idempotency on the job id. Then the two mechanisms stop competing: whichever arrives first does the work, the second is a no-op, and you can lose either one entirely without losing results. A system built this way degrades from “fast and correct” to “slow and correct” when the callback path breaks, which is the only acceptable failure mode for money-shaped or data-shaped work.
It also gives you a metric worth alerting on that no error rate can express: the number of completions recovered by the sweep. In a healthy week that counter is near zero. When it climbs, your callback path is broken and every user-visible symptom is merely “things feel slower” — which is why the counter is the alert.
Six Ways an Async Result Disappears
Every row below has been a production incident somewhere, and in five of the six the HTTP layer reports success at the moment the data is lost.
| Failure | What the wire says | What you see | The fix |
|---|---|---|---|
| Duplicate delivery processed twice | 200 on both | Two ledger rows, two emails, two charges | Dedupe insert on event id, unique constraint in the database |
| Callback never arrives | Nothing at all | Job pending forever in your UI | Scheduled reconciler polls every non-terminal job |
| Handler acked, consumer died | 200 to the sender | Sender is satisfied, nothing happened | Ack only after a durable enqueue; monitor queue depth and DLQ |
| Job terminal, rows inside failed | Success status | Silent data holes downstream | Assert submitted == succeeded + failed before marking complete |
| Unknown terminal state added | Handler treats it as pending | Infinite poll loop, growing job table | Explicit state enum; default branch raises and alerts |
| Result URL expired before fetch | 403 on download | Reads as an auth incident, escalated wrong | Store the job id, request the download reference at fetch time |
The Mistral Trap: A Job-Level SUCCESS That Says Nothing About Your Rows
Mistral’s asynchronous surface is job-shaped: you upload a file, you create a job that references it, and you poll the job until it reports a terminal state. The trap is that the terminal state is about the job, not about your work. A batch of ten thousand requests where nine hundred individually failed is still, from the orchestration layer’s point of view, a job that ran to completion. If your completion handler branches on the job status alone, those nine hundred rows are quietly abandoned in whatever state your application left them in before submission.
The fix is to make the row, not the job, the unit of accounting. Record how many inputs you submitted, count how many results you parsed, count how many carried a per-row error, and require the three numbers to reconcile before you allow the job to be marked complete on your side. When they do not reconcile, the job is not finished — it is partially finished, which is a different and much more dangerous state, because it looks identical to success in every dashboard.
There is also a lifecycle detail that catches teams once: the artifacts of a job are files, files are objects with their own retention and their own identifiers, and a completion handler that fetches the result file two days later because a queue backed up may find that the reference outlived the object. Download and persist the raw result to storage you control as the first action of the completion handler, before any parsing, so a slow downstream step can never cost you the payload.
What to Log
Log the event id, the job id and the environment on every delivery; the count of deliveries suppressed by the dedupe store; the count of completions recovered by the sweep rather than by a callback; the age of the oldest non-terminal job; and the submitted-versus-returned record reconciliation for every batch. Those five turn “a customer says their export never arrived” into a job id and a timestamp in about a minute.
What none of them can tell you is whether api.mistral.ai was degraded while your queue was quiet. A provider incident on the async path is close to invisible from inside your own telemetry: no errors are raised, no latency percentile moves, because the requests that would have carried the bad news were never made. The graph just flattens, and a flat graph looks like a slow Tuesday. That is the gap external monitoring closes.
Frequently Asked Questions
Does Mistral AI push a webhook when my job finishes, or do I have to poll?
Treat that as a per-surface question you check in the current Mistral AI reference rather than a property of the vendor, because it varies by endpoint and changes over time: some long-running surfaces expose a callback, others expose only a job object you retrieve. What matters architecturally is that the answer does not change your design. Build the completion handler first, as a function that takes a job id and a terminal state and is safe to call twice. Then feed it from whatever transports are available -- a push if there is one, a scheduled sweep in every case. Teams that build the push path first and bolt on polling after an incident end up with two completion paths that behave differently, and the rare one is always the buggy one.
Why acknowledge the webhook before doing the work?
Because the sender is measuring your response time and interprets a slow reply as a failed delivery. If your handler downloads a result file, embeds it, writes to three tables and then returns 200 forty seconds later, you have built a system where every slow database is indistinguishable from an outage, and the sender will retry -- so now two copies of that forty-second job are running concurrently. Read, verify, record, enqueue, return. Everything expensive happens on the other side of the queue, where you control the concurrency and where a failure is a retryable job rather than a lost event.
What is the right idempotency key: the job id or the event id?
Both, for different purposes. Dedupe delivery on the event id, because the same event redelivered is the exact case you are trying to suppress and two genuinely different events about one job -- started, then completed -- must not cancel each other out. Then make the downstream effect idempotent on the job id, because a completion that arrives twice by two different routes, once by webhook and once by your reconciler, carries two different event ids and must still produce one set of database writes. The first check protects the receiver; the second protects the business logic. Skipping the second is the more expensive mistake, and it is the one that produces double charges and duplicated records.
How do I test a webhook path when the provider only calls a public URL?
Split the transport from the logic and test them separately. The completion handler takes a job id and a payload; that is a pure function you can unit test against captured fixtures, including the partial-failure and unknown-state cases that are almost impossible to provoke on demand against a live service. The transport -- signature verification, replay window, dedupe insert -- is tested by replaying a captured raw body with a signature you compute in the test, which is exactly the code path an attacker would probe. Reserve the tunnel-to-localhost setup for a single end-to-end smoke test that proves the URL is reachable and the secret matches, and do not try to run your whole suite through it.
Events arrive out of order. Is that a bug?
No, and designing for ordering is the trap. Independent deliveries over the public internet with independent retry timers will occasionally land a completion before the started event that logically preceded it, and any handler with an implicit state machine -- refusing to complete a job it does not believe has begun -- will drop the important one. Make each handler tolerate arriving first: upsert rather than update, and let a later, staler event lose to a newer one on a monotonic field carried in the payload. State transitions should be write-if-newer, not assert-then-write.
My handler ran, returned 200, and the job still shows as pending in my app. Where did it go?
Almost always one of four places, and all four return a success somewhere. The event was deduped against a stale row from a previous incident and skipped. The handler acknowledged, enqueued, and the queue consumer died before committing, so the ack was truthful and the work never happened. The job reported a terminal state while individual records inside it failed, so your row count never reached the total. Or the payload was for a job id your application does not recognise, because it was submitted from a different environment pointed at the same api.mistral.ai account and the same callback URL. Log the event id, the job id and the environment on every delivery, and the fourth one stops being a mystery immediately.
Related Guides
A Silent Queue Looks Exactly Like a Quiet Day
When the async path degrades, your own telemetry goes quiet rather than red — no errors, no latency spike, just results that never arrive. API Status Check probes api.mistral.ai and the rest of your stack independently and alerts on errors and latency, so you find out from a monitor instead of from a customer.
Start Your Free Trial →🛠 Tools We Use & Recommend
Tested across our own infrastructure monitoring 200+ APIs daily
Uptime Monitoring & Incident Management
Used by 100,000+ websites
Monitors your APIs every 30 seconds. Instant alerts via Slack, email, SMS, and phone calls when something goes down.
“We use Better Stack to monitor every API on this site. It caught 23 outages last month before users reported them.”
Secrets Management & Developer Security
Trusted by 150,000+ businesses
Manage API keys, database passwords, and service tokens with CLI integration and automatic rotation.
“After covering dozens of outages caused by leaked credentials, we recommend every team use a secrets manager.”
SEO & Site Performance Monitoring
Used by 10M+ marketers
Track your site health, uptime, search rankings, and competitor movements from one dashboard.
“We use SEMrush to track how our API status pages rank and catch site health issues early.”