Docs menu
API referenceRender a PDF
Render a PDF
POST /v1/render takes markdown and answers with the finished PDF.
Send markdown to https://api.legivel.com/v1/render and the response body is the finished PDF.
Request#
| Name | Type | Description |
|---|---|---|
Authorizationrequired | header | Bearer lgv_your_key_here, an API key from the account page. See Authentication. |
Content-Type | header | With application/json the body is the object described under Body fields. With text/markdown, no header, or any other type, the body is the raw markdown bytes and every field takes its default. A JSON body without a string markdown field answers 400 invalid_request. |
Idempotency-Key | header | An optional retry key of 1 to 128 characters, scoped to your account. A later request with the same key and the same document renders again at no charge. A different document under a key already used answers 422. A sealed request with this header is refused; see Idempotency. |
Body fields#
A JSON body carries these fields and no others. An unknown field, at the top level or inside features, answers 400 invalid_request naming it, so a typo never costs a document on a render you did not ask for.
| Name | Type | Description |
|---|---|---|
markdownrequired | string | The Markdown source of the document. An empty string is refused with empty_document. |
features | object | What the converter does with the document. Every feature is on unless it is sent as false, except mermaid_appendix and embed_source, which are off unless they are sent as true. An unknown or camelCase key is refused with invalid_request before the render starts, so a typo never costs a document. |
output | "pdf" | "json" | "upload" | How the document comes back: pdf streams the file, json returns it Base64-encoded beside its metadata, upload sends it with one HTTP PUT to upload_url and answers with a receipt. upload needs upload_url. Default "pdf". |
upload_url | string | A pre-signed PUT URL for your own storage, for output: "upload" and only valid together with it. It must be an absolute https URL on a public host; any other address is refused with invalid_request before a document is reserved. The PDF is sent once, as given, with only Content-Type: application/pdf and Content-Length, so a URL that requires any other signed header fails at the storage. The URL is never stored, logged or repeated in a response. Uri format. |
filename | string | The name the streamed file is offered under. The stem is trimmed to 80 characters and .pdf is enforced; the default is document.pdf. |
sign | object | The certificate to seal this document with. Exactly one form: a stored certificate named by the account, a PKCS#12 bundle, or a key and certificate sent apart. Only the requesting account's own material is used; there is no service certificate. Sealing makes no claim of qualified status or of a reader trusting the document automatically. |
resources | "strict" | "lenient" | What a resource that cannot be loaded does: strict refuses the render with resource_fetch_failed, lenient draws a placeholder and reports the failures in the resources-failed headers. Default "strict". |
language | string | The document's language as a BCP 47 tag, such as en-GB. It sets hyphenation patterns and text shaping. The default is the front matter's language, if the document carries one. At most 63 characters. |
Features#
Every feature is on unless you send false for it, except mermaid_appendix and embed_source, which are off unless you send true. The theme is "light" unless you ask for "dark". A feature name the API does not know, a non-boolean value, or a theme other than the two answers 400 invalid_request naming the key. The names are the ones below; the editor's own spellings are not accepted here.
| Name | Type | Description |
|---|---|---|
mermaid | boolean | Draw fenced mermaid blocks as diagrams. Default true. |
mermaid_appendix | boolean | Put each diagram on a page of its own after the document, with a link where the diagram was written. Off unless set to true: diagrams stay where they are written. Default false. |
highlight | boolean | Colour fenced code blocks by language. Default true. |
images | boolean | Draw the resources the document references. With this off, each one is left out of the page. Default true. |
smart_typography | boolean | Set straight quotes, dashes and ellipses as their typographic forms. Default true. |
tasks | boolean | Draw task list items as checkboxes. Default true. |
emoji | boolean | Replace :shortcode: names with emoji. Default true. |
math | boolean | Typeset mathematics written in LaTeX notation. Default true. |
barcode | boolean | Draw barcode and QR code blocks. Default true. |
svg | boolean | Draw vector resources as vectors instead of leaving them out. Default true. |
html_blocks | boolean | Render HTML blocks and inline HTML with their styles. CSS applies only inside its own HTML block. Default true. |
bare_autolinks | boolean | Turn bare URLs and email addresses into links. With this off they stay plain text, also in raster output. Default true. |
indented_code | boolean | Render blocks indented by four spaces as code. Default true. |
embed_source | boolean | Attach the Markdown source to the PDF as an embedded file named document.md. Off unless set to true. The free, watermarked delivery leaves it out. Default false. |
tagged | boolean | Tag the PDF for screen readers: headings, paragraphs, lists, tables, links, and figures and formulas with their text alternatives. Off leaves the structure out. Default true. |
theme | "light" | "dark" | The page and text colors. A theme: light or theme: dark key in the document's front matter wins over this. Default "light". |
hyphenation | boolean | Hyphenate justified text. It needs a document language: without one the text is set unhyphenated whatever this says. |
curl -X POST https://api.legivel.com/v1/render \
-H "Authorization: Bearer $LEGIVEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"markdown":"# Quarterly report\n\n2026 Q3 closed with revenue up 12%.","features":{"theme":"dark","math":false,"mermaid_appendix":true}}' \
-o document.pdfResponse#
A success is 200. With the default output: "pdf" the body is the PDF bytes under Content-Type: application/pdf, and Content-Disposition names the file. The ASCII form is always present; a name with characters outside ASCII additionally carries the RFC 8187 filename*= form.
| output | Media type | Body |
|---|---|---|
"pdf" | application/pdf | The document itself. |
"json" | application/json | The answer to output: "json": the document and the numbers that describe it. |
"upload" | application/json | The answer to output: "upload": the receipt for a document your storage accepted at upload_url. It is sent only after the storage answered the PUT with a 2xx status, and it does not repeat the URL. |
HTTP/1.1 200 OK
content-type: application/pdf
content-disposition: attachment; filename="Quarterly report.pdf"
cache-control: no-store
server-timing: setup;dur=0.8, render;dur=41.2, serialize;dur=5.6, total;dur=63
x-legivel-pages: 3
x-legivel-delivery: vector
x-legivel-sha256: 9c1185a5c5e9fc54612808977ee8f548b2258d31c1e4e1b4a1ba5f0f1a7f0d2b
%PDF-1.7 ...With output: "json" the body is JSON: pdf is the same bytes Base64-encoded, bytes their decoded length, sha256 the digest the header also carries, and signed says whether a seal was applied. The digest is the same for both deliveries of one document, so the delivery mode never changes the file.
{
"pdf": "JVBERi0xLjcKJc...",
"pages": 3,
"bytes": 48213,
"sha256": "9c1185a5c5e9fc54612808977ee8f548b2258d31c1e4e1b4a1ba5f0f1a7f0d2b",
"signed": false
}Apart from content type and disposition, all three deliveries carry the same headers:
| Header | Present | Description |
|---|---|---|
Cache-Control | Always | no-store: a rendered document is never cached by an intermediary. |
Server-Timing | Always | The phases of the render and the total, as total;dur=<milliseconds> with the converter's own phase marks before it where it reports them. |
X-Legivel-Pages | Always | Pages in the delivered document. |
X-Legivel-Delivery | Always | What was served: vector for a full document, raster for the watermarked one. This endpoint always answers vector. |
X-Legivel-SHA256 | Always | The SHA-256 of the exact bytes delivered, in lower-case hexadecimal. The document is hashed after any seal is applied, so the digest is of the file as saved. |
X-Legivel-Signature | When it applies | signed when a seal was applied and confirmed; absent otherwise. |
X-Legivel-Replay | When it applies | true when the request replayed an earlier render under the same Idempotency-Key, so no document was spent. |
X-Legivel-Fetch-Id | When it applies | The public identity the resource loads were made under. It names no account and no internal token. |
X-Legivel-Resources-Failed | When it applies | <failed>/<asked for> when resources: "lenient" drew placeholders for resources that could not be loaded. |
X-Legivel-Resources-Failed-Ids | When it applies | The ids of those resources, comma-separated, at most sixteen of them with ,+N for the rest. |
API renders carry no watermark on any plan. Only the web editor's free live preview does.
Examples#
Send a markdown file as text/markdown and save the PDF. The key is read from the LEGIVEL_API_KEY environment variable, as in every example on this page.
curl -X POST https://api.legivel.com/v1/render \
-H "Authorization: Bearer $LEGIVEL_API_KEY" \
-H "Content-Type: text/markdown" \
--data-binary @doc.md \
-o doc.pdfSign in to run this sample against your own account. Sign in
The same call with a JSON body, for markdown that is already a string in your program.
curl -X POST https://api.legivel.com/v1/render \
-H "Authorization: Bearer $LEGIVEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"markdown": "# Hello"}' \
-o hello.pdfName the file and set the document's language.
curl -X POST https://api.legivel.com/v1/render \
-H "Authorization: Bearer $LEGIVEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"markdown":"# Quarterly report\n\n2026 Q3 closed with revenue up 12%.","filename":"Quarterly report.pdf","language":"en-GB"}' \
-o quarterly-report.pdfRender a document with images linked from the web and accept placeholders for any that fail, reading the resources headers to see what was missing. Linked images are loaded for signed-in accounts with documents left and never stored; see how we load images.
curl -X POST https://api.legivel.com/v1/render \
-H "Authorization: Bearer $LEGIVEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"markdown":"# Quarterly report\n\n2026 Q3 closed with revenue up 12%.\n\n","resources":"lenient"}' \
-o quarterly-report.pdfTake the PDF as a JSON value, for a runtime that cannot read a binary response body.
curl -X POST https://api.legivel.com/v1/render \
-H "Authorization: Bearer $LEGIVEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"markdown":"# Quarterly report\n\n2026 Q3 closed with revenue up 12%.","output":"json"}' \
-o render.jsonSeal the result with a certificate stored in your account.
curl -X POST https://api.legivel.com/v1/render \
-H "Authorization: Bearer $LEGIVEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"markdown":"# Quarterly report\n\n2026 Q3 closed with revenue up 12%.","sign":{"certificate":"Acme certificate","reason":"Quarterly close"}}' \
-o quarterly-report.pdfUpload to your storage#
With output: "upload", legivel sends the finished PDF to your own storage with one HTTP PUT to a pre-signed URL you create, and answers with a receipt instead of the file. Amazon S3, Cloudflare R2, Google Cloud Storage and any store that accepts a pre-signed PUT work. You sign the URL on your side with your own credentials. No access key, secret or bucket policy is sent to legivel; the URL is the only thing you share, and it authorizes one object.
output: "upload"needsupload_url, andupload_urlis refused with any otheroutput. Either mistake answers 400invalid_request.upload_urlmust be an absolutehttpsURL on a public host. A host that resolves to a loopback, private, link-local or other non-public address, a blocked domain, a host with no IPv4 address, or a URL that would not be sent exactly as written is refused with 400invalid_request. The host must resolve within 10 seconds. If the address check itself cannot run, the answer is 503quota_unavailable. Both happen before a document is reserved, so a refused URL costs nothing.- A deployment that has no outbound address for requests to your storage answers 503
render_unavailableto everyoutput: "upload", also before a document is reserved.pdfandjsonoutput are unaffected. - The URL is used exactly as you sent it, query string included. It is never stored, logged or repeated in a response or an error message.
curl -X POST https://api.legivel.com/v1/render \
-H "Authorization: Bearer $LEGIVEL_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"markdown\": \"# Quarterly report\", \"output\": \"upload\", \"upload_url\": \"$UPLOAD_URL\"}"Headers the URL may be signed for. The PUT carries exactly two headers: Content-Type: application/pdf and Content-Length with the exact size of the PDF. Sign the URL for Content-Type: application/pdf, and for Content-Length only if you know the size in advance. legivel adds no storage-specific header, so a URL that requires a signed x-amz-* header, for example x-amz-server-side-encryption or a checksum header, fails with 403 at the storage. Use the bucket's default encryption instead.
The PUT is tried once and never follows a redirect. The connection to your storage must be established within 10 seconds, and the whole PUT must finish within 60 seconds. The plan's render time limit covers the render only, so a slow bucket does not turn into render_timeout.
Receipt. A 200 answer means your storage accepted the PDF with a 2xx status. status is that status, so you can tell a 200 from a 201 or a 204. bytes and sha256 describe the exact bytes your storage received: the digest is the same one the pdf and json deliveries report for the same document, and for a sealed PDF it is taken after the seal. The receipt carries the same headers as the other deliveries, without Content-Disposition, and never includes the URL.
{
"uploaded": true,
"pages": 3,
"bytes": 48213,
"sha256": "9c1185a5c5e9fc54612808977ee8f548b2258d31c1e4e1b4a1ba5f0f1a7f0d2b",
"status": 200
}Failed upload. When your storage answers with anything other than 2xx, a redirect included, or does not answer in time, or the connection fails, the answer is 424 upload_failed. status is the storage's HTTP status, such as 403 for an expired signature, or null when no answer arrived. A failed upload returns the document to your period: you pay only for PDFs that reached your bucket.
HTTP/1.1 424 Failed Dependency
content-type: application/json
{
"code": "upload_failed",
"error": "the storage at upload_url did not accept the PDF",
"status": 403
}Retries. A request repeated under the same Idempotency-Key renders and uploads again at no charge, within the plan's replay allowance described under Idempotency. upload_url is not part of the key's fingerprint, so the repeat may carry a fresh pre-signed URL once the first one has expired. A repeat whose upload fails answers 424 and leaves the original charge as it was. A first attempt whose upload failed releases the key, so a retry under the same key is a first attempt again.
Send an Idempotency-Key with every upload. The render and the PUT together can take longer than 100 seconds on Pro and Scale. A connection that is closed on the way, by a proxy or by your own client, then ends without an answer, although the PDF may already be in your bucket and the document charged. Repeat the request under the same key: it uploads again at no charge and returns the receipt.
Amazon S3
With the AWS SDK for JavaScript v3, pass signableHeaders so the content type is part of the signature; requestChecksumCalculation: "WHEN_REQUIRED" keeps the SDK from adding checksum parameters the PUT does not send. The credentials come from your own AWS environment.
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({
region: "eu-central-1",
requestChecksumCalculation: "WHEN_REQUIRED",
});
const uploadUrl = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: "my-bucket",
Key: "reports/2026-q3.pdf",
ContentType: "application/pdf",
}),
{ expiresIn: 900, signableHeaders: new Set(["content-type"]) },
);Cloudflare R2
R2 speaks the S3 API: the same code with your account's R2 endpoint, region auto and an R2 API token's access key.
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const r2 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
},
requestChecksumCalculation: "WHEN_REQUIRED",
});
const uploadUrl = await getSignedUrl(
r2,
new PutObjectCommand({
Bucket: "my-bucket",
Key: "reports/2026-q3.pdf",
ContentType: "application/pdf",
}),
{ expiresIn: 900, signableHeaders: new Set(["content-type"]) },
);Google Cloud Storage
A V4 signed URL with the write action is a PUT URL. The signing identity needs permission to write the object and to sign, for example a service account key.
import { Storage } from "@google-cloud/storage";
const [uploadUrl] = await new Storage()
.bucket("my-bucket")
.file("reports/2026-q3.pdf")
.getSignedUrl({
version: "v4",
action: "write",
expires: Date.now() + 15 * 60 * 1000,
contentType: "application/pdf",
});Customer seals#
On an eligible paid plan, ask legivel to seal the finished PDF on your instruction with your certificate. The seal makes later changes detectable and identifies the certificate used. Legivel does not claim qualified status, personal signing intent or equivalence to a handwritten signature. Reader validation depends on the certificate chain, trust settings and validation policy; uploading a certificate does not automatically establish trust.
Customer sealing is available through REST, MCP and the account-render backend. Editor and playground sealing controls are unavailable pending a separate redesign. Legivel does not provide a service certificate.
{
"markdown": "# Approved invoice",
"sign": {
"p12": "<Base64 PKCS#12/PFX bundle>",
"password": "<bundle password>"
}
}- Inline
p12must contain the matching private key and certificate chain, use standard Base64, and decode to at most 65,536 bytes. A public certificate alone cannot seal.passwordis required; an empty string is valid for an unprotected bundle. Inline credentials are used for this request and are never stored in your account or logs. Supply them again on every retry. keywithcert, and optionallychain, carry the same credential as separate parts. Each is PEM text or standard Base64 of DER, at most 65,536 decoded bytes; a PEMchainmay hold several certificates, leaf issuer first. RSA, ECDSA and Ed25519 keys are read, with the private key as PKCS#8 (plain or encrypted), PKCS#1 or SEC1.passwordis needed only for an encrypted key. These parts are treated exactly like an inline bundle: used for this request, never stored or logged.certificateis the name of a certificate uploaded to your account. Upload requires your authorisation to apply it to documents you submit on your behalf, until you delete it. Stored bundles and passwords are encrypted. A missing, deleted or expired certificate fails the request; another certificate is never substituted.- Optional
reason,locationandcontactinsidesigneach accept up to 200 characters without control characters. These fields describe the seal in the PDF; they do not change whose certificate is used. - Certificate data counts towards the overall request-body ceiling, with its own decoded bundle cap. It does not count as Markdown text or image/resource data. No certificate chain, timestamp or revocation service is fetched for a customer seal.
- A refused credential answers 400
invalid_bundlewith a reason (wrong_password,no_private_key,cert_chain_incomplete,key_cert_mismatch,certificate_expired,certificate_not_yet_valid,unsupported_algorithmoriterations_exceeded) that describes the problem without repeating what you sent. Seal PDFs says what to check for each. - Omitting
signor usingfalserequests an unsealed PDF.truereturns 400invalid_request: name a stored certificate with{"certificate": "<name>"}or send the bundle in the request. Other unsupported forms and ambiguous combinations also return 400invalid_request. A Free plan returns 403plan_requiredfor a valid sealing request.
Idempotency#
Set Idempotency-Key to a value that identifies the request on your side, for example a document id plus a content hash. Use one key per document and set of output options. Keys are scoped to the account, so its API keys and its web downloads share one namespace. We never store your PDF: we keep a fingerprint of the request, the key and the outcome.
A later request with the same key and the same document is a replay. It renders the document again, answers 200 with X-Legivel-Replay: true, and costs no document.
- Same key, same document: a free replay, until your plan's replay allowance for that key is used up: 2 on Free, 5 on the paid plans. Past it the answer is 429
idempotency_replay_limit, andRetry-Aftergives the seconds until the key expires. - Same key, a different document: 422
idempotency_mismatch. Nothing renders and nothing is charged. The document and the feature toggles are both part of what "same" means. - Same key, the first request still running: 409
idempotency_in_progresswithRetry-After: 2. Two requests under one key never both render. - A key longer than 128 characters, or one that carries control characters, answers 400
invalid_request. - A replay still passes authentication and the per-account limits (requests per minute and concurrent renders), so a burst of replays can answer 429 like any other burst.
A key lives 24 hours. After that the same key is a fresh request and costs a document again. Only a successful render completes a key: if the request fails (queue full, timeout, backend unavailable), the key is released at once and a retry with the same key is a first attempt again, charged like any other render.
Sealed requests. A request that asks for a customer seal and also sends an Idempotency-Key is refused with 503 signing_unavailable. A seal's identity is not yet part of the key's fingerprint, and we will not replay one account's seal for another request. Send a sealed request without the header.
Errors#
Errors are JSON with a stable code and a human error message. Invalid certificate bundles may include a content-free reason, such as wrong_password. This endpoint can answer with the codes below; the full list with recovery advice is on Errors and quota, and the plan limits a refusal comes from are on Limits.
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_bundle | The supplied certificate cannot be used; check its reason and credentials. Compatibility mode may return wrong_password or key_cert_mismatch directly. |
| 400 | unknown_signing_identity | The named certificate is not available for this account. |
| 400 | certificate_expired | The stored certificate expired and must be replaced. |
| 403 | plan_required | The account plan does not include customer seals. |
| 422 | signing_failed | Sealing failed. No unsealed PDF is returned; the reserved document returns to your period. |
| 503 | signing_unavailable | The requested sealing capability or certificate service is unavailable. |
| 400 | invalid_request | An unknown field or feature key, a wrong field type, an output or theme outside its set, a malformed seal request, an unsupported seal form or an oversized bundle. The message names the offending key. Also an upload_url without output: "upload" or the reverse, and an upload_url that is not https or not on a public host; that message never repeats the URL. |
| 400 | empty_document | The markdown is zero bytes long. |
| 413 | payload_too_large | The body, the markdown or the resource budget is over the plan's ceiling. Nothing renders and nothing is charged. |
| 422 | resource_fetch_failed | A remote resource could not be loaded in resources: "strict" mode. Returned to your period. Send resources: "lenient" to render with placeholders instead. |
| 422 | too_many_resources | The document declares more remote resources than the plan allows. |
| 401 | unauthorized | Missing or invalid bearer token. |
| 402 | quota_exhausted | The period's documents are used up. |
| 422 | render_timeout | The render exceeded its deadline. Returned to your period. |
| 422 | too_many_pages | The document exceeds its configured page limit. Returned to your period. |
| 429 | rate_limited | Over the plan's requests per minute or concurrent renders. Retry-After says how many seconds to wait. |
| 422 / 500 | render_failed | The render worker failed. Returned to your period. |
| 503 | queue_full | The render queue is full. Retry-After: 2. Returned to your period. |
| 503 | quota_unavailable | The usage count or the audit storage is unavailable. The PDF is withheld; follow Retry-After when present. |
| 503 | render_unavailable | The render backend is not available. Returned to your period. No Retry-After header. |
| 424 | upload_failed | With output: "upload", the storage did not accept the PDF or did not answer in time. status is the storage's HTTP status, or null when no answer arrived. Returned to your period. |