Building Async-First APIs: The 202 Accepted Pattern for the AI Era
Building Async-First APIs: The 202 Accepted Pattern for the AI Era For a decade, most web APIs followed one rule: a client sends a request, the server does the work, and a status...

Building Async-First APIs: The 202 Accepted Pattern for the AI Era
For a decade, most web APIs followed one rule: a client sends a request, the server does the work, and a status code comes back in the same breath — 200 OK, 201 Created, maybe a 4xx if something went wrong. That model works beautifully when "doing the work" takes milliseconds.
It falls apart once "doing the work" means running an LLM inference pass, generating a video, transcribing an hour of audio, or embedding a few million documents. Hold an HTTP connection open for 30 seconds to several minutes and you'll start hitting gateway timeouts, exhausted connection pools, and a genuinely bad developer experience. The fix isn't a bigger timeout value — it's a different contract between client and server: accept the job now, deliver the result later. That's the async API pattern built around the HTTP 202 Accepted status code.
What 202 Accepted actually means
202 Accepted is defined in RFC 9110 (which folded in and superseded the older RFC 7231/RFC 2616 text). The wording is deliberately vague:
The request has been accepted for processing, but the processing has not been completed. The request might or might not eventually be acted upon, as it might be disallowed when processing actually takes place. There is no facility for re-sending a status code from an asynchronous operation.
Two things are worth underlining:
- It's a promise about receipt, not about outcome. A
202only confirms the server understood and queued the request. The job can still fail, and there's no HTTP-native way to "amend" that response later — the client has to check back. - It's the correct code for anything that can't be confirmed synchronously.
200 OKimplies the work is done;201 Createdimplies a resource now exists.202implies neither.
A well-formed 202 response should give the client somewhere to go next:
HTTP/1.1 202 Accepted
Content-Type: application/json
Location: /v1/jobs/job_8f9a2b4c-9012
Retry-After: 15
{
"job_id": "job_8f9a2b4c-9012",
"status": "queued",
"status_url": "https://api.example.com/v1/jobs/job_8f9a2b4c-9012"
}
Locationpoints to where the client can check status or fetch the final result.Retry-After— seconds, or an HTTP-date — tells the client how long to wait before the first poll, so it doesn't hammer the endpoint immediately.
A small but common mistake worth flagging: people often cite "Cloudflare 544" as one of the timeout errors that pushes teams toward this pattern. That code doesn't exist — the actual error is 524 ("A Timeout Occurred"), which fires when Cloudflare's connection to your origin succeeds but the origin doesn't send a response within the proxy's read timeout (100 seconds by default, extendable on paid plans). It's a good example of why moving long jobs behind a 202 response — instead of trying to out-wait the proxy — is the more durable fix.
Three ways to get the result back
Once a job is accepted, the client needs the output eventually. There are three common strategies, and most serious platforms end up supporting more than one:
| Strategy | How it works | Strengths | Weaknesses | Fits best |
|---|---|---|---|---|
| Polling | Client repeatedly calls GET /jobs/{id} until it sees a terminal status | Simple, firewall-friendly, no inbound connectivity needed | Wasted requests, latency tied to poll interval | Desktop/mobile clients, simple batch jobs |
| Webhooks | Server sends an HTTP POST to a client-supplied callback URL when the job finishes | No wasted bandwidth, near real-time, decouples systems | Client must run a public, reliable receiver; needs retry/idempotency handling | Server-to-server integrations, AI pipelines |
| Streaming (SSE/WebSockets) | A persistent connection pushes incremental progress or tokens | True real-time, good for partial results | Harder to scale (stateful connections, load balancing) | Chat UIs, live progress bars, token-by-token generation |
Webhooks are generally the most efficient option for server-to-server integrations, but polling is worth keeping around as a fallback — more on that in the checklist below.
The webhook + job ID pattern
The hard part of an async, webhook-driven API isn't sending the callback — it's correlation: when a webhook lands minutes or hours after the original request, how do you match it back to the right context?
The pattern that solves this has four moving parts:
- Initiation & metadata storage — the caller submits a job with an optional
client_reference_idand acallback_url. The provider generates its ownjob_idand stores the mapping between the two, plus the processing parameters. - Asynchronous processing — the job goes onto an internal queue (Redis Streams, SQS, Kafka, Temporal, etc.), and worker processes pick it up independently of the web tier.
- Completion & dispatch — when the worker finishes, it builds a payload containing the
job_id, the final status, and either the result or a link to it, then delivers it to thecallback_url. - Reconciliation — the receiving system verifies the request is authentic, looks up the
job_idin its own database, updates the parent record, and (only after that's durable) triggers whatever comes next.
Provider side: accepting and enqueuing the job
// server.ts — API provider endpoint
import express, { Request, Response } from 'express';
import { randomUUID } from 'crypto';
import { jobQueue, db } from './queueService';
const app = express();
app.use(express.json());
interface JobRequest {
prompt: string;
callback_url: string;
client_reference_id?: string;
}
app.post('/v1/generations', async (req: Request<{}, {}, JobRequest>, res: Response) => {
const { prompt, callback_url, client_reference_id } = req.body;
if (!prompt || !callback_url) {
// RFC 9457 problem+json, not an ad hoc error shape
return res.status(400)
.type('application/problem+json')
.json({
type: 'https://api.example.com/problems/missing-field',
title: 'Missing required field',
status: 400,
detail: 'Both "prompt" and "callback_url" are required.',
});
}
const jobId = `job_${randomUUID()}`;
await db.jobs.create({
id: jobId,
clientReferenceId: client_reference_id ?? null,
status: 'QUEUED',
callbackUrl: callback_url,
createdAt: new Date(),
});
await jobQueue.add('inference-task', { jobId, prompt, callbackUrl: callback_url });
return res.status(202)
.header('Location', `/v1/jobs/${jobId}`)
.header('Retry-After', '15')
.json({
job_id: jobId,
status: 'queued',
status_url: `https://api.example.com/v1/jobs/${jobId}`,
});
});
Consumer side: a receiver that actually follows a real spec
The original version of this pattern usually shows a hand-rolled X-Signature header. In practice, you don't have to invent your own scheme anymore. Standard Webhooks — a spec backed by a steering committee from Zapier, Twilio, Mux, ngrok, Supabase, Svix, and Kong, and reportedly adopted by OpenAI, Anthropic, Google Gemini, PagerDuty, Etsy, and others — defines a concrete header and signature format:
webhook-id— a unique ID for this delivery attempt, reused as the idempotency keywebhook-timestamp— Unix seconds, checked against a tolerance window to block replay attackswebhook-signature— one or morev1,<base64 HMAC-SHA256>(orv1a,<base64 ed25519>) values, space-delimited, signing the exact string{webhook-id}.{webhook-timestamp}.{raw body}
// webhookReceiver.ts — consumer application
import express, { Request, Response } from 'express';
import crypto from 'crypto';
import { inboundQueue } from './clientService';
const app = express();
app.use(express.json({ verify: (req: any, _res, buf) => { req.rawBody = buf; } }));
const SIGNING_SECRET = process.env.WEBHOOK_SECRET!; // e.g. "whsec_..."
const TOLERANCE_SECONDS = 5 * 60;
app.post('/webhooks/job-completion', async (req: Request, res: Response) => {
const id = req.header('webhook-id');
const timestamp = req.header('webhook-timestamp');
const signatureHeader = req.header('webhook-signature');
if (!id || !timestamp || !signatureHeader) {
return res.status(400).json({ error: 'Missing webhook headers' });
}
// 1. Reject stale or future-dated attempts (replay protection)
const skewSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (skewSeconds > TOLERANCE_SECONDS) {
return res.status(400).json({ error: 'Timestamp outside tolerance' });
}
// 2. Recompute the HMAC and compare in constant time
const signedContent = `${id}.${timestamp}.${(req as any).rawBody}`;
const secretBytes = Buffer.from(SIGNING_SECRET.split('_')[1], 'base64');
const expected = crypto.createHmac('sha256', secretBytes).update(signedContent).digest('base64');
const provided = signatureHeader
.split(' ')
.find((sig) => sig.startsWith('v1,'))
?.slice(3);
const isValid = provided &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
if (!isValid) {
return res.status(400).json({ error: 'Invalid signature' });
}
// 3. Acknowledge fast — do NOT run business logic inline
res.status(200).json({ received: true });
// 4. Hand off to a durable queue using webhook-id as the idempotency key
await inboundQueue.add('process-webhook-event', {
eventId: id,
payload: req.body,
receivedAt: new Date(),
});
});
Building receivers that survive restarts
The single most common architectural mistake in webhook consumption is treating ingestion and processing as one step:
BAD: [Webhook POST] → [Route handler does the DB write / sync] → [200 OK]
^
If the process restarts here, the event is gone.
GOOD: [Webhook POST] → [Route handler writes to a queue] → [200 OK]
|
(separate worker)
v
[Reconcile job_id, update state]
The route should do almost nothing: verify the signature, persist the raw event to a durable queue, and return within well under a second. A separate worker pool does the actual reconciliation, and if it crashes mid-task, the message is still sitting in the queue.
Idempotency is not optional. The Standard Webhooks spec is explicit that delivery is at-least-once — network retries and provider backoff mean you will see the same webhook-id more than once:
CREATE TABLE processed_webhooks (
event_id VARCHAR(255) PRIMARY KEY,
job_id VARCHAR(255) NOT NULL,
processed_at TIMESTAMPTZ DEFAULT now()
);
async function processWebhookEvent(eventId: string, jobId: string, payload: unknown) {
const already = await db.processedWebhooks.findUnique({ where: { eventId } });
if (already) return; // safe no-op
await db.$transaction([
db.jobs.update({ where: { id: jobId }, data: { status: (payload as any).status } }),
db.processedWebhooks.create({ data: { eventId, jobId } }),
]);
}
On the sending side, retries should back off exponentially, and the Standard Webhooks spec publishes a concrete reference schedule worth copying:
| Attempt | Delay | Cumulative time |
|---|---|---|
| 1 | Immediate | 00:00:00 |
| 2 | 5s | 00:00:05 |
| 3 | 5m | 00:05:05 |
| 4 | 30m | 00:35:05 |
| 5 | 2h | 02:35:05 |
| 6 | 5h | 07:35:05 |
| 7 | 10h | 17:35:05 |
| 8 | 14h | 31:35:05 |
| 9 | 20h | 51:35:05 |
| 10 | 24h | 75:35:05 |
The spec also gives clear guidance on how to interpret the receiver's response: any 2xx is success; 410 Gone means "stop sending, we don't want this endpoint anymore"; 429 and 5xx mean "back off, don't disable." Requests that fail past the retry window should land in a dead-letter queue for manual replay rather than silently vanishing.
How this looks at real AI API providers
This isn't theoretical — it's how the current wave of AI inference APIs are actually built:
- Anthropic's Message Batches API accepts up to 100,000 requests in a single batch, begins processing immediately, and can take up to 24 hours to finish. There's no push notification for an individual
messages.createcall — the batch flow is aresults_urland aprocessing_statusfield (in_progress,ended, etc.) that you poll, withrequest_countsbroken out bysucceeded,errored,canceled, andexpired. - OpenAI's Batch API works on the same shape: upload a
.jsonlfile of up to 50,000 requests, get back abatchobject with a fixed 24-hourcompletion_window, and pollstatus(validating→in_progress→completed/failed/expired) until anoutput_file_idappears. Both providers offer roughly a 50% price discount for accepting the delay. - Standard Webhooks reached v1.0.2 in February 2026 and lists OpenAI, Anthropic, Google Gemini, Twilio, PagerDuty, Kong, Supabase, and others as adopters — which is a decent signal that hand-rolled
X-Signatureheaders are on their way to becoming legacy. - On the documentation side, AsyncAPI reached v3.1.0 in January 2026, and is increasingly used as the "OpenAPI for event-driven systems" — a way to formally describe the shape of jobs, webhooks, and channels rather than leaving it to prose.
- For error responses specifically, RFC 9457 ("Problem Details for HTTP APIs") — which obsoletes the older RFC 7807 — standardizes a
application/problem+jsonshape (type,title,status,detail,instance) so clients don't have to learn a bespoke error format per provider. It pairs naturally with the202pattern: a job that ultimately fails can report its failure reason as a problem-details object at the status endpoint.
Production checklist
Provider side
- Sign outgoing webhooks (HMAC-SHA256 or ed25519, per Standard Webhooks) and cover both the body and the timestamp/ID in the signed content.
- Support key rotation by accepting multiple space-delimited signatures during the rollover window.
- Always expose a
GET /jobs/{id}status endpoint, even if webhooks are the primary channel — it's the client's recovery path when their receiver has been down. - Return errors as
application/problem+json(RFC 9457), not ad hoc JSON. - Decide deliberately between "thin" payloads (just IDs, fetch the rest) and "full" payloads (everything inline) — thin is more future-proof and easier to audit; full saves a round trip.
- Retry failed deliveries on an exponential backoff schedule, honor
Retry-Afteron429/503, and stop retrying (don't just silently drop) once you hit410 Gone.
Receiver side
- Acknowledge within a few hundred milliseconds by writing to a local queue — never run business logic synchronously inside the webhook route.
- Verify signatures with a constant-time comparison, and reject timestamps outside a ~5-minute tolerance window.
- Treat the delivery ID as an idempotency key; store processed IDs in a transaction alongside the state update.
- Run ingestion and processing as separate, independently restartable services.
- Fall back to polling the status endpoint if webhooks stop arriving for a given job past some SLA.
The takeaway
None of the individual pieces here are new — 202 Accepted has been in the HTTP spec for decades, and webhooks predate the current AI boom by a long way. What's changed is that long-running, expensive, non-deterministic AI workloads have made this pattern the default rather than the exception, and the ecosystem has caught up with actual shared standards — Standard Webhooks for signing, RFC 9457 for errors, AsyncAPI for documentation — instead of every provider improvising its own X-Signature header. Building on those specs instead of a bespoke version of the same idea is now the cheaper option, not just the more correct one.