Developers

Build with PrintHook.

Start with a guide, look up the API contract, or explore the fundamentals below.

Guides
Follow the quickstart, then learn reliable job submission and signed webhooks.
API Reference
Browse operations in the live Explorer, download the OpenAPI specification, and look up error codes.

Base URL https://api.printhook.io. Every integrator route is under /v1. JSON in, JSON out, strict on both sides. This page is the overview; the OpenAPI 3 document is the complete reference for every public route and is what the console's own client is generated from.

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.

POST /v1/jobs
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 url is 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 against limits.max_content_bytes; over it is 413 content_too_large.
  • On read, content never echoes bytes or credentials: you get type, delivery, size_bytes, sha256 and url_host.

Status and timeline

statusMeaningterminal
queuedaccepted and durable; not yet on the machinefalse
deliveredthe computer has the job and its content on diskfalse
printedthe spooler reported successtrue
failedit will not print; see failuretrue
expiredthe deadline passed before it printedtrue
canceledcanceled through the API while still queuedtrue

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}.

POST /v1/webhooks
{"url": "https://hooks.example.com/printhook",
 "event_types": ["job.*", "computer.offline"]}
what your receiver gets
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 on webhook-id and treat GET /v1/events as the ordered authority.
  • Event types: job.created, job.delivered, job.printed, job.failed, job.expired, job.canceled, computer.online, computer.offline are announced to a ["*"] subscription. computer.superseded and the alert.* types are opt-in and delivered only when named literally. GET /v1/event-types publishes the catalog.
  • Secrets are write-only. The 201 carries the secret once; later reads show only secret_set and 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

FamilyRoutes
DiscoveryGET /v1/meta, GET /v1/me, GET /v1/event-types
JobsPOST /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
PrintersGET /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
Computerslist, read, rename, retire, bulk delete; live connected / disconnected state and last-seen
DevicesGET /v1/devices with kind (scale today) and a live reading
Events and webhooksGET /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 releasesGET /v1/agent-releases/latest?os=&arch=&channel= and the release list
Frozen, outside /v1GET /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.

Get a key and print a test page.

The console's guided setup walks from install to a tracked test print, then shows you the printer id to use in your first request.