REST API
One base URL, one key, two ways to render. Send a template and data, get a PDF or an image back.
Authentication
Every /v1 request needs an API key in the x-api-key header. Keys are issued in the console and shown once — we store only a hash, so a lost key is replaced, not recovered.
A missing, wrong, or revoked key all return the same 401. We do not tell you which, because that difference is only useful to someone guessing.
Render now
POST /v1/create renders and answers in the same request. The response carries a signed download URL, not the file itself — so the same response works for a 20 KB label and a 40 MB report.
The URL expires. Download it when you get it; do not store it and do not cache it.
Render in the background
POST /v1/create-async returns a job id immediately and renders on a queue. Use it for large documents, or when the caller cannot wait.
GET /v1/jobs/{id} reports the state and, once done, the same signed URL.
# 1. enqueue the job
curl -X POST https://brewmypdf.brewmypdf.workers.dev/v1/create-async \
-H "x-api-key: bmp_live_..." \
-H "content-type: application/json" \
--data-binary @invoice.json
# 2. poll for the result
curl https://brewmypdf.brewmypdf.workers.dev/v1/jobs/01M0... \
-H "x-api-key: bmp_live_..."Many at once
POST /v1/batch takes an items array — up to 50 — and queues them together. Each item carries its own template or template_id and its own data, so one call can render a statement for every customer. The response is a list of job ids; collect each result from /v1/jobs/{id}. It never waits, because waiting on fifty renders would outlive the request.
Set merge to true and you get one PDF instead, with the documents joined in the order you sent them. That path is capped at 20, because the renders happen one after another inside a single job. It waits and answers with a download URL, like /v1/create does. Merging images is refused rather than quietly turned into a PDF.
Quota is checked once, for the whole batch, before anything is queued. A batch of fifty on an account with three renders left is refused outright — we would rather take none than take some, because a half-processed batch is awkward to refund and awkward to retry.
A merged file keeps each document’s own header and footer; we do not renumber the pages afterwards. Renumbering would claim it is one document, and it is not — it is several documents in one file. If you want continuous numbering, build one document with a repeating group instead.
Telling you when it is done
Add webhook to an async request or a batch and we POST to that address once, when the job finishes. It exists so you do not have to poll. Only https is accepted, and private or internal addresses are refused. The address is checked before anything is queued, so a bad one comes back as a 400 before it costs you a render.
The body is only this: {"job_id":"...","status":"done"} — with "status":"failed" and an error_code when it failed. There is no download URL in it. Our download URLs are themselves the permission, so putting one in a notification hands the document to whoever sees the notification. Fetch the URL afterwards with your own API key: GET /v1/jobs/{id}.
Which means the notification is not evidence. It is unsigned, and anyone can POST the same shape to your endpoint. Treat it as a nudge to go and look, and let GET /v1/jobs/{id} decide what is true. Make your handler idempotent on job_id too, since the same notification can arrive twice.
We try up to three times — after a failure, one second later, then two. Only 5xx and no-answer are retried; a 4xx is the same the second time, so we stop. Answer within five seconds, and note that we do not follow redirects. Return 200-299 straight away and do your work after that.
A failed notification does not fail the render: the file is there and GET /v1/jobs/{id} will hand it to you. The delivery outcome is kept on that same response as webhook_status — the HTTP status we got, or 0 if we could not reach you at all. No field means the job had no webhook. An unmerged batch is several jobs, so it sends one notification per job.
PNG and JPEG
The same template can come out as an image instead of a PDF. Add format to the request: "png" or "jpeg". Leave it out and you get a PDF, exactly as before. For JPEG you may also set quality, 1 to 100.
quality only applies to JPEG. Sending it with PNG is rejected rather than ignored — a silently ignored option looks like a bug on our side, and the renderer itself refuses that combination.
The image is the size of the page you designed, drawn at 2x so it stays sharp, and it keeps the page margins. Images and PDFs both count as one render against your monthly quota.
What an image cannot have: pages. A document that spans three printed pages becomes one tall image, because page breaks are a printing idea. For the same reason there is no header, no footer, and no page number — [[page]] and [[bpage]] are filled in while printing, and nothing is printing.
Limits
Rate limits are counted on three axes at once — key, account, and IP — because limiting only one of them is trivially bypassed by issuing a second key or calling from a second host.
Exceeding a limit returns 429 with a Retry-After header. Honour it; retrying immediately makes it worse.
Monthly render quota is separate and tracked per account. Running out returns 402, not 429 — the difference matters, because waiting fixes one and not the other.
Content limits are separate from rate limits, and they are the ones that surprise people. A bound array renders at most 500 rows unless you raise max; a single request expands at most 20,000 nodes; the whole document JSON must stay under 1 MB; inline images add up to 30 MB per request; and a template may nest at most 4 levels deep. When we cut something we say so — the response carries a warnings array naming the node and what happened.
Rendering has a 5 second budget inside the browser. A document that needs longer fails rather than hanging, and the queue does not retry it — a document that is too big now is too big on the second try too.
MCP server
An AI agent can drive brewmypdf directly. The MCP endpoint is /mcp, it speaks JSON-RPC 2.0 over HTTP, and it authenticates with the same x-api-key header as the REST API — the agent is not a separate identity, so quota and audit stay on your account.
Four tools are exposed: list_templates, describe_template_schema (what data a template expects), preview_template (render without spending render quota), and render_pdf (the same path as POST /v1/create).
Point any MCP client at https://brewmypdf.brewmypdf.workers.dev/mcp with your key. Generating a template from a description is a separate, credit-metered feature in the console; the MCP tools do not call a model.
Errors
Every error carries a stable code. Match on the code, not on the message — messages get rewritten and translated.
| Code | Status | Meaning |
|---|---|---|
E:api.auth.key#unauthorized | 401 | No key, unknown key, or a revoked one. |
E:api.create#bad-json | 400 | The request body is not valid JSON. |
E:api.create#missing-template | 400 | Neither template nor template_id was given. |
E:editor.doc.model#schema-mismatch | 400 | The template document violates the schema. The response lists what failed and where. |
E:api.limit.payload#too-large | 413 | The request body is over the size limit. |
E:api.rate.limit#exceeded | 429 | Rate limit exceeded. Wait for Retry-After. |
E:acct.quota#exceeded | 402 | Monthly render quota exhausted. Upgrade or wait for the next period. |
E:api.job.route#not-found | 404 | No such job, or it belongs to another account. We do not tell the two apart. |
E:job.webhook#bad-url | 400 | The webhook address is not https, is an internal address, or is too long. |