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.

A failed job can be run again with POST /v1/jobs/{id}/retry. It works only while the job is failed, and only within 24 hours — the request data is kept that long; after that, submit it again. A finished job is never re-run: that would bill the same render twice.

POST /v1/create-async
# 1. enqueue the job
curl -X POST https://brewmypdf.com/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.com/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.

Uploading background images

Upload a background image with POST /v1/assets. The body is the file itself, with content-type image/jpeg or image/png. Put the asset_id from the response into page.backgrounds[].assetId in your template.

Note this is a different address from the PDF upload (POST /v1/files). That one is deleted after 24 hours; a background is part of the template and stays until you delete it. That is why the addresses differ — had we split it with a parameter, getting it wrong would break your templates the next morning while the response looked identical.

JPEG and PNG only. WebP, GIF and SVG are rejected. Up to 2MB each, and up to three backgrounds per document.

PNG files are carried through without being decompressed — that is fast and the file does not grow. But a PNG with transparency (an alpha channel) or interlacing has to be recompressed on our side, so we reject those once their pixels add up to more than 4 million. One A4 page at 200dpi is about 3.87 million. Upload a full-page form scanned at 300dpi as JPEG instead — there is no pixel limit on that path and the result is the same.

When passthrough in the response is false, that image is the kind that counts against the limit above. You learn it the moment you upload, so check it.

Delete with DELETE /v1/assets/{id}. Deleting something that is not there also succeeds — telling the two apart would tell you whether someone else has that asset. We never delete assets for you, so remove the ones you stop using.

A background is laid on every page and is always at the very bottom. You cannot target particular pages — we do not know the page count before rendering. To put something on top, use POST /v1/pdf/stamp. Background coordinates are measured against the whole sheet, unlike node coordinates which start inside the margin. Using a PDF as a background is not supported yet.

Locking the file

Add password to the request and the PDF will not open without it — for contracts, payslips and anything that must stay shut even if the file gets out. A download URL protects the delivery, not the file: once downloaded, anyone can open it.

Add owner_password too and opening with that one lifts every restriction. Leave it out and the open password doubles as the owner password, which makes the restrictions below meaningless — if you intend to restrict anything, make the two different.

permissions turns off print, copy, modify, annotate and fill individually. Omit it and everything is allowed. Careful with fill on a document that has form fields: turning it off makes those fields useless. Text extraction for screen readers is never blocked.

A password may be up to 127 bytes of UTF-8 — about 42 characters in Korean, where each one takes three bytes. Longer is refused rather than truncated, because a silently truncated password produces a file nobody can open, and there is no way back from that.

We do not keep your password. It arrives with the request and is deleted when the job finishes — so if you lose it, we cannot open the file for you either. Images (PNG and JPEG) cannot be locked, and asking for both is refused.

Working on PDFs you already have

You can also work on your own PDFs. Upload one with POST /v1/files — put the PDF straight in the body with content-type: application/pdf. The file_id in the response is what the operations below take. Uploads are deleted after 24 hours, and a file may be up to 10MB and 100 pages. Merging is capped at 20 files and 100 pages in total — the same line, because merging produces one file and that is what actually gets opened. The page limit is separate from the size limit on purpose: a 3,000-page PDF can be under 600KB, so size alone would let it through. And the limit is set by the reading end, not by us: a 1,000-page PDF our servers handle fine will still stall a phone. We would rather refuse it than hand you a file your recipient cannot open.

GET /v1/files/{id} tells you what the file is: how many pages, the size and rotation of each one, whether it is encrypted, whether it carries an XFA form (xfa: true — see the fields note below), and the names of any form fields inside. The size is what a viewer shows — the CropBox clipped to the MediaBox, normalized to positive numbers — so it matches the page on screen rather than a raw box from inside the file. Look at this before wiring anything up.

