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.
Before you start
Prefer an interactive HTTP client? Import the Postman collection and environment templates, then follow the explicit printer-selection and mutation-safety steps.
Install and enroll the PrintHook Agent on the computer that can print. In the console, create an integration key under Keys. Keep the full key in a secret store and expose it to the process as PRINTHOOK_API_KEY. All examples use https://api.printhook.io and JSON.
1. Check the key and account
curl --connect-timeout 5 --max-time 20 --fail-with-body --silent --show-error \
"https://api.printhook.io/v1/me" \
--header @"$PRINTHOOK_AUTH_HEADER_FILE" \
-H "Accept: application/json"GET /v1/me returns the acting account, key metadata, and account-specific limits. A missing or invalid credential is 401 invalid_credentials.
2. Find an online printer
curl --connect-timeout 5 --max-time 20 --fail-with-body --silent --show-error \
"https://api.printhook.io/v1/printers?status=online&limit=25" \
--header @"$PRINTHOOK_AUTH_HEADER_FILE" \
-H "Accept: application/json"The response is a collection: {"data": [...], "page": {...}}. Store the opaque data[0].id, such as prn_80000017; do not parse it. Use q to search by printer name or description, and follow page.next_cursor while page.has_more is true.
3. Create a durable job
curl --connect-timeout 5 --max-time 20 --fail-with-body --silent --show-error \
-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"}}'A successful create is 201 Created. Its bare JSON object has an opaque id, starts with terminal: false, and is also named by the Location response header. Reuse the same stable idempotency key only for the same logical print and the exact same body.
4. Poll until the job is terminal
curl --connect-timeout 5 --max-time 20 --fail-with-body --silent --show-error \
"https://api.printhook.io/v1/jobs/job_3000000913" \
--header @"$PRINTHOOK_AUTH_HEADER_FILE" \
-H "Accept: application/json"Poll GET /v1/jobs/{job_id} with bounded backoff until terminal is true. Do not maintain your own terminal list. Current terminal statuses are printed, failed, expired, and canceled; queued and delivered are not terminal. A printed job means the operating-system spooler accepted it, not that a sensor confirmed paper output.
Complete raw HTTP examples
The standalone examples below verify the key, select the first online printer, create the job with a stable business key, then poll terminal. The Odoo section instead isolates submission in a background worker. These examples submit a real print when pointed at a real account: confirm the selected printer first. Replace the reserved example document URL with an immutable PDF URL the agent can fetch without a browser session. Do not place the integration key in that URL. Keep signed document URLs private and valid through delivery.
Run JavaScript as a server-side .mjs file, Python with requests installed, PHP with its cURL extension, and C# in a .NET 8 console project. Never put an integration key in browser JavaScript. Requests time out after 20 seconds; polling stops after roughly two minutes (plus the last in-flight request). A local deadline does not cancel the remote job. Save the returned job id and reconcile it rather than resubmitting with a new key. The quickstart does not automatically retry failures; use the reliability guide for that policy.
JavaScript fetch (Node.js 18+)
const base = "https://api.printhook.io";
const deadline = Date.now() + 120_000;
async function timedFetch(url, options = {}) {
if (Date.now() >= deadline) throw new Error("Workflow deadline reached; reconcile the saved job/key, do not create a new print");
return fetch(url, { ...options, redirect: "error", signal: AbortSignal.timeout(Math.min(20_000, deadline - Date.now())) });
}
const headers = { Authorization: `Bearer ${process.env.PRINTHOOK_API_KEY}`, Accept: "application/json" };
async function json(response) {
const body = await response.json();
if (!response.ok) throw Object.assign(new Error(body.error?.message || `HTTP ${response.status}`), { response, body });
return body;
}
await json(await timedFetch(`${base}/v1/me`, { headers }));
const printers = await json(await timedFetch(`${base}/v1/printers?status=online&limit=1`, { headers }));
if (!printers.data.length) throw new Error("No online printer");
const body = { printer_id: printers.data[0].id, title: "Order 51233", source: "erp", content: { type: "pdf", url: "https://erp.example.com/labels/51233.pdf" } };
let job = await json(await timedFetch(`${base}/v1/jobs`, { method: "POST", headers: { ...headers, "Content-Type": "application/json", "Idempotency-Key": "order-51233-label-v1" }, body: JSON.stringify(body) }));
while (!job.terminal) {
if (Date.now() >= deadline) throw new Error(`Polling deadline: reconcile ${job.id}`);
await new Promise(resolve => setTimeout(resolve, 1000));
job = await json(await timedFetch(`${base}/v1/jobs/${encodeURIComponent(job.id)}`, { headers }));
}
if (job.status !== "printed") throw new Error(`Job ended as ${job.status}; inspect saved job, do not auto-reprint`);
console.log(job.status, job.failure);Python requests
import os, time, requests
base = "https://api.printhook.io"
deadline = time.monotonic() + 120
s = requests.Session()
s.max_redirects = 0 # Refuse redirects rather than silently changing API origin.
s.headers.update({"Authorization": f"Bearer {os.environ['PRINTHOOK_API_KEY']}", "Accept": "application/json"})
def checked(response):
data = response.json()
if not response.ok:
raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
return data
checked(s.get(f"{base}/v1/me", timeout=20))
printers = checked(s.get(f"{base}/v1/printers", params={"status": "online", "limit": 1}, timeout=20))
if not printers["data"]: raise RuntimeError("No online printer")
payload = {"printer_id": printers["data"][0]["id"], "title": "Order 51233", "source": "erp", "content": {"type": "pdf", "url": "https://erp.example.com/labels/51233.pdf"}}
job = checked(s.post(f"{base}/v1/jobs", json=payload, headers={"Idempotency-Key": "order-51233-label-v1"}, timeout=20))
while not job["terminal"]:
if time.monotonic() >= deadline: raise TimeoutError(f"Polling deadline: reconcile {job['id']}")
time.sleep(1)
job = checked(s.get(f"{base}/v1/jobs/{job['id']}", timeout=20))
if job["status"] != "printed": raise RuntimeError(f"Job ended as {job['status']}; inspect saved job, do not auto-reprint")
print(job["status"], job["failure"])PHP cURL
<?php
$base = 'https://api.printhook.io'; $key = getenv("PRINTHOOK_API_KEY");
function call_api($method, $url, $key, $body = null, $extra = []) {
$ch = curl_init($url); $headers = array_merge(["Authorization: Bearer $key", "Accept: application/json"], $extra);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 20, CURLOPT_HTTPHEADER => $headers]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_THROW_ON_ERROR));
$raw = curl_exec($ch); if ($raw === false) throw new Exception(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
if ($status < 200 || $status >= 300) throw new Exception($data["error"]["code"] . ": " . $data["error"]["message"]);
return $data;
}
call_api("GET", "$base/v1/me", $key);
$printers = call_api("GET", "$base/v1/printers?status=online&limit=1", $key);
if (!$printers["data"]) throw new Exception("No online printer");
$payload = ["printer_id" => $printers["data"][0]["id"], "title" => "Order 51233", "source" => "erp", "content" => ["type" => "pdf", "url" => "https://erp.example.com/labels/51233.pdf"]];
$job = call_api("POST", "$base/v1/jobs", $key, $payload, ["Content-Type: application/json", "Idempotency-Key: order-51233-label-v1"]);
$deadline = microtime(true) + 120;
while (!$job["terminal"]) { if (microtime(true) >= $deadline) throw new Exception("Polling deadline; reconcile saved job ID"); sleep(1); $job = call_api("GET", "$base/v1/jobs/" . rawurlencode($job["id"]), $key); }
if ($job["status"] !== "printed") throw new Exception("Job ended as " . $job["status"] . "; inspect saved job, do not auto-reprint");
printf("%s\n", $job["status"]);C# HttpClient (.NET 8)
using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json;
var baseUrl = "https://api.printhook.io"; using var http = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = TimeSpan.FromSeconds(20) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("PRINTHOOK_API_KEY") ?? throw new Exception("Missing key"));
async Task<JsonElement> Read(HttpResponseMessage response) { var value = await response.Content.ReadFromJsonAsync<JsonElement>(); if (!response.IsSuccessStatusCode) throw new Exception(value.GetProperty("error").GetProperty("code").GetString()); return value; }
await Read(await http.GetAsync($"{baseUrl}/v1/me"));
var printers = await Read(await http.GetAsync($"{baseUrl}/v1/printers?status=online&limit=1"));
var printer = printers.GetProperty("data").EnumerateArray().FirstOrDefault(); if (printer.ValueKind == JsonValueKind.Undefined) throw new Exception("No online printer");
var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/v1/jobs") { Content = JsonContent.Create(new { printer_id = printer.GetProperty("id").GetString(), title = "Order 51233", source = "erp", content = new { type = "pdf", url = "https://erp.example.com/labels/51233.pdf" } }) };
request.Headers.Add("Idempotency-Key", "order-51233-label-v1"); var job = await Read(await http.SendAsync(request));
var timer = System.Diagnostics.Stopwatch.StartNew();
while (!job.GetProperty("terminal").GetBoolean()) { if (timer.Elapsed > TimeSpan.FromSeconds(120)) throw new TimeoutException("Polling deadline; reconcile saved job ID"); await Task.Delay(1000); job = await Read(await http.GetAsync($"{baseUrl}/v1/jobs/{Uri.EscapeDataString(job.GetProperty("id").GetString()!)}")); }
if (job.GetProperty("status").GetString() != "printed") throw new Exception("Job did not print; inspect saved job, do not auto-reprint");
Console.WriteLine(job.GetProperty("status").GetString());Odoo raw HTTP
This is a worker transport helper, not a complete installable addon. Do not print inside a button, automated business action, or create/write transaction: the print can happen even if Odoo later rolls back. The user action must only enqueue a company-scoped outbox row in the same transaction as the business change. Snapshot the authorized printer, immutable document payload, and a unique business-operation key. Enforce uniqueness in the database; do not use a freshly generated key on each retry.
A scheduled worker reads only committed rows in a fresh cursor, locks/claims each row to prevent concurrent sending, resolves the company's credential from a restricted secret store, and invokes the helper below once. Store the remote id and schedule a later status check instead of blocking an Odoo worker with polling. If the worker crashes after remote acceptance but before saving the id, repeat the identical stored request and key within the published idempotency window. After that window, stop and reconcile manually. Do not manually commit the user's transaction or use a post-commit callback as a substitute for a durable outbox.
import requests
def submit_committed_job(api_key, payload, request_key):
# Worker-only helper, NOT a sale.order button or create/write override.
# self.env is valid only in the worker's fresh Odoo cursor/environment.
response = requests.post(
"https://api.printhook.io/v1/jobs",
headers={"Authorization": f"Bearer {api_key}",
"Accept": "application/json", "Idempotency-Key": request_key},
json=payload, timeout=(5, 20), allow_redirects=False,
)
if response.status_code != 201:
raise RuntimeError(f"Submission needs reconciliation: HTTP {response.status_code}")
return response.json() # Save id; poll in a later worker run, never sleep here.
The outbox model, ACLs, company rules, document rendering, worker scheduling, retry deadline, and status persistence are application-specific prerequisites, not provided by this helper. Render reports with the requesting user's access and selected company; do not use unrestricted sudo() to fetch arbitrary documents or expose API keys through system parameters readable by ordinary users.
For production retry and error behavior, continue to Reliable jobs. For push delivery instead of polling, see Webhooks.