Errors and rate limits
Error shape
Errors return a non-2xx status and {"error": {…}} with code and message. Validation errors add fields, mapping each bad field to what's wrong. Rate limits add retry_after_seconds.
HTTP/1.1 400 Bad Request
{
"error": {
"code": "invalid",
"message": "Must be between 1 and 50.",
"fields": { "copies": "Must be between 1 and 50." }
}
}The SDKs turn these into typed errors — ValidationError, PermissionError, OutOfPrintsError, RateLimitError and so on (exceptions in PHP, Error variants in Rust) — carrying the same status, code, message, fields and retry delay. See SDKs.
Error codes
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid | A field is wrong, has the wrong type, or isn't a field we know. See fields. |
| 400 | invalid_json | The body isn't a single valid JSON object. |
| 401 | unauthenticated | No credentials, or the session has ended. |
| 401 | invalid_api_key | The key is wrong, revoked or expired. |
| 402 | out_of_prints | The organization has used its prints. Upgrade or top up. A job needs prints left for all of its copies (each copy is a print). |
| 403 | forbidden | Your role or the key's scopes don't allow this — for example cancelling someone else's job without the Operator role, or sending "live": false below the Developer role. |
| 403 | ip_not_allowed | The key is limited to other IP addresses. |
| 403 | org_suspended | The organization is suspended. Contact support. |
| 403 | two_factor_required | This action needs a two-factor sign-in. |
| 404 | not_found | It doesn't exist, or you can't see it. Organizations you can't access also answer 404. |
| 409 | printer_disabled | The printer, station or location is turned off and set to refuse jobs. |
| 410 | content_deleted | The job's document was deleted after the retention period (30 days after the job finished, by default). Preview, content and reprint answer this; the job record stays. Send the document again as a new job. See Retention. |
| 413 | content_too_large | Job content is over 50 MB. |
| 422 | no_printer | No printer matches the role or location. Add one or set printer_id. |
| 422 | conversion_failed | An Office document couldn't be converted. Send a PDF. |
| 422 | conversion_unavailable | Document conversion is temporarily unavailable. Send a PDF or retry later. |
| 429 | rate_limited | Too many requests. Wait for Retry-After. |
| 429 | test_limit | Test mode allows 200 prints a day per organization; each copy counts as a print. |
| 500 | internal | Something went wrong on our side. It's safe to retry with the same idempotency key. |
Rate limits
Limits are counted per IP address with a token bucket, so short bursts are fine:
| What | Limit |
|---|---|
| All API requests | 1,200 a minute, with bursts of up to 240 |
| Public endpoints (/v1/public/…), sign-in and device pairing | 30 a minute, with bursts of up to 10 |
| Test-mode jobs | 200 a day per organization |
| Contact form messages | 3 an hour per email address or IP |
Over a limit you get 429 with a Retry-After header (seconds) and retry_after_seconds in the body.
HTTP/1.1 429 Too Many Requests
Retry-After: 3
{
"error": {
"code": "rate_limited",
"message": "Too many requests. Slow down.",
"retry_after_seconds": 3
}
}Retrying safely
The SDKs do this for you: network errors, 429 and 502/503/504 are retried with back-off, waiting as long as Retry-After asks. Every print carries an idempotency key — yours, or one the SDK generates — so a retry after a timeout can never print twice. Pass your own (an order number) to make your own retries safe too. Other 4xx errors (including 410 content_deleted) are not retried — fix the request instead.
import { CatzPrintError, RateLimitError, ValidationError } from "@catzprint/sdk";
try {
await cz.printReceipt(commands, { role: "receipt", idempotencyKey: `order-${order.id}` });
} catch (err) {
if (err instanceof ValidationError) console.error(err.fields); // fix the request
else if (err instanceof RateLimitError) console.error(`still limited; retry in ${err.retryAfter}s`);
else if (err instanceof CatzPrintError) console.error(err.status, err.code, err.message);
else throw err;
}message is written for people. Show it in your UI rather than inventing your own text for each code.