Skip to content
Crixaa
REST API

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

response.json
{
  "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.

cURL
# 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.pdf

Reference

The whole surface area

Six endpoints. Each one states the scope your key needs.

EndpointScope
POST/api/v1/templates/{templateId}/generate

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).

documents:read
GET/api/v1/documents/{id}/file

302 redirect to a short-lived presigned download URL.

documents:read
GET/api/v1/documents

List documents, newest first. Filter by source; size caps at 100.

documents:read
GET/api/v1/templates/{id}

Template metadata.

templates:read
GET/api/v1/templates/{id}/versions

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.

ScopeGrants
documents:generateGenerate a PDF from a template
documents:readRead document metadata, list documents, download files
templates:readRead template metadata and version history
webhooks:readReserved — manage webhooks in the console for nowreserved
webhooks:writeReserved — 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.

EventFires when
document.generatedA document finishes generating, from the API or the console.
template.version.committedA new template version is committed.
review.requestedSomeone requests review of a template version.
review.decidedA 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

payload.json
{
  "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

120 requests

Per workspace, per day

500 on the free plan. Get in touch to raise it

10,000 requests

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.