spicyapiDocs
Main content

Idempotency

Retry after a timeout without generating twice or paying twice.

An idempotency key is a label you attach to one piece of work, such as "render the video for order 8814". If the same labelled request reaches us twice, we do the work once.

createTask accepts it in the Idempotency-Key header. Submitting twice with the same key from the same account and the same API key returns the task created the first time: no second queue entry, hold or charge.

POST /api/v1/jobs/createTask
Idempotency-Key: order-8814-render-1

The key is tied to what you sent: the model, the whole input, and callBackUrl. Field order and whitespace do not matter, but any real change does. Sending the same key with a different request returns 409 with Idempotency-Key has already been used for a different request. That 409 never succeeds on retry: if the request really changed, it is a new piece of work and needs a new key.

Why you want it

A network timeout leaves you in the worst kind of uncertainty: the request may never have arrived, or it may have arrived and only the response was lost. Without an idempotency key you get two bad options — don't retry (the user waits for nothing) or retry (possibly generating twice and paying twice).

With a key, retrying is safe: either the task is created, or you get back the one created a moment ago.

Which endpoints read the header

EndpointWhat a repeat with the same key does
POST /api/v1/jobs/createTaskReturns the original task, in its current state
POST /api/v1/jobs/retryReturns the task that retry created. The key is scoped to the source task, so the same key used with different source tasks does not collide
POST /v1/videosReturns the original task
POST /api/v1/jobs/stream and the text endpoints under /v1 and /v1betaReturns 400 — see below

Read endpoints, and the endpoints that issue upload and download links, ignore the header. POST /api/v1/jobs/purge needs no key: repeating it for the same taskId is already harmless.

On text and streaming endpoints a key prevents a double charge, not a lost answer

The answer to a text request is sent to you as it is generated and is not stored. Repeating a key whose request completed, with the same body, returns 400 (This Idempotency-Key was already used for a completed request, and its answer cannot be replayed. Use a new Idempotency-Key to send a new request) and charges nothing more; the text cannot be fetched anywhere. If that earlier request failed, the repeat returns the same error it did, so you can decide whether to try again with a new key. With a different body the key returns 409.

So on those endpoints: use a new key for every new message, and reuse a key only for the exact same request after a network error, accepting that its reply is lost. If you want an answer again, send it with a new key and pay for it. See Text and streaming.

The window is 24 hours

A key stays valid for 24 hours, counted from the moment it was first accepted. Replays do not extend it. After that, the same key is treated as a fresh request and creates a new, billed task, so stop automatic retries well before then.

Twenty-four hours is a compromise. Client retries finish in seconds or minutes, so the window only needs to cover that; but the longer the window, the longer a key cannot be reused — with a hard-coded key you would find you can only submit one task per day.

Choosing keys

  • One key per logical request, not per HTTP call. Retries must reuse the same key or the whole thing is pointless.
  • Derive it from an identifier you already have: order-8814-render-1 beats a random UUID, because you can recompute it after a process restart.
  • When there is nothing to derive from, generate a UUID before the first attempt and save it (in your database, next to the order it belongs to); retries read the saved value.
  • Keys are claimed within your account and need not be globally unique, but every retry for one logical request must also use the same API key. Maximum length is 128 characters.

A sibling API key cannot replay it

The idempotency mapping is claimed account-wide while task visibility is isolated by API key. A sibling key using an already claimed idempotency key receives 409 Conflict (Idempotency-Key is already in use by this account), and the response does not reveal the original task ID. This prevents one key from probing another integration's tasks through replay. Retrying does not help; send the retry with the API key that made the first attempt.

Never generate the key inside the retry loop

// Wrong: a new key per attempt, which defeats the whole mechanism
for (let i = 0; i < 3; i++) {
  await create({ 'Idempotency-Key': crypto.randomUUID() });
}

// Right: generate once, outside the loop
const key = crypto.randomUUID();
for (let i = 0; i < 3; i++) {
  await create({ 'Idempotency-Key': key });
}

What a replayed request looks like

On createTask, the response has the same taskId and estimatedCost, but state is the original task's current state. It may be queued, running, or terminal. A replay creates no second task, hold, or charge.

There is no header or field telling you that you hit a replay. To read the complete task record, take the taskId to recordInfo.

Concurrent submissions with the same key