POST /v1/pdf/merge joins files in the order you list them, up to 20. POST /v1/pdf/stamp writes text onto a file: text is required, and you may add pages (1-based, all pages if omitted), x and y (points from the bottom-left of the page), size, opacity, rotate and color. A watermark and added text are the same thing with different options.

POST /v1/pdf/fields fills in form fields that already exist. Pass names and values in values; set flatten to true and the fields become part of the drawing, so nothing can change them afterwards — that is irreversible, so use it deliberately. A document with a signature space is never flattened: freezing it would remove the very place meant to be signed. Always read filled, missing and rejected in the response: names your document does not have, and values we could not write, are reported rather than passed over in silence.

What it will not do: stamped text is Latin-1 only — Korean, Chinese, Japanese and emoji are refused, because the standard fonts cannot draw them and refusing beats quietly stamping tofu. Encrypted PDFs cannot be operated on, since we cannot open them. A field with a list of options only accepts a value from that list. XFA forms (Adobe LiveCycle — common in government and banking paperwork) are refused outright: their fields look fillable but viewers render the XFA layer, so written values would silently not appear. None of these operations use a browser, so they do not count against your monthly render quota — only the rate limit applies.

Your account, over the API

GET /v1/account tells you where you stand before a limit tells you by refusing: your plan, renders used and remaining this month, template count against its cap, and a summary of this month's jobs (ok, failed, bytes). The numbers come from the same functions that enforce the limits — what this endpoint shows as remaining is exactly what the quota gate would still accept. It is read-only; plan changes go through the billing portal, and API keys are managed in the console only.

Activity log

Every action on your account is written to an append-only log — who did it, when, from where, and whether it succeeded. You read it in the console under Activity. Records move to our archive after 90 days rather than being deleted.

Each record carries a stable action code. The screen shows a plain-language name, but the code is what you branch on — it does not change when we reword the label. Records written by our own staff are marked as the BrewMyPDF team, so you can see whenever we open your account.

CodeMeaning
acct.billing.webhookBilling update
acct.team.acceptInvitation accepted
acct.team.inviteMember invited
acct.team.removeMember removed
acct.team.roleMember role changed
admin.account.activateAccount reactivated by the BrewMyPDF team
admin.account.eraseAccount deleted by the BrewMyPDF team
admin.account.listAccount listed by the BrewMyPDF team
admin.account.planPlan changed by the BrewMyPDF team
admin.account.suspendAccount suspended by the BrewMyPDF team
admin.account.viewAccount opened by the BrewMyPDF team
admin.job.retryRender retried by the BrewMyPDF team
admin.job.viewRender opened by the BrewMyPDF team
admin.usage.viewUsage opened by the BrewMyPDF team
api.limit.ip.setAPI IP allowlist changed
auth.apikey.issueAPI key created
auth.apikey.rotateAPI key rotated
auth.apikey.verifyAPI key used
auth.login.logoutSigned out
auth.login.submitSigned in
auth.password.changePassword changed
auth.reset.confirmPassword reset completed
auth.reset.requestPassword reset requested
auth.signup.formSigned up
auth.signup.verifyEmail verified
job.dlqRender failed for good
job.queue.consumeRender finished
job.queue.enqueueRender requested
legal.eraseAccount deleted
store.byo.deliverDelivered to your storage
store.eu.setData residency changed
tpl.crud.createTemplate created
tpl.crud.deleteTemplate deleted
tpl.crud.updateTemplate updated
tpl.version.restoreVersion restored

Teams

From the Professional plan you can invite people to your workspace in the console (Team menu): editors can change templates, viewers can only look and preview. Members reach your templates only — billing, API keys and settings stay with the owner, by design: those are the account's own credentials, and delegating them would erase the answer to "who paid" and "who leaked". Invitations go by email and must be accepted while signed in with the invited address. Everything a member does is recorded in the audit log under their own identity. There is no team API — managing people is deliberate work, done in the console.

Managing templates over the API

