Errors, limits & CORS
Examples use the public sandbox tokens, so you can run them as they are.
Error format
Errors are RFC 9457 problem documents, except where the endpoint answers in plain text (see Plain-text errors):
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
X-Request-Id: 7b370052-9487-42fd-813f-af5b68db84a6
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "firmware not found",
"instance": "/firmware/00000000-0000-0000-0000-000000000000",
"request_id": "7b370052-9487-42fd-813f-af5b68db84a6"
}
Some errors add fields, e.g. what_strings, missing and conflicts on firmware
422s, conflicts or errors on device imports, and id on firmware 409s.
Plain-text errors
The endpoints devices and build scripts call directly — firmware uploads (PUT /firmware,
PUT /firmware/{hw}/{hwver}/{fw}), the update check (GET /firmware/{domain}/…) and
PUT/GET /logdata/{name} — support compact plain-text answers. Unless the request asks
for JSON, their errors are one line of text (see
Response formats):
HTTP/1.1 409 Conflict
Content-Type: text/plain; charset=utf-8
409 Conflict: a log file with this name already exists with different content (log files cannot be replaced)
Send Accept: application/json to get problem documents from these endpoints too:
curl -si -X PUT https://fw.unitcircle.ca/firmware -H "Authorization: Bearer eyJhbGciOiJFZERTQSIsImtpZCI6IjIwMjYtMTAtMDEtYjItaiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2Z3LnVuaXRjaXJjbGUuY2EiLCJzdWIiOiJzdmM6c2FuZGJveCB3cml0ZXIgKHB1YmxpYykiLCJhdWQiOlsidWNsb2dzZXJ2ZXIiXSwiZXhwIjoxODIyNDE0NTE3LCJuYmYiOjE3OTA4Nzg1MTIsImlhdCI6MTc5MDg3ODUxNywianRpIjoiMDFhMGY4YWQtODkxNC03MjcxLWE4MGEtMjI3NzYyYWY5NjliIiwidHlwIjoiYXBpIiwiY3VzdCI6InNhbmRib3guZXhhbXBsZSIsImNpZCI6IjAxYTBmOGExLTEzYWMtN2QzNC05NDEyLTMwYjAwOGFkYzk4NyIsInNjcCI6ImZ3OnJlYWQgbG9nZGF0YTpyZWFkIGRldmljZXM6cmVhZCB0YWdzOnJlYWQgc3RhdHM6cmVhZCBmdzp3cml0ZSBsb2dkYXRhOndyaXRlIGRldmljZXM6d3JpdGUgdGFnczp3cml0ZSJ9.AdKQR9H96-zqRYvWVf7ALksV1UAht2CzSHdomiepLAfuVjFOPiTowNWssFRgmMuu9z7CHTdug1AcY6jWiMW6DA" -H 'Accept: application/json' \
--form-string 'firmware=@(#)1.6.0-10-g30d0049, demo, example-hw@2.0.0, 2025-12-05T15:25:18-05:00, AFI' \
--form-string 'notes=# a different build claiming the same version'
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{"detail":"firmware demo 1.8.0 for example-hw@2.0.0 (AFI) already exists with different content (firmware cannot be replaced; release a new version)","id":"01a0daca-299d-7acd-9e99-176507b3f170","instance":"/firmware","request_id":"76257c73-…","status":409,"title":"Conflict","type":"about:blank"}
Request ids
Every response carries X-Request-Id. Send your own (8–64 characters of letters, digits,
., _, -) to correlate with your logs, or the service generates a UUID. Quote it when
asking for support; operators can trace it in the logs and the access log.
Status codes
| Status | Meaning here | Typical causes |
|---|---|---|
| 200 | OK | also an idempotent re-upload of identical bytes |
| 201 | Created | new firmware, log file, device, token, member, customer |
| 202 | Accepted | login code requested (always); backup/restore queued |
| 204 | No Content | tag add/remove/delete, revocations, logout |
| 302 | Found | log/firmware download → /dl/… → object store |
| 400 | Bad Request | invalid name/hwid (1–64 printable characters, no spaces)/version/tag/date, malformed JSON, unknown JSON field, missing multipart part |
| 401 | Unauthorized | no token, bad, revoked or expired token, wrong login code, expired session |
| 403 | Forbidden | missing scope, missing CSRF token, not an admin, blocked IP, CORS preflight from a disallowed origin |
| 404 | Not Found | unknown object, or a customer you can't access (?customer= for someone else; never 403, so customers can't be probed) |
| 405 | Method Not Allowed | wrong method for the URL, e.g. DELETE on firmware, log files or devices (they are never deleted) |
| 409 | Conflict | same name/identity with different bytes, re-upload of a withdrawn build, changing a withdrawn device, device key change (use ?force=true), import conflicts, last owner, duplicate customer/tag |
| 410 | Gone | expired or invalid download link |
| 412 | Precondition Failed | stale If-Match on firmware PATCH |
| 413 | Payload Too Large | size limits below |
| 422 | Unprocessable Entity | firmware what-string problems (missing/conflicting fields, no or several what strings, bad version) |
| 429 | Too Many Requests | rate limit; see Retry-After |
| 500 | Internal Server Error | a bug on our side; the detail includes the request id |
| 503 | Service Unavailable | maintenance mode (writes only, Retry-After: 300); /status//readyz when dependencies are down |
Size limits
| What | Limit |
|---|---|
| Firmware binary | 16 MiB (operator-configurable) |
| Release notes | 1 MiB, UTF-8 |
| Log file | 16 MiB (operator-configurable) |
| JSON request bodies | 1 MiB |
| Device import | 8 MiB, 10,000 rows |
| What string | 512 bytes |
HTTP/1.1 413 Request Entity Too Large
Content-Type: text/plain; charset=utf-8
413 Request Entity Too Large: logdata larger than 16777216 bytes
Rate limits
Token buckets, per client IP unless noted. Exceeding one gives 429 with Retry-After
(seconds):
| Class | Applies to | Sustained | Burst |
|---|---|---|---|
| update check | GET /firmware/{domain}/{hw}/{hwver}/{fw}/{fwver}[/{hwid}] |
60 / min | 30 |
| downloads | /dl/… |
120 / min | 60 |
| login | POST /auth/login (plus max. 5 codes per email per hour) |
20 / hour | 10 |
| code verification | POST /auth/verify (plus max. 5 guesses per code) |
30 / hour | 10 |
| API token | any request with a Bearer token, per token | 600 / min | 200 |
HTTP/1.1 429 Too Many Requests
Content-Type: text/plain; charset=utf-8
Retry-After: 1
429 Too Many Requests: rate limit exceeded; retry after 1 s
Repeated 429s and other abuse patterns raise security events for the operator, and the
IP can be blocked (403 your address has been blocked; contact the service operator).
CORS
| URLs | Cross-origin policy |
|---|---|
Update checks, /status, /healthz, /readyz, /health/history, /.well-known/jwks.json, /openapi.json |
any origin (Access-Control-Allow-Origin: *), no credentials |
Customer API (/firmware…, /logdata…, /devices…, /tags…, /tokens…, /members…, /stats/…) |
only origins the operator allows globally, or that are registered by an active customer (cors_origins); the origin is echoed back; Bearer tokens only (no cookies). The token still decides whose data you reach. |
/auth/…, /admin/…, console, /dl/… |
same-origin only (no CORS headers) |
Allowed preflight methods: GET, HEAD, PUT, POST, PATCH, DELETE. Allowed headers:
Authorization, Content-Type, If-Match, X-Request-Id. Exposed headers:
X-Request-Id, Location, Retry-After, ETag. Preflights are cached for 10 minutes.
curl -si -X OPTIONS https://fw.unitcircle.ca/devices -H 'Origin: https://app.example.com' -H 'Access-Control-Request-Method: GET'
HTTP/1.1 204 No Content
Access-Control-Allow-Headers: Authorization, Content-Type, If-Match, X-Request-Id
Access-Control-Allow-Methods: GET, HEAD, PUT, POST, PATCH, DELETE
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Expose-Headers: X-Request-Id, Location, Retry-After, ETag
Access-Control-Max-Age: 600
Vary: Origin
OPTIONS /devices
Origin: https://evil.example
Access-Control-Request-Method: GET
HTTP/1.1 403 Forbidden
{"detail":"origin not allowed", …}
OPTIONS /firmware/sandbox.example/example-hw/2.0.0/demo/0.0.0
Origin: https://any.example
Access-Control-Request-Method: GET
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, HEAD, PUT, POST, PATCH, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type, If-Match, X-Request-Id
Access-Control-Max-Age: 600
To call the customer API from your own web app, ask your account administrator to add
your app's origin (e.g. https://portal.example.com) to your customer's CORS origins.
Unknown URLs
GET /no/such/url
HTTP/1.1 404 Not Found
{"detail":"no such URL; see https://fw.unitcircle.ca/docs", …}
Security headers
All responses include X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer,
X-Frame-Options: DENY, and Strict-Transport-Security on HTTPS. Downloaded files are
always served as attachments.