API documentation
Doc2api turns a PDF form into a JSON-fillable API. Upload a form once, then fill it (one at a time, in batches, or from a CSV), embed it, sign it, and receive results over signed webhooks. This is the complete REST + SDK reference.
Overview
Every request targets one template (an uploaded PDF) by id. The base URL is https://www.doc2api.co; endpoints live under /api/v1/templates/:id. Requests and responses are JSON unless the response is the finished PDF itself (application/pdf).
Create a template by uploading a PDF, image, or text file in the dashboard (or with no account). PDFs keep their AcroForm fields — every detected input opens in the editor as an editable box (move, resize, rename, or draw more; edits override the PDF's own fields under the same names); images/text convert to a flat PDF you draw fields onto. Each template gets three keys — see Authentication.
Quickstart
Fill a form and get the completed PDF back in one call:
curl -X POST 'https://www.doc2api.co/api/v1/templates/<TEMPLATE_ID>/fill' \
-H 'Content-Type: application/json' \
-H 'X-Api-Key: d2a_live_<your_fill_key>' \
-d '{ "data": { "full_name": "Ada Lovelace", "date": "2026-07-25" }, "flatten": true }' \
--output filled.pdfThe response body is the completed application/pdf. Send "pdf": "none" or a webhook_url to get JSON instead.
Authentication
Each template has three keys.
| Key | Sent as | Grants |
|---|---|---|
d2a_live_… fill | X-Api-Key | Secret, server-side. Fills via the API (/fill, /fill/batch); works on every endpoint. |
d2a_pub_… publishable | X-Api-Key / ?key= | Browser-safe. The embed only (load form + submit). Rejected by the server /fill API. |
d2a_admin_… admin | X-Admin-Key | Server-side. Manages audiences, logic, webhooks, and embed domains. |
Any key rotates independently from the template page; the old value stops working immediately.
One key for the whole workspace
Every template has its own fill key, which is right for a server that fills one form. It is wrong for anything that has to work across your whole account — an automation platform, a script that picks a template at runtime, an internal tool with a dropdown. For those, use the automation key on your dashboard. It starts with d2a_auto_.
It is deliberately narrow. It can identify your workspace, list your templates, and fill or render any of them. It cannot read a template's own keys, change a definition, alter webhooks or field rules, read submission history, or delete anything — so it is safe to paste into a third-party tool in a way an admin key is not.
# Does this key work, and whose is it?
curl https://www.doc2api.co/api/v1/me -H "X-Api-Key: d2a_auto_…"
# → { "workspace": { "name": "Acme" }, "plan": "pro",
# "documents": { "used": 412, "limit": 10000, "remaining": 9588 } }
# What can it fill? (ids + names, no keys)
curl https://www.doc2api.co/api/v1/templates -H "X-Api-Key: d2a_auto_…"
# Fill any of them with the same key
curl -X POST https://www.doc2api.co/api/v1/templates/<ID>/fill \
-H "X-Api-Key: d2a_auto_…" -H "Content-Type: application/json" \
-d '{"data":{"full_name":"Sarah Whitfield"}}'Rotating it from the dashboard invalidates the old one immediately — no grace period, because the reason to rotate is that somebody else has it. Live automations stop until the new key is pasted in.
Fill a PDF
POST /api/v1/templates/:id/fill — auth: fill key.
{
"data": { "field_name": "value", "consent": true },
"params": { "order_id": "A-1029" }, // your own values, sent to your webhooks
"flatten": true, // lock the result (default true)
"sign": false, // embed a PKCS#7 digital signature
"webhook_url": "https://…" // optional: POST the result instead of returning it
}| Field | Type | Description |
|---|---|---|
| data | object | Field name → value. Strings, booleans, numbers, or arrays (multi-select). |
| params | object | Your own values (order id, tenant, user…) delivered to your webhooks and stored with the record. Never written into the PDF. See Extra params. |
| flatten | boolean | Flatten (lock) the output. Default true. |
| sign | boolean | Digitally sign the final PDF (see Digital signatures). |
| webhook_url | string | If set, deliver the PDF as base64 JSON to this URL and return a summary. |
| preview | boolean | A test fill. Returns the real document but fires no webhooks, and does not count against your monthly documents — see Free test fills below. |
Returns the completed application/pdf (or a JSON delivery summary when webhooks are involved). Form logic and computed fields are enforced server-side. Unknown/invalid values return 422 with a details array.
Free test fills
Getting a template right means filling it a dozen times to see where the text lands. Send "preview": true and that does not come out of your monthly documents: the first 100 previews each month are free, on every plan including Free. They return the same document as a normal fill — no watermark, nothing missing — but they never fire your webhooks, so testing your own form cannot tell your production systems that paperwork exists when it does not.
Two response headers say what happened, so you never have to guess whether a call was billed:
| Field | Type | Description |
|---|---|---|
| X-Doc2api-Metered | string | "false" when the call was a free preview, "true" when it came off your monthly allowance. |
| X-Doc2api-Free-Previews-Left | string | How many free previews remain this month. Only present when the call was free. |
Past 100 a month, previews meter exactly like any other document — they keep working, they simply start counting. The dashboard's “Try it live” form sends preview, which is why testing a template there is free.
Per-field limits and formats
A text box can carry two rules of its own, set in the editor's field popover and enforced on every fill and submission. Max chars refuses a value longer than the limit — a box seeded from an AcroForm field inherits the PDF's own limit automatically. Validate checks the value against a named shape: email address, digits only, letters only, letters & digits, phone number, or web address. A value that fails either rule is a 422 naming the field and the rule, before anything is printed. Both rules appear in the template's JSON schema (maxLength and pattern), and the embedded form passes them to the input itself — the length stops at the keyboard, and phones show the matching keyboard for emails, digits and URLs. For rules beyond these — cross-field conditions, custom expressions — see Form logic.
Pictures and formatted text
Three field types on drawn (flat-PDF) templates take values that are not ordinary strings. All three appear in the template's JSON schema, and all three are drawn into the PDF exactly as the person filling the form saw them.
| Field | Type | Description |
|---|---|---|
| signature | string | A signature, drawn by hand on a pad. Drawn only — a signature that could be uploaded is a picture of a signature, which is what a photo field is for. The drawing is posted to your upload endpoint and the link it returns is the value. |
| photo | string | A picture the audience chooses from their device — a passport photograph, proof of address. Posted to your upload endpoint; the link it returns is the value. |
| richtext | string | Formatted text as HTML. Bold, italic, underline, strikethrough, links and bullets survive into the PDF. |
Draw all three in the visual editor. A photo field also takes a prompt — what its empty box asks for, so a form wanting three different pictures can say which is which; it reads + Photo if you leave it blank.
Where images go
A signature or photo arrives as a data:image/…;base64,… value, and that is already a working answer — it is drawn into the PDF and kept in the record exactly as sent. Give the field an upload endpoint of yours and we do one more thing first: POST the image to it and store the link it returns in place of the bytes. Optional, and worth doing — base64 records are large.
We make that request from our server, after the submission arrives. That matters more than it sounds. It used to come from the browser, which meant your endpoint had to publish Access-Control-Allow-Origin for the page the form was embedded in — and if it did not, the upload failed, the field would not fill, and a form somebody had completed could not be submitted at all. CORS is a browser rule about what page scripts may read. Server to server, it does not apply. Your endpoint needs no CORS headers.
What your endpoint receives
A POST with multipart/form-data. The file is under file — read it as $_FILES["file"] in PHP, or req.file with upload.single("file") in Express. Two ordinary text parts travel with it so you can file the image without parsing its name:
| Field | Type | Description |
|---|---|---|
| file | the image | The upload itself — PHP: $_FILES["file"]. Named for the field, e.g. passport_photo.png. |
| field | string | Which box it came from, e.g. "passport_photo" — PHP: $_POST["field"]. |
| template | string | The template id — PHP: $_POST["template"]. |
There is no API key on this request — it is your endpoint, so authenticate it however you like. A token in the URL is the usual answer, and the URL is no longer published to browsers at all.
What it must reply
{ "data": { "url": "https://your-cdn.com/uploads/abc.jpg" } }A receiver, in full:
<?php
// PHP
$file = $_FILES['file']; // the image
$field = $_POST['field'] ?? ''; // "passport_photo"
$template = $_POST['template'] ?? ''; // the template id
$name = bin2hex(random_bytes(16)) . '.' . pathinfo($file['name'], PATHINFO_EXTENSION);
move_uploaded_file($file['tmp_name'], "/var/www/uploads/$name");
header('Content-Type: application/json');
echo json_encode(['data' => ['url' => "https://your-cdn.com/uploads/$name"]]);// Node / Express
app.post('/uploads', upload.single('file'), (req, res) => {
const url = save(req.file, req.body.field, req.body.template);
res.json({ data: { url } });
});url, secure_url, location and link are all read, at the top level or under data. That link is what arrives in data.photo_field.
When it fails
Nothing is lost and nothing is refused. The image stays in the submission as the data URI it arrived as, and a line naming the field and the reason appears in warnings — in the API response, in the webhook payload, and stored on the submission itself. You can upload it yourself from there, or fix the endpoint and the next one will be stored properly.
{
"data": { "photo": "data:image/png;base64,iVBORw0KG…" },
"warnings": [
"photo: the upload endpoint answered 500: disk full. The image is kept in this submission as a data URI so nothing is lost — upload it yourself, or fix the endpoint and it will be stored there next time."
]
}This is deliberate. Refusing the submission would throw away a form somebody had already filled in, because of an outage on a server that is not ours. A large record is a smaller problem than a lost signature.
Test endpoint beside the field posts a small image and tells you exactly what came back, so you can confirm it works before a real one arrives. Endpoints are saved on your account, so a field offers the ones you already use and fills in your default.
What rich text may contain
The value is HTML, and it is reduced to a small inline allow-list before it is stored, delivered or drawn — strong, em, u, s, code, a, br, sub, sup, mark and the block tags p, ul, ol, li, blockquote. Scripts, event handlers, iframes, images and javascript: URLs are removed. This happens server-side on every fill, so it holds whether the value came from the embedded form or straight from your own POST.
Prefill a rich-text field and the markup is kept. Prefill an ordinary text field with the same HTML and it is shown as words instead — a value stored by an editor of your own no longer prints its tags onto the page.
How values are printed
Everything here is about the page, not the data. A drawn template decides how a value looks where it lands; what you send, what we store, and what comes back from the API and your webhooks is untouched by any of it.
Date formats
A date box on a printed form is rarely a place for 2026-04-20. It is captioned Day / Month / Year, or it is six separate little boxes wanting 20042026. Give a date field a format — a pattern in the tokens you already know from moment and date-fns — and that is how it prints:
| Field | Type | Description |
|---|---|---|
| yyyy / yy | year | 2026 / 26. A two-digit year reads 70–99 as last century. |
| MMMM / MMM | month name | April / Apr. |
| MM / M | month number | 04 / 4. |
| dd / d | day | 20 / 20 (M and d drop the leading zero). |
| EEEE / EEE | weekday | Monday / Mon. Also spelled dddd / ddd. |
So dd/MM/yyyy prints 20/04/2026, ddMMyyyy prints 20042026, and EEEE d MMMM yyyy prints Monday 20 April 2026. Punctuation between tokens is kept as written. Pick one in the visual editor or set it yourself:
{ "name": "date_of_acceptance", "type": "date", "dateFormat": "ddMMyyyy", "letterSpacing": 9 }The value itself never changes. Submissions, the API response and the webhook payload all carry the ISO date that arrived — 2026-04-20 — which is what a database will accept and sort. Only the ink is reformatted.
We only reformat a date we can read without guessing: ISO (2026-04-20, with or without a time) and compact 20260420. Anything else is printed exactly as sent, on purpose — 03/04/2026 is the third of April in London and the fourth of March in New York, and quietly picking one would put a wrong date on a signed form with nothing to show for it.
The one place we do read a formatted date is a prefill, because there the field's own pattern says which number is the day. A form whose date prints dd/MM/yyyy accepts "20/04/2026" as a prefill and shows it correctly; it still submits 2026-04-20.
Padding
Text sits 2 points in from the left of its box and centred top to bottom. That is usually right and occasionally not — a printed rule sits low in its rectangle, or a caption eats the first few millimetres. Rather than move the box away from the rectangle it is meant to line up with, inset the text inside it:
{ "name": "full_name", "type": "text", "padding": { "top": 4, "left": 8 } }| Field | Type | Description |
|---|---|---|
| padding.top | number | Points from the top edge. Default 0. Pushes a single line down as it recentres, and moves the first line of a multiline field. |
| padding.right | number | Points from the right edge. Default 2. Narrows the wrap width, and holds right-to-left text off the edge. |
| padding.bottom | number | Points from the bottom edge. Default 0. Lifts a single line as it recentres. |
| padding.left | number | Points from the left edge. Default 2 — the original inset, so a field without padding does not move. |
Any side you leave out keeps its default, so { "left": 8 } changes the left inset and nothing else. Padding applies to the text-bearing fields — text, multiline, number, date and rich text.
The visual editor draws a faint sample value in every box, in the real font, size, case, tracking and padding, positioned by the same arithmetic the renderer uses. It is the quickest way to see whether the characters will land inside a comb before you spend a fill finding out. Toggle it with Show / hide sample text.
The form shows it too
Tracking, padding and the date pattern all apply to the embedded form as somebody fills it in, not only to the finished PDF. An NHS number typed into one box used to come back spread across ten printed cells, and a date shown as 12/08/2026 came back as 120826 — the person signing found out when they opened the download. Now the control looks like the page while they type.
One browser limitation worth knowing: a native date picker draws its text in the viewer's own locale format and cannot be told otherwise. So when a date field has a pattern we render the formatted value ourselves and keep the real picker over it with its text transparent — the calendar button, the keyboard behaviour and the screen-reader label are all still the browser's.
Watermarks
Any template can carry a stamp drawn diagonally across every page of every fill — DRAFT, PAID, CONFIDENTIAL, a caseworker's name. Set it in the dashboard, or with the admin key:
PUT https://www.doc2api.co/api/v1/templates/<TEMPLATE_ID>/watermark
{ "watermark": { "text": "DRAFT", "color": "#0f172a", "opacity": 0.12, "fontSize": 64 } }{ "watermark": null } removes it, and so does an empty text. It applies to the API, the embedded form and the dashboard's test fill alike, and is drawn over the values rather than under them — a stamp a long answer covers up is not a stamp.
Batch & CSV fill
POST /api/v1/templates/:id/fill/batch — auth: fill key. Fill up to 200 records in one call; each row is metered as one document.
JSON
{
"rows": [ { "name": "Ada" }, { "name": "Alan" } ],
"flatten": true,
"sign": false,
"format": "json" // "json" (default) or "combined"
}json → { count, succeeded, failed, results: [ { index, filename, pdf_base64, warnings } | { index, error } ] } — per-row errors are isolated. combined → a single application/pdf with every row's pages concatenated (headers X-Doc2api-Batch-Count / X-Doc2api-Batch-Errors).
CSV
POST a CSV body with Content-Type: text/csv: the header row names the fields. Options come from query params.
curl -X POST 'https://www.doc2api.co/api/v1/templates/<ID>/fill/batch?format=combined&sign=true' \
-H 'X-Api-Key: d2a_live_…' -H 'Content-Type: text/csv' \
--data-binary $'name,date\nAda,2026-01-01\nAlan,2026-06-02' --output out.pdfDocument builder
Design a document once — headings, tables, repeating sections — then POST JSON at it and get the finished PDF back. Where the rest of Doc2api fills a form you uploaded, this builds the page itself, so there is no PDF to find in the first place.
Included from the Pro plan. A design counts against the same document limit as an uploaded template — they're both templates you own.
Making one
From the dashboard, choose Start from a blank page. Drag elements onto the page, and drop them where you want them in the flow. The panel on the right shows the real rendered PDF beside the canvas, because the canvas is an HTML approximation and an approximation that quietly disagrees with the output is worse than none.
Elements land in the flow rather than at fixed coordinates. That is deliberate: a table pinned to an x/y can't grow past the bottom of the page, so pagination and repeating sections would stop working the moment anything was positioned freely.
Static and dynamic values
Any value becomes dynamic by containing a {{placeholder}}. There is no mode to switch — an element is dynamic because it references data, which is the thing that is actually true about it.
Static: "Invoice"
Dynamic: "Invoice {{invoice_ref}}"
Nested: "Billed to {{customer.name}}"A placeholder with no matching value renders as nothing, rather than printing {{invoice_ref}} into a customer's invoice.
Repeating sections and tables
A table bound to an array covers line items. Plenty of documents repeat something that isn't a row — a block per employee, a section per property — so repetition is its own container:
{
"type": "repeat",
"each": "{{properties}}",
"elements": [
{ "type": "heading", "value": "{{address}}", "level": 2 },
{ "type": "table", "rows": "{{rooms}}",
"columns": [{ "header": "Room", "value": "{{name}}" }] }
]
}Inside a repeat, a placeholder resolves against the current item first and falls back outward to the document root. So {{address}} is the item's own field while {{company.name}} still reaches the top level — you don't have to copy shared values into every array element. Repeats nest, which is how the table above is bound to an array on each property.
Tables paginate on their own. A long one flows onto the next page and its header repeats there; a row that would be split across the break is pushed whole onto the next page instead.
Elements
| Field | Type | Description |
|---|---|---|
| text | value | A paragraph. Wraps and flows. |
| heading | value, level | Levels 1–3. |
| table | rows, columns | `rows` is a placeholder naming an array. Each column has a header, a value, an alignment and a format. |
| repeat | each, elements | Renders its children once per array item. Nests. |
| list | items, ordered | Bulleted or numbered, from an array or fixed strings. |
| image | src, width | `src` is a URL, usually a placeholder. A missing image leaves the space blank rather than failing the render. |
| box | elements, style | A container with padding, background and border. |
| divider | style | A horizontal rule. |
| spacer | height | Vertical space, in points. |
| pageBreak | — | Starts a new page. |
| pageNumber | format | Repeats on every page. `{{page}}` and `{{total}}` are substituted at render time. |
Column formats: text, currency, number, date. Limits are 500 elements per design, 12 columns per table, and 5,000 rows or repeat items per render.
The shape to send
The schema is derived from the design's placeholders every time you save, so what we document can never drift from what the design reads. Ask for it rather than working it out:
curl https://www.doc2api.co/api/v1/documents/{id}/schema \
-H "Authorization: Bearer d2a_live_…"Scope is respected: a {{address}} inside a repeat over {{properties}} is described as properties[].address, not as a top-level field.
Rendering
POST /api/v1/documents/{id}/render
Authorization: Bearer d2a_live_…
Idempotency-Key: 9f8e7d6c-…
{
"data": {
"invoice_ref": "INV-0042",
"customer": { "name": "Acme Ltd" },
"line_items": [
{ "description": "Consulting", "qty": 3, "amount": 1250 }
]
},
"params": { "job_ref": "JOB-77" },
"webhook": { "url": "https://your-server.com/hooks/pdf", "secret": "whsec_…" }
}{
"event": "render",
"document_id": "3f0c…",
"pdf_url": "https://…/invoice.pdf?token=…",
"pdf_bytes": 18402,
"pdf_url_expires_at": "2026-07-30T09:14:22.000Z",
"sha256": "41ac…",
"params": { "job_ref": "JOB-77" },
"created_at": "2026-07-29T09:14:22.000Z"
}The PDF comes back as a link, not base64 — inlining a document breaks receivers sitting behind a default body limit. It is good for one day; fetch it and keep it on your own infrastructure. Pass a webhook and the same payload is POSTed to you, signed exactly like every other Doc2api webhook (see verifying the signature).
Send an Idempotency-Key on anything you might retry: a repeat with the same key returns the first result rather than rendering, metering and delivering again. Data of the wrong shape — a string where an array belongs — is a 422 naming the problem, not a 500.
A standing endpoint
Rather than passing a webhook on every request, give the document one endpoint and every render goes to it — set it on the Api tab of the editor, or over the API:
PUT /api/v1/documents/{id}/webhooks
X-Admin-Key: d2a_admin_…
{ "webhook": { "name": "EHR", "url": "https://ehr.example.com/hook" } }The admin key, not the render key: a key you hand an integrator so they can render shouldn't be able to change where the output goes. One endpoint per document, as for templates — send "webhook": null to remove it, echo back the id you were given to keep the signing secret when you change the URL, and GET the same path to read it back. A webhook on a single render is delivered as well as the standing one, not instead of it.
Either way the delivery is durable: failures are retried on the same backoff as a template's, and every attempt shows up under Webhook deliveries on the document, with the exact body sent and a Retry now button.
What is kept
One rule across the whole product: anything you made is kept until you delete it, and anything we generated from your data lasts a day. So the design stays; the rendered PDFs don't.
Limits
| Field | Type | Description |
|---|---|---|
| Designs | Your plan's document limit | Shared with uploaded templates — both are templates you own |
| Renders | Your monthly document allowance | A render meters the same as a fill |
| Rate | 60 renders / minute | Per document |
| Rows | 5,000 | Per table, and per repeat |
| Elements | 500 | Per design |
Digital signatures
Add "sign": true to /fill, /submissions, or /fill/batch to embed a PKCS#7 (PAdES-style) signature in the document. Any change after signing invalidates it, and PDF viewers show a signature. Signing runs last, over the final bytes.
The signature is Doc2api's, not the form-filler's. It attests that we produced the document and that nothing has changed since — it does not identify the person who filled it in. For that, use a draw-to-sign field, which captures their actual mark. The two are independent and can be combined.
Signing uses a single platform certificate, so your private keys are never involved. That certificate is currently self-signed, which means a viewer verifies the document is unaltered but reports the signer's identity as unverified — Acrobat shows “signature validity is unknown” rather than a green tick. If you need a signature that reads as trusted to a third party such as an insurer or a court, email support@doc2api.co before relying on this.
Embed SDK
Drop the SDK in and users fill the real PDF in their browser — signatures included. Uses the publishable key.
<div id="pdf-form"></div>
<script src="https://www.doc2api.co/sdk/v1.js"></script>
<script>
Doc2api.render({
container: "#pdf-form",
templateId: "<TEMPLATE_ID>",
apiKey: "d2a_pub_<publishable_key>",
pdf: "base64", // "none" | "base64" | "download"
profile: "patient", // apply this audience's rules
prefill: { patient_name: "Ada" }, // pre-populate fields
params: { order_id: "A-1029" }, // passed through to your webhooks
paged: true, // multi-page: step one page at a time
mode: "edit", // "edit" (default) or "preview" (read-only)
height: 600, // obey a height (see Sizing below)
signatureFields: ["signature"], // draw-to-sign these fields
submitText: "Sign & submit", // submit button label
submitColor: "#16a34a", // hex, or ".primary" / ".secondary" / …
branding: false, // hide "Powered by Doc2api" (Pro/Business)
brandingText: "Powered by Acme", // custom footer text (Pro/Business)
onSubmit: function (result) {
// result.data = filled values (JSON); result.pdfBase64 = completed PDF
},
onError: function (err) { console.error(err); },
});
</script>| Field | Type | Description |
|---|---|---|
| templateId | string | The template to embed. Required. |
| apiKey | string | The publishable key (d2a_pub_). |
| string | "none" (values only), "base64" (return PDF), or "download". | |
| profile | string | Audience name — applies its field rules (hide/require/preset). |
| prefill | object | Field → value to pre-populate (coerced to the field type). HTML reaching an ordinary text field is shown as its text — a value your own editor stored no longer prints its tags onto the page. A rich text field keeps the markup. A date takes ISO (2026-04-20), or the format that field prints in — a dd/MM/yyyy field accepts "20/04/2026". Either way the value submitted is ISO. |
| params | object | Your own values passed through to your webhooks with the submission (order id, tenant, signed-in user). Not written into the PDF. See Extra params. |
| paged | boolean | Multi-page: step through pages with Back/Next, validating each page's required fields. Pages hidden by logic or audience rules are skipped. |
| mode | string | "edit" (default) fills the form; "preview" locks every input and the action becomes "Download PDF". See Modes. |
| height | number | string | Make the form obey a height: 600, "600px", "80vh", or "100%" to fill a container that has its own height. Omit for auto-height. See Sizing. |
| fit | string | "height" fills the given height with the pages scrolling inside; "page" also scales each page so a whole page fits. See Sizing. |
| maxPageWidth | number | Raises the 860px page-rasterisation cap so a wide container renders a larger page (up to 2000). See Sizing. |
| signatureFields | string[] | Text fields to render a draw-to-sign pad for. A drawn template's own signature fields already show one — this is for AcroForm text fields you want signed. |
| submitText / submitColor | string | Customize the submit button label & colour. See Button customization. |
| nextText / nextColor, backText / backColor | string | The paged: true navigation buttons. |
| downloadText / downloadColor, goBackText / goBackColor | string | The two buttons on the thank-you screen ("Download your copy" and "Go back"). |
| successTitle / successMessage | string | Wording of the thank-you screen after a submission. See After submitting. |
| autoScroll | boolean | Set false to stop the form scrolling itself into view after a submission or a page change. See After submitting. |
| onBack | function | Called when "Go back" is clicked; replaces the default history.back(). |
| branding / brandingText | boolean / string | Hide or relabel the footer (Pro & Business only). |
| onSubmit / onError | function | Callbacks; onSubmit gets { data, warnings, pdfBase64 }. |
| onPreview | function | Fired instead of onSubmit in mode: "preview"; gets { data, pdfBase64 }. |
Sizing — width, height & responsiveness
Width is responsive out of the box. The form fills 100% of the container you point it at, and the page image is re-rasterised whenever that width changes — resize the window, drop it in a flex or grid cell, or put it in a phone-width column and it follows. You don’t need to configure anything.
Height is content-driven by default. The form is as tall as the document and we grow the iframe to match, which is why a height on your own <div> appears to be ignored. Pass height (or fit) and the form obeys the box instead: the pages scroll inside it and the action bar stays pinned to the bottom edge.
| Field | Type | Description |
|---|---|---|
| (nothing) | auto height | Default. The iframe grows to the document; your container's height isn’t used. |
| height: 600 | fixed | Pixels. Also accepts any CSS length as a string — "600px", "80vh", "40rem". |
| height: "100%" | fill the container | Obeys the height of your own element, which must have one. If the container is auto-height we fall back to auto and warn in the console, rather than collapsing the form to nothing. |
| fit: "height" | scroll inside | Implied by height. The page area scrolls; the action bar is pinned. |
| fit: "page" | whole page visible | Scales each page down so a full page fits the height: a document viewer rather than a scrolling form. Best with paged: true. |
| maxPageWidth: 1200 | sharper on wide layouts | Pages rasterise up to 860px wide by default; raise it (max 2000) when the container is genuinely wider. |
<!-- Fill a sized container: the form obeys both dimensions -->
<div id="pdf-form" style="width: 100%; max-width: 900px; height: 70vh"></div>
<script>
Doc2api.render({
container: "#pdf-form",
templateId: "<TEMPLATE_ID>",
apiKey: "d2a_pub_<publishable_key>",
height: "100%", // obey the container's height
fit: "page", // and scale each page to fit it
paged: true,
});
</script>Sizing is independent of mode and paged: a read-only preview in a fixed-height panel is just mode: "preview" plus height. The iframe is only ever as wide as its container, so a form never causes horizontal page scroll.
Button customization
Every button in the embed takes an optional label and an optional colour. Each one is independent and each falls back to its default, so you can restyle one button and leave the rest alone.
| Field | Type | Description |
|---|---|---|
| submitText / submitColor | "Submit form" · .primary | The action button that submits the form (or downloads in preview mode). |
| nextText / nextColor | "Next" · .primary | Only with paged: true. Falls back to submitColor when that’s set, so the whole flow matches by default. |
| backText / backColor | "Back" · .secondary | Only with paged: true. |
| downloadText / downloadColor | "Download your copy" · .primary | Thank-you screen; shown when pdf is "base64". Falls back to submitColor. |
| goBackText / goBackColor | "Go back" · .secondary | Thank-you screen. See onBack for the action. |
A colour is either a hex value — #16a34a, #fff, #16a34acc, or one of the built-in variant classes: .primary, .secondary, .ghost, .outline, .success, .danger, .dark, .link. With a hex value the label flips to white or near-black automatically, whichever stays readable on it. Anything we don’t recognise falls back to that button’s default variant, so a typo can never produce an invisible button.
Variants exist because the form renders inside an iframe on our origin — your stylesheet can’t reach into it, so a class name only means something if we ship it. Use .secondary-style names to match a design system loosely, or hex values to match it exactly.
onBack replaces what “Go back” does. Pass a function and the embed calls it instead of navigating — route your SPA, close a modal, whatever fits. With no handler the button runs history.back(), and when there’s nowhere to go back to (the usual case for a freshly-loaded iframe) it returns to the filled form.
Doc2api.render({
container: "#pdf-form",
templateId: "<TEMPLATE_ID>",
apiKey: "d2a_pub_<publishable_key>",
pdf: "base64",
paged: true,
submitText: "Sign & submit", submitColor: "#16a34a", // hex
nextText: "Continue", nextColor: ".dark", // built-in variant
backText: "Previous", backColor: ".ghost",
downloadText: "Save my PDF", downloadColor: ".success",
goBackText: "Back to my account",
goBackColor: ".secondary",
onBack: function () {
window.location.href = "/account/forms"; // instead of history.back()
},
});After submitting
When a submission succeeds the form swaps itself for a short confirmation. All of it is yours to change:
| Field | Type | Description |
|---|---|---|
| successTitle | "Form submitted" | The heading. |
| successMessage | "Thank you — your responses have been recorded." | The line underneath. Say what happens next: an email is coming, someone will call, the claim is filed. |
| downloadText / downloadColor | "Download your copy" | Only appears when pdf is "base64". |
| goBackText / goBackColor | "Go back" | Pair it with onBack to send the reader wherever makes sense in your app. |
Doc2api.render({
container: "#pdf-form",
templateId: "<TEMPLATE_ID>",
apiKey: "d2a_pub_<publishable_key>",
pdf: "base64",
successTitle: "Thanks, we've got it",
successMessage: "Your claim reference is on its way to your email.",
downloadText: "Save a copy",
goBackText: "Back to my account",
onBack: function () { window.location.href = "/account"; },
});The form also scrolls itself back into view at that point, and when you move between pages with paged: true. Submitting from the bottom of a long document otherwise leaves the reader looking at empty space, with the confirmation somewhere above them. Pass autoScroll: false if your page manages scrolling itself.
Modes — fill vs. read-only preview
mode switches the embed between filling a form and showing a finished document. It’s a display setting layered on top of everything else — it never loosens an audience’s rules.
| Field | Type | Description |
|---|---|---|
| mode: "edit" | default | The normal fill experience. Every field the audience allows is editable; audience-hidden fields stay hidden and audience-readonly fields stay locked. The action button submits the form. |
| mode: "preview" | read-only | Every input is disabled, including fields the audience would allow, and required fields no longer block Back/Next. The action button reads "Download PDF" and returns the filled document instead of submitting. |
Audience rules always win. mode: "edit" doesn’t override a profile: a field that audience makes read-only or hidden stays that way. mode: "preview" only ever locks more, never less, so a preview of a profile shows exactly what that audience sees.
Where the button appears. With paged: true the “Download PDF” button replaces “Submit form” on the last page only — earlier pages keep Back/Next so the reader can page through the document. On a single-page form (or paged off) it shows straight away.
What preview does on the server. The PDF is generated for real, so it counts toward your monthly document quota and is recorded in your submissions history, but webhooks aren’t fired, because nothing was actually submitted. Combine it with prefill to render a completed copy for the user to check or keep:
Doc2api.render({
container: "#pdf-form",
templateId: "<TEMPLATE_ID>",
apiKey: "d2a_pub_<publishable_key>",
mode: "preview", // read-only
paged: true, // "Download PDF" on the last page
profile: "patient", // still enforced
prefill: { patient_name: "Ada", dob: "1815-12-10" },
submitText: "Download my copy", // optional: rename the action
onPreview: function (result) {
// result.data = the values shown; result.pdfBase64 = the generated PDF
},
});Hosted link (no code): open https://www.doc2api.co/embed/:id?key=<pub>&pdf=download directly, adding &profile= to apply an audience and &mode=preview for the read-only view.
Submissions endpoint
POST /api/v1/templates/:id/submissions — auth: fill or publishable key. This is what the embed calls: it accepts field values (any value may be a PNG/JPEG data URL, stamped as a drawn signature) and returns the values plus the completed PDF. Every submission is recorded in your history.
{
"data": { "full_name": "Ada", "signature": "data:image/png;base64,…" },
"profile": "patient", // enforce this audience's rules
"pdf": "base64", // "base64" | "none"
"flatten": true,
"sign": false
}The dashboard's history shows when each record expires and exports any selection as JSON or CSV — records are pruned on your plan's retention window, so an export before then is the only copy you keep. A drawn signature or an uploaded photo is recorded as a placeholder, never the image.
Reading them back
GET /api/v1/templates/:id/submissions — auth: admin key. Newest first. It is the admin key rather than the fill key on purpose: reading everyone's submitted answers is a far larger privilege than filling a form, and the fill key is the one that ends up in embed snippets.
| Field | Type | Description |
|---|---|---|
| limit | number | 1–200, default 50. |
| before | ISO 8601 | Only records older than this — the cursor for the next page. Use next_before from the previous reply rather than an offset, which shifts as new records arrive. |
| since | ISO 8601 | Only records at or after this instant. |
| source | string | "fill" for the API, "submission" for the embedded form. |
| profile | string | An audience name, or "none" for records made without one. |
curl 'https://www.doc2api.co/api/v1/templates/<TEMPLATE_ID>/submissions?source=submission&limit=100' \
-H 'X-Admin-Key: d2a_admin_<your_admin_key>'{
"submissions": [
{ "id": "…", "source": "submission", "profile": "patient",
"data": { "full_name": "Ada" }, "params": {}, "warnings": [],
"filename": "form-filled.pdf", "createdAt": "2026-08-03T17:23:25.996Z" }
],
"total": 412,
"next_before": "2026-08-03T17:23:25.996Z"
}Bounded by your plan's retention window, because it reads the same store the dashboard does — nothing here brings back history that retention has already pruned.
Audiences & field rules
Split one form between audiences — hide the practice-only section from patients, make consent compulsory, or preset values. PUT /api/v1/templates/:id/profiles — auth: admin key. Rules are enforced by the API on every submission (restricted fields rejected, required fields checked, presets applied).
{
"profiles": [
{
"name": "patient",
"hidden": ["practitioner_notes"],
"readonly": ["practice_name"],
"required": ["consent"],
"presets": { "practice_name": "Acme Dental" }
}
]
}Apply one with "profile": "patient" on a submission, or ?profile=patient on the embed / hosted link.
Read-only vs hidden vs preset
The three do different jobs, and the difference decides what ends up in the document:
| Field | Type | Description |
|---|---|---|
| hidden | never sent, never accepted | The field isn't rendered for this audience and the API rejects any value for it with a 422. Use it for anything this audience must not see or set. |
| readonly | shown, not typeable, still submitted | The person filling the form can't edit it, but it's on the page and its value — usually from your prefill — is sent and written into the document. Use it to show someone a value they shouldn't change. |
| preset | fixed by you, always wins | Applied server-side after the submission, so it overrides whatever arrived. Use it when the value must be a particular thing no matter what the browser sends. |
So if you prefill a read-only field, that value reaches your webhook and the finished PDF. If you need it locked down rather than merely uneditable, give the rule a value as well and the preset takes over.
Hiding whole pages from an audience
Add hiddenPages (1-based page numbers) to a profile. Every field on those pages is hidden and rejected on submission, exactly like access: "hidden", and the pages don't render in the embed. You can't hide every page.
{ "profiles": [ { "name": "patient", "rules": {}, "hiddenPages": [3, 4] } ] }Form logic
Dependent fields and computed values. PUT /api/v1/templates/:id/logic — auth: admin key. Evaluated live in the embed and enforced by the API, so computed fields can't be spoofed.
{
"logic": [
{ "type": "show", "target": "spouse_name", "when": "married == true" },
{ "type": "require", "target": "reason", "when": "amount > 1000" },
{ "type": "compute", "target": "line_total", "expr": "quantity * unit_price" }
]
}Expressions support + - * /, comparisons, and/or/not, and {field} references — evaluated by a safe engine (no eval).
Multi-page: skipping a whole page
Target a page with page:N (1-based) and a visibleIf clause. Fields on a hidden page are treated as hidden everywhere: never rendered, never required, and their values are dropped server-side, so a skipped section can't be smuggled in.
{
"logic": [
{ "target": "page:2", "visibleIf": "{needs_details} == \"yes\"" }
]
}Page targets only support visibleIf (put requiredIf/computed on individual fields). In the embed, hidden pages drop out of the flow — with paged: true the page counter adjusts live (“Page 1 of 2” → “Page 1 of 3”).
Webhooks
Deliver every completed PDF and its values to your server. PUT /api/v1/templates/:id/webhooks — auth: admin key. The endpoint gets a signing secret and subscribes to fill and/or submission events. Failed deliveries are retried with exponential backoff(1m → 5m → 30m → 2h → 6h); see delivery & retries and the delivery log on the template page.
{
"webhook": {
"name": "EHR",
"url": "https://ehr.example.com/hook",
"events": ["fill", "submission"],
"enabled": true
}
}One object, because a document has one endpoint. Send "webhook": null to remove it. Echo back the id you were given when you change the URL and the signing secret stays as it is; omit it and you get a new secret. GET the same path to read it back.
The older "webhooks": [ … ] array is still accepted, and every response includes both webhook and webhooks, so nothing integrated before this needs changing.
One endpoint per document
A document delivers to exactly one endpoint. Sending the same payload to two places from a single document turned out to mean duplicate deliveries far more often than a real second destination, and it made “did it arrive?” harder to answer than it should be. Saving a second one returns 422.
To deliver somewhere else you have two options:
- Change the URL. Edit the endpoint and save. Anything already queued for retry follows it to the new address, so nothing in flight is lost.
- Duplicate the document. Each copy is its own template with its own keys, its own endpoint and secret, and its own delivery log. That is the route when a second system genuinely needs the same document.
If one payload has to reach several of your internal services, the usual answer is a single receiver that fans out on your side. It keeps the signature check, the retry behaviour and the audit trail in one place.
What we send
Every delivery is a POST with Content-Type: application/json and a single JSON object. The event field tells you which kind it’s, and the PDF is never inlined, you get a download URL instead, and that file only lives on our servers for one day, so download it when the webhook arrives (see Fetching the PDF).
submission — someone completed an embedded form
{
"event": "submission",
"template_id": "8f14e45f-…",
"template_name": "Patient consent form",
"profile": "patient", // only when an audience was applied
"submitted_at": "2026-07-25T20:41:12.884Z",
"params": { "order_id": "A-1029" }, // only when the caller sent params
"data": { // every field of the finished document
"full_name": "Ada Lovelace",
"dob": "1815-12-10",
"consent": true,
"treatments": ["exam", "x-ray"], // multi-selects are arrays
"total_due": "45.00", // computed fields hold the server's value
"signature": "signed" // drawn signatures are stamped, not returned
},
"warnings": [], // values that could not be placed
"filename": "patient-consent-form-filled.pdf",
"pdf_url": "https://…/deliveries/…/patient-consent-form-filled.pdf?token=…",
"pdf_bytes": 155640,
"pdf_url_expires_at": "2026-07-26T20:41:13.104Z"
}fill: a document was filled through the API
{
"event": "fill",
"template_id": "8f14e45f-…",
"template_name": "Patient consent form",
"filename": "patient-consent-form-filled.pdf",
"pdf_url": "https://…/deliveries/…/patient-consent-form-filled.pdf?token=…",
"pdf_bytes": 155640,
"pdf_url_expires_at": "2026-07-26T20:41:13.104Z",
"warnings": []
}| Field | Type | Description |
|---|---|---|
| event | string | "submission" or "fill": the same field the X-Doc2api-Event header carries. |
| template_id / template_name | string | Which template produced the document. |
| profile | string? | submission only, and only when the form was rendered for an audience. |
| params | object? | The extra params the caller sent, verbatim. Omitted when there were none. See Extra params. |
| submitted_at | string | submission only. ISO 8601, UTC. |
| data | object | submission only. Every field of the finished document: typed answers, prefilled values, audience presets, and server-computed fields. Blank fields are present and empty; fields hidden by logic or by the audience are omitted. |
| warnings | string[] | Values the PDF could not accept (e.g. text longer than the field). Empty on a clean run. |
| filename | string | Suggested filename, matching what the API returns. |
| pdf_url | string? | Signed download link for the completed PDF. Valid for 24 hours, after which the file is deleted from our servers — see Fetching the PDF. No API key needed, so treat the URL as a secret. Absent only if storage was unavailable at delivery time. |
| pdf_bytes | number? | Size of the PDF behind pdf_url. |
| pdf_url_expires_at | string? | When pdf_url stops working and the file is deleted, one day after delivery (ISO 8601). |
Fetching the PDF: you’ve one day
pdf_url is a signed link valid for 24 hours. When it expires the file is deleted from our servers: we are a delivery mechanism, not your archive, and these documents usually hold personal data we should not keep. Download it in your webhook handler and store it on your side. After that window the document is gone: the only way to get it again is to fill the template again.
Anyone holding the URL can fetch the document, so treat it like a password: don’t log it, don’t forward it, and don’t put it in a client-side page. The window is comfortably longer than the retry schedule, so a receiver that was down still has the full day once it comes back.
// after verifying the signature
const res = await fetch(payload.pdf_url);
const pdf = Buffer.from(await res.arrayBuffer());
await myStorage.put(payload.filename, pdf);We deliver a link rather than the bytes because an inlined base64 PDF makes a single delivery hundreds of kilobytes — past the default body limit of most frameworks (Express’s body-parser rejects it with request entity too large). Payloads stay a few KB, so your receiver only needs its normal JSON limits.
Delivery, retries & ordering
| Field | Type | Description |
|---|---|---|
| Timeout | 10s | A delivery that takes longer counts as failed. |
| Success | 2xx | Any other status (or a network error) is a failure. |
| Retries | 5 | 1m → 5m → 30m → 2h → 6h after the first attempt, then the delivery is marked failed. |
| Ordering | none | Deliveries are independent and retries mean a later document can arrive first. Use submitted_at, and make your handler idempotent. |
| Log | dashboard + API | Template → "Webhook deliveries" lists every queued delivery with the exact body sent and the response we got, plus a "Retry now" button. The same rows as JSON: GET /api/templates/:id/deliveries, with your admin key in an X-Admin-Key header. |
Reply 2xx as soon as you’ve the payload and do your own work afterwards; a slow handler turns into a retry storm. Deliveries that succeed on the first attempt aren’t stored, so an empty log means everything is being accepted.
Fixed a broken receiver and don’t want to wait for the next scheduled attempt? Open the delivery in the log and press Retry now, or POST /api/templates/:id/deliveries/:deliveryId/retry with your admin key in an X-Admin-Key header. It sends immediately, using the endpoint’s current URL and secret, and works even on a delivery that has already used up its attempts.
Verifying the signature
Each delivery carries X-Doc2api-Event, X-Doc2api-Timestamp, and X-Doc2api-Signature: sha256=…. The signed string is timestamp + "." + rawBody:
import crypto from "node:crypto";
function verify(req, secret) {
const ts = req.headers["x-doc2api-timestamp"];
const sig = req.headers["x-doc2api-signature"]; // "sha256=<hex>"
const expected =
"sha256=" + crypto.createHmac("sha256", secret)
.update(ts + "." + req.rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}Verify against the raw body, before any JSON parsing — re-serialising changes the bytes and the signature won’t match. Reject a delivery whose timestamp is more than a few minutes old to blunt replays. Each endpoint has its own secret (returned once when you save it, and shown on the template page).
Extra params
Params are your values carried alongside a document — an order id, a tenant, the signed-in user, the case a form belongs to. They are delivered to your webhooks and stored with the record, and they are never written into the PDF. Use them to tie a delivery back to something in your own system without having to add hidden fields to the document.
Sending them
From the embed SDK, so every submission of that form carries them:
Doc2api.render({
container: "#pdf-form",
templateId: "<TEMPLATE_ID>",
apiKey: "d2a_pub_<publishable_key>",
params: { order_id: "A-1029", tenant: "acme", user_id: 4417 },
});Or per request on /fill and /submissions: the same object, alongside data:
curl -X POST 'https://www.doc2api.co/api/v1/templates/<TEMPLATE_ID>/fill' -H 'Content-Type: application/json' -H 'X-Api-Key: d2a_live_<your_fill_key>' -d '{
"data": { "full_name": "Ada Lovelace" },
"params": { "order_id": "A-1029", "tenant": "acme" }
}'They come back to you inside the webhook payload under params, exactly as you sent them, and appear on the submission in the dashboard.
Declaring them (optional)
Any param you send is passed through whether or not you declare it. Declaring one — on the template page under Extra params, or via the admin API — adds it to the template’s fill schema, coerces it to the declared type, and lets you mark it required so a request without it’s rejected with 422.
PUT /api/v1/templates/:id/params # auth: admin key
{
"params": [
{ "name": "order_id", "type": "string", "description": "Your order reference", "required": true },
{ "name": "tenant", "type": "string" },
{ "name": "retry", "type": "boolean" }
]
}A declared number or boolean accepts the string form too, so "42" and "true" from a URL or form field still satisfy it. Declared params show up in the schema as their own params object, which keeps them clearly separate from field names.
Limits
| Field | Type | Description |
|---|---|---|
| Count | 20 | Per request. |
| Name | 64 chars | Letters, numbers, and _ . : - — must start with a letter or number. |
| Value | 512 chars | Strings; numbers and booleans are also accepted. null is treated as not sent. |
| Total size | 4 KB | Serialized. Params are metadata, not a payload channel — put bulk data behind an id. |
Anything outside these limits is rejected with 422 and a message naming the param, rather than being silently truncated.
International text
Values aren’t limited to Latin characters. Send Cyrillic, Greek, Arabic, Hebrew, Hindi, Thai, Chinese, Japanese or Korean and the document is filled in the right script, no configuration, no font to upload. Right-to-left text is shaped and reordered for you.
Scripts covered
| Field | Type | Description |
|---|---|---|
| Latin | including extended | Accents and diacritics of every European language, plus Vietnamese and Welsh (ŵ ŷ ệ ł ø). |
| Cyrillic, Greek | covered | Russian, Ukrainian, Serbian, Bulgarian, Greek. |
| Arabic | covered, shaped | Arabic, Persian and Urdu letterforms, contextually shaped and right-aligned. |
| Hebrew | covered | Reordered right-to-left. |
| Devanagari | covered | Hindi, Marathi, Sanskrit, including conjuncts and reordered vowel signs. |
| Thai | covered | Including stacked tone marks. |
| Chinese | covered | Simplified and traditional. |
| Japanese | covered | Kanji, hiragana, katakana. |
| Korean | covered | Hangul syllables and jamo. |
Not bundled today: Bengali, Tamil, Gujarati and the other Indic scripts beyond Devanagari, plus Lao, Khmer, Myanmar, Georgian, Armenian, Ethiopic, and emoji. A character we can’t draw is never silently dropped: it’s left out of the document and named in warnings, so you can see exactly what happened.
{
"submission": {
"warnings": ["full_name: 🚀 could not be rendered, no bundled font covers it"]
}
}Tell us which script you need and we’ll add it, each one is a font away.
Right-to-left text
A PDF stores glyphs in the order they are drawn, so Arabic and Hebrew have to be laid out before they go in. We do what a text engine does: Arabic letters are rewritten into their contextual forms (a letter is shaped differently at the start, middle and end of a word), the line is reordered by the Unicode bidi algorithm, and a right-to-left value is aligned to the right of its box. Send plain logical text — "مرحبا بالعالم", and it comes out reading correctly. Mixed direction works too: "Name: محمد / ID 4417" keeps the Latin parts left-to-right and the Arabic run right-to-left.
One caveat for AcroForm templates: right-to-left values are drawn exactly only when the output is flattened (the default). Ask for "flatten": false to keep the form editable and the value is stored as-is for the reader’s PDF viewer to shape, which most do correctly: a warning tells you when this applies.
A note on Chinese, Japanese and Korean file sizes
Documents containing CJK come out several MB larger: a whole CJK font is embedded rather than the handful of glyphs used. That’s deliberate: the subsetting available to us produces files that some viewers (notably macOS Preview and Quick Look) refuse to draw, and a document that looks empty to your reader is worse than a large one. Every other script is subsetted normally, so a Cyrillic or Arabic document stays a few KB, and a Latin-only document is byte-for-byte what it always was.
Because of this, prefer pdf_url delivery for CJK (webhooks already work this way) over passing base64 through your own stack.
Embed domains
Lock the embed to your own sites. PUT /api/v1/templates/:id/origins — auth: admin key.
{ "allowed_origins": ["https://app.yourco.com"] }When set, only those origins may iframe the form (a frame-ancestors policy) and the publishable key is rejected (403 origin_not_allowed) from any other origin. Empty = any site. The fill key is exempt (server-to-server).
Errors, limits & quotas
Errors return { "error": "message", "code"?: "…" } with a standard status:
| Field | Type | Description |
|---|---|---|
| 400 | bad_request | Malformed body / missing fields. |
| 401 | — | Invalid or missing key. |
| 402 | template_limit / fill_limit / trial_limit | Plan quota reached. |
| 403 | origin_not_allowed | Publishable key used from a disallowed origin. |
| 404 | — | Template / profile not found. |
| 413 | — | File or batch too large (25 MB / 200 rows). |
| 415 | — | Unsupported upload format. |
| 422 | — | Values or form logic rejected (see details[]). |
| 429 | rate_limited | Slow down (Retry-After + X-RateLimit-* headers). |
Rate limits
Per template unless noted: fill 60/min, submissions 60/min, batch 6/min, uploads 20/min per org. Public demo 12/min per IP; auth endpoints are throttled too.
Plan quotas
Free 3 templates / 50 docs/mo · Starter 15 / 1,000 · Pro ∞ / 10,000 · Business ∞ / 100,000. Retention and white-label branding scale with the plan — see pricing.
Ready to build?
Create a free account →