Skip to content
CastPDF
Core concepts

Idempotency

Send an Idempotency-Key header so a retried PDF or batch request returns the first result instead of making and counting a second document.

On this page

Networks fail. A request can time out after CastPDF has already made the PDF, and then you cannot tell whether to send it again. An idempotency key solves this: you send a unique value with the request, and if you send the same request with the same key again, CastPDF returns the first result instead of rendering again. Nothing is made twice and nothing is counted twice. Use it for every live request that makes a document.

The Idempotency-Key header

Add the header to the request. Its value is any string of 1 to 255 printable ASCII characters, without spaces. A value that identifies the document in your own system works best, because it is the same on every attempt:

curl
curl https://api.castpdf.com/v1/pdf \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: invoice-INV-042" \
  -d '{"template_id": "8d1f2c3b-4a5e-4f60-9b7a-1c2d3e4f5a6b", "data": {"number": "INV-042"}, "response": "url"}'

A random UUID per document is fine too, as long as you create it once, before the first attempt, and reuse it for the retries. A key that is empty, too long, or contains a space or a non-ASCII character gets 400 invalid_request.

These endpoints accept the header:

EndpointWhat a replay returns
POST /v1/pdfThe stored PDF of the first request, as a PDF or as JSON (whichever this request asks for)
POST /v1/pdf with "async": true202 with the id of the background PDF queued first, and its current status
POST /v1/batches202 with the batch created first, in its current state

Other endpoints ignore the header. Keys are scoped to your team: two teams can use the same value without effect on each other.

How CastPDF compares requests

CastPDF remembers each key with a fingerprint of what the request produces: the JSON body with its object keys sorted, and the mode of the request (test or live). The order of the fields in your JSON does not matter. For POST /v1/pdf, the response field is left out of the comparison, so a retry may ask for "binary" where the first request asked for "url" (or the other way round) and still get the same document.

You sendYou get
A new keyThe request runs as usual, and the key is remembered with its result
The same key and the same body, after the first request finishedThe first result. No new render, and the document is not counted again
The same key with a different body422 idempotency_mismatch
The same key from the other mode (a test key, or "test": true, after a live request)422 idempotency_mismatch
The same key and the same body while the first request is still running409 idempotency_conflict
The same key after the first request failedThe request runs again: a failed request releases its key
The same key after the stored PDF was deleted410 file_expired
422
{
  "error": {
    "code": "idempotency_mismatch",
    "message": "This Idempotency-Key was already used with a different request body",
    "docs_url": "https://castpdf.com/docs/errors#idempotency_mismatch"
  }
}

The 24 hour window

A key is remembered for at least 24 hours from the first request. After that it is forgotten, and a request with the same value is treated as new: it renders and counts a new document. Retries happen within minutes, so this window is long enough for them. It is not a way to deduplicate the same order days later: keep your own record of which documents you already made, and use a new key for each new document.

If the first request never finishes (for example because a server restarted during the render), its key is held for 5 minutes. After that, a new request with the key takes it over and renders.

Replays and stored files

A direct POST /v1/pdf replay sends back the stored file of the first document, so a request with an Idempotency-Key always stores its PDF, even with a test key and a binary response. Test PDFs are stored for 1 day, live PDFs for your plan's file retention (usage and billing). Once the file is gone, a replay gets 410 file_expired: generate the document again with a new key. Because the file has to be stored, a request with a key over the team's stored PDF limit gets 429 storage_limit_reached.

A replay is not a new render, so some details of the first response are not repeated:

  • Blocked resources are reported as none (x-blocked-resources: 0, "blocked_resources": []), even if the first response listed some.
  • Warnings are reported as none (x-warnings: [], "warnings": []).
  • Webhooks are not sent again, and an email in deliver is not sent again.

So read warnings and blocked resources from the first successful response, and check your PDFs while you develop.

Background PDFs and batches

With "async": true, the key protects the queueing step: the same key and body within 24 hours return 202 with the id of the background PDF queued first, in its current status (queued, processing, succeeded or failed), and nothing new is queued. A different body gets 422 idempotency_mismatch. If two identical requests arrive at the same instant, one may get 409 idempotency_conflict; retry it and it gets the queued job.