Templates are manageable over the API, so they can live in your repository and deploy like code. GET /v1/templates lists them (id, name, timestamps — pass ?limit up to 200); POST /v1/templates creates one from { name, doc } and returns template_id and version_id. The doc is the same document JSON the editor saves and the schema section below describes, and it is validated on write — a template that would fail at render time is refused at save time, with the violations listed in the response.

GET /v1/templates/{id} returns the template with its current doc and schema_version. PUT /v1/templates/{id} updates it: pass doc to write a new version, name to rename, or both — a PUT with neither is refused rather than pretending to succeed. Updating never overwrites: every doc you write becomes a new version and the template points at it, so nothing you rendered from is ever lost. DELETE /v1/templates/{id} is a soft delete — past renders keep their history, and the template stops appearing in lists.

GET /v1/templates/{id}/versions lists versions (without doc bodies — newest first, current marked). POST /v1/templates/{id}/versions/{vid}/restore makes an older version current again. Restore moves a pointer rather than copying, so it is instant and shows up in the version list as-is: that one call is your rollback.

These calls share your key's rate limits but do not consume render quota. Changes made with an API key are recorded in the audit log as the key, distinct from console edits made by a person. Template count limits from your plan apply to creation the same way they do in the console.

Embedding the editor

You can put our template editor inside your own product, without our branding and without your users needing a BrewMyPDF account. From your server, call POST /v1/embed/sessions with your API key and { template_id, mode, ttl_seconds } — mode is "edit" or "view", the lifetime defaults to 15 minutes and caps at an hour. The response carries a url; put it in an iframe. Never call this from the browser: the whole point is that your API key stays on your server and only the short-lived URL reaches the page.

Inside the session, saving writes a new version of that one template (audited as your API key), and previews render through the normal pipeline against your quota — an embedded render costs the same as any other. A "view" session cannot save. When the session expires, saving is refused with 403: open a new session from your server and load the URL again. Note the token rides in the URL, so treat the embed URL itself as a secret with a short life.

Delivering to your own bucket

From the Professional plan, the console (Settings → BYO) can copy every successful render into an S3-compatible bucket you own — Cloudflare R2 or AWS S3 — under an optional key prefix, named {job_id}.{ext}. Be clear about the contract: delivery is a copy, not a replacement. Our stored output and its signed URL keep working either way, and a delivery failure never fails the job — it is recorded in the audit log, and the next render simply tries again. Saving the configuration fires a test upload immediately, so bad credentials surface before your first real render, not after. The secret key is stored encrypted and never shown or returned again.

EU data residency

From the Premium plan, Settings → Data residency can pin your documents to the EU: with it on, rendered outputs and request payloads are stored in a Cloudflare R2 EU-jurisdiction bucket, and downloads are always served uncached. Be precise about the boundary: this covers document bodies (outputs and request data) created after the switch — objects stored before it are not moved, template assets and fonts remain global, and job metadata (status rows, audit entries) lives in our global database. Where the render itself executes is decided by the network edge and is not part of this guarantee.

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. One render produces at most 100 pages. Past that we reject the request with a 400 before rendering anything — we do not truncate, because a batch cut to 100 invoices looks complete and is worse than an error. Beyond pages there are two limits and they count different things. Table rows: one render draws at most 2,000 rows in total, and a single table at most 500 — several tables share the 2,000. Repeating blocks that start a new page (the same layout repeated per record, one page per copy): at most 100 per render — one copy is one page, so that is 100 pages. Blocks that do not start a new page do not count toward this. The two are separate budgets and do not eat each other — 100 invoices with 8 line items each is 100 blocks and 800 rows, both within limits. Anything beyond is truncated and reported in warnings. The limit comes from cost and predictability: without it a single document can multiply render time, and where it would be cut depends on how many columns you have, so you could not predict it. 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.

When rendering falls behind, we refuse new work — you get a 429 with a Retry-After header. Telling you now beats accepting the job and delivering it minutes late. Wait the Retry-After and send the same request again.