When two requests from the same API key carrying the same idempotency key arrive at once, exactly one creates a task and the other receives that same taskId. The decision rests on a database uniqueness constraint rather than a check-then-insert — under concurrency, both checks would come back empty, both tasks would be created and both holds would stand.

In rare cases the race surfaces as:

{ "code": 409, "msg": "Idempotency key conflict, please retry shortly" }

Back off a few hundred milliseconds and retry with the same key.

Two other 409s on createTask are not worth retrying: Idempotency-Key has already been used for a different request (you changed the request) and Idempotency-Key is already in use by this account (another API key on your account already used this key). All three share code: 409, and your code should not branch on msg, so allow one or two 409 retries at most and then surface the error for a person to look at.

Full example

The function takes the key as an argument. Compute it from your own records (here, the order ID) so that a restarted process sends the same key again.

JavaScript
// These are business codes from the response body, not HTTP statuses.
const RETRYABLE = new Set([429, 500, 50301]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const backoff = (attempt) => sleep(2 ** attempt * 500 + Math.random() * 300);

async function createTaskWithRetry(payload, idempotencyKey, { attempts = 4 } = {}) {
  let conflicts = 0;

  for (let i = 0; i < attempts; i++) {
    // Declared outside try so the checks below can read them.
    let res;
    let body;
    try {
      res = await fetch('https://api.spicyapi.ai/api/v1/jobs/createTask', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.SPICY_API_KEY}`,
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey,
        },
        body: JSON.stringify(payload),
      });
      body = await res.json();
    } catch {
      // Transport failure or unreadable body: the request may well have landed.
      // Retrying with the same key is safe.
      await backoff(i);
      continue;
    }

    if (res.status === 202 && body.code === 200) return body.data;

    // Only one of the three 409s is a transient race, so cap 409 retries at one.
    const retry = body.code === 409 ? ++conflicts <= 1 : RETRYABLE.has(body.code);
    if (!retry) {
      throw new Error(`${body.code} ${body.msg} (request_id=${body.request_id})`);
    }
    await backoff(i);
  }
  // Still unknown whether the task exists. Try again later with the SAME key.
  throw new Error(`out of retries for Idempotency-Key ${idempotencyKey}`);
}

// payload is { model, input } built from the live catalog; order is your own record.
// The key is derived from the order, so it is identical after a restart.
const task = await createTaskWithRetry(payload, `order-${order.id}-render-1`);
console.log(task.taskId);
Python
import os, random, time, requests

# These are business codes from the response body, not HTTP statuses.
RETRYABLE = {429, 500, 50301}


def create_task_with_retry(payload: dict, idempotency_key: str, attempts: int = 4) -> dict:
    conflicts = 0

    for i in range(attempts):
        try:
            res = requests.post(
                "https://api.spicyapi.ai/api/v1/jobs/createTask",
                headers={
                    "Authorization": f"Bearer {os.environ['SPICY_API_KEY']}",
                    "Content-Type": "application/json",
                    "Idempotency-Key": idempotency_key,
                },
                json=payload,
                timeout=30,
            )
            body = res.json()
        except (requests.RequestException, ValueError):
            # Transport failure or unreadable body: the request may well have landed.
            # Retrying with the same key is safe.
            time.sleep(2**i * 0.5 + random.random() * 0.3)
            continue

        if res.status_code == 202 and body["code"] == 200:
            return body["data"]

        # Only one of the three 409s is a transient race, so cap 409 retries at one.
        if body["code"] == 409:
            conflicts += 1
            retry = conflicts <= 1
        else:
            retry = body["code"] in RETRYABLE
        if not retry:
            raise RuntimeError(f'{body["code"]} {body["msg"]} (request_id={body.get("request_id")})')
        time.sleep(2**i * 0.5 + random.random() * 0.3)

    # Still unknown whether the task exists. Try again later with the SAME key.
    raise RuntimeError(f"out of retries for Idempotency-Key {idempotency_key}")


# payload is {"model": ..., "input": ...} built from the live catalog; order_id is yours.
# The key is derived from the order, so it is identical after a restart.
task = create_task_with_retry(payload, f"order-{order_id}-render-1")
print(task["taskId"])

Transport failures deserve the same key too

A thrown fetch, a connect timeout, a read timeout — in all of these you do not know whether the request arrived. That is precisely what the idempotency key is for: retry with the same key and the worst outcome is getting the earlier task back. If you run out of attempts, keep the key: a later call with it still returns the same task within the 24-hour window.

On this page