Guide · Reliable jobs

Retry requests without printing twice.

A network timeout does not tell you whether a print job was created. Give each logical print a stable identity, retry the same request, and let the job object's terminal value end your wait.

For cURL, provision a private mode-0600 header file from your secret store containing Authorization: Bearer YOUR_INTEGRATION_KEY, and set PRINTHOOK_AUTH_HEADER_FILE to its path. Do not commit it, enable shell tracing, or use verbose request logging. Passing a file keeps the key out of command-line arguments. For language examples, inject PRINTHOOK_API_KEY through your server-side secret manager.

Choose a stable business key

Idempotency-Key is honored only by POST /v1/jobs. Derive it from the business operation that should print once: an order id, document purpose, and revision. For example, order-51233-label-v1 identifies one version of one shipping label. Persist the key before sending the request.

Do not generate a new random key for each network attempt. That makes every retry a new job. Use a new key only when the business intent is a new print, such as an operator explicitly choosing “Print another copy.” The header may contain up to 255 characters.

the request that may be repeated
curl --connect-timeout 5 --max-time 20 --silent --show-error -D response.headers \
  -X POST "https://api.printhook.io/v1/jobs" \
  --header @"$PRINTHOOK_AUTH_HEADER_FILE" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -H "Idempotency-Key: order-51233-label-v1" \
  --data '{"printer_id":"prn_80000017","title":"Order 51233","source":"erp","content":{"type":"pdf","url":"https://erp.example.com/labels/51233.pdf"}}'

Retry the same request, not a reconstruction

The fingerprint covers the method, path, and canonical JSON body. JSON whitespace, object key order, and header order do not matter. Values do. Save the submitted payload beside the key so every automated retry sends the same logical body.

  • First use: the server creates the job and returns 201, a job object, and Location.
  • Same key and body within the published idempotency window: the server returns 201 with the original Location, the original job's current representation, and Idempotency-Replayed: true.
  • Same key, different body: 409 idempotency_key_reused, with meta.original_job_id. Do not retry with that key.
  • Original request still running: 409 idempotency_in_progress with retry_after_sec: 1. Wait, then repeat the same request.

The window is published as the limits.idempotency_window_sec field of GET /v1/meta. Do not hardcode its example value. If a legacy call timed out without a key, reconcile through GET /v1/jobs?source=erp&created_after=…; do not blindly create another job.

Idempotency is time-bounded, not an indefinite exactly-once guarantee. Never automatically replay a stale saved request after the published window: it may create another print. Persist the original attempt time, key, body, and returned job id. The example below uses a two-minute local retry budget; in production also cap it at the remaining server idempotency window and reconcile when either expires. A polling timeout never cancels a job.

Poll the returned resource to terminal state

The create response means the job is durable, not printed. Save its id, then request GET /v1/jobs/{job_id}. Stop only when the response says terminal: true. Use bounded backoff—one second at first, growing to five seconds—and a workflow deadline appropriate to your operation.

Python requests: create, retry, and poll
import os, time, requests
base = "https://api.printhook.io"; key = "order-51233-label-v1"
deadline = time.monotonic() + 120
def remaining():
    seconds = deadline - time.monotonic()
    if seconds <= 0: raise TimeoutError("Workflow deadline; reconcile the saved job/key")
    return min(20, seconds)
def pause(seconds):
    if seconds >= deadline - time.monotonic():
        raise TimeoutError("Retry exceeds workflow deadline; reconcile later")
    time.sleep(max(1, seconds))
headers = {"Authorization": f"Bearer {os.environ['PRINTHOOK_API_KEY']}", "Accept": "application/json"}
payload = {"printer_id": "prn_80000017", "title": "Order 51233", "source": "erp", "content": {"type": "pdf", "url": "https://erp.example.com/labels/51233.pdf"}}
while True:
    try:
        response = requests.post(f"{base}/v1/jobs", headers={**headers, "Idempotency-Key": key}, json=payload, timeout=remaining(), allow_redirects=False)
    except (requests.Timeout, requests.ConnectionError):
        pause(2); continue  # Uncertain outcome: same key AND same stored payload.
    try:
        body = response.json()
    except ValueError:
        body = {}  # A proxy may return HTML rather than the API error envelope.
    error = body.get("error", {})
    if response.status_code == 429 or (response.status_code == 409 and error.get("code") == "idempotency_in_progress"):
        seconds = response.headers.get("Retry-After") or error.get("retry_after_sec") or 1
        pause(float(seconds)); continue
    if response.status_code >= 500:
        pause(2); continue
    if response.status_code != 201:
        raise RuntimeError(f"HTTP {response.status_code}: {error.get('class')}/{error.get('code')}; request_id={error.get('request_id')}")
    job = body; break
