Cohere 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.

15 min read
Staff Pick

📡 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.

Start Free →

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.cohere.com, 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.

PropertyWhat you actually getWhat that forces you to build
DeliveryAt-least-once, if it is delivered at allAssume duplicates are normal traffic, not incidents. Dedupe on the event id before any side effect.
OrderingNone across separate deliveriesNever require a started event before a completed one. Upsert, and let the newer state win.
RetriesOn any non-2xx, or on a slow responseReturn 200 in milliseconds. A 40-second handler is indistinguishable from a dead one.
AuthenticitySignature over the raw body, if offeredVerify before parsing, in constant time, with a timestamp window to block replays.
CompletenessJob-level, not record-levelReconcile submitted / succeeded / failed counts. A terminal job can contain failed rows.
Endpoint reachabilityNot the sender's problemA 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.

FailureWhat the wire saysWhat you seeThe fix
Duplicate delivery processed twice200 on bothTwo ledger rows, two emails, two chargesDedupe insert on event id, unique constraint in the database
Callback never arrivesNothing at allJob pending forever in your UIScheduled reconciler polls every non-terminal job
Handler acked, consumer died200 to the senderSender is satisfied, nothing happenedAck only after a durable enqueue; monitor queue depth and DLQ
Job terminal, rows inside failedSuccess statusSilent data holes downstreamAssert submitted == succeeded + failed before marking complete
Unknown terminal state addedHandler treats it as pendingInfinite poll loop, growing job tableExplicit state enum; default branch raises and alerts
Result URL expired before fetch403 on downloadReads as an auth incident, escalated wrongStore the job id, request the download reference at fetch time

The Cohere Trap: A Bulk Embedding Job Is a Data Migration, Not an API Call

Cohere’s long-running surface is dominated by bulk embedding over datasets, and that makes its completion semantics different in kind from every other provider on this list. When a chat batch half-fails you retry the missing rows and move on. When an embedding job half-fails, you are left with a vector index that is partially populated, and a partially populated index does not throw — it returns confident, plausible, wrong neighbours for anything whose vector never landed. The failure surfaces as degraded search relevance weeks later, attributed to the model, and nobody goes looking at a job that reported success.

So the completion handler for an embedding job has one non-negotiable step that the other providers do not need: a coverage assertion. Count the records you intended to embed, count the vectors that actually exist in the index for that job, and refuse to flip the index into serving until they match. Treat a shortfall as a failed migration and re-run the missing slice, because there is no user-visible error to catch later.

Third, re-running is not free in either money or time, which means idempotency matters more here than anywhere else in this guide. A duplicate completion event that triggers a second full embedding pass over a large dataset is a real invoice and a real multi-hour delay. The event-id dedupe store described above is the cheapest insurance in this entire architecture, and this is the workload that justifies it on its own.

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.cohere.com 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 Cohere 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 Cohere 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.cohere.com 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.cohere.com 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

Better StackBest for API Teams

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.

Free tier · Paid from $24/moStart Free Monitoring
1PasswordBest for Credential Security

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.

SEMrushBest for SEO

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.

From $129.95/moTry SEMrush Free
View full comparison & more tools →Affiliate links — we earn a commission at no extra cost to you