You can pin API access to your own servers: Settings → API IP allowlist in the console takes up to 20 IPs or CIDR ranges, and with a list in place any API-key request from outside it gets 403 — even with a valid key. Console sign-in is deliberately exempt, so a mistake in the list never locks you out of fixing it. The same rule covers the MCP endpoint.

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 your REST API key sent as Authorization: Bearer <key> — note that this is a different header from the REST API, which uses x-api-key. The key itself is the same one, so the agent is not a separate identity and 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.com/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.

Spreadsheet runs

GET /v1/templates/{id}/form?format=xlsx (or format=csv&sheet=doc) returns the same data form the console offers: one sheet per array level, row 1 headers, a hidden sheet (xlsx) or the file name (csv) carrying the template id and a 12-hex hash of the path set. That hash is what you send back when submitting.

POST /v1/intake/runs takes documents already folded into data objects — you parse the spreadsheet on your side — in chunks of up to 50: { template_id, hash, total, seq_start, docs: [{ key, data }], run_id }. Omit run_id on the first chunk; the response gives one. When seq_start + docs.length reaches total the run is queued. Each chunk goes through the same quota, backlog, budget and page checks as /v1/batch; if a chunk is refused the run is marked failed at that point and what was already submitted is still made.

GET /v1/intake/runs/{id} returns status (submitting, queued, done, partial, failed), counts, a signed url per finished document with its key, the failed documents with their error_code, and zip_url once a ZIP exists. POST /v1/intake/runs/{id}/zip builds that ZIP for a finished run — streamed to storage, up to 500 MB in total; beyond that you get 413 and download documents individually.

Errors: E:intake.serve#stale-form (409, the form no longer matches the template — fetch a new one), E:intake.serve#bad-chunk (400), E:intake.run#chunk-failed (the cause is inside: quota, budget or pages), E:intake.run#not-found (404), E:intake.run#zip-not-ready (409), E:intake.run#zip-too-large (413).

Building a connector

Zapier, Make, n8n and anything like them need the same five things, and all five are here. Test the key with GET /v1/account — it is cheap and has no side effects. Fill a template dropdown with GET /v1/templates. Draw the field-mapping UI with GET /v1/templates/{id}/schema. Render with POST /v1/create. Poll long jobs with GET /v1/jobs/{id}, or pass a webhook and be told.

The schema endpoint is the one worth knowing about. It returns paths (a flat list like data.customer and data.items[*].name) and schema (JSON Schema). The flat list is what you turn into input fields; the JSON Schema is there when you want types. Array paths end in [*] — one entry describes every element, so build one row of inputs and repeat it.

Two shapes catch people out. plan in the account response is an object ({ id, renders_per_month, templates_max }), not a string — a label built from it prints [object Object] if you forget. And a template with no expressions returns an empty paths list, which is correct: that template takes no data.

Use the same API key the customer already has. There is no separate connector identity, no OAuth, and no separate rate limit — quota, audit and limits stay on their account, whichever way they call us.

Errors

Every error carries a stable code. Match on the code, not on the message — messages get rewritten and translated.

