Skip to main content

Implementation guide

Finish the job after the request is gone.

A media task outlives the HTTP call that created it, the page that triggered it, and often the deploy that was running at the time. Everything here is about making that survivable.

The challenge

The connection is not the transaction

Between pressing send and holding a file there are four places to lose work: a timed-out create you retry blindly, a callback you cannot authenticate, a poll loop that hammers or gives up, and a result URL that expired while you were deciding what to do with it.

01

A retried create without a key is a second bill

Idempotency-Key makes a repeat of the same model, input and callback within 24 hours return the original task instead of reserving funds twice. It is scoped to one API key; a sibling key replaying the same value gets 409 and no task ID.

02

An unverified callback is an open endpoint

Deliveries carry X-Webhook-Timestamp and a Base64 HMAC-SHA256 in X-Webhook-Signature, computed over taskId.timestamp.sha256(raw_body). Verify against the raw bytes in constant time and reject stale timestamps before touching task or billing state.

03

Polling without a ceiling is a rate limit waiting to happen

recordInfo is a fallback, not a transport. Start around two seconds, grow by half each time, cap at fifteen, and stop at a deadline you chose rather than one the network chose for you.

The approach

One durable record per task, written before you reply

Persist the idempotency key, the taskId and your own correlation ID in the same transaction that answers your caller. Every later event — a callback, a poll, a retry, a support question — resolves against that row, and a duplicate delivery becomes an update instead of a second order.

Verify first, then read. The signature covers the raw bytes, so parse only after the comparison passes.

Verify the callback before you trust it

import { createHash, createHmac, timingSafeEqual } from "node:crypto";

// rawBody is the exact bytes that arrived. Parse only to read the task ID:
// the digest is always computed over those raw bytes, never over a re-encode.
export function verifyDelivery(rawBody, headers, webhookKey) {
  const timestamp = headers["x-webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const taskId = JSON.parse(rawBody).data.taskId;          // payload version 2
  const digest = createHash("sha256").update(rawBody).digest("hex");
  const expected = createHmac("sha256", webhookKey)
    .update(`${taskId}.${timestamp}.${digest}`)
    .digest("base64");

  const sent = Buffer.from(headers["x-webhook-signature"] ?? "", "utf8");
  const mine = Buffer.from(expected, "utf8");
  return sent.length === mine.length && timingSafeEqual(sent, mine);
}

Delivery headers

X-Webhook-Timestamp: 1788652800
X-Webhook-Signature: <base64>
X-Webhook-Payload-Version: 2

{
  "code": 200,
  "msg": "success",
  "request_id": "whd_01k3m8x9q2z4v7n5p6r8s0t1w3",
  "data": { "taskId": "job_01k3…", "state": "succeeded", "settled": true, … }
}

Contract

The parts worth pinning down

Async media API: tasks, webhooks, retries — The parts worth pinning down
Field or headerValue or limit
Idempotency-Key24 h · scoped to one API key
X-Webhook-Signaturebase64(HMAC-SHA256)
signed stringtaskId.timestamp.sha256(raw_body)
X-Webhook-Payload-Version2 for new integrations

Swipe horizontally to see all columns.

Integration

From first call to production

  1. 01Generate and store an idempotency key, then POST createTask with it and with callBackUrl pointing at an HTTPS endpoint on port 80 or 443.
  2. 02Write data.taskId to your database before responding to the caller; the 202 is the only place it appears first.
  3. 03Accept the callback, verify the signature over the raw body, deduplicate on request_id, and update your record.
  4. 04Fall back to recordInfo with exponential backoff, and copy output.assets into your own storage before the signed URL expires.

Fit

When this path is the right one

Choose this when

  • Anything that produces media. Image, video and audio endpoints are asynchronous by construction.
  • You need a durable record of what was submitted, what it was expected to cost, and how it ended.

Look elsewhere when

  • You are calling a text model. Those stream inside the request and settle on measured usage.
  • You want the browser to own the job. Keys belong on your server, and so does the task record.

Evidence

What you can verify yourself

Every response carries a request_id

The envelope is {code, msg, data, request_id} on every /api/v1 operation. In payload version 2 the delivery's request_id stays stable across resends, which makes it the natural deduplication key.

Holds are visible in the record

cost with settled: false is the reserved estimate; settled: true is the final charge. Success never exceeds the hold, and failure or expiry releases it in full.

Failures use a closed vocabulary

errorCode is one of nine stable values: invalid_request, unsupported_combination, content_rejected, rate_limited, upstream_unavailable, generation_failed, timeout, invalid_asset, upstream_failed. Treat anything else as upstream_failed.

Before launch

Platform behaviour to confirm

  • Store the idempotency key with the task, not in process memory.
  • Make the callback handler idempotent; deliveries are retried by design.
  • Set a polling deadline and alert on tasks that pass it.
  • Copy assets before expiresAt, and re-request a URL rather than caching a signed one.

FAQ

Questions this page gets asked

Retry the same request with the same Idempotency-Key. Within 24 hours and on the same API key you get the original task back with no second hold. Never submit a fresh body after a timeout — that is a new, separately billed task.

Keep going

Build a task flow that survives a deploy

Submit long media jobs from your server and finish them reliably: idempotency keys, signed callbacks, bounded polling and a five-state machine.

Related guides