Authentication
Create an integration key in the console under Keys. New keys are phk_ followed by 40 lowercase hex characters. Send it as a Bearer token on every request; there is no HTTP Basic on /v1, and no route ever emits a Basic challenge, so a browser credential dialog cannot appear.
curl https://api.printhook.io/v1/me \
-H "Authorization: Bearer phk_…"GET /v1/me answers with the acting account, the principal, and the account's effective limits. A 401 outside /v1/auth/* carries WWW-Authenticate: Bearer realm="PrintHook".
Create a job
POST /v1/jobs answers 201 with the job object and a Location header the moment the job is durable. It never waits for the agent or the printer.
curl https://api.printhook.io/v1/jobs \
-H "Authorization: Bearer phk_…" \
-H "Idempotency-Key: pack-line-7-2026-09-15-000412" \
-H "Content-Type: application/json" \
-d '{"printer_id": "prn_80000017",
"title": "Order 51233",
"content": {"type": "pdf", "url": "https://erp.example.com/labels/51233.pdf"},
"options": {"copies": 2, "duplex": "one-sided"},
"expires_in_sec": 3600}'# HTTP/1.1 201 Created
# Location: /v1/jobs/job_3000000913
{"id": "job_3000000913",
"status": "queued", "terminal": false,
"printer": {"id": "prn_80000017", "name": "Label printer 1", "status": "online",
"computer": {"id": "cmp_1000042", "name": "pack-01", "status": "connected"}},
"content": {"type": "pdf", "delivery": "url", "url_host": "erp.example.com",
"size_bytes": null, "sha256": null},
"title": "Order 51233", "source": "", "quantity": 1,
"options": {"copies": 2, "duplex": "one-sided"},
"duplicate_of": null, "failure": null,
"created_at": "2026-09-15T10:22:41.118Z", "expires_at": "2026-09-15T11:22:41.118Z",
"delivered_at": null, "terminal_at": null}Print options are bin, collate, color, copies, dpi, duplex, fit_to_page, media, nup, pages, paper and rotate. They apply to PDF content and are ignored for raw. A value outside the printer's capabilities fails the job with a message naming the allowed values. Types are strict: "copies": "2" is 400 invalid_option_value. The grammars are published in GET /v1/meta.print_options.
expires_in_sec sets the deadline (default 14 days, minimum 60 seconds, maximum one year). expires_at on the response is always the effective expiry, never null.
The target is printer_id or station_id with role, exactly one of the two. A station is a named bench that binds its computer's printers to roles (label, slip); {"station_id": "stn_500003", "role": "label"} resolves through the binding at submit time, and the job object carries station and role beside the printer it resolved to. A role with no working printer is 409 role_unbound, never a print somewhere else. If the bench recorded a scan in the last ten minutes, the job also carries that window's correlation_id, and GET /v1/events?correlation_id= lists the whole transaction.
Content
content.type is pdf or raw and is never inferred from the bytes. Exactly one of data (base64) or url selects delivery.
# inline PDF
"content": {"type": "pdf", "data": "JVBERi0xLjQK…"}
# raw printer language (ZPL, ESC/POS, …) fetched by the agent from your own network
"content": {"type": "raw", "url": "https://erp.internal/zpl/51233",
"auth": {"scheme": "basic", "username": "u", "password": "p"}}- A
urlis fetched by the agent, from inside your network, never by the platform. That is how a document on an internal file server reaches a printer.auth(Basic or Digest) is sealed at rest and unsealed only when the frame is built. - Base64 input is forgiving: missing padding, whitespace and a
data:…;base64,prefix are accepted. Decoded size is checked againstlimits.max_content_bytes; over it is413 content_too_large. - On read, content never echoes bytes or credentials: you get
type,delivery,size_bytes,sha256andurl_host.
Status and timeline
| status | Meaning | terminal |
|---|---|---|
queued | accepted and durable; not yet on the machine | false |
delivered | the computer has the job and its content on disk | false |
printed | the spooler reported success | true |
failed | it will not print; see failure | true |
expired | the deadline passed before it printed | true |
canceled | canceled through the API while still queued | true |
Every representation that carries a status also carries terminal, so a client never keeps its own terminal list. The finer-grained progress rows the agent writes (downloading, received, in_progress, spooled, spool_error) are on the timeline: GET /v1/jobs/{id}/timeline for one job, GET /v1/jobs/timeline for the account. A printed status means the operating system spooler accepted the job; it is not proof of paper coming out.
Cancel with POST /v1/jobs/{id}/cancel. Only a job still queued can be canceled; anything later is 409 not_cancelable. There is no unfiltered destructive verb: canceling everything is POST /v1/jobs/cancel {"all": true, "confirm_count": N}.
Idempotency
Send Idempotency-Key (up to 255 characters) on POST /v1/jobs and a retry can never print twice. Within 24 hours, the same key with the same body returns 201, the original Location, the job's current state and Idempotency-Replayed: true. The same key with a different body is 409 idempotency_key_reused. It is the only route that honors the header; anywhere else it is 400 idempotency_not_supported.
Separately, the server flags a probable duplicate (same account, printer, title and content hash within 3 seconds) as duplicate_of on the job. That is a warning, not a refusal.
Webhooks
Up to 5 subscriptions per account. Each delivery is one event per request, HMAC-SHA256 signed, with the body byte-identical to GET /v1/events/{id}.
{"url": "https://hooks.example.com/printhook",
"event_types": ["job.*", "computer.offline"]}webhook-id: evt_01J8ZKC6Q0R4T7V9X1Y3Z5A7B9
webhook-timestamp: 1757931778
webhook-signature: v1,<base64 HMAC-SHA256 over "id.timestamp.body">
Content-Type: application/json
User-Agent: PrintHook-Webhooks/1.0
{"id": "evt_01J8ZKC6Q0R4T7V9X1Y3Z5A7B9",
"type": "job.printed",
"created_at": "2026-09-15T10:22:58.441Z",
"account_id": "acct_4021", "principal_account_id": "acct_4000",
"job_id": "job_3000000913", "computer_id": "cmp_1000042",
"correlation_id": null,
"data": { … }}Verify a delivery in any language with an HMAC library:
import base64, hashlib, hmac, time
def verify(secret: str, headers: dict, body: bytes, tolerance_sec: int = 300) -> bool:
msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"]
if abs(time.time() - int(ts)) > tolerance_sec:
return False
digest = hmac.new(secret.encode(), f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()
expected = "v1," + base64.b64encode(digest).decode()
return any(hmac.compare_digest(sig, expected) for sig in headers["webhook-signature"].split())- Answer any
2xx. Delivery is at-least-once and unordered: one immediate attempt, one retry about five seconds later, then the event is dropped for that subscription and counted. Deduplicate onwebhook-idand treatGET /v1/eventsas the ordered authority. - Event types:
job.created,job.delivered,job.printed,job.failed,job.expired,job.canceled,computer.online,computer.offlineare announced to a["*"]subscription.computer.supersededand thealert.*types are opt-in and delivered only when named literally.GET /v1/event-typespublishes the catalog. - Secrets are write-only. The
201carries the secret once; later reads show onlysecret_setand a four-character hint. The console shows every delivery attempt and lets an operator replay a failed one. - Receivers must be reachable on the public Internet.
Collections
A collection is {"data": [...], "page": {...}}. Paging is forward-only with a signed opaque cursor; limit is 1 to 200 (default 25), and an out-of-range value is a 400, never a silent clamp. Sorting is by whitelisted field; every order is total, so a page boundary is never ambiguous. Ask for a count with ?include=total&limit=1.
GET /v1/jobs?status=printed&created_after=2026-09-15T00:00:00Z&include=total&limit=1
{"data": [ { … } ],
"page": {"limit": 1, "sort": "created_at", "order": "desc",
"has_more": true, "next_cursor": "v1.eyJrIj…", "prev_cursor": null,
"total": 4128, "total_is_estimate": false, "unresolved_ids": []}}Bulk actions take ids (up to 200), a filter, or all with a confirm_count, and answer {"results", "summary"}.
Errors
One envelope, every member always present. class is the coarse enum to branch on when a code is new: invalid_request, authentication, permission, not_found, conflict, rate_limit, server. Every field problem is reported in one response.
{"error": {
"class": "invalid_request",
"code": "invalid_argument",
"message": "The request has 3 problems.",
"request_id": "8f0b4a0c-1f2e-4e7a-9b3d-6a2c5d1e0f77",
"doc_url": "https://printhook.io/docs/errors/invalid_argument",
"details": [
{"field": "content.url", "code": "invalid_format", "message": "content.url must be an absolute http or https URL."},
{"field": "quantity", "code": "out_of_range", "message": "quantity must be between 1 and 1000."},
{"field": "options.duplex", "code": "not_allowed", "message": "duplex must be one of long-edge, short-edge, one-sided."}],
"retry_after_sec": null,
"meta": {}}}Status codes: 400 for every validation failure (there is no 422), 401 for a missing or bad credential, 403 when authenticated but refused, 404 when the resource does not exist on the acting account (a well-formed id belonging to another tenant is also 404, never 403), 409 for a state conflict, 413 for content over the limit, 429 with Retry-After for a rate limit.
Limits and rate limits
Nothing a client would otherwise hardcode is undiscoverable. GET /v1/meta publishes every limit, enum, print-option grammar, event type and error code; GET /v1/me.limits is the account's view of the same names. Size a caller to /v1/meta, never to a number in a document.
Rate limits are on by default and set well above any legitimate client: 600 safe requests a minute (burst 200) and 120 unsafe requests a minute (burst 60) per account. Every authenticated response carries RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset and RateLimit-Policy. One request charges exactly one bucket, and bad credentials never spend tokens.
Route families
| Family | Routes |
|---|---|
| Discovery | GET /v1/meta, GET /v1/me, GET /v1/event-types |
| Jobs | POST /v1/jobs, GET /v1/jobs, GET /v1/jobs/{id}, POST /v1/jobs/{id}/cancel, POST /v1/jobs/cancel, GET /v1/jobs/{id}/timeline, GET /v1/jobs/timeline |
| Printers | GET /v1/printers, GET /v1/printers/{id}, GET /v1/printers/{id}/capabilities, DELETE /v1/printers/{id} |
| Stations | /v1/stations CRUD, PUT /v1/stations/{id}/devices/{role} to bind a device to a role, POST /v1/stations/{id}/pairing-code, POST /v1/stations/{id}/scan; jobs and events filter by station_id |
| Computers | list, read, rename, retire, bulk delete; live connected / disconnected state and last-seen |
| Devices | GET /v1/devices with kind (scale today) and a live reading |
| Events and webhooks | GET /v1/events, GET /v1/events/{id}, /v1/webhooks CRUD, /v1/webhook-deliveries with /{id}/replay |
| Keys, usage, audit | /v1/keys, GET /v1/usage, GET /v1/audit |
| Agent releases | GET /v1/agent-releases/latest?os=&arch=&channel= and the release list |
Frozen, outside /v1 | GET /ping, GET /healthz, GET /readyz and the three /agent/v1/* transports, frozen byte for byte |
Every route declares its scope, minimum role and principal kind; the OpenAPI document publishes them as x-printhook-* extensions. Download the OpenAPI document.
Child accounts, for platforms
An integration key on a parent account can act as one of its direct children by sending PrintHook-Account: acct_…. The request then sees that child's computers, printers and jobs and nothing else. It is how a platform gives each of its customers a separate fleet without a separate contract, and how a support team looks at one customer's queue. Child accounts consume the parent's print allowance.
Versioning
The API is /v1. Adding a member, a route, an enum value or an opt-in event type is not a breaking change; removing or renaming one is, and would be a new version. Every response carries PrintHook-Version, the build stamp, and Request-Id, which is the value to quote to support@printhook.io.