Documentation
REST API · Render
The public REST API renders Markdown into themed PDFs and manages your templates. It's a server-to-server API authenticated by an API key.
- Base URL:
https://inkrun.dev. - Timing: a render is synchronous and can take up to ~60s.
- CORS: cross-origin browser reads are denied by design. Call it from your backend, not client-side JS.
Authentication
Every request needs an API key in the Authorization header:
Authorization: Bearer sk_...Create and revoke keys in the dashboard under API & MCP (https://inkrun.dev/api-mcp). A key is shown once at creation — store it securely.
| Failure | Status |
|---|---|
| Deployment has no auth configured | 501 |
| Missing / malformed / invalid key | 401 |
| Key tripped its rate limit | 429 (+ Retry-After) |
POST /api/v1/render
Render Markdown to a PDF.
Request body (JSON)
| Field | Type | Required | Description |
|---|---|---|---|
markdown | string | yes | Markdown source (min length 1). |
template | string | no | A saved template — slug or id. Supplies base theme, page format, and style tweaks. |
theme | string | no | A base theme name (default, editorial, minimal, technical, bold) or a custom theme's slug/id. Overrides the template's theme. |
format | string | no | Page size: A4, Letter, or Legal. Overrides the template's format. |
title | string | no | Title stored with the render. Defaults to the document's frontmatter title: when present. |
sendEmail | object | no | Deliver the PDF by email as a download link valid for 3 days: { "to", "senderName", "message?" }. Paid plans — skipped with a notice on the free tier. See Email delivery. |
Precedence when both a template and an explicit theme/format are given: the explicit values win. With no template and no theme, you get the default theme on A4.
Request headers
| Header | Purpose |
|---|---|
Authorization: Bearer sk_... | Required. Your API key. |
Content-Type: application/json | Required for the JSON body. |
Accept: application/pdf | Return the raw PDF bytes instead of JSON (see below). |
Idempotency-Key | Optional retry-safety key (1–255 visible ASCII chars). See Idempotency. |
x-request-id | Optional correlation id; echoed back on the response (a generated one is returned if you omit it). |
x-folio-source | Optional origin tag (mcp supported); defaults to api for History/analytics. |
Response — JSON (default)
200 OK with:
{
"id": "rnd_9f2a…",
"status": "rendered",
"size": 28211,
"url": "https://…s3…/rnd_9f2a.pdf?X-Amz-Signature=…",
"stored": true
}| Field | Description |
|---|---|
id | Render id (also the stored filename). |
status | rendered on success. |
size | PDF size in bytes. |
url | Presigned download URL when S3 is configured; omitted otherwise. |
stored | Whether a re-downloadable copy was stored. false means storage failed — the PDF was still produced but there's no url. |
storageError | Present only when storage failed; a short reason string. |
email | Present only when sendEmail was requested: { "sent": true, "to": "…" }. On the free tier the send is skipped: "sent": false plus "skipped": "free_tier" and a message. A runtime send failure carries an error string instead. |
The x-request-id header is echoed on every response.
Response — raw PDF
Send Accept: application/pdf and you get the bytes directly:
200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="rnd_9f2a….pdf"Examples
Raw PDF straight to a file:
curl "https://inkrun.dev/api/v1/render" \
-H "Authorization: Bearer $INKRUN_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/pdf" \
-d '{ "template": "quarterly-report", "markdown": "# Q2 Revenue\n\nStrong quarter…" }' \
--output report.pdfJSON with a presigned link:
curl "https://inkrun.dev/api/v1/render" \
-H "Authorization: Bearer $INKRUN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "theme": "editorial", "format": "Letter", "markdown": "# Whitepaper" }'Idempotency
Send an Idempotency-Key header and a retried request is answered from the original render — no second render, no second quota charge:
curl "https://inkrun.dev/api/v1/render" \
-H "Authorization: Bearer $INKRUN_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: invoice-2026-07-0042" \
-d '{ "template": "quarterly-report", "markdown": "# Q2 Revenue" }'- A repeat within the retention window (24 h by default) returns the original render's
{ "id", "status", "size", "url", "stored" }with"replayed": truein the body and anidempotency-replayed: trueresponse header. Theurlis freshly presigned on every replay. - Replays always return JSON (the original bytes aren't re-streamed) — use the
urlto download. - A failed original is not replayed: retrying the same key runs a fresh render (the failed attempt's quota was refunded).
- The guard targets retries. Two simultaneous first requests with the same key may both render.
Email delivery
Pass sendEmail and Inkrun emails the recipient a branded message with a presigned download link that stays valid for 3 days (the response's url is short-lived; the emailed link is the long one). The email is attributed to senderName — “Ada sent you a PDF” — and can quote an optional personal message.
curl "https://inkrun.dev/api/v1/render" \
-H "Authorization: Bearer $INKRUN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"template": "quarterly-report",
"markdown": "# Q2 Revenue\n\nStrong quarter…",
"title": "Q2 Report",
"sendEmail": {
"to": "[email protected]",
"senderName": "Ada Lovelace",
"message": "Here is the report we discussed."
}
}'- Paid plans only. Email delivery requires a paid subscription. A free-tier request is not rejected: the render succeeds as usual and the response's
emailfield reports the skip —{ "sent": false, "skipped": "free_tier", "message": "…" }. Checkemail.sent, don't assume delivery. Full details: Email delivery → Plans. - Requires the deployment to have email (Resend) and S3 storage configured — otherwise the request fails with
400before rendering. An invalidtoaddress or emptysenderNameis also a400. - A provider hiccup after a successful render never fails the request: you still get
200with the PDF/link, and the outcome is reported in the response'semailfield ("sent": false+error). On the raw-PDF path (Accept: application/pdf) the outcome comes back in anx-email-sentresponse header instead.
Errors
Errors carry a stable envelope with a machine-readable code:
{ "error": { "code": "input_too_large", "message": "Markdown input is 5.0 MB…" } }| Status | code | When |
|---|---|---|
400 | invalid_request | Malformed body / failed validation (details are logged server-side, not returned). |
400 | template_not_found / theme_not_found | Unknown template / theme reference. |
400 | email_undeliverable | sendEmail requested but undeliverable (bad recipient, or the deployment lacks email/S3). |
413 | input_too_large | Markdown over your plan's size limit — the message names the limit and your actual size. |
413 | output_too_large | The produced PDF exceeded the output ceiling; nothing was stored. |
429 | quota_exceeded | Monthly render quota exhausted. |
429 | rate_limited | Burst rate limit tripped (+ Retry-After). |
503 | pool_busy | Render pool saturated (+ Retry-After: 5). |
504 | render_timeout | The render exceeded the per-render time budget (45 s by default). Simplify or split the document. |
500 | internal_error | Unexpected failure — quote the x-request-id when contacting support. |
Auth failures (401 missing/invalid key, 501 API not configured) still use the simple { "error": "…" } string shape.
On 429 / 503, honour Retry-After (seconds) and retry. 4xx errors are not retryable as-is; 504 may succeed with a smaller/simpler document.
Reading renders back
The id returned by a render POST stays queryable, so you can poll status, re-fetch metadata, and mint fresh download links long after the original response's presigned url expired.
GET /api/v1/render/{id}
curl "https://inkrun.dev/api/v1/render/pdf_9f2a4e" \
-H "Authorization: Bearer $INKRUN_API_KEY"{
"id": "pdf_9f2a4e",
"status": "rendered",
"size": 421888,
"source": "api",
"format": "A4",
"title": "Q2 Report",
"createdAt": "2026-07-07T10:12:00.000Z",
"completedAt": "2026-07-07T10:12:03.000Z",
"stored": true,
"url": "https://…s3…?X-Amz-Signature=…"
}urlis a fresh, short-lived presigned link, minted on every call — fetch this endpoint again whenever you need to re-download.stored: falsemeans no re-downloadable copy exists (urlomitted).- Failed renders include an
errorMessagestring. - A render that doesn't exist or belongs to another account returns
404 not_found— ids are never confirmed across accounts.
GET /api/v1/renders
Your render history, newest first, cursor-paginated:
curl "https://inkrun.dev/api/v1/renders?limit=20&status=rendered" \
-H "Authorization: Bearer $INKRUN_API_KEY"{
"renders": [ { "id": "pdf_9f2a4e", "status": "rendered", "size": 421888, "…": "…" } ],
"next_cursor": "eyJ0IjoiMjAyNi0wNy0wN1QxMDoxMjowMC4wMDBaIiwiaWQiOiJwZGZfOWYyYTRlIn0"
}| Query param | Default | Notes |
|---|---|---|
limit | 20 | 1–100. |
cursor | — | Opaque cursor from the previous page's next_cursor. |
source | all | Filter: web | api | mcp. |
status | all | Filter: queued | processing | rendered | failed. |
next_cursor is null on the last page. Paging is stable while new renders arrive (keyset, not offset). List items carry metadata only — get a download url from GET /api/v1/render/{id}.
Templates API
Manage the style options you render with. All endpoints use the same Authorization: Bearer sk_... auth.
GET /api/v1/templates
List your account templates plus the built-in system templates.
curl "https://inkrun.dev/api/v1/templates" \
-H "Authorization: Bearer $INKRUN_API_KEY"{
"templates": [
{
"id": "tpl_…", "name": "Sales Proposal", "slug": "sales-proposal",
"description": "…", "themeName": "default", "format": "A4",
"styles": { "accent": "#1F6FEB", "fontHeading": "sans", "fontBody": "sans" },
"system": true, "createdAt": "2026-01-05T09:01:00.000Z"
}
],
"next_cursor": null
}Optionally paginated: ?limit= (1–100, default 100) + ?cursor= (opaque, from the previous page's next_cursor; null on the last page). Omitting both returns the full list for typical accounts — existing clients keep working.
POST /api/v1/templates
Create a template owned by your account. Returns 201 with the created record.
| Field | Type | Notes |
|---|---|---|
name | string | Required. 1–80 chars. |
description | string | Optional, ≤280 chars. |
themeName | string | Base theme: a built-in name or a custom theme's slug. Default default. |
format | string | A4 | Letter | Legal. Default A4. |
styles.accent | string | Hex colour, e.g. #2f5d50. |
styles.fontBody / fontHeading / fontMono | string | Font preset key (see Templates → Fonts). |
curl "https://inkrun.dev/api/v1/templates" \
-H "Authorization: Bearer $INKRUN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Proposal",
"themeName": "default",
"format": "A4",
"styles": { "accent": "#1F6FEB", "fontHeading": "sans", "fontBody": "serif" }
}'Unknown/invalid style fields are dropped (only hex accents and known font presets survive). Then render with the returned slug:
curl "https://inkrun.dev/api/v1/render" \
-H "Authorization: Bearer $INKRUN_API_KEY" \
-H "Content-Type: application/json" -H "Accept: application/pdf" \
-d '{ "template": "acme-proposal", "markdown": "# Proposal for Acme" }' \
--output proposal.pdfPATCH /api/v1/templates/{slugOrId}
Partial update — only the fields you send change; the slug is preserved so existing render references keep working. Same field shapes as POST. 404 if no matching template you own.
DELETE /api/v1/templates/{slugOrId}
Soft-delete (archive) one of your templates. 404 if not found.
{ "slug": "acme-proposal", "deleted": true }Quotas, rate limits & size limits
Independent limits, all scaling with your plan:
| Plan | Monthly renders (quota) | API key rate limit | Max Markdown input |
|---|---|---|---|
| Free | 50 | 1 request / sec | 1 MiB |
| Pro | 500 | 5 requests / sec | 5 MiB |
- Quota is per calendar month (UTC). Exceeding it →
429 quota_exceeded. Failed renders don't consume quota (they're refunded). - Rate limit is a per-key burst guard. Tripping it →
429 rate_limitedwith aRetry-Afterheader; back off and retry. - Input size is measured in UTF-8 bytes of the
markdownfield. Over the cap →413 input_too_largenaming the limit and your actual size. - Output size: a produced PDF over 50 MiB fails with
413 output_too_large(quota refunded) instead of being delivered. - Render time: a render has a 45 s budget; exceeding it →
504 render_timeout. Split very large or complex documents.
Versioning
/api/v1 is the stable version: changes are additive only — new endpoints and new optional/response fields; ignore fields you don't recognise. Breaking changes ship as a new version path, announced with Deprecation/Sunset headers and a 6-month overlap.
Charts
Any render (REST, MCP, or dashboard) can include charts via a ```chart fenced code block. Full spec and a worked example: Templates → Charts.