CodeStatusMeaning
E:api.auth.key#unauthorized401No key, unknown key, or a revoked one.
E:api.auth.key#plan-required403The key is valid, but the account plan does not include API access. It starts on the Professional plan.
E:api.create#bad-json400The request body is not valid JSON.
E:api.create#missing-template400Neither template nor template_id was given.
E:api.export.mode#unsupported400export_type is neither "json" nor "file".
E:api.export.mode#not-sync400export_type "file" works only on POST /v1/create.
E:editor.doc.model#schema-mismatch400The template document violates the schema. The response lists what failed and where.
E:api.limit.payload#too-large413The request body is over the size limit.
E:api.rate.limit#exceeded429Rate limit exceeded. Wait for Retry-After.
E:api.limit.backlog#full429Rendering is backed up, so intake is paused. Wait the Retry-After and send it again.
E:api.job.retry#not-failed400Only a failed job can be run again.
E:api.job.retry#payload-expired400The request data has expired (24 hours) — submit it again.
E:acct.quota#exceeded402Monthly render quota exhausted. Upgrade or wait for the next period.
E:acct.quota#daily-exceeded402Free plan only — the daily render cap was reached. It resets at 00:00 UTC. Upgrading removes the daily cap; the monthly quota still applies.
E:api.job.route#not-found404No such job, or it belongs to another account. We do not tell the two apart.
E:job.webhook#bad-url400The webhook address is not https, is an internal address, or is too long.
E:render.pdf.password#bad-password400The password is not a string, or is longer than 127 bytes of UTF-8.
E:render.pdf.password#not-pdf400Images cannot be password protected.
E:render.pdf.password#failed400We could not apply the password. No file is produced — that is better than shipping an unprotected one.
E:render.pdfpage#too-large400The background image is over 2MB.
E:render.pdfpage#too-many400Up to 3 backgrounds per document.
E:render.pdfpage#too-many-pixels400PNGs with transparency or interlacing add up to more than 4 million pixels — upload a full-page form as JPEG instead.
E:render.pdfpage.png#not-png400Neither JPEG nor PNG. WebP, GIF and SVG cannot be used as a background.
E:render.pdfpage.png#bad-idat400The PNG is damaged — the image inside does not match the size it declares.
E:tpl.asset#not-found400No such asset. It was deleted, or it belongs to another account.
E:pdf.op.file#not-pdf400What you uploaded is not a PDF — the file does not start with %PDF-.
E:pdf.op.file#not-found400No such file. It expired after 24 hours, or it belongs to another account.
E:pdf.op.file#too-large400The uploaded PDF is over 10MB. Split it, or shrink it first.
E:pdf.op.merge#empty400files is empty. There is nothing to merge.
E:pdf.op.merge#too-many400You can merge up to 20 files at a time.
E:pdf.op.stamp#not-latin1400The text cannot be drawn with the standard fonts (Latin-1 only).
E:pdf.op.stamp#empty400text is empty. There is nothing to stamp.
E:pdf.op.stamp#too-long400The text to stamp is over 200 characters.
E:pdf.op.fields#empty400values is empty. There is nothing to fill.
E:pdf.op.fields#too-many400You can fill up to 500 fields at a time.
E:pdf.op.fields#encrypted400We cannot open a password-protected PDF, so we cannot fill its fields.
E:pdf.op.fields#xfa400This is an XFA form (Adobe LiveCycle). Values written into it would not appear in viewers, so we refuse instead of answering "filled". Check xfa in GET /v1/files/{id} before wiring up.
E:pdf.op.file#too-many-pages400The PDF has more pages than we accept (100 max). Split it and upload the parts.
E:render.pdf.merge#too-many-pages400The files add up to more than 100 pages. Merge fewer at a time.
E:api.template#not-found404No such template. It may belong to another account or have been deleted; we do not tell those apart.
E:api.template#nothing-to-update400The PUT has neither name nor doc. There is nothing to update.
E:tpl.crud#not-found404No such template. It may belong to another account or have been deleted; we do not tell those apart.
E:tpl.version#not-found404No such version on this template.
E:tpl.crud#invalid-doc400The template document violates the schema. The response lists what failed and where.
E:tpl.crud#missing-name400name is empty. A template needs one.
E:pdf.op.fields#flatten-signature400We do not flatten a document that has a signature field — flattening would remove the place to sign.
E:pdf.op.fields#flatten-failed400Flattening failed. Nothing was written, so call again without flatten.
POST /v1/create
curl -X POST https://brewmypdf.com/v1/create \
  -H "x-api-key: bmp_live_..." \
  -H "content-type: application/json" \
  --data-binary @invoice.json
RESPONSE
{
  "ok": true,
  "id": "01M0...",
  "format": "pdf",
  "url": "https://.../d/eyJ0...",
  "expiresAt": 1786884000
}