Documentation
API reference
Last updated 2 September 2026
One endpoint does the work. You send HTML, you get back a tagged PDF and a report. Everything else on this page is detail around that.
There are two verdicts, and the difference matters. Wellform runs a fast
preflight on every render — a subset of the Matterhorn Protocol,
chosen to catch what actually goes wrong in HTML-to-PDF conversion. Send
verify: true and veraPDF, the reference
implementation, validates the document independently and returns its own
verdict. When you need something you can hand to an auditor, that is the one to
ask for.
The base URL is https://api.wellform.dev. All request and
response bodies are JSON unless stated otherwise.
Contents
- Authentication
- Rendering a document
- Reading the conformance report
- Independent verification
- Variants
- Attachments and e-invoices
- External images and fonts
- Storing the result
- Quotas and plan headers
- Errors
- Keys, plans and billing
- Limits
- Checking a PDF you did not make
- What conformance does and does not mean
Authentication
Every request outside /v1/signup needs a key. Send it as a
bearer token:
Authorization: Bearer wf_live_...
X-Api-Key: wf_live_... works too, for clients that make bearer
tokens awkward. Keys are checked at the edge, so an invalid key is rejected
without starting a renderer and without counting against your quota.
We store only a SHA-256 hash of your key. The plaintext exists once, at the moment it is created, and is not recoverable — if you lose it, you need a new one.
Rendering a document
Two endpoints render. They do identical work and differ only in what comes
back: /v1/render returns JSON with the report and the PDF encoded
in it, /v1/render.pdf returns the bytes with the report in
headers. Reach for the second when you are piping to a file, the first when a
program is going to read the verdict.
POST /v1/render
| Field | Type | Default | Meaning |
|---|---|---|---|
html | string | required | The document to render. |
variant | string | pdf/ua-1 | Output family. See Variants. |
allow_remote_assets | boolean | false | Permit fetching external images and fonts. |
base_url | string | null | Base for resolving relative paths. |
store | boolean | false | Store the PDF and return a URL instead of bytes. |
verify | boolean | false | Also validate with veraPDF. See Independent verification. |
attachments | array | [] | Files to embed. PDF/A-3 only. See Attachments. |
curl https://api.wellform.dev/v1/render \
-H "Authorization: Bearer $WELLFORM_KEY" \
-H "Content-Type: application/json" \
-d '{"html":"<h1>Quarterly report</h1><p>Figures cover the year to March.</p>"}'
The response:
{
"pdf_base64": "JVBERi0xLjcK...",
"url": null,
"key": null,
"conformant": true,
"conformance": { "conformant": true, "failures": 0, "warnings": 0, "findings": [] },
"enrichment": { "table_scopes_applied": 3, "viewer_preferences_fixed": true },
"blocked_assets": [],
"render_ms": 104,
"variant": "pdf/ua-1"
}
pdf_base64 is null when store is true, and
url and key are null when it is false. Exactly one of
the two is populated.
POST /v1/render.pdf
Same request body. The response is application/pdf, with the
verdict in headers so you can branch on it without parsing the document:
| Header | Meaning |
|---|---|
X-Wellform-Conformant | true only when there are zero failures. |
X-Wellform-Failures | Checks the document did not pass. |
X-Wellform-Warnings | Things worth looking at that are not defects. |
X-Wellform-Blocked-Assets | How many external references were refused. |
X-Wellform-Render-Ms | Server-side render time. |
curl -sD headers.txt https://api.wellform.dev/v1/render.pdf \
-H "Authorization: Bearer $WELLFORM_KEY" \
-H "Content-Type: application/json" \
-d @request.json -o document.pdf
Reading the conformance report
The conformance object is the point of the product. Its
findings array names every check that did not pass cleanly, keyed
by Matterhorn Protocol checkpoint:
{
"conformant": false,
"failures": 1,
"warnings": 1,
"profile": "PDF/UA-1, 16 machine-checkable Matterhorn checkpoints",
"findings": [
{
"check": "13-004",
"severity": "fail",
"message": "Figure has no alternate text",
"detail": "/Figure at index 2"
},
{
"check": "09-004",
"severity": "warn",
"message": "Heading level skipped",
"detail": "/H1 followed by /H3"
}
]
}
severity is fail or warn. A failure
means the document is not conformant and a real user will hit a real problem —
an image a screen reader cannot describe, a table whose header cells are not
associated with their data. A warning means something is unusual but permitted.
conformant is true only when failures is zero;
warnings never affect it.
profile names what was measured, and it changes with the
variant. A tagged variant — the pdf/ua family and
pdf/a-3a — is checked against the sixteen machine-decidable
Matterhorn checkpoints above. An untagged PDF/A variant is not: conformance
level B does not require a structure tree, a document language or a displayed
title, so checking for them would report a failure on a file that is exactly
what you asked for. Those variants get the checks that do apply — font
embedding and PDF/A identification — and the profile string says so. Read
conformant as "no failures among the checks named in
profile", never as "accessible".
The enrichment object reports what we repaired on the way out.
table_scopes_applied counts header cells given an explicit
/Scope; viewer_preferences_fixed says whether we had
to set /DisplayDocTitle. If a
table_scopes_skipped key appears, the structure tree and the
rendered table disagreed and we declined to guess rather than assign scopes to
the wrong cells.
Independent verification
Wellform decides conformance with its own preflight, which means that on
its own the verdict is the renderer vouching for itself. Send
verify: true and the document is also validated by
veraPDF, the reference implementation of the
PDF/A and PDF/UA standards and the tool an auditor is most likely to run.
{"html": "<h1>Quarterly report</h1>", "verify": true}
The response gains a verification object:
{
"conformant": true,
"verification": {
"available": true,
"verifier": "veraPDF 1.28.1",
"profile": "PDF/UA-1 validation profile",
"conformant": true,
"passed_rules": 87,
"failed_rules": 0,
"failures": [],
"agreement": "agree",
"duration_ms": 940
}
}
agreement compares the two verdicts and is the field worth
alerting on:
| Value | Meaning |
|---|---|
agree | Both reached the same verdict. |
verapdf_stricter | Wellform passed the document and veraPDF failed it. Treat veraPDF as correct and tell us — it means our preflight has a gap. |
wellform_stricter | Wellform failed the document and veraPDF passed it. Usually a check of ours that goes beyond the machine-verifiable set. |
On /v1/render.pdf the same verdict arrives as
X-Wellform-Verified, which has three values rather than two:
true, false, and unavailable. The last
means nothing judged the document — it is never a pass. If verification was
not requested the header reads not-requested.
Verification is opt-in because it costs about four seconds. veraPDF is a separate JVM process, against a render that usually takes about a hundred milliseconds, so making every caller pay for it to serve the few who need an auditable verdict would be the wrong default.
Variants
variant | Use it for |
|---|---|
pdf/ua-1 | Accessibility. The default, and what most procurement requirements name. |
pdf/ua-2 | Accessibility, PDF 2.0 based. |
pdf/a-3a | Archiving with attachments, tagged. The Factur-X container that is also accessible. |
pdf/a-3b | Archiving with attachments — the container Factur-X and ZUGFeRD e-invoices normally use. |
pdf/a-2b | Archiving, no attachments. |
An unrecognised variant is a 422, and the message lists the
supported set. A conformance report is produced for every variant, but the
accessibility checks run only on the tagged ones — the pdf/ua
family and pdf/a-3a. The other PDF/A levels do not require tagging,
so they are measured against what does apply to them and the report says which
profile it used.
a-3b and a-3a differ by one letter and by everything else.
Level B constrains appearance; Level A additionally requires a structure tree,
a language, a title and logical reading order — an invoice a screen reader can
read aloud. Both carry the same XML payload and both satisfy Factur-X, so
pdf/a-3a is the same container with the accessibility work done.
Wellform writes the PDF/UA-1 identifier onto that output as well, so a
validator asked either question gets a yes. Verified with veraPDF 1.30.2:
the PDF/A-3a and PDF/UA-1 profiles both pass on the same file.
Attachments
An e-invoice is a PDF carrying its own machine-readable payload. Pass
attachments alongside the HTML and the file is embedded with the
AFRelationship the standard requires, listed in the catalog's
/AF array rather than only in the name tree — a file that appears
in one and not the other opens fine everywhere and fails PDF/A-3 validation.
{
"html": "<html lang=\"en\">…</html>",
"variant": "pdf/a-3a",
"attachments": [
{
"filename": "factur-x.xml",
"content_base64": "PD94bWwgdmVyc2lvbj0iMS4wIj8+…",
"description": "EN 16931 invoice data",
"relationship": "Alternative"
}
]
}
| Field | Meaning |
|---|---|
filename | Exactly as the consuming standard names it. Factur-X and ZUGFeRD 2.x require factur-x.xml; a reader looking for that name will not find invoice.xml. |
content_base64 | The file's bytes. Up to 2 MB each, eight per document. |
relationship | One of the ISO 32000-2 values: Source, Data, Alternative, Supplement, EncryptedPayload, FormData, Schema, Unspecified. Factur-X requires Alternative. Defaults to Data. |
description | Optional, shown in a viewer's attachment pane. |
Attachments are accepted only on pdf/a-3a and
pdf/a-3b. On any other variant the request is refused rather than
rendered, because the underlying renderer silently drops attachments a format
disallows — and a valid-looking invoice with the invoice data gone is a worse
outcome than an error. embedded_files in the response lists what
actually made it into the document.
External images and fonts
By default a render fetches nothing. Your HTML is treated as untrusted
input, and a document that can pull in arbitrary URLs is a document that can be
used to probe things it should not reach. Every refused reference is listed in
blocked_assets, so a missing logo is visible in the response
rather than silently absent from the page.
Set allow_remote_assets: true to permit them. This is available
on paid plans; on the free plan the flag is quietly forced back to
false rather than erroring, and the references appear in
blocked_assets as usual.
Embedding images as data: URIs avoids the question entirely and
is faster, since nothing has to be fetched.
Storing the result
With store: true the PDF is written to object storage and you
get a time-limited URL instead of base64. Worth doing for anything large —
base64 inflates a document by about a third, and the URL costs nothing to serve
back.
{
"url": "https://...r2.cloudflarestorage.com/...?X-Amz-Expires=86400...",
"key": "c8/ca/c8ca01b35f9c00d56a3aff3de70c26d0....pdf",
"pdf_base64": null
}
URLs expire after 24 hours. Keys are content-addressed — the same bytes
rendered twice are stored once — so key is stable and you can hold
onto it rather than the URL.
If storage is not configured on our side, this returns 503 with
a message saying so. It will never return a URL that does not resolve.
Quotas and plan headers
Every rendered document counts once. Failed renders are not billed — metering happens only after a document is produced. Three headers come back on every render so you never have to guess where you stand:
| Header | Meaning |
|---|---|
X-Wellform-Plan | Your current plan id. |
X-Wellform-Quota | Documents included per month. |
X-Wellform-Remaining | Documents left in this period. |
Periods are calendar months in UTC and reset at the first of the month. Past
the limit, renders return 429 until you upgrade or the period
rolls over. There is no overage billing — you will never get a surprise
invoice.
Errors
Errors are JSON with an error code and, where it helps, a
detail string meant to be read by a person.
| Status | error | What happened |
|---|---|---|
| 401 | missing_api_key | No key on the request. |
| 401 | malformed_api_key | Key does not begin with wf_live_. |
| 401 | invalid_api_key | No such key. |
| 401 | revoked_api_key | The key was revoked; detail gives the time. |
| 404 | not_found | No route for that path. |
| 405 | method_not_allowed | Wrong HTTP method. |
| 422 | — | The document could not be rendered: empty HTML, over the size limit, an unknown variant, or markup WeasyPrint refused. detail says which. |
| 429 | quota_exceeded | Monthly limit reached. |
| 503 | — | Storage was requested but is unavailable. |
A 422 is a problem with the document you sent and will fail
again identically — do not retry it. A 503 is ours.
Keys, plans and billing
POST /v1/signup issues a free key. It is what the form on the home page calls, and it expects a Turnstile token, so it is not a useful programmatic endpoint — get your first key from the signup form.
POST /v1/billing/checkout takes {"plan": "starter"}
and returns a Stripe Checkout URL. Send your existing key; the subscription is
attached to that account. Add {"interval": "year"} to be billed
yearly at ten months for twelve — the quota is unchanged and still resets every
month; yearly changes the invoice, not the allowance. A plan with no yearly
price is refused rather than quietly billed monthly.
curl https://api.wellform.dev/v1/billing/checkout \
-H "Authorization: Bearer $WELLFORM_KEY" \
-H "Content-Type: application/json" \
-d '{"plan":"starter"}'
POST /v1/billing/portal returns a Stripe billing portal URL for changing a card, downloading invoices, or cancelling. No body needed.
POST /v1/account/rotate replaces your key. It issues a new one and revokes the key that made the request, so the old key stops working immediately — there is no overlap. Use it the moment a key ends up somewhere it should not be: a commit, a screenshot, a support thread.
curl -X POST https://api.wellform.dev/v1/account/rotate \
-H "Authorization: Bearer $WELLFORM_KEY"
The new key is in the response and nowhere else. We store only a hash of it, so if you lose that response the key is gone and there is currently no self-serve way to recover it — write to support@wellform.dev. Rotation authenticates with the key it replaces, which means it answers "this key leaked", not "I lost my key".
POST /v1/account/recover is for when the key is gone
rather than merely compromised. It takes {"email": "...", "token": "..."}
where the token comes from a Turnstile challenge, and mails a single-use link
to the address the account was opened with. It always answers 202,
whether or not that address has an account — the endpoint will not tell a
stranger who our customers are.
Redeeming the link issues a new key and revokes every existing key on the account. "I lost my key" and "someone else has my key" look identical from here, and only one of those is safe to assume. Expect anything still running on an old key to stop.
GET /v1/health reports whether the renderer is up,
whether document storage is configured, and whether veraPDF is available in
the container answering you. Useful before a batch that relies on
store or on verify.
Limits
| Limit | Value |
|---|---|
| Maximum HTML per request | 5 MB |
| Stored document URL lifetime | 24 hours |
| External assets, free plan | Not permitted |
| Quota period | Calendar month, UTC |
| Render capacity, paid plans | Reserved — free traffic cannot reach it |
That last one is worth a sentence. Renders run on a pool of container instances, and free-tier requests are confined to a subset of it. Paid requests go to instances free traffic never touches, so no amount of free-tier load can put your render in a queue behind it. It is a partition, not a preference — the two sets do not overlap.
What it does not promise: a fixed latency number, or that paid requests never wait behind each other. It promises only that the free tier cannot be the reason you are waiting.
There is no fixed rate limit. Renders are metered per document rather than per second, and the pool scales — but if you are planning something unusual, write to support@wellform.dev first so we are not surprised together.
Checking a PDF you did not make
POST /v1/check takes a PDF body and returns what the file is and what veraPDF makes of it. No key, no account, no charge — it is the same verifier the paid path uses, pointed at a document Wellform did not produce.
curl -X POST "https://api.wellform.dev/v1/check?variant=pdf/ua-1" \
-H "Content-Type: application/pdf" \
--data-binary @invoice.pdf
The answer has two halves. document is what the file says about
itself — PDF version, whether it is tagged and marked, its language and title,
the PDF/A and PDF/UA claims in its XMP, and every embedded file with its
relationship, including whether the names match a known e-invoice profile.
verification is veraPDF's verdict against the profile you asked
for, with each failure named by clause and test number.
| Limit | Value |
|---|---|
| Requests | 15 per hour, per address |
| File size | 15 MB |
variant | Query parameter. Any supported variant; defaults to pdf/ua-1 |
This is the one endpoint a browser may call cross-origin: it takes no key,
so there is no credential for a page to leak. The rest of /v1
deliberately sends no CORS headers, because a key-authenticated endpoint that a
page can call is an invitation to put wf_live_… in front-end
JavaScript where every visitor can read it. Call those from your server, which
is where CORS does not apply.
An uploaded file is written to a temporary directory inside the container so veraPDF can open it, and that directory is deleted when the request returns. It is never written to object storage, never billed, and never associated with an account, because the endpoint does not know who you are. There is a browser version at wellform.dev/check that does the same thing without curl.
What conformance does and does not mean
Be precise about which verdict you are reading.
Wellform's preflight implements 16 Matterhorn checkpoints. The full PDF/UA-1 profile the reference implementation ships has 106 rules across 32 clauses, so the preflight is a fast pre-check, not a validator. Measured against veraPDF's own conformance corpus, it rejects 34 of the 155 documents that corpus defines as non-conformant. We publish that number because the alternative is letting you assume a larger one.
The reason it is useful anyway: Wellform renders your HTML itself, through a single engine. Most of what the preflight does not check — XFA forms, embedded file streams, exotic font encodings, annotation subtypes — cannot arise from that pipeline. It is tuned for the documents Wellform produces, which is not the same job as validating a PDF of unknown origin.
A conformant verdict from veraPDF is the stronger claim: it means the document passed every machine-checkable rule in the PDF/UA-1 profile, as judged by the implementation the standards community maintains. That is the verdict worth putting in front of an auditor.
It is not the whole standard, and the shortfall is measurable rather than vague. The PDF Association, which maintains the Matterhorn Protocol, puts it at 136 tests, of which 87 can be fully automated and 47 require human judgement (their guidance; counts vary slightly by protocol version). Roughly a third of the standard is outside the reach of any tool, ours included.
The 47 are the ones that turn on meaning: whether alternate text is accurate, whether reading order matches intent, whether a table's structure reflects what the table is actually saying. We report what we can verify and stay quiet about what we cannot.
Conformance is also not a legal opinion. Whether a document satisfies Section 508, EN 301 549, or the European Accessibility Act depends on how it is used and by whom, and that is a question for your counsel rather than your PDF generator. What we give you is evidence: a machine-readable report, attached to every document, that you can hand to an auditor.