POST /v1/batches works the same way: a retry with the same key and body returns the existing batch with 202, so a timeout while creating a batch of hundreds of PDFs never creates it twice.

The keys of background PDFs and of batches are kept apart from the keys of direct POST /v1/pdf requests. Use a different key for each request anyway: it keeps your logs easy to follow.

A retry sequence

This is what happens when a request times out and your code retries it with the same key:

  1. Your server sends POST /v1/pdf with Idempotency-Key: invoice-INV-042. CastPDF claims the key and starts rendering.
  2. The connection drops before the response arrives. Your code does not know whether the PDF exists.
  3. Your code waits a moment and sends the same request with the same key. If the first render is still running, it gets 409 idempotency_conflict, waits again, and retries.
  4. The first render finishes. CastPDF stores the PDF, counts it once and marks the key as completed with that document.
  5. The next retry gets the stored PDF back with 200, exactly as the first response would have been, apart from the warnings and blocked resources. It is not counted.

If the first render had failed instead (a template error, a timeout), the key is released in step 4, and the retry renders again and returns its own result.

Retry recipe

Retry only what can succeed on a second try: network errors and timeouts, 409 idempotency_conflict, 429 rate_limited, 500 internal_error and 503 service_busy. Wait for the Retry-After header when there is one, and otherwise back off exponentially with some random jitter. Other 4xx codes, 410 file_expired and 504 render_timeout give the same answer again: fix the request or the document instead (errors).

Retry with an idempotency key
const RETRYABLE = new Set(['idempotency_conflict', 'rate_limited', 'internal_error', 'service_busy']);

async function createPdf(body, idempotencyKey, maxAttempts = 6) {
  for (let attempt = 1; ; attempt++) {
    let res;
    try {
      res = await fetch('https://api.castpdf.com/v1/pdf', {
        method: 'POST',
        headers: {
          Authorization: 'Bearer YOUR_API_KEY',
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey,
        },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(60_000),
      });
    } catch (err) {
      // network error or timeout: the PDF may exist, so retry with the same key
      if (attempt === maxAttempts) throw err;
      await sleep(backoff(attempt));
      continue;
    }
    if (res.ok) return res;
    const { error } = await res.clone().json();
    if (!RETRYABLE.has(error.code) || attempt === maxAttempts) return res;
    const retryAfter = Number(res.headers.get('retry-after'));
    await sleep(retryAfter > 0 ? retryAfter * 1000 : backoff(attempt));
  }
}

// 1 s, 2 s, 4 s, ... up to 30 s, plus up to 1 s of jitter
const backoff = (attempt) => Math.min(30_000, 1000 * 2 ** (attempt - 1)) + Math.random() * 1000;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
import random
import time
import requests

RETRYABLE = {"idempotency_conflict", "rate_limited", "internal_error", "service_busy"}

def backoff(attempt):
    # 1 s, 2 s, 4 s, ... up to 30 s, plus up to 1 s of jitter
    return min(30, 2 ** (attempt - 1)) + random.random()

def create_pdf(body, idempotency_key, max_attempts=6):
    for attempt in range(1, max_attempts + 1):
        try:
            res = requests.post(
                "https://api.castpdf.com/v1/pdf",
                headers={"Authorization": "Bearer YOUR_API_KEY", "Idempotency-Key": idempotency_key},
                json=body,
                timeout=60,
            )
        except requests.RequestException:
            # network error or timeout: the PDF may exist, so retry with the same key
            if attempt == max_attempts:
                raise
            time.sleep(backoff(attempt))
            continue
        if res.ok:
            return res
        code = res.json()["error"]["code"]
        if code not in RETRYABLE or attempt == max_attempts:
            return res
        retry_after = int(res.headers.get("Retry-After", "0"))
        time.sleep(retry_after if retry_after > 0 else backoff(attempt))

Call it with a key derived from your record, such as createPdf(body, 'invoice-' + invoice.number). A 429 can also mean a limit that resets slowly, such as background PDFs per day; those come without Retry-After, and the rate limits page explains each one.

Next steps

  • Rate limits: the limits behind 429 and how to back off.
  • Errors: every error code and whether a retry helps.
  • Usage and billing: how documents are counted and how long files are kept.
  • Test and live mode: why a key used in one mode does not replay in the other.
Updated Something unclear? [email protected], a real person replies.