delay = 1
while not job["terminal"]:
    pause(delay)
    response = requests.get(f"{base}/v1/jobs/{job['id']}", headers=headers, timeout=remaining(), allow_redirects=False)
    response.raise_for_status(); job = response.json(); delay = min(delay * 2, 5)
if job["status"] != "printed": raise RuntimeError(f"Print ended as {job['status']}: {job['failure']}")

printed, failed, expired, and canceled are terminal today. Branch on terminal, not that list, so a future additive status cannot leave your worker polling forever. Use GET /v1/jobs/{id}/timeline when a person needs finer progress such as downloading or spooling.

Handle the typed error envelope

Errors produced by the /v1 application have the same shape; proxies and transport failures may not return JSON. Parse it even when the status is unexpected, record request_id, and make control-flow decisions from class and code. The US English message is for people and must not be parsed.

{"error": {
  "class": "rate_limit",
  "code": "rate_limited",
  "message": "Too many requests.",
  "request_id": "8f0b4a0c-1f2e-4e7a-9b3d-6a2c5d1e0f77",
  "doc_url": "https://printhook.io/docs/errors/rate_limited",
  "details": [],
  "retry_after_sec": 3,
  "meta": {"bucket": "account.write"}
}}

All eight members are always present. For validation errors, details contains every field problem as {field, code, message}. Unknown top-level codes can be handled through the coarse class: invalid_request, authentication, permission, not_found, conflict, rate_limit, or server.

JavaScript fetch: typed error handling
async function printhook(url, options = {}) {
  const response = await fetch(url, { ...options, redirect: "error", signal: AbortSignal.timeout(20_000) });
  const body = await response.json().catch(() => null);
  if (!body || (!response.ok && !body.error)) throw new Error(`Non-API response: HTTP ${response.status}`);
  if (response.ok) return body;
  const { error } = body; console.error("PrintHook request", error.request_id, error.class, error.code);
  if (error.code === "idempotency_in_progress" || error.class === "rate_limit") {
    const rawDelay = response.headers.get("Retry-After");
    const headerDelay = rawDelay === null ? NaN : Number(rawDelay);
    const seconds = Number.isFinite(headerDelay) && headerDelay >= 0 ? headerDelay : (error.retry_after_sec ?? 1);
    throw Object.assign(new Error(error.message), { retryAfterMs: seconds * 1000, error });
  }
  throw Object.assign(new Error(error.message), { status: response.status, error });
}

On 429 rate_limited, honor the whole-second Retry-After header; error.retry_after_sec carries the same scheduling value in JSON. Retry 500 internal_error and transport failures only with the same idempotency key and body. A 400, most 403 responses, and a normal 409 require data or operator action rather than blind retries.

Retry decision table

OutcomeAction
Transport timeout before any responseRepeat the identical create request with the same stable key.
201, including Idempotency-Replayed: truePersist the returned job id and poll that resource.
409 idempotency_in_progressWait retry_after_sec, then repeat the identical request.
409 idempotency_key_reusedStop. Compare the intended request with the original job in meta.original_job_id.
429 rate_limitedWait Retry-After; keep the key and body unchanged.
400 invalid_argumentShow every entry in details; correct the payload before trying again.
401 invalid_credentialsStop and replace the credential. Do not retry it in a loop.
403 quota_exceeded or trial_expiredStop new print admission and direct an account owner to the account state.

For lower-latency status updates, subscribe to job events and retain polling as reconciliation. See Receive and verify webhooks.

Push terminal status to your application.