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.
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, andLocation. - Same key and body within the published idempotency window: the server returns
201with the originalLocation, the original job's current representation, andIdempotency-Replayed: true. - Same key, different body:
409 idempotency_key_reused, withmeta.original_job_id. Do not retry with that key. - Original request still running:
409 idempotency_in_progresswithretry_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.
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.
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
| Outcome | Action |
|---|---|
| Transport timeout before any response | Repeat the identical create request with the same stable key. |
201, including Idempotency-Replayed: true | Persist the returned job id and poll that resource. |
409 idempotency_in_progress | Wait retry_after_sec, then repeat the identical request. |
409 idempotency_key_reused | Stop. Compare the intended request with the original job in meta.original_job_id. |
429 rate_limited | Wait Retry-After; keep the key and body unchanged. |
400 invalid_argument | Show every entry in details; correct the payload before trying again. |
401 invalid_credentials | Stop and replace the credential. Do not retry it in a loop. |
403 quota_exceeded or trial_expired | Stop 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.