Document processing, metered per page.
Upload a file, start a job, get the result — OCR with real coordinates, conversion, compression, merge/split, page operations, protection and redaction. Every job runs on the same workers as the editor; nothing is faked. Bearer API keys, JSON everywhere, signed webhooks, a free sandbox.
Quickstart
Create a key under Account → API keys (pick Sandbox for a dxk_test_ key that never bills, or Live for dxk_live_). Then: upload, create a job, poll, download. Three calls.
# 1. Upload
curl -s https://docaxo.com/api/v1/files \
-H "Authorization: Bearer $DOCAXO_API_KEY" \
-F "[email protected]"
# → {"id":"f_…","object":"file","page_count":3,"status":"ready",…}
# 2. Start a job (safe to retry thanks to Idempotency-Key)
curl -s https://docaxo.com/api/v1/jobs \
-H "Authorization: Bearer $DOCAXO_API_KEY" \
-H "Idempotency-Key: order-42-ocr" \
-H "Content-Type: application/json" \
-d "{\"operation\":\"ocr\",\"file_id\":\"$FILE_ID\",\"options\":{\"mode\":\"base\"}}"
# → 202 {"id":"…","status":"queued","credits":{"billed":true,"pages_estimated":3},…}
# 3. Poll, then download
curl -s "https://docaxo.com/api/v1/jobs/$JOB_ID" -H "Authorization: Bearer $DOCAXO_API_KEY"
curl -sL "https://docaxo.com/api/v1/jobs/$JOB_ID/result?format=json" \
-H "Authorization: Bearer $DOCAXO_API_KEY" -o blocks.jsonPrefer push over polling? Register a webhook and wait for job.succeeded. The OpenAPI document lives at /api/v1/openapi.json.
Authentication
Send your key as a Bearer token: Authorization: Bearer dxk_live_…. Browser cookies are never accepted on /api/v1; a request without a key gets 401 authentication_required.
- Prefix decides the mode.
dxk_live_keys process and bill;dxk_test_keys use the sandbox. Files, jobs and webhooks are partitioned per mode — a live key cannot see sandbox objects (403 mode_mismatch). - Plan. Live keys need the API Starter plan, otherwise
403 plan_requiredwith anupgrade_url. Sandbox keys work on every plan. - Scopes.
documents:read,documents:write,jobs:read,jobs:write,usage:read,webhooks:manage. A missing scope is403 insufficient_scope. - Rotation. Rotate from the account page; the old key keeps working for the grace period you choose.
GET /api/v1/accounttells you which key, plan and limits a request is running under.
Files
POST /api/v1/files accepts a multipart/form-data body with a single file part (PDF, DOCX, XLSX, CSV, JPEG, PNG, WebP; the bytes must match the declared type). The response includes page_count — that is what a job will reserve credits for — and the file is ready immediately.
For large uploads send JSON {filename, content_type, size_bytes} instead: you get a presigned PUT URL; upload the bytes, then call POST /files/{id}/complete. Presigned URLs need the S3 object store — on a local MinIO-less setup the API answers 501 presigned_unavailable, so fall back to multipart.
expires_at), sandbox files too; a file.expired webhook fires when the sweeper removes one. Delete earlier with DELETE /files/{id}.Jobs & operations
POST /api/v1/jobs with {operation, file_id | file_ids, options, metadata} returns 202 and a job in queued. Poll GET /jobs/{id} until succeeded, failed or cancelled, then fetch GET /jobs/{id}/result (format=download streams the file, json returns OCR blocks, url gives a short-lived presigned link). metadata (≤ 20 string keys) is echoed back on the job and in webhooks.
| operation | job kind | What it does | options | credits |
|---|---|---|---|---|
| ocr | ocr | Recognise text — layout blocks with coordinates; optional searchable PDF. | mode: gundam (single page) | base (multi-page), output: blocks | searchable_pdf, pages[] (0-based) | per page |
| searchable_pdf | ocr | OCR plus an invisible text layer → searchable PDF. | mode, pages[] | per page |
| convert | convert | pdf→docx, pdf→jpg, docx→pdf, xlsx→csv, csv→xlsx. pptx / png / html / txt targets answer operation_unsupported. | target: docx | jpg | pdf | csv | xlsx, dpi, quality, pages[], sheet, delimiter, sheet_name, has_header | per page |
| compress | compress | Reduce PDF size. | level: light | balanced | strong | per page |
| merge | merge | Merge 2–50 PDFs in the given order (file_ids). | — | free |
| split | split | Split by ranges, every page, or fixed chunks. | mode: every_page (default) | ranges | chunks, ranges [[1,3],[4,4]] (1-based), chunk_size | free |
| rotate | page_ops | Rotate pages clockwise. | degrees: 90 | 180 | 270, pages[] (0-based, default all) | free |
| reorder | page_ops | Reorder pages. | order[] — full permutation of 0-based indexes | free |
| delete_pages | page_ops | Delete pages. | pages[] (0-based) | free |
| protect | protect | Encrypt with passwords and permissions. | user_password, owner_password, allow_print, allow_modify, allow_copy, allow_annotate | free |
| unlock | unlock | Remove encryption (password required when set). | password | free |
| redact | redact | Secure redaction — affected pages are rasterised and rebuilt. | regions[] {page, x, y, w, h}, dpi | per page |
| watermark | 422 operation_unsupported | No watermark handler exists in the document worker yet; the browser editor stamps watermarks client-side. | ||
| flatten | 422 operation_unsupported | No flatten handler exists in the document worker yet; exports are flattened client-side. | ||
| sign | 422 operation_unsupported | Signing goes through the share / signature-invitation flow and is not exposed to API keys. | ||
Operations map one-to-one onto real worker job kinds. Anything without a handler — including convert targets pptx, png, html, txt — answers 422 operation_unsupported with a reason; we never return a fake result. GET /api/v1/operations serves this table as JSON.
POST /jobs/{id}/cancel stops a queued job immediately (its reservation is released) and asks a running one to stop at the next checkpoint. Per-job wall-clock timeouts and page caps (page_limit_exceeded) follow your plan; see GET /api/v1/account.
Idempotency
Network failures happen between “request sent” and “response read”. Send an Idempotency-Key header on POST /api/v1/jobs and retries are safe: the stored response is replayed with Idempotent-Replayed: true and no second job or reservation is created. Keys are scoped to the API key and kept for 24 hours.
POST /api/v1/jobs
Idempotency-Key: order-42-ocr # ≤ 255 chars, scoped to the API key, kept 24h
# first call → 202 + job
# retry → 202 + the *same* job, header Idempotent-Replayed: true
# same key, different body → 422 idempotency_key_reused
# key still in flight → 409 idempotency_in_progressWebhooks
Register up to 10 HTTPS endpoints per mode with POST /api/v1/webhooks {url, events?, description?}. The response contains the signing secret (whsec_…) exactly once. POST /webhooks/{id}/test sends a signed webhook.test synchronously so you can wire things up before the first real job.
| event | When |
|---|---|
| job.succeeded | A job finished and its result is downloadable. |
| job.failed | A job failed; `data.object.error.code` explains why. Reserved credits are released. |
| job.cancelled | A job was cancelled via POST /jobs/{id}/cancel. |
| file.expired | An uploaded file reached its TTL and was deleted. |
| webhook.test | Sent synchronously by POST /webhooks/{id}/test. |
POST https://example.com/docaxo
Docaxo-Event: job.succeeded
Docaxo-Signature: t=1757700000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Content-Type: application/json
{
"id": "evt_01J7…",
"object": "event",
"type": "job.succeeded",
"api_version": "2026-09-01",
"livemode": true,
"created_at": "2026-09-12T18:00:00+00:00",
"data": {
"object": {
"id": "…", "object": "job", "operation": "ocr", "status": "succeeded",
"metadata": { "order": "42" },
"result": { "download_url": "https://docaxo.com/api/v1/jobs/…/result", "pages_processed": 3 }
}
}
}Verify the signature
Docaxo-Signature is t=<unix seconds>,v1=<hex> where v1 = HMAC-SHA256(secret, `${t}.${raw body}`). Compute it over the raw bytes (before JSON parsing), compare in constant time, and reject timestamps older than 5 minutes.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyDocaxoSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const t = Number(parts.t);
if (!parts.v1 || !Number.isFinite(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; // replay window
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return expected.length === parts.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
// Express: app.post("/docaxo", express.raw({ type: "application/json" }), (req, res) => {
// if (!verifyDocaxoSignature(req.body.toString("utf8"), req.get("Docaxo-Signature"), process.env.WHSEC)) return res.sendStatus(400);
// const event = JSON.parse(req.body); … ; res.sendStatus(200);
// });Retries
Any non-2xx response or a timeout (10 s) is retried with backoff: 1m → 5m → 30m → 2h → 12h — six attempts in total. Event ids are stable across retries, so deduplicate on id. After 30 consecutive failures an endpoint is disabled (disabled_reason); re-enable it with PATCH /webhooks/{id} {enabled: true}. Every attempt is visible at GET /webhooks/{id}/deliveries.
Rate limits
Each key has a token bucket of 60 requests per minute (sandbox: 30) and may have 2 jobs queued or running at once (sandbox: 2). Exceeding either answers 429 with rate_limited or concurrency_limit; back off for Retry-After seconds. Limits are per key, so give each integration its own. The concurrency figure is part of the API Starter plan; the effective limits for your key are always returned by GET /account → limits.
HTTP/1.1 429 Too Many Requests
Retry-After: 3
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 3
{"error":{"type":"rate_limit_error","code":"rate_limited",…}}| Header | Meaning |
|---|---|
| X-RateLimit-Limit | Requests allowed per minute for this key |
| X-RateLimit-Remaining | Requests left in the current window |
| X-RateLimit-Reset | Seconds until the bucket refills |
| Retry-After | Only on 429 — seconds to wait |
| X-Request-Id | Every response; quote it in support requests |
| Idempotent-Replayed | `true` when a stored idempotent response was returned |
| X-Docaxo-Sandbox | `true` on sandbox result downloads |
Errors
Every error is JSON with one envelope. type groups codes for coarse handling, code is stable and machine-readable, doc_url deep-links into this page and request_id matches the X-Request-Id header. Validation errors add param; billing errors add pages/remaining.
HTTP/1.1 422 Unprocessable Entity
X-Request-Id: req_5f1c…
{
"error": {
"type": "invalid_request_error",
"code": "operation_unsupported",
"message": "Operation 'watermark' is not available through the API",
"doc_url": "https://docaxo.com/api-docs#error-operation_unsupported",
"request_id": "req_5f1c…",
"param": "operation"
}
}| code | status | type | Meaning |
|---|---|---|---|
| authentication_required | 401 | authentication_error | No Bearer API key on the request. Cookies are never accepted here. |
| invalid_api_key | 401 | authentication_error | Unknown, revoked or expired key. |
| plan_required | 403 | permission_error | Live keys need the API Starter plan. `upgrade_url` points to checkout. |
| insufficient_scope | 403 | permission_error | The key lacks the scope this route needs (`jobs:write`, `webhooks:manage`, …). |
| mode_mismatch | 403 | permission_error | The object belongs to the other mode (live ↔ test). |
| validation_error | 422 | invalid_request_error | Malformed body or parameters; `param` names the field. |
| operation_unsupported | 422 | invalid_request_error | Operation (or convert target) has no real worker handler. Never faked. |
| invalid_options | 422 | invalid_request_error | Options do not validate for the chosen operation. |
| unsupported_file_type | 415 | invalid_request_error | Content type not accepted or does not match the bytes. |
| file_too_large | 413 | invalid_request_error | Upload exceeds the maximum size. |
| file_not_ready | 409 | conflict_error | Presigned upload has not been completed yet. |
| page_limit_exceeded | 422 | invalid_request_error | More pages than the plan allows per job. |
| insufficient_credits | 402 | billing_error | Not enough page credits and overage is not available. |
| spending_cap_reached | 402 | billing_error | The job would push overage past your spending cap. |
| concurrency_limit | 429 | rate_limit_error | Too many queued/running jobs for this key. Retry after one finishes. |
| rate_limited | 429 | rate_limit_error | Token bucket empty; honour `Retry-After`. |
| idempotency_key_reused | 422 | idempotency_error | Same Idempotency-Key, different request body. |
| idempotency_in_progress | 409 | conflict_error | The original request with this key is still running. |
| not_found | 404 | not_found_error | No such file, job or webhook for this account and mode. |
| job_not_finished | 409 | conflict_error | Result requested before the job succeeded. |
| no_downloadable_result | 409 | conflict_error | The job produced no downloadable output. |
| result_expired | 404 | not_found_error | Result object was swept after its TTL. |
| presigned_unavailable | 501 | invalid_request_error | Presigned URLs need the S3 store; use multipart upload / streamed download. |
| webhook_delivery_failed | 502 | api_error | Your endpoint rejected the test event (attempt kept in the delivery log). |
| queue_unavailable | 503 | api_error | Job queue unreachable; safe to retry with the same Idempotency-Key. |
| internal_error | 500 | api_error | Our fault. Quote `request_id` when reporting. |
Retry 429, 503 and network errors with exponential backoff and the same Idempotency-Key. Do not retry 4xx codes without changing the request.
Pricing & credits
API Starter
2,000 processing page credits every month, then €0.015 per credit (€0.045 per OCR page), metered through Stripe (docaxo_api_processing_page) and added to your monthly invoice. EU VAT / reverse charge is handled by Stripe Tax.
How credits are counted
- One credit = one page of a metered operation (convert, compress, redact). GPU OCR is the exception:
ocrandsearchable_pdfcost 3 credits per page. Merge, split, rotate, reorder, delete pages, protect and unlock are free. - Credits are reserved when a job is accepted (using the file’s page count) and settled to the pages actually processed when it finishes. Failed or cancelled jobs release their reservation.
- When included credits are exhausted, jobs continue as overage. Set a spending cap on the billing page to stop at a budget: past it, new jobs get
402 spending_cap_reached. GET /api/v1/usageshows included / used / remaining, overage pages and amount, pages in flight and the recent ledger.
Sandbox vs live
| Sandbox (dxk_test_) | Live (dxk_live_) | |
|---|---|---|
| plan | Any plan, including Free | API Starter |
| billing | Never. credits.billed is always false; usage stays at zero. | Reserve → settle per page; overage metered to Stripe |
| processing | Cheap operations (merge, split, rotate, compress, protect, …) run for real on the same workers. OCR is queued to the real worker when the API runs with the mock OCR engine; otherwise a canned fixture result (provider: sandbox-fixture) is returned instantly. | Everything runs for real |
| limits | 100 pages per job · 30 rpm · 2 concurrent jobs | Plan page cap · 60 rpm · 2 concurrent jobs |
| data | Deleted after 24h. Responses carry sandbox: true, downloads X-Docaxo-Sandbox: true. | Inputs and results expire after 24h |
| webhooks | Delivered with livemode: false; http URLs allowed in development | livemode: true; https only |
Endpoint reference
| Method | Path | Summary | Scope |
|---|---|---|---|
| POST | /api/v1/files | Upload (multipart) or request a presigned upload URL | documents:write |
| GET | /api/v1/files | List files | documents:read |
| GET | /api/v1/files/{file_id} | Retrieve a file | documents:read |
| POST | /api/v1/files/{file_id}/complete | Finish a presigned upload | documents:write |
| DELETE | /api/v1/files/{file_id} | Delete a file | documents:write |
| POST | /api/v1/jobs | Create a job (Idempotency-Key supported) | jobs:write |
| GET | /api/v1/jobs | List jobs (filter by status) | jobs:read |
| GET | /api/v1/jobs/{job_id} | Retrieve a job | jobs:read |
| POST | /api/v1/jobs/{job_id}/cancel | Cancel a queued or running job | jobs:write |
| GET | /api/v1/jobs/{job_id}/result | Download the result (format=download | json | url) | jobs:read |
| POST | /api/v1/webhooks | Create a webhook endpoint (secret shown once) | webhooks:manage |
| GET | /api/v1/webhooks | List webhook endpoints | webhooks:manage |
| GET | /api/v1/webhooks/{webhook_id} | Retrieve a webhook endpoint | webhooks:manage |
| PATCH | /api/v1/webhooks/{webhook_id} | Update url / events / enabled | webhooks:manage |
| DELETE | /api/v1/webhooks/{webhook_id} | Delete a webhook endpoint | webhooks:manage |
| POST | /api/v1/webhooks/{webhook_id}/test | Send a signed webhook.test event now | webhooks:manage |
| GET | /api/v1/webhooks/{webhook_id}/deliveries | Delivery log | webhooks:manage |
| GET | /api/v1/account | Account, plan, key and limits | any |
| GET | /api/v1/usage | Credits, overage and ledger for this period | usage:read |
| GET | /api/v1/operations | Operation catalogue | any |
| GET | /api/v1/errors | Error code reference | none |
| GET | /api/v1/pricing | Pricing and limits | none |
| GET | /api/v1/openapi.json | This API as OpenAPI 3.1 | none |
Static list — the live spec is served at https://docaxo.com/api/v1/openapi.json and Swagger UI at https://docaxo.com/api/v1/docs.