Docs menu

API referenceRender a PDF

Render a PDF

POST /v1/render takes markdown and answers with the finished PDF.

POST/v1/render

Send markdown to https://api.legivel.com/v1/render and the response body is the finished PDF.

Request#

NameTypeDescription
AuthorizationrequiredheaderBearer lgv_your_key_here, an API key from the account page. See Authentication.
Content-TypeheaderWith 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-KeyheaderAn 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.

NameTypeDescription
markdownrequiredstringThe Markdown source of the document. An empty string is refused with empty_document.
featuresobjectWhat 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_urlstringA 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.
filenamestringThe name the streamed file is offered under. The stem is trimmed to 80 characters and .pdf is enforced; the default is document.pdf.
signobjectThe 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".
languagestringThe 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.

NameTypeDescription
mermaidbooleanDraw fenced mermaid blocks as diagrams. Default true.
mermaid_appendixbooleanPut 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.
highlightbooleanColour fenced code blocks by language. Default true.
imagesbooleanDraw the resources the document references. With this off, each one is left out of the page. Default true.
smart_typographybooleanSet straight quotes, dashes and ellipses as their typographic forms. Default true.
tasksbooleanDraw task list items as checkboxes. Default true.
emojibooleanReplace :shortcode: names with emoji. Default true.
mathbooleanTypeset mathematics written in LaTeX notation. Default true.
barcodebooleanDraw barcode and QR code blocks. Default true.
svgbooleanDraw vector resources as vectors instead of leaving them out. Default true.
html_blocksbooleanRender HTML blocks and inline HTML with their styles. CSS applies only inside its own HTML block. Default true.
bare_autolinksbooleanTurn bare URLs and email addresses into links. With this off they stay plain text, also in raster output. Default true.
indented_codebooleanRender blocks indented by four spaces as code. Default true.
embed_sourcebooleanAttach 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.
taggedbooleanTag 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".
hyphenationbooleanHyphenate 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.pdf

Response#

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.

outputMedia typeBody
"pdf"application/pdfThe document itself.
"json"application/jsonThe answer to output: "json": the document and the numbers that describe it.
"upload"application/jsonThe 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.
output: "pdf"
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.

output: "json"
{
  "pdf": "JVBERi0xLjcKJc...",
  "pages": 3,
  "bytes": 48213,
  "sha256": "9c1185a5c5e9fc54612808977ee8f548b2258d31c1e4e1b4a1ba5f0f1a7f0d2b",
  "signed": false
}

Apart from content type and disposition, all three deliveries carry the same headers:

HeaderPresentDescription
Cache-ControlAlwaysno-store: a rendered document is never cached by an intermediary.
Server-TimingAlwaysThe 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-PagesAlwaysPages in the delivered document.
X-Legivel-DeliveryAlwaysWhat was served: vector for a full document, raster for the watermarked one. This endpoint always answers vector.
X-Legivel-SHA256AlwaysThe 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-SignatureWhen it appliessigned when a seal was applied and confirmed; absent otherwise.
X-Legivel-ReplayWhen it appliestrue when the request replayed an earlier render under the same Idempotency-Key, so no document was spent.
X-Legivel-Fetch-IdWhen it appliesThe public identity the resource loads were made under. It names no account and no internal token.
X-Legivel-Resources-FailedWhen it applies<failed>/<asked for> when resources: "lenient" drew placeholders for resources that could not be loaded.
X-Legivel-Resources-Failed-IdsWhen it appliesThe 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.pdf

Sign 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.pdf

Name 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.pdf

Render 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![Revenue](https://example.com/revenue.png)","resources":"lenient"}' \
  -o quarterly-report.pdf

Take 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.json

Seal 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.pdf

Upload 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" needs upload_url, and upload_url is refused with any other output. Either mistake answers 400 invalid_request.
  • upload_url must be an absolute https URL 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 400 invalid_request. The host must resolve within 10 seconds. If the address check itself cannot run, the answer is 503 quota_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_unavailable to every output: "upload", also before a document is reserved. pdf and json output 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.

output: "upload"
{
  "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.

upload_failed
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 p12 must 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. password is 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.
  • key with cert, and optionally chain, carry the same credential as separate parts. Each is PEM text or standard Base64 of DER, at most 65,536 decoded bytes; a PEM chain may 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. password is needed only for an encrypted key. These parts are treated exactly like an inline bundle: used for this request, never stored or logged.
  • certificate is 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, location and contact inside sign each 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_bundle with a reason (wrong_password, no_private_key, cert_chain_incomplete, key_cert_mismatch, certificate_expired, certificate_not_yet_valid, unsupported_algorithm or iterations_exceeded) that describes the problem without repeating what you sent. Seal PDFs says what to check for each.
  • Omitting sign or using false requests an unsealed PDF. true returns 400 invalid_request: name a stored certificate with {"certificate": "<name>"} or send the bundle in the request. Other unsupported forms and ambiguous combinations also return 400 invalid_request. A Free plan returns 403 plan_required for 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, and Retry-After gives 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_progress with Retry-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.

StatusCodeMeaning
400invalid_bundleThe supplied certificate cannot be used; check its reason and credentials. Compatibility mode may return wrong_password or key_cert_mismatch directly.
400unknown_signing_identityThe named certificate is not available for this account.
400certificate_expiredThe stored certificate expired and must be replaced.
403plan_requiredThe account plan does not include customer seals.
422signing_failedSealing failed. No unsealed PDF is returned; the reserved document returns to your period.
503signing_unavailableThe requested sealing capability or certificate service is unavailable.
400invalid_requestAn 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.
400empty_documentThe markdown is zero bytes long.
413payload_too_largeThe body, the markdown or the resource budget is over the plan's ceiling. Nothing renders and nothing is charged.
422resource_fetch_failedA remote resource could not be loaded in resources: "strict" mode. Returned to your period. Send resources: "lenient" to render with placeholders instead.
422too_many_resourcesThe document declares more remote resources than the plan allows.
401unauthorizedMissing or invalid bearer token.
402quota_exhaustedThe period's documents are used up.
422render_timeoutThe render exceeded its deadline. Returned to your period.
422too_many_pagesThe document exceeds its configured page limit. Returned to your period.
429rate_limitedOver the plan's requests per minute or concurrent renders. Retry-After says how many seconds to wait.
422 / 500render_failedThe render worker failed. Returned to your period.
503queue_fullThe render queue is full. Retry-After: 2. Returned to your period.
503quota_unavailableThe usage count or the audit storage is unavailable. The PDF is withheld; follow Retry-After when present.
503render_unavailableThe render backend is not available. Returned to your period. No Retry-After header.
424upload_failedWith 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.