{"openapi":"3.1.0","info":{"title":"PaperJet API","version":"0.3.0","summary":"Programmable PDF generation, powered by Typst.","description":"PaperJet renders PDFs from Typst source code. Bring your own Typst, or upload templates and call them with a JSON `data` payload — same shape on the wire as your code reads via `data.foo`.\n\nCompared to HTML→PDF services (DocRaptor, PDFShift): document-native pagination, real layout primitives, no headless browser. Compared to Carbone.io's `.docx` placeholder model: a real programming language for templates, not text replacement.","contact":{"name":"PaperJet support","email":"hello@paperjet.dev"},"license":{"name":"Proprietary"}},"servers":[{"url":"https://api.paperjet.dev","description":"production"}],"security":[{"bearerAuth":[]}],"tags":[{"name":"render","description":"Generate PDFs from Typst source or templates."},{"name":"templates","description":"Pre-built Typst templates referenced by id."},{"name":"account","description":"Information about the authenticated caller."},{"name":"audit","description":"Read-only audit trail scoped to the caller."},{"name":"webhooks","description":"Outbound webhook endpoints — PaperJet POSTs signed events to your URL after a render. Available on every plan."}],"paths":{"/v1/me":{"get":{"tags":["account"],"summary":"Identify the authenticated caller.","responses":{"200":{"description":"Caller info — useful for clients verifying their key works.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeResponse"},"examples":{"example":{"value":{"user":{"id":"01H...","plan":"hobby"},"api_key":{"id":"01K...","env":"live"},"quota":{"limit":1000,"used":42,"remaining":958,"hard_cap":false,"resets_at":1779837600}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/v1/render":{"post":{"tags":["render"],"summary":"Render Typst source (or a template) to PDF.","description":"Either `source` or `template_id` is required. When `data` is provided, it is interpolated as a top-level Typst binding `data` so templates can reference `data.foo`, `data.items.at(0).name`, etc.\n\nThe response body is the raw PDF (`application/pdf`). On error the body is an `ApiError` JSON envelope and the status code carries the failure type.\n\nPass an `Idempotency-Key` header to make this call safe to retry on every plan: a retry within 24 h with the same key + same body replays the cached successful PDF (no re-render, no double billing). Errors are not cached. A retry with the same key but different body returns 409.","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderRequest"},"examples":{"inline":{"summary":"Inline source + data","value":{"source":"= Hello #data.name\n\nYou rendered #data.count PDFs.","data":{"name":"Thomas","count":42}}},"template":{"summary":"Use a stored template","value":{"template_id":"invoice-v1","data":{"invoice":{"number":"INV-2026-0042","date":"2026-04-29"},"from":{"name":"PaperJet","address":"France"},"to":{"name":"Acme Corp","address":"123 Main St, NYC"},"currency":"€","items":[{"description":"PDF API","quantity":1,"unit_price":9}]}}}}}}},"responses":{"200":{"description":"PDF rendered.","headers":{"Content-Disposition":{"schema":{"type":"string","example":"attachment; filename=\"out.pdf\""}},"X-Request-Id":{"schema":{"type":"string","format":"uuid"}},"X-RateLimit-Quota":{"description":"Monthly render cap for the plan.","schema":{"type":"integer"}},"X-RateLimit-Quota-Remaining":{"description":"Renders left in the current quota window after this call.","schema":{"type":"integer"}},"X-RateLimit-Quota-Reset":{"description":"Unix seconds at which the quota resets.","schema":{"type":"integer"}}},"content":{"application/pdf":{"schema":{"type":"string","format":"binary"}}}},"400":{"$ref":"#/components/responses/InvalidRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"`template_id` does not exist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"},"examples":{"notFound":{"value":{"error":{"type":"not_found_error","code":"template_not_found","message":"template 'foo' not found","request_id":"00000000-0000-4000-8000-000000000000"}}}}}}},"409":{"$ref":"#/components/responses/IdempotencyConflict"},"413":{"$ref":"#/components/responses/RenderTooLarge"},"422":{"description":"Typst compilation failed (syntax error, sandbox denial, etc).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"504":{"description":"Compilation timed out (5 s wall-clock cap).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/renders":{"get":{"tags":["render"],"summary":"Paginated history of the caller’s recent renders.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},{"name":"before","in":"query","description":"Cursor — pass `next_before` from the previous page.","schema":{"type":"string"}},{"name":"status","in":"query","description":"Filter to a render status.","schema":{"type":"string","enum":["ok","failed","timeout"]}},{"name":"template_id","in":"query","description":"Exact stored template id from the render row (`user:invoice-v1`, `system:invoice-v1`) or `inline`.","schema":{"type":"string","maxLength":128}},{"name":"since","in":"query","description":"Unix-seconds inclusive lower bound on `created_at`.","schema":{"type":"integer","minimum":0,"maximum":9999999999}}],"responses":{"200":{"description":"Paginated list (newest first).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RendersList"}}}},"400":{"$ref":"#/components/responses/InvalidRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/v1/templates":{"get":{"tags":["templates"],"summary":"List Typst templates available to the caller.","description":"Stored templates are available on every plan.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":1000,"default":100}},{"name":"cursor","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"Paginated list of template ids.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TemplatesList"}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}},"post":{"tags":["templates"],"summary":"Create or replace a Typst template.","description":"Stored templates are available on every plan. Last-writer-wins: re-uploading the same `id` replaces the stored source. Max 500 KB.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["id","source"],"additionalProperties":false,"properties":{"id":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,60}$"},"source":{"type":"string","minLength":1,"maxLength":500000}}}}}},"responses":{"201":{"description":"Template stored.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TemplateEntry"}}}},"400":{"$ref":"#/components/responses/InvalidRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"413":{"$ref":"#/components/responses/SourceTooLarge"}}}},"/v1/templates/{id}":{"delete":{"tags":["templates"],"summary":"Remove a template by id.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,60}$"}}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"type":"object","required":["deleted","id"],"properties":{"deleted":{"type":"boolean","enum":[true]},"id":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/InvalidRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No template with that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}},"/v1/audit":{"get":{"tags":["audit"],"summary":"Paginated audit trail for the caller (newest first).","description":"Sensitive events for your account: account creation, API key minted / revoked, template create / delete, subscription change, webhook signature failures.\nAudit log access is available on every plan.\n\nCustomers can self-serve their audit trail without contacting support. Operators see a wider view via support tooling — this endpoint is strictly scoped to the caller’s `user_id`.\n\nCursor-based pagination on the row id (which is a ULID — time-sortable). Pass `next_before` from the previous page as `before` to continue.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}},{"name":"before","in":"query","description":"Cursor — pass `next_before` from the previous page.","schema":{"type":"string"}},{"name":"event","in":"query","description":"Exact event name (`apikey.created`) or prefix match with trailing `*` (`apikey.*`).","schema":{"type":"string","pattern":"^[a-z][a-z0-9._]*\\*?$"}},{"name":"since","in":"query","description":"Unix-seconds inclusive lower bound on `created_at`.","schema":{"type":"integer","minimum":0,"maximum":9999999999}}],"responses":{"200":{"description":"Paginated event list.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuditList"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/v1/webhooks":{"get":{"tags":["webhooks"],"summary":"List the caller’s webhook endpoints.","description":"Returns every endpoint the caller has registered, including the masked secret prefix and the most recent delivery outcome. Plaintext signing secrets are NEVER returned here — they are surfaced only once on POST.","responses":{"200":{"description":"All registered endpoints (no plaintext).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhooksList"}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}},"post":{"tags":["webhooks"],"summary":"Register a webhook endpoint.","description":"Webhooks are available on every plan. Subscriptions only alter API usage limits.\n\nPass an `Idempotency-Key` header when retrying creation. A retry within 24 h with the same key + same body replays the original response, including the one-time plaintext secret, without creating a duplicate endpoint.\n\nOn creation, PaperJet returns the plaintext signing `secret` exactly ONCE. Store it now — both you and PaperJet need it to verify the `X-Paperjet-Signature` header on each delivery (`HMAC_SHA256(secret, t + \".\" + raw_body)` hex-encoded, compared with the `v1=` part of the header).\n\nDelivery: PaperJet POSTs each event through the delivery queue. Permanent 4xx responses (other than 429) are not retried for that delivery; the endpoint is auto-disabled after 10 consecutive failed deliveries. Re-enable by deleting + recreating.","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url"],"additionalProperties":false,"properties":{"url":{"type":"string","format":"uri","description":"HTTPS URL. Loopback addresses + paperjet.dev refused.","maxLength":500},"events":{"type":"string","description":"Comma-separated event names. Empty / omitted = subscribe to all. Known: `render.completed`, `render.failed`."}}}}}},"responses":{"201":{"description":"Endpoint registered. The plaintext secret is in the response — store it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookCreated"}}}},"400":{"$ref":"#/components/responses/InvalidRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"$ref":"#/components/responses/IdempotencyConflict"}}}},"/v1/webhooks/{id}":{"delete":{"tags":["webhooks"],"summary":"Delete a webhook endpoint.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted.","content":{"application/json":{"schema":{"type":"object","required":["deleted","id"],"properties":{"deleted":{"type":"boolean","enum":[true]},"id":{"type":"string"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No webhook with that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"tk_live_<base64url>","description":"Pass your key as `Authorization: Bearer tk_live_...`."}},"schemas":{"ApiError":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["type","code","message"],"properties":{"type":{"type":"string","enum":["invalid_request_error","authentication_error","rate_limit_error","not_found_error","idempotency_error","api_error"]},"code":{"type":"string","enum":["invalid_request","authentication_failed","rate_limited","source_too_large","pdf_too_large","template_not_found","compilation_failed","idempotency_error","compilation_timeout","internal_error"]},"message":{"type":"string"},"request_id":{"type":"string","format":"uuid"}}}}},"MeResponse":{"type":"object","required":["user","api_key","quota"],"properties":{"user":{"type":"object","required":["id","plan"],"properties":{"id":{"type":"string"},"plan":{"type":"string","enum":["free","hobby","pro","scale"]}}},"api_key":{"type":"object","required":["id","env"],"properties":{"id":{"type":"string"},"env":{"type":"string","enum":["live"]}}},"quota":{"type":"object","required":["limit","used","remaining","hard_cap","resets_at"],"description":"Monthly render quota for the caller. Free is a hard cap anchored to account creation; paid plans reset on the active Stripe billing period.","properties":{"limit":{"type":"integer","description":"Monthly cap for the current plan."},"used":{"type":"integer","description":"Renders consumed in this quota window so far."},"remaining":{"type":"integer","description":"limit - used (never negative)."},"hard_cap":{"type":"boolean","description":"True when the plan is hard-capped at `limit`; false when paid metering may allow overage if billing is configured."},"resets_at":{"type":"integer","description":"Unix seconds at which the current quota window resets."}}}}},"RenderRequest":{"type":"object","anyOf":[{"required":["source"]},{"required":["template_id"]}],"properties":{"source":{"type":"string","description":"Typst source. Required unless `template_id` is set.","minLength":1,"maxLength":500000},"template_id":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,60}$","description":"Slug of a stored template (see GET /v1/templates)."},"data":{"description":"Arbitrary JSON value injected as the top-level `data` Typst binding."},"filename":{"type":"string","maxLength":255},"options":{"type":"object","additionalProperties":false,"properties":{"ua_compliant":{"type":"boolean","description":"Ask the renderer to emit tagged-PDF output. Validate representative documents before making accessibility/compliance claims."}}}},"additionalProperties":false},"RenderEntry":{"type":"object","required":["id","status","bytes","duration_ms","template_id","api_key_id","request_id","error_code","error_message","created_at"],"properties":{"id":{"type":"string"},"status":{"type":"string","enum":["ok","failed","timeout"]},"bytes":{"type":"integer"},"duration_ms":{"type":"integer"},"template_id":{"type":["string","null"]},"api_key_id":{"type":["string","null"]},"request_id":{"type":["string","null"]},"error_code":{"type":["string","null"],"description":"Stable failure code for failed/timeout renders. Null on success."},"error_message":{"type":["string","null"],"description":"Sanitized failure message from compute/edge. Null on success."},"created_at":{"type":"integer","description":"Unix epoch (seconds)."}}},"RendersList":{"type":"object","required":["renders","has_more","next_before"],"properties":{"renders":{"type":"array","items":{"$ref":"#/components/schemas/RenderEntry"}},"has_more":{"type":"boolean"},"next_before":{"type":["string","null"]}}},"TemplateEntry":{"type":"object","required":["id","size","last_modified"],"properties":{"id":{"type":"string"},"size":{"type":"integer"},"last_modified":{"type":"string","format":"date-time"}}},"TemplatesList":{"type":"object","required":["templates","next_cursor"],"properties":{"templates":{"type":"array","items":{"$ref":"#/components/schemas/TemplateEntry"}},"next_cursor":{"type":["string","null"]}}},"AuditEvent":{"type":"object","required":["id","event","metadata","ip_truncated","user_agent","created_at"],"properties":{"id":{"type":"string","description":"ULID — unique, time-sortable."},"event":{"type":"string","description":"Stable event name (snake_case dotted). Examples: `auth.signup.success`, `apikey.created`, `template.deleted`, `subscription.updated`.","example":"apikey.created"},"metadata":{"description":"Free-shape JSON payload, parsed. May be null."},"ip_truncated":{"type":["string","null"],"description":"IPv4 with last octet zeroed, or IPv6 first /48; reduced-PII format for audit logs."},"user_agent":{"type":["string","null"],"maxLength":500},"created_at":{"type":"integer","description":"Unix epoch (seconds)."}}},"AuditList":{"type":"object","required":["events","has_more","next_before"],"properties":{"events":{"type":"array","items":{"$ref":"#/components/schemas/AuditEvent"}},"has_more":{"type":"boolean"},"next_before":{"type":["string","null"]}}},"WebhookEndpoint":{"type":"object","required":["id","url","events","secret_prefix","last_status","last_status_code","last_response_body","last_error","last_duration_ms","last_attempts","last_delivery_at","disabled_at","created_at"],"properties":{"id":{"type":"string"},"url":{"type":"string","format":"uri"},"events":{"type":"string","description":"Comma-separated event names (`render.completed,render.failed`). Empty string = all events."},"secret_prefix":{"type":"string","description":"First 12 chars of the signing secret (e.g. `whsec_abc123`)."},"last_status":{"type":["string","null"],"description":"`ok`, `failed_<http_status>`, `timeout`, or `failed_network`."},"last_status_code":{"type":["integer","null"]},"last_response_body":{"type":["string","null"],"description":"Bounded, sanitized response-body preview from the latest delivery/test."},"last_error":{"type":["string","null"],"description":"Network, DNS, validation, or delivery reason for the latest failed attempt."},"last_duration_ms":{"type":["integer","null"]},"last_attempts":{"type":["integer","null"]},"last_delivery_at":{"type":["integer","null"]},"disabled_at":{"type":["integer","null"],"description":"Set when the endpoint was auto-disabled after 10 consecutive failed deliveries."},"created_at":{"type":"integer"}}},"WebhooksList":{"type":"object","required":["webhooks"],"properties":{"webhooks":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"WebhookCreated":{"type":"object","required":["id","url","events","secret","secret_prefix","created_at"],"properties":{"id":{"type":"string"},"url":{"type":"string","format":"uri"},"events":{"type":"string"},"secret":{"type":"string","description":"Plaintext signing secret. Returned ONCE on creation — store it now, we will never show it again."},"secret_prefix":{"type":"string"},"created_at":{"type":"integer"}}}},"parameters":{"IdempotencyKey":{"name":"Idempotency-Key","in":"header","required":false,"description":"Opaque caller-chosen identifier (8–255 chars, `[A-Za-z0-9_\\-:.]`) used to deduplicate retries. A retry with the SAME key + SAME body within 24 h replays the successful PDF byte-for-byte. Errors are not cached. A retry with the same key but a DIFFERENT body returns 409 `idempotency_error`.","schema":{"type":"string","minLength":8,"maxLength":255,"pattern":"^[A-Za-z0-9_\\-:.]+$"}}},"responses":{"Unauthorized":{"description":"Missing or invalid bearer token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"RateLimited":{"description":"Too many requests. Headers `X-RateLimit-*` and `Retry-After` indicate the recovery window.","headers":{"Retry-After":{"schema":{"type":"integer"}},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"InvalidRequest":{"description":"Body shape mismatch or missing required fields.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"IdempotencyConflict":{"description":"`Idempotency-Key` was reused with a different request body.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"RenderTooLarge":{"description":"Body, `source`, or generated PDF exceeds the size cap.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"SourceTooLarge":{"description":"Body or `source` exceeds the size cap.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}}}