# PrintScaler API — complete reference for AI coding assistants

> You are reading the machine-friendly documentation of PrintScaler (https://printscaler.com), a cloud printing API.
> Use it to write code that prints PDFs, shipping labels (ZPL/EPL) and receipts (ESC/POS) on physical printers
> located anywhere. Everything below describes the real, current behaviour of API v1. Do not invent endpoints,
> fields or options that are not listed here.

## 1. How PrintScaler works

```
Your backend --HTTPS--> PrintScaler API --WebSocket--> PrintScaler agent (on a PC next to the printers) --> OS print queue --> printer
```

- **Account**: owns computers, printers, print jobs, API keys and webhooks.
- **Computer**: a Windows, macOS, Linux or Raspberry Pi machine running the PrintScaler agent. It connects outbound,
  so it works behind any firewall/NAT. Each computer reports its printers.
- **Printer**: a printer installed in that computer's operating system, identified by a numeric `id`.
- **Print job**: one document sent to one printer. It moves through states (`new` → … → `done`).

To print you only need: an **API key** and a **printer id**. Your code never talks to the agent directly.

## 2. Base URL and authentication

- Base URL: `https://api.printscaler.com/v1` (also served at `https://printscaler.com/v1`).
- All requests and responses are JSON (`Content-Type: application/json`). Form-encoded bodies are also accepted.
- Authenticate with an API key (created in the dashboard → *API keys*, starts with `psk_`, shown only once):
  - HTTP Basic auth with the key as the **username** and an **empty password** (PrintNode-compatible):
    `curl -u "psk_xxx:" ...` → header `Authorization: Basic base64("psk_xxx:")`
  - or `Authorization: Bearer psk_xxx`
- Keep the key on the server (environment variable `PRINTSCALER_API_KEY`). Never expose it in browsers or mobile apps.

## 3. Quick start

```bash
# 1. Find your printers and their ids
curl -u "$PRINTSCALER_API_KEY:" https://api.printscaler.com/v1/printers

# 2. Print a PDF from a public URL
curl -u "$PRINTSCALER_API_KEY:" https://api.printscaler.com/v1/printjobs \
  -H "Content-Type: application/json" \
  -d '{"printerId": 34, "title": "Invoice 2026-0042", "contentType": "pdf_uri", "content": "https://example.com/invoice.pdf"}'
# -> 201 Created, body: 1337   (the print job id, a bare JSON number)

# 3. Follow the job
curl -u "$PRINTSCALER_API_KEY:" "https://api.printscaler.com/v1/printjobs/1337/states?dir=asc"
```

## 4. Conventions

### Errors
Non-2xx responses always have this body:
```json
{ "uid": "4f8c3c1e-0d0e-4b8a-9f6e-2b1f0c7e9a10", "code": "NotFound", "message": "Printer 99 not found" }
```
`uid` equals the `Request-Id` response header. Every response also has an `Elapsed` header (seconds).

| HTTP | code(s) | meaning |
|---|---|---|
| 400 | `ValidationError`, `BadContent`, `BadOptions`, `BadIdSet`, `BadPagination`, `BadWebhookUrl` | invalid input; `message` says which field |
| 401 | `Unauthorized` | missing or invalid API key |
| 403 | `Forbidden` | endpoint only available from the dashboard (API key management) |
| 404 | `NotFound` | resource does not exist or belongs to another account |
| 409 | `DuplicateRequest` | `X-Idempotency-Key` reused within 24 h (the job was NOT printed again) |
| 413 | `PayloadTooLarge` | body over 70 MB |
| 429 | `TooManyRequests` | rate limit; wait `Retry-After` seconds |
| 5xx | `InternalError` | retry with backoff (use an idempotency key) |

### Rate limit
10 requests/second per account, bursts up to 30. Exceeding it returns `429` with `Retry-After: 1`.

### Pagination (list endpoints)
Query parameters: `limit` (1–500, default 100), `dir` (`asc` | `desc`, default `desc` = newest first),
`after` (id cursor: returns records after this id in the chosen direction).

### Id sets
Paths like `/printers/{set}` accept one id or a comma-separated list: `/printers/12,15,40`. Max 500 ids.
Unknown ids are simply not returned (no error).

### Timestamps
ISO 8601 UTC strings, e.g. `"2026-10-14T08:22:40.838Z"`.

## 5. Objects

### Computer
```json
{
  "id": 12,
  "type": "agent",
  "name": "PC-OFICINA",
  "hostname": "DESKTOP-3KIHQVV",
  "os": "Windows 10 Pro 22H2 (build 19045)",
  "inet": "192.168.9.36",
  "inet6": null,
  "version": "0.2.1",
  "state": "connected",
  "createTimestamp": "2026-10-14T08:20:24.008Z",
  "lastSeenTimestamp": "2026-10-14T08:20:26.923Z"
}
```
`type`: `agent` (PrintScaler agent) | `epson_sdp` (Epson printer connected directly, section 12) | `network`
(receipt printer reached by IP over the internet, section 13; these also have `"target": { "host", "port" }`).
`state`: `connected` | `disconnected`. `version` is the agent version.

### Printer
```json
{
  "id": 34,
  "computer": { "...": "Computer object" },
  "name": "HP BLANCA OFI",
  "description": "HP Smart Universal Printing",
  "capabilities": {
    "papers": { "A4": [2100, 2970], "Letter": [2159, 2794] },
    "bins": ["Automatically Select", "Tray 1"],
    "medias": ["Plain", "Labels"],
    "dpis": ["600x600", "1200x1200"],
    "duplex": true,
    "color": true,
    "collate": true,
    "copies": 999,
    "nup": [],
    "supports_custom_paper_size": false
  },
  "default": true,
  "state": "online",
  "createTimestamp": "2026-10-14T08:20:25.120Z"
}
```
- `state`: `online` | `offline` | `paused` | `error` | `not_accepting_jobs` | `removed` (no longer installed on the computer).
  A printer can be `online` while its computer is `disconnected`: check `printer.computer.state` too.
- `capabilities.papers`: paper **name → [width, height] in tenths of a millimetre** (`null` when unknown).
  Names depend on the operating system: Windows uses driver names (`"A4"`, `"Letter"`, `"4x6"`), macOS/Linux use
  PWG names (`"iso_a4_210x297mm"`, `"na_letter_8.5x11in"`, `"oe_4x6-label_4x6in"`).
  **Always pick paper/bin/media/dpi values from the printer's own capabilities; never hard-code them.**
- `copies`: maximum copies per job supported by the driver.

### Print job
```json
{
  "id": 1337,
  "printer": { "...": "Printer object" },
  "title": "Order #1042 label",
  "contentType": "raw_base64",
  "source": "shop-backend",
  "qty": 1,
  "options": {},
  "state": "done",
  "expireAt": "2026-10-28T08:22:40.838Z",
  "createTimestamp": "2026-10-14T08:22:40.838Z"
}
```

### Print job state (history entry)
```json
{
  "printJobId": 1337,
  "state": "in_progress",
  "message": null,
  "data": { "spooler": "windows", "spoolerJobId": 3 },
  "clientVersion": "0.2.1",
  "createTimestamp": "2026-10-14T08:22:44.064Z"
}
```

## 6. Endpoints

### Account
| Method | Path | Description |
|---|---|---|
| GET | `/whoami` | Account (`id`, `email`, `name`, `plan`, `state`) plus `numComputers`, `numPrinters`, `printsThisMonth`, `totalPrints` |
| GET | `/account/stats` | `{ daily: [{day, total, done, failed}] (14 days), states: {state: count} (30 days) }` |
| GET | `/ping` | Health check, no auth |

### Computers
| Method | Path | Description |
|---|---|---|
| GET | `/computers` | List computers (paginated) |
| GET | `/computers/{set}` | Get computers by id |
| GET | `/computers/{set}/printers` | Printers of these computers |
| PATCH | `/computers/{id}` | Rename: `{ "name": "Packing station 2" }` → Computer |
| POST | `/computers/epson` | Add an Epson printer without a PC (section 12) |
| POST | `/computers/network` | Add a network printer by public IP / dynamic DNS name (section 13) |
| PATCH | `/computers/{id}/network` | Change its `{ "host", "port" }` (section 13) |
| POST | `/computers/{id}/network/check` | Test the connection without printing (section 13) |
| DELETE | `/computers/{set}` | Delete computers (their agents are unlinked). Returns deleted ids `[12]` |

### Printers
| Method | Path | Description |
|---|---|---|
| GET | `/printers` | List printers of all computers (paginated) |
| GET | `/printers/{set}` | Get printers by id |
| GET | `/printers/{set}/printjobs` | Print jobs of these printers (paginated) |
| POST | `/printers/{id}/test` | Print a PrintScaler test page. Returns the print job id (201) |

### Print jobs
| Method | Path | Description |
|---|---|---|
| POST | `/printjobs` | **Create a print job.** Returns the job id as a bare number, status 201 |
| GET | `/printjobs` | List print jobs (paginated, newest first) |
| GET | `/printjobs/{set}` | Get print jobs by id |
| GET | `/printjobs/states` | State history of all jobs (paginated) |
| GET | `/printjobs/{set}/states` | State history of these jobs (use `?dir=asc` for chronological order) |
| DELETE | `/printjobs/{set}` | Cancel jobs that have not finished. Returns the ids actually cancelled, e.g. `[1337]` |
| DELETE | `/printjobs` | Cancel every unfinished job of the account |

### Webhooks
| Method | Path | Description |
|---|---|---|
| GET | `/webhooks` | List webhooks |
| POST | `/webhooks` | `{ "url": "https://...", "events": ["printjob.state"], "description": "..." }` → Webhook **with `secret`** (shown only once) |
| PATCH | `/webhooks/{id}` | Update `url`, `events`, `description`, `enabled` |
| DELETE | `/webhooks/{id}` | Delete |
| POST | `/webhooks/{id}/test` | Queue a `webhook.test` event → `{ "deliveryId": 981 }` |
| GET | `/webhooks/{id}/deliveries` | Last 50 deliveries with `status`, `attempts`, `lastStatusCode`, `lastError` |

### Downloads (public, no auth)
| Method | Path | Description |
|---|---|---|
| GET | `/downloads` | Agent packages: `{ version, files: [{ name, os, arch, size, sha256, url }] }` |

## 7. Creating print jobs — `POST /printjobs`

### Body fields
| Field | Type | Required | Notes |
|---|---|---|---|
| `printerId` | integer | yes | from `GET /printers` |
| `contentType` | string | yes | `pdf_uri`, `pdf_base64`, `raw_uri`, `raw_base64` |
| `content` | string | yes | a public `http(s)` URL for `*_uri`, or the base64-encoded bytes for `*_base64` |
| `title` | string ≤255 | no | shown in the dashboard and the OS print queue |
| `source` | string ≤255 | no | free text identifying your system |
| `options` | object | no | see below. **Not allowed for `raw_*` jobs** (400 `BadOptions`) |
| `qty` | integer 1–100 | no | print the whole job N times (default 1). Use it for copies of RAW jobs |
| `expireAfter` | integer seconds | no | an undelivered job expires after this (default and max 14 days = 1209600) |
| `authentication` | object | no | credentials the agent uses to download a `*_uri`: `{ "type": "BasicAuth", "credentials": { "user": "u", "pass": "p" } }` |

Header `X-Idempotency-Key: <string ≤255>` (strongly recommended, e.g. your order id): a second request with the
same key within 24 h returns `409 DuplicateRequest` instead of printing twice. Treat 409 as success.

### Content types
- `pdf_uri` / `pdf_base64`: a PDF. The agent renders it and prints it through the printer driver (options apply).
  Base64 content must decode to a file starting with `%PDF-`, otherwise 400 `BadContent`.
- `raw_uri` / `raw_base64`: bytes sent to the printer untouched: ZPL/EPL for label printers (Zebra…), ESC/POS
  for receipt printers, PCL/PostScript. Use RAW for label and receipt printers whenever you have native commands.
- `*_uri` documents are downloaded **by the agent**, from the customer's computer: the URL must be reachable from
  there over the internet. URLs on private/local network addresses are blocked by the agent by default.
  Prefer short-lived signed URLs (e.g. S3 presigned) or use base64.
- Max request body 70 MB (≈50 MB of document after base64). Use `*_uri` for bigger files.

### Options (PDF jobs only; all optional)
| Option | Type | Values |
|---|---|---|
| `copies` | integer | 1–999 (and ≤ `capabilities.copies`) |
| `duplex` | string | `one-sided`, `long-edge`, `short-edge` (only if `capabilities.duplex`) |
| `paper` | string | a key of `capabilities.papers` (e.g. `"A4"` on Windows, `"iso_a4_210x297mm"` on macOS/Linux) |
| `bin` | string | one of `capabilities.bins` |
| `media` | string | one of `capabilities.medias` |
| `dpi` | string | one of `capabilities.dpis`, e.g. `"600x600"` |
| `color` | boolean | `false` forces monochrome |
| `collate` | boolean | collate copies |
| `fit_to_page` | boolean | scale the page to the printable area |
| `pages` | string | `"1,3-5"`, `"-5"` (first five), `"3-"` (from page 3) |
| `rotate` | integer | `0`, `90`, `180`, `270` |
| `nup` | integer | pages per sheet (macOS/Linux only; ignored on Windows) |

Unknown option keys are rejected (400 `ValidationError`). On Windows, without `fit_to_page` pages print at actual
size (shrunk only if larger than the paper) and landscape pages are rotated automatically on portrait paper unless
`rotate` is given; on macOS/Linux the CUPS defaults apply.

### Examples
```json
{ "printerId": 34, "contentType": "pdf_uri", "content": "https://shop.example.com/invoices/42.pdf",
  "title": "Invoice 42", "options": { "copies": 2, "duplex": "long-edge", "paper": "A4" } }
```
```json
{ "printerId": 35, "contentType": "raw_base64", "content": "XlhBXkZPNTAsNTBeQUROLDM2LDIwXkZESGVsbG9eRlNeWFo=",
  "title": "Order #1042 label", "qty": 2 }
```
(`XlhB...` is base64 of `^XA^FO50,50^ADN,36,20^FDHello^FS^XZ`.)

## 8. Print job lifecycle

| state | final? | meaning |
|---|---|---|
| `new` | no | accepted; waiting for the computer to be online |
| `sent_to_client` | no | delivered to the agent |
| `queued` | no | the agent accepted it and is downloading the document |
| `in_progress` | no | handed to the operating system print queue |
| `done` | yes | the OS print queue finished the job (printed) |
| `error` | yes | failed; `message` explains why (printer not found, out of paper and timed out, download failed…) |
| `cancelled` | yes | cancelled via `DELETE /printjobs/{id}` or the dashboard |
| `expired` | yes | never delivered before `expireAt` (computer offline too long) |

- If the computer is offline, jobs wait in state `new` and are delivered automatically when it reconnects.
- A job is printed **at most once**: the agent de-duplicates deliveries. If the agent restarts while printing, the job
  ends in `error` ("may not have printed") instead of being printed twice; resend it with a new idempotency key if needed.
- If the printer does not finish within 15 minutes (offline, out of paper), the agent removes it from the OS queue
  and reports `error`, so it will not print unexpectedly later.
- Typical timing: `new` → `done` in 2–10 seconds when the computer is online.

To know the outcome either **subscribe to the `printjob.state` webhook** (recommended) or poll
`GET /printjobs/{id}` every 2–5 seconds until `state` is final.

## 9. Webhooks

Create one with `POST /webhooks` (or in the dashboard). Store the returned `secret`.

### Events
| event | `data` |
|---|---|
| `printjob.state` | `{ printJobId, printerId, computerId, title, state, message, data, createTimestamp }` |
| `computer.connected` | `{ computerId, agentVersion }` |
| `computer.disconnected` | `{ computerId }` |
| `printer.state` | `{ computerId, printerId, name, previousState, state }` (e.g. a printer went `offline`) |
| `printers.updated` | `{ computerId, count }` |
| `webhook.test` | `{ message }` |
| `*` | subscribe to everything |

### Delivery
```http
POST https://your-server.example.com/printscaler-webhook
Content-Type: application/json
User-Agent: PrintScaler-Webhooks/1.0
PrintScaler-Event: printjob.state
PrintScaler-Delivery: 981
PrintScaler-Signature: t=1791937807,v1=5f2b7c...

{"id":981,"event":"printjob.state","accountId":7,"timestamp":"2026-10-14T10:41:02.200Z",
 "data":{"printJobId":1337,"printerId":34,"computerId":12,"title":"Order #1042 label","state":"done",
         "message":null,"data":null,"createTimestamp":"2026-10-14T10:41:02.120Z"}}
```
- Respond with any 2xx within 10 seconds; do slow work asynchronously.
- Failures are retried with exponential backoff (30 s, 1 min, 2 min… up to 8 attempts, ~2 hours). Deliveries to the
  same URL are sent in order. Use `id` to de-duplicate retries.
- URLs must be public `https`.

### Verifying the signature (always do this)
`v1` is the hex HMAC-SHA256 of `"{t}.{raw request body}"` with your webhook secret. Use the **raw** body bytes,
not re-serialized JSON, and reject timestamps older than 5 minutes.

```js
// Node.js (Express): app.post('/printscaler-webhook', express.raw({ type: 'application/json' }), handler)
import crypto from 'node:crypto';

export function verifyPrintScaler(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.split('=')));
  const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  return fresh && expected.length === parts.v1?.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
```
```python
# Python
import hmac, hashlib, time

def verify_printscaler(raw_body: bytes, signature_header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return abs(time.time() - int(parts["t"])) < 300 and hmac.compare_digest(expected, parts.get("v1", ""))
```
```php
// PHP
function verifyPrintScaler(string $rawBody, string $header, string $secret): bool {
    parse_str(str_replace(',', '&', $header), $p);
    $expected = hash_hmac('sha256', $p['t'] . '.' . $rawBody, $secret);
    return abs(time() - (int) $p['t']) < 300 && hash_equals($expected, $p['v1'] ?? '');
}
```

## 10. Client code

### Node.js (fetch, no dependencies)
```js
const BASE = 'https://api.printscaler.com/v1';
const headers = { Authorization: `Bearer ${process.env.PRINTSCALER_API_KEY}`, 'Content-Type': 'application/json' };

async function printscaler(method, path, body, extraHeaders = {}) {
  const res = await fetch(BASE + path, { method, headers: { ...headers, ...extraHeaders }, body: body && JSON.stringify(body) });
  const data = await res.json();
  if (res.status === 409) return { duplicate: true };          // idempotency key already used: not reprinted
  if (!res.ok) throw new Error(`PrintScaler ${res.status} ${data.code}: ${data.message}`);
  return data;
}

// Print a PDF buffer and wait for the result (polling version; prefer webhooks in production)
export async function printPdf(printerId, pdfBuffer, { title, idempotencyKey, options } = {}) {
  const jobId = await printscaler('POST', '/printjobs', {
    printerId, title, contentType: 'pdf_base64', content: pdfBuffer.toString('base64'), options,
  }, idempotencyKey ? { 'X-Idempotency-Key': idempotencyKey } : {});
  for (let i = 0; i < 60; i++) {
    const [job] = await printscaler('GET', `/printjobs/${jobId}`);
    if (['done', 'error', 'cancelled', 'expired'].includes(job.state)) return job;
    await new Promise((r) => setTimeout(r, 2000));
  }
  return { id: jobId, state: 'pending' };
}
```

### Python (requests)
```python
import base64, os, time, requests

BASE = "https://api.printscaler.com/v1"
AUTH = (os.environ["PRINTSCALER_API_KEY"], "")  # API key as username, empty password
FINAL = {"done", "error", "cancelled", "expired"}

def print_label_zpl(printer_id: int, zpl: str, order_id: str) -> int:
    r = requests.post(f"{BASE}/printjobs", auth=AUTH, timeout=30,
                      headers={"X-Idempotency-Key": f"label-{order_id}"},
                      json={"printerId": printer_id, "title": f"Order {order_id}",
                            "contentType": "raw_base64", "content": base64.b64encode(zpl.encode()).decode()})
    if r.status_code == 409:
        return -1  # already printed for this order
    r.raise_for_status()
    return r.json()

def wait_for_job(job_id: int, timeout_s: int = 120) -> dict:
    deadline = time.time() + timeout_s
    while time.time() < deadline:
        job = requests.get(f"{BASE}/printjobs/{job_id}", auth=AUTH, timeout=30).json()[0]
        if job["state"] in FINAL:
            return job
        time.sleep(2)
    raise TimeoutError(job_id)
```

### PHP (cURL)
```php
function printscaler(string $method, string $path, ?array $body = null, array $headers = []) {
    $ch = curl_init('https://api.printscaler.com/v1' . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_USERPWD => getenv('PRINTSCALER_API_KEY') . ':',
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => array_merge(['Content-Type: application/json'], $headers),
        CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
    ]);
    $data = json_decode(curl_exec($ch), true);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    if ($status >= 400 && $status !== 409) throw new RuntimeException("PrintScaler $status: " . ($data['message'] ?? ''));
    return $data;
}

$jobId = printscaler('POST', '/printjobs', [
    'printerId' => 34, 'title' => 'Invoice 42', 'contentType' => 'pdf_uri',
    'content' => 'https://shop.example.com/invoices/42.pdf', 'options' => ['copies' => 2],
], ['X-Idempotency-Key: invoice-42']);
```

## 11. Receipt printers (ESC/POS)

Thermal receipt printers (Epson TM, Star, Bixolon, Rongta, generic "POS-80"…) are best driven with **ESC/POS** bytes
sent as a **RAW** job: faster (the job reaches the printer ~200 ms after the API call), sharper than a PDF, and you
control the paper cut and the cash drawer.

Rules:
- `contentType: "raw_base64"` with the ESC/POS bytes; **no `options`** (use `qty` for copies).
- Line width: 80 mm paper = **48 characters** (normal font); 58 mm = 32. Double width halves it.
- Text must be encoded in the printer code page, **not UTF-8**: send `ESC t 2` and encode with **CP850** (covers
  á é í ó ú ñ ç ¡ ¿ ü). `€` is not in CP850: write `EUR`.
- Pad/align columns **after** encoding (1 byte per character). In PHP use `str_pad(iconv('UTF-8','CP850//TRANSLIT',$s), n)`.
- Always start with `ESC @` and finish with a few line feeds + `GS V B 0` (feed and cut).
- On Windows the printer must use the manufacturer's driver so the queue accepts RAW data.

| Bytes (hex) | Command | Effect |
|---|---|---|
| `1B 40` | `ESC @` | initialize |
| `1B 74 02` | `ESC t 2` | code page PC850 |
| `1B 61 n` | `ESC a n` | align: `00` left, `01` center, `02` right |
| `1D 21 n` | `GS ! n` | size: `00` normal, `11` double width+height, `01` double height, `10` double width |
| `1B 45 n` | `ESC E n` | bold on `01` / off `00` |
| `1B 2D n` | `ESC - n` | underline on `01` / off `00` |
| `0A` | `LF` | new line |
| `1D 56 42 00` | `GS V B 0` | feed and partial cut (`1D 56 00` full cut) |
| `1B 70 00 19 FA` | `ESC p 0 25 250` | open the cash drawer |
| `1D 28 6B ...` | `GS ( k` | QR code (tested on Rongta RP80): see the `qr()` helper below |

```python
# Python: build an 80 mm receipt and print it (tested on a Rongta RP80)
import base64, os, requests

ESC, GS = b"\x1b", b"\x1d"
t = lambda s: s.encode("cp850", "replace")

def receipt(number, items):
    out = ESC + b"@" + ESC + b"t\x02"                                          # init + PC850
    out += ESC + b"a\x01" + GS + b"!\x11" + t("MY SHOP\n") + GS + b"!\x00"   # big centered title
    out += t(f"Receipt #{number:04d}\n") + ESC + b"a\x00" + t("-" * 48 + "\n")
    total = 0
    for name, qty, price in items:
        amount = f"{qty * price:.2f} EUR"
        out += t(f"{qty} x {name}"[:36].ljust(48 - len(amount)) + amount + "\n")
        total += qty * price
    out += t("-" * 48 + "\n")
    out += ESC + b"E\x01" + t(f"{'TOTAL':<36}{total:>8.2f} EUR\n") + ESC + b"E\x00"  # bold total
    out += ESC + b"a\x01" + t("\nThank you! ¡Gracias!\n") + ESC + b"a\x00"
    out += b"\n" * 4 + GS + b"V\x42\x00"                                       # feed and cut
    return out

requests.post("https://api.printscaler.com/v1/printjobs", auth=(os.environ["PRINTSCALER_API_KEY"], ""),
              headers={"X-Idempotency-Key": "receipt-0001"},
              json={"printerId": 34, "title": "Receipt 0001", "contentType": "raw_base64",
                    "content": base64.b64encode(receipt(1, [("Café con leche", 2, 1.60)])).decode()})
```

```python
# QR code printed by the printer itself (GS ( k, model 2). Center it with ESC a 1.
def qr(data: str, size: int = 6) -> bytes:
    d = data.encode("ascii")
    n = len(d) + 3                                          # pL + pH*256 = data length + 3
    return (GS + b"(k\x04\x001A2\x00"                       # model 2
            + GS + b"(k\x03\x001C" + bytes([size])          # module size 1-16
            + GS + b"(k\x03\x001E1"                         # error correction M
            + GS + b"(k" + bytes([n % 256, n // 256]) + b"1P0" + d   # store data
            + GS + b"(k\x03\x001Q0")                        # print

# e.g. ESC + b"a\x01" + qr("https://example.com/order/1042") + ESC + b"a\x00"
```

```js
// Node.js without dependencies: CP850 bytes for common accented characters
const CP850 = { 'á': 0xa0, 'é': 0x82, 'í': 0xa1, 'ó': 0xa2, 'ú': 0xa3, 'Á': 0xb5, 'É': 0x90, 'Í': 0xd6, 'Ó': 0xe0, 'Ú': 0xe9,
  'ñ': 0xa4, 'Ñ': 0xa5, 'ü': 0x81, 'Ü': 0x9a, '¡': 0xad, '¿': 0xa8, 'ç': 0x87, 'º': 0xa7, 'ª': 0xa6 };
const text = (s) => Buffer.from([...s].map((c) => CP850[c] ?? (c.charCodeAt(0) < 128 ? c.charCodeAt(0) : 0x3f)));
const cmd = (...bytes) => Buffer.from(bytes);
// e.g. Buffer.concat([cmd(0x1b, 0x40), cmd(0x1b, 0x74, 2), text('Café\n'), cmd(0x1d, 0x56, 0x42, 0)]).toString('base64')
```

PDF receipts also work (`pdf_base64` with `options.paper` set to the roll size from `capabilities.papers`), but ESC/POS is
recommended.

## 12. Epson printers without a PC (Server Direct Print)

Compatible Epson receipt printers connect to PrintScaler by themselves, with no computer or agent (typical next to a POS
iPad). The printer polls PrintScaler over HTTPS every few seconds (Epson "Server Direct Print"), so it works on private
networks without opening ports.

- Compatible: TM-m30III, TM-m30II-H/-NT/-S/-SL, TM-m50, TM-m50II, TM-T88VI, TM-T88VII, TM-i series.
- Create it with `POST /computers/epson` `{ "name": "Bar" }` → `{ computer, printer, setup: { url, id, intervalSeconds } }`
  (or dashboard → Computers → Add Epson printer). `POST /computers/{id}/epson/reset-url` issues a new URL.
- The user enters `url` and `id` in the printer web page (`http://<printer IP>` → Server Direct Print → Enable, interval
  3 s, empty password, Access Test, Apply).
- It appears as a computer with `"type": "epson_sdp"` and one printer; print to that `printerId` with `POST /printjobs`.
- **ESC/POS only** (`raw_base64` / `raw_uri`, see section 11); PDF → `400 UnsupportedContentType`. Max 500 KB per job; `qty` works, `options` do not.
- Latency = the printer's polling interval (typically ≤ 3 s).
- States: collected → `sent_to_client` → `in_progress` → `done` / `error` (message from Epson codes, e.g. `EPTR_REC_EMPTY` out
  of paper, `EPTR_COVER_OPEN`, `EX_BADPORT`). No result within 3 minutes → `error` (never resent: no duplicates).
- Online while polling; offline after 90 s without polls (`computer.disconnected` webhook).

## 13. Network printers by IP (port forwarding, No-IP)

For an ESC/POS receipt printer with an Ethernet port on a network without a computer (e.g. a POS iPad) when the router
can forward a port to it. PrintScaler opens a TCP connection to the router's public address and writes the ESC/POS bytes
(like a POS does on port 9100). Prefer the agent (on a PC or Raspberry Pi) or section 12 when possible: this needs router
configuration and exposes the printer port to the internet.

Router setup (done by the customer once):
1. Fixed local IP for the printer (e.g. 192.168.1.50).
2. Port forwarding: external TCP port (9100 or e.g. 49100) → printer IP, port 9100.
3. If the public IP changes: Dynamic DNS on the router, e.g. a free **No-IP** hostname (`bar-shop.ddns.net`). Free No-IP
   hostnames must be confirmed every 30 days or they stop resolving.
4. The line needs a real public IP: a WAN IP in `100.64.0.0/10` means CG-NAT and it cannot work.

API:
- `POST /computers/network` `{ "name": "Bar", "host": "bar-shop.ddns.net", "port": 9100 }` → `{ computer, printer, check }`.
  `host` accepts a hostname or public IP (`"bar-shop.ddns.net:49100"` also works); private/CG-NAT addresses are rejected;
  ports 1024–65535.
- `PATCH /computers/{id}/network` `{ "host", "port" }` → `{ computer, check }`. `POST /computers/{id}/network/check` → check.
- `check` = `{ reachable, host, port, address, latencyMs, status, error }`: `address` is the IP the name resolved to (compare
  it with the router's public IP to verify No-IP is up to date); `status` = `{ paperOut, paperNearEnd, coverOpen, error }`
  from the printer (ESC/POS `DLE EOT`), or `null` if the printer does not answer status queries. Nothing is printed.
- The hostname is resolved on every connection, so dynamic DNS changes are picked up automatically (No-IP TTL is 60 s).
- Print with `POST /printjobs` to its `printerId`: **ESC/POS only** (`raw_base64` / `raw_uri`), max 500 KB, `qty` works.
- States: `new` → `in_progress` → `done` ("sent to the printer": raw printing gives no proof the ticket came out).
  If the printer is unreachable, out of paper or its cover is open, jobs stay `queued` with the reason in the state
  message and are retried every 10 s **in order** until they print or expire. If the connection drops in the middle of
  sending, the job ends in `error` and is not resent (no duplicates).
- Online/offline: checked every minute with a TCP connect (`computer.connected` / `computer.disconnected`, `printer.state` webhooks).

## 14. Integration recipes

1. **Let the user choose a printer**: call `GET /printers`, show `name` + `printer.computer.name`, store the chosen
   `id` in your settings (per warehouse/station/user). Grey out printers whose `state != "online"` or whose
   `computer.state != "connected"` (jobs still queue and print when they come back).
2. **Receipts**: build ESC/POS bytes (section 11) and send them as `raw_base64` to the receipt printer. For a POS iPad
   with a compatible Epson printer and no computer, connect the printer directly (section 12); for other network
   receipt printers behind a router with port forwarding, add them by IP / No-IP name (section 13).
3. **Shipping labels**: if the carrier gives ZPL, send `raw_base64` to the label printer (fastest, exact). If it gives
   a PDF, send `pdf_base64`/`pdf_uri` and set `paper` to the label size from `capabilities.papers`.
4. **Never print twice**: always send `X-Idempotency-Key` built from your business id (order, invoice, shipment) and
   treat `409` as "already sent".
5. **Track results**: subscribe to `printjob.state`; mark your record as printed on `done`, alert on `error`/`expired`.
6. **Monitor stations**: subscribe to `computer.disconnected` and `printer.state` to alert when a station goes down.
7. **Testing without paper**: use `POST /printers/{id}/test`, or print to "Microsoft Print to PDF" on a Windows
   computer (the agent saves the file to `Documents\PrintScaler`).

## 15. Common mistakes to avoid

- Sending `options` with a `raw_*` job (rejected). Use `qty` for RAW copies.
- Sending UTF-8 text to an ESC/POS printer (accents print as garbage): encode with the code page you selected (CP850).
- Hard-coding paper names: they differ between Windows and macOS/Linux. Read `capabilities.papers`.
- Using a `*_uri` that is only reachable inside your own network, or that expires in seconds: the agent downloads it
  from the customer's computer, possibly after a delay if it was offline.
- Parsing the create response as an object: `POST /printjobs` returns a bare number (the id).
- Verifying webhook signatures on re-serialized JSON instead of the raw body.
- Calling the API from frontend code with the API key.
- Retrying a failed POST without an idempotency key (may print twice).

## 16. Installing the agent (for setup instructions)

- Windows: download `https://printscaler.com/downloads/printscaler-agent-windows-amd64.zip`, extract it and run
  `.\printscaler-agent.exe install` (starts at login) or `.\printscaler-agent.exe install --service` as
  administrator (starts at boot; only sees printers installed for all users).
- macOS / Linux / Raspberry Pi: `curl -fsSL https://printscaler.com/install.sh | sh`
- The installer shows a pairing code: approve it in the dashboard (Computers → Link computer). The computer and its
  printers then appear in `GET /computers` and `GET /printers`.
- Headless: `sudo printscaler-agent register --api-key psk_xxx --name "Warehouse Pi"` then `sudo printscaler-agent install`.
- Installed agents start automatically after reboots and update themselves to new releases (no action needed).
  `Computer.version` shows the running agent version. Computers with `"type": "epson_sdp"` (section 12) or
  `"type": "network"` (section 13) are printers connected without a computer and have no agent.

Human documentation: https://printscaler.com/docs · Dashboard: https://printscaler.com/app
