A document API you can read in one sitting
Six endpoints, one header, predictable JSON. Generate PDFs from your templates, fetch them back, and get a signed webhook when they are ready.
https://api.crixaa.com
Quickstart
Your first document
Create a key in the console under Settings → Developer, pick the scopes it needs, and POST your data to a template. The key is shown once — store it somewhere safe.
Pass it as an X-Api-Key header on every request. There is no OAuth dance and no bearer token to refresh.
curl --location 'https://api.crixaa.com/api/v1/templates/TEMPLATE_ID/generate' \
--header "X-Api-Key: $CRIXAA_API_KEY" \
--header 'Content-Type: application/json' \
--data '{
"data": {
"company.name": "Acme Corp",
"invoice.number": "INV-2026-0042",
"invoice.total": 2400,
"line_items": [
{ "sku": "A-100", "qty": 2, "amount": 1200 },
{ "sku": "B-250", "qty": 1, "amount": 1200 }
]
}
}'201 Created
{
"documentId": "1f2dd3b4-0420-4710-8a09-77b72372a9ce",
"fileUrl": "/api/v1/documents/1f2dd3b4-.../file"
}Use the variable name exactly as the editor shows it
If the canvas shows {{company.name}}, send "company.name". Nested objects work too — { "company": { "name": "Acme" } } is equivalent, and you can mix the two. Keys you send that the template does not use are ignored.
Rows for a table are a plain array of objects, keyed by the column keys the template declares. Extra keys are ignored and empty rows are dropped. You do not need to stringify it — the editor stores rows as a JSON string internally, and that form is still accepted, but nothing requires you to write it.
currentDate, pageNumber and totalPages are filled by the renderer. Sending them has no effect.
Fetching the file
Download when you need it
The fileUrl you get back points at the download endpoint, which answers with a 302 to a short-lived presigned storage URL. Most HTTP clients follow that automatically.
Because the link is short-lived, fetch it when you need the bytes rather than caching the redirect target.
# The file endpoint answers 302 with a short-lived presigned URL,
# so follow redirects (-L) rather than reading the body directly.
curl -L 'https://api.crixaa.com/api/v1/documents/DOCUMENT_ID/file' \
--header "X-Api-Key: $CRIXAA_API_KEY" \
--output invoice.pdfReference
The whole surface area
Six endpoints. Each one states the scope your key needs.
| Endpoint | What it does | Scope |
|---|---|---|
POST /api/v1/templates/{templateId}/generateRender a template to PDF. Omit versionId to use the latest committed version. | Render a template to PDF. Omit versionId to use the latest committed version. | documents:generate |
GET /api/v1/documents/{id}Document metadata and status (PENDING, COMPLETED, FAILED). | Document metadata and status (PENDING, COMPLETED, FAILED). | documents:read |
GET /api/v1/documents/{id}/file302 redirect to a short-lived presigned download URL. | 302 redirect to a short-lived presigned download URL. | documents:read |
GET /api/v1/documentsList documents, newest first. Filter by source; size caps at 100. | List documents, newest first. Filter by source; size caps at 100. | documents:read |
GET /api/v1/templates/{id}Template metadata. | Template metadata. | templates:read |
GET /api/v1/templates/{id}/versionsCommitted versions, newest first. Pin generation to a version id. | Committed versions, newest first. Pin generation to a version id. | templates:read |
API keys
Keys that fit how you deploy
Created and managed by workspace admins in the console.
Scoped by default
Grant a key only what it needs. Keys are org-scoped, so one workspace can never reach another workspace’s data.
Rotate without downtime
Issue a successor key while the old one keeps working for a grace period of 1–30 days. Revoke is immediate when you need it.
Locked to your infrastructure
Optional IP allowlist per key, a per-key requests-per-minute ceiling, and an optional expiry date with warning emails before it lapses.
| Scope | Grants |
|---|---|
documents:generate | Generate a PDF from a template |
documents:read | Read document metadata, list documents, download files |
templates:read | Read template metadata and version history |
webhooks:read | Reserved — manage webhooks in the console for nowreserved |
webhooks:write | Reserved — not yet used by any endpointreserved |
Webhooks
Know the moment it is ready
Register an endpoint in the console and we POST to it as things happen — no polling.
| Event | Fires when |
|---|---|
document.generated | A document finishes generating, from the API or the console. |
template.version.committed | A new template version is committed. |
review.requested | Someone requests review of a template version. |
review.decided | A reviewer approves or requests changes. |
Headers on every delivery
X-Crixaa-Event- The event name, e.g. document.generated
X-Crixaa-Delivery- Unique delivery id — de-duplicate retries on this
X-Crixaa-Signature- t=1769510400,v1=9f86d0…
A document.generated body
{
"documentId": "1f2dd3b4-0420-4710-8a09-77b72372a9ce",
"templateId": "f5040b56-3fcf-497b-a0a4-25653f1f2400",
"versionId": "9c2e1b77-1a4f-4f0e-bb02-3d5e6a7c8901",
"status": "COMPLETED",
"fileUrl": "/api/documents/1f2dd3b4-.../file",
"createdAt": "2026-07-27T10:15:32Z"
}Verify before you trust it
Your endpoint is public, so check the signature on every request. The header carries a timestamp and an HMAC-SHA256 of {timestamp}.{raw body}, signed with the whsec_… secret shown when you create the webhook.
import crypto from 'node:crypto'
// Sign the RAW body — re-serialising the JSON changes the bytes
// and the signature will never match.
export function verify(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((kv) => kv.split('='))
)
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(parts.v1 ?? '')
if (a.length !== b.length) return false
if (!crypto.timingSafeEqual(a, b)) return false
// Reject anything older than 5 minutes to blunt replays.
return Math.abs(Date.now() / 1000 - Number(parts.t)) < 300
}Any 2xx counts as success. Anything else retries with exponential backoff — up to 8 attempts, doubling from 2 seconds to about 2 minutes, over a window of roughly 4 minutes. After that the delivery is abandoned. Deliveries can repeat, so key your handler on X-Crixaa-Delivery and make it idempotent. The last status and error for each delivery are shown in the console.
Rate limits
Generous, and visible
Per key, per minute
Configurable when you create the key
Per workspace, per day
500 on the free plan. Get in touch to raise it
Remaining budget comes back on every response as X-RateLimit-Remaining-key and X-RateLimit-Remaining-org. Exceed either and you get a 429 with a Retry-After.
Errors
Standard codes, useful messages
Failures come back as JSON: { "error": "..." }.
- 400
- Malformed body, or the template has no committed version yet.
- 401
- Missing, invalid, revoked or expired API key.
- 403
- Key lacks the scope, or the resource belongs to another workspace.
- 404
- No such template or document.
- 429
- Rate limit exceeded — wait for Retry-After, then retry.
Ready to generate your first document?
Create a workspace, design a template, and mint an API key in a few minutes. Full reference docs live inside the console.