API reference
Every call, on one page. If you are starting from nothing, the quickstart gets you to a PDF in about five minutes.
/api/v1 will GET an address you hand it, because a server that
would is a server that will read a cloud provider's metadata endpoint. Send
the bytes.What you can send#
| Field | Type | Endpoint | What it is |
|---|---|---|---|
markdown | string | /pdfs | Markdown. Headings, bold, lists, tables, links, images, quotes and code fences. Set as real text, not a picture of it. |
html | string | /pdfs | The same closed vocabulary as Markdown, if you already have HTML. |
image | base64 string | /pdfs | A PNG or JPEG. Or post the bytes as the request body with the matching Content-Type. |
pdf | base64 string | /pdfs | An existing PDF, imported page by page so it becomes editable. "mode": "image" keeps each page as a picture instead. |
mermaid | string | /diagrams | A mermaid flowchart or graph. Laid out on arrival, because mermaid carries no coordinates. |
svg | string | /diagrams | An SVG, read into real shapes. |
drawio | string | /diagrams | A .drawio file's XML. |
vsdx | base64 string | /diagrams | A Visio .vsdx file. |
model | object | both | A document model of your own — the same shape .json gives back. |
| Format out | Content type | What it is |
|---|---|---|
.pdf | application/pdf | The whole document, rendered now. ?page=2 for one page. |
.png | image/png | One page as a picture. ?page=, and ?dpi= up to 200 (110 by default). |
.svg | image/svg+xml | One page as vector SVG. ?page=. |
.mmd | text/vnd.mermaid | A diagram as mermaid text. Only what mermaid can say — the rest is counted in the response headers. |
.drawio | application/xml | A diagram as a .drawio file, openable in diagrams.net. |
.json | application/json | The document's own model — the same JSON the editor works on. |
Make a PDF#
POST
/api/v1/pdfs
documents:write
Send one source field and get back a document kept in My documents. Markdown and HTML are set as real, selectable text — headings, lists, tables, links and pictures — rather than a screenshot of a page. A picture becomes a page the size of the picture; a PDF is imported page by page so it stays editable.
Answers 201 with the document. It is a real docubend
document: it opens in the editor, can be annotated, shared and searched,
and it keeps a version history.
title | optional | What to call it. Defaults to Untitled PDF, or use ?title= when posting raw bytes. |
markdown / html / image / pdf / model | one required | The source. See what you can send. |
images | optional | For Markdown and HTML: {"chart.png": "<base64>"}, matching the names used in . |
mode | optional | For a pdf source: image keeps each page as a picture instead of importing its text. |
unlock | optional | For a locked pdf. A locked PDF is refused rather than silently unlocked, and the refusal names the lock in a locked field. 1 removes it — keeping the document intact where it can, drawing a brand new PDF from it where it cannot. decrypt insists on keeping it intact; rebuild always redraws. Also takes as ?unlock=1 in the URL. |
password | optional | The password of a PDF that will not open without one. Send it here or as the X-Doc-Password header — never in the query string, which is refused, because a URL is written into every access log it passes through. There is no way to open a PDF whose password you do not have. |
curl -s https://docubend.com/api/v1/pdfs \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Nightly build report",
"markdown": "# Nightly build\n\n**All green.** 412 tests, 0 failures."
}'
const r = await fetch(`${BASE}/api/v1/pdfs`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json" },
body: JSON.stringify({
title: "Nightly build report",
markdown: "# Nightly build\n\n**All green.** 412 tests, 0 failures.",
}),
});
const { document: doc } = await r.json();
console.log(doc.id, doc.links.open);
doc = requests.post(f"{BASE}/api/v1/pdfs",
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"title": "Nightly build report",
"markdown": "# Nightly build\n\n**All green.** 412 tests, 0 failures.",
}, timeout=60).json()["document"]
print(doc["id"], doc["links"]["open"])
Response
{
"ok": true,
"document": {
"id": "ba59b6ce5221411285daea90f4be16b6",
"title": "Nightly build report",
"kind": "pdf",
"pages": 1,
"shapes": 11,
"version": 2,
"created": 1786717599.3359814,
"updated": 1786717599.4855263,
"your_role": "owner",
"links": {
"self": "https://docubend.com/api/v1/documents/ba59b6ce5221411285daea90f4be16b6",
"pdf": "https://docubend.com/api/v1/documents/ba59b6ce5221411285daea90f4be16b6.pdf",
"svg": "https://docubend.com/api/v1/documents/ba59b6ce5221411285daea90f4be16b6.svg",
"png": "https://docubend.com/api/v1/documents/ba59b6ce5221411285daea90f4be16b6.png",
"model": "https://docubend.com/api/v1/documents/ba59b6ce5221411285daea90f4be16b6.json",
"mermaid": "https://docubend.com/api/v1/documents/ba59b6ce5221411285daea90f4be16b6.mmd",
"open": "https://docubend.com/d/ba59b6ce5221411285daea90f4be16b6"
}
}
}
Make a PDF from a picture#
POST
/api/v1/pdfs
documents:write
The same endpoint, with the file as the request body instead of
base64 inside JSON — which is what you want for a dashboard screenshot
or anything large. Set Content-Type to the file's type and
put the title in the query string.
PNG and JPEG for pictures, application/pdf for a PDF,
and text/markdown, text/html or
text/vnd.mermaid for text.
curl -s "https://docubend.com/api/v1/pdfs?title=Traffic%20dashboard" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: image/png" \
--data-binary @dashboard.png
import { readFile } from "node:fs/promises";
const png = await readFile("dashboard.png");
const r = await fetch(
`${BASE}/api/v1/pdfs?title=${encodeURIComponent("Traffic dashboard")}`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "image/png" },
body: png,
});
const { document: doc } = await r.json();
with open("dashboard.png", "rb") as fh:
doc = requests.post(f"{BASE}/api/v1/pdfs",
params={"title": "Traffic dashboard"},
headers={"Authorization": f"Bearer {TOKEN}",
"Content-Type": "image/png"},
data=fh.read(), timeout=120).json()["document"]
Response
{
"ok": true,
"document": {
"id": "7c1d0f4a9b2e4c6f8a0d3b5e7f9c1a2b",
"title": "Traffic dashboard",
"kind": "pdf",
"pages": 1,
"shapes": 1,
"version": 2,
"your_role": "owner",
"links": {
"…": "as above"
}
}
}
Make a diagram#
POST
/api/v1/diagrams
documents:write
From mermaid, an SVG, a .drawio or a Visio file, or a model of your own. What comes back is an editable diagram — boxes, connectors that stay attached, and text you can search — not a picture of one.
Mermaid carries no coordinates, so it is laid out on arrival:
ranked so the arrows travel one way down the page, with subgraphs kept
together. flowchart and graph only; sequence,
class, state, gantt and the rest are refused by name with
422, because loose rectangles that mean nothing are worse
than an honest refusal.
title | optional | Defaults to Untitled diagram. |
mermaid / svg / drawio / vsdx / model | one required | The source. |
curl -s https://docubend.com/api/v1/diagrams \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Release pipeline",
"mermaid": "flowchart LR\n A[Build] --> B{Tests pass?}\n B -->|yes| C[(Ship)]\n B -->|no| D[Fix]\n D --> A"
}'
const mermaid = `flowchart LR
A[Build] --> B{Tests pass?}
B -->|yes| C[(Ship)]
B -->|no| D[Fix]
D --> A`;
const r = await fetch(`${BASE}/api/v1/diagrams`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json" },
body: JSON.stringify({ title: "Release pipeline", mermaid }),
});
const { document: doc } = await r.json();
mermaid = """flowchart LR
A[Build] --> B{Tests pass?}
B -->|yes| C[(Ship)]
B -->|no| D[Fix]
D --> A"""
doc = requests.post(f"{BASE}/api/v1/diagrams",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"title": "Release pipeline", "mermaid": mermaid},
timeout=60).json()["document"]
Response
{
"ok": true,
"document": {
"id": "6b35e4159e2e4b0e80ebae85f41cd6fd",
"title": "Release pipeline",
"kind": "diagram",
"pages": 1,
"shapes": 4,
"version": 2,
"your_role": "owner",
"links": {
"…": "as above"
}
}
}
Replace what is in one#
PUT
/api/v1/pdfs/{id} · /api/v1/diagrams/{id}
documents:write
The same body as the two calls above, aimed at a document that already exists. Use it for anything that is regenerated on a schedule — the URL somebody bookmarked, or shared, or embedded keeps working, and the version they were looking at yesterday is still in the history.
Answers 200, with version raised.
curl -s -X PUT https://docubend.com/api/v1/diagrams/6b35e4159e2e4b0e80ebae85f41cd6fd \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "Release pipeline v2", "mermaid": "flowchart LR\n A[Build] --> B[Ship]"}'
const r = await fetch(`${BASE}/api/v1/diagrams/${docId}`, {
method: "PUT",
headers: { Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json" },
body: JSON.stringify({ title: "Release pipeline v2",
mermaid: "flowchart LR\n A[Build] --> B[Ship]" }),
});
const { document: doc } = await r.json();
doc = requests.put(f"{BASE}/api/v1/diagrams/{doc_id}",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"title": "Release pipeline v2",
"mermaid": "flowchart LR\n A[Build] --> B[Ship]"},
timeout=60).json()["document"]
print(doc["version"]) # one higher than it was
Response
{
"ok": true,
"document": {
"id": "6b35e4159e2e4b0e80ebae85f41cd6fd",
"title": "Release pipeline v2",
"kind": "diagram",
"pages": 1,
"shapes": 2,
"version": 3,
"your_role": "owner",
"links": {
"…": "as above"
}
}
}
List documents#
GET
/api/v1/documents
documents:read
Everything this account can open — what it owns and what has been shared with it — in the order the workspace shows them.
kind | optional | pdf or diagram. |
limit | optional | Up to 200. 50 by default. |
offset | optional | Where to start. total in the response is the count before the window is applied. |
curl -s "https://docubend.com/api/v1/documents?kind=diagram&limit=2" \
-H "Authorization: Bearer $TOKEN"
const r = await fetch(
`${BASE}/api/v1/documents?kind=diagram&limit=2`,
{ headers: { Authorization: `Bearer ${TOKEN}` } });
const { documents } = await r.json();
docs = requests.get(f"{BASE}/api/v1/documents",
headers={"Authorization": f"Bearer {TOKEN}"},
params={"kind": "diagram", "limit": 2},
timeout=30).json()["documents"]
for d in docs:
print(d["updated"], d["kind"], d["title"])
Response
{
"ok": true,
"total": 2,
"documents": [
{
"id": "6b35e4159e2e4b0e80ebae85f41cd6fd",
"title": "Release pipeline",
"kind": "diagram",
"pages": 1,
"shapes": 4,
"updated": 1786717599.9,
"created": 1786717599.5,
"version": 2,
"your_role": "owner",
"links": {
"…": "as above"
}
}
]
}
One document#
GET
/api/v1/documents/{id}
documents:read
What it is, how big it is and when it changed. Add
?include=model for the document's own JSON as well — left
out by default because it can be megabytes and a list view that wanted a
title should not pay for one.
include | optional | model to include the document's JSON under data. |
curl -s https://docubend.com/api/v1/documents/ba59b6ce5221411285daea90f4be16b6 \
-H "Authorization: Bearer $TOKEN"
const r = await fetch(`${BASE}/api/v1/documents/${docId}`,
{ headers: { Authorization: `Bearer ${TOKEN}` } });
const { document: doc } = await r.json();
doc = requests.get(f"{BASE}/api/v1/documents/{doc_id}",
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30).json()["document"]
print(doc["kind"], doc["pages"], "pages, version", doc["version"])
Response
{
"ok": true,
"document": {
"id": "ba59b6ce5221411285daea90f4be16b6",
"title": "Nightly build report",
"kind": "pdf",
"pages": 1,
"shapes": 11,
"version": 2,
"created": 1786717599.3359814,
"updated": 1786717599.4855263,
"your_role": "owner",
"links": {
"…": "as above"
}
}
}
Download it in any format#
GET
/api/v1/documents/{id}.{format}
documents:read
The document, said differently. Rendered at the moment you ask, so it always matches the document as it stands — including changes somebody made by hand in the editor after your software created it.
If a PDF could not be drawn exactly — characters outside what the
standard PDF fonts can set, or a picture that could not be read — it is
said in an X-Docubend-Warning header rather than left for
you to notice. Mermaid exports carry
X-Docubend-Shapes, X-Docubend-Dropped and
X-Docubend-Loose-Connectors, because not everything on a
page has a spelling in mermaid.
.pdf | application/pdf | The whole document, rendered now. ?page=2 for one page. |
.png | image/png | One page as a picture. ?page=, and ?dpi= up to 200 (110 by default). |
.svg | image/svg+xml | One page as vector SVG. ?page=. |
.mmd | text/vnd.mermaid | A diagram as mermaid text. Only what mermaid can say — the rest is counted in the response headers. |
.drawio | application/xml | A diagram as a .drawio file, openable in diagrams.net. |
.json | application/json | The document's own model — the same JSON the editor works on. |
# a PDF of the whole document
curl -s https://docubend.com/api/v1/documents/ba59b6ce5221411285daea90f4be16b6.pdf \
-H "Authorization: Bearer $TOKEN" -o report.pdf
# page 1 as a picture, at 150 dpi
curl -s "https://docubend.com/api/v1/documents/ba59b6ce5221411285daea90f4be16b6.png?page=1&dpi=150" \
-H "Authorization: Bearer $TOKEN" -o page-1.png
import { writeFile } from "node:fs/promises";
const r = await fetch(`${BASE}/api/v1/documents/${docId}.pdf`,
{ headers: { Authorization: `Bearer ${TOKEN}` } });
const warning = r.headers.get("x-docubend-warning");
if (warning) console.warn("note:", warning);
await writeFile("report.pdf", Buffer.from(await r.arrayBuffer()));
pdf = requests.get(f"{BASE}/api/v1/documents/{doc_id}.pdf",
headers={"Authorization": f"Bearer {TOKEN}"}, timeout=120)
if pdf.headers.get("X-Docubend-Warning"):
print("note:", pdf.headers["X-Docubend-Warning"])
open("report.pdf", "wb").write(pdf.content)
png = requests.get(f"{BASE}/api/v1/documents/{doc_id}.png",
headers={"Authorization": f"Bearer {TOKEN}"},
params={"page": 1, "dpi": 150}, timeout=120)
open("page-1.png", "wb").write(png.content)
Response
%PDF-1.4
…3,184 bytes of PDF…
A diagram back as mermaid#
GET
/api/v1/documents/{id}.mmd
documents:read
Anything drawn as boxes and arrows comes back out as mermaid text — for a README, a ticket, or a diff that a human can read. What has no spelling in mermaid (a pen stroke, a picture, a connector with a loose end) is counted in the headers rather than dropped silently.
A round trip through mermaid keeps the structure and not the arrangement, because the format has no coordinates to keep.
curl -s https://docubend.com/api/v1/documents/6b35e4159e2e4b0e80ebae85f41cd6fd.mmd \
-H "Authorization: Bearer $TOKEN" -D headers.txt
const r = await fetch(`${BASE}/api/v1/documents/${docId}.mmd`,
{ headers: { Authorization: `Bearer ${TOKEN}` } });
console.log(await r.text());
console.log(r.headers.get("x-docubend-shapes"), "shapes");
r = requests.get(f"{BASE}/api/v1/documents/{doc_id}.mmd",
headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30)
print(r.text)
print(r.headers["X-Docubend-Shapes"], "shapes,",
r.headers["X-Docubend-Dropped"], "could not be said in mermaid")
Response
flowchart TD
Build[Build]
Tests_pass{Tests pass?}
Ship[(Ship)]
Fix[Fix]
Build --> Tests_pass
Tests_pass -->|yes| Ship
Tests_pass -->|no| Fix
Fix --> Build
Turn anything into a form#
POST
/api/v1/forms
documents:write
A form is an ordinary document with fields on it. So anything that can become a document can become a form — a PDF, a photograph of a paper form, a screenshot, a Markdown page, a drawing — and the same sources listed under What you can send apply here unchanged.
Send fields to place them yourself, or
detect: true to have the blanks on a scanned page found —
ruled lines and empty tick boxes, with the words beside them read as
the question. Detection is a guess and is never applied unless you ask
for it.
What you get back is a real PDF form. Fetch
/api/v1/documents/{id}.pdf and the file carries a genuine
AcroForm: fields anybody can type into in Acrobat, Preview or a
browser, each drawing its own appearance so the answers show even in
readers that will not generate one.
fields | optional | Where the fields go: {key, type, label, required, page, x, y, w, h} each, and options for the ones that choose. Coordinates are in the document's own units, from the top left of the page. |
detect | optional | true to look for the blanks instead of placing them yourself. |
title | optional | What it is called in My documents. |
curl -s -X POST https://docubend.com/api/v1/forms -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{
"title": "Membership form",
"image": "'"$(base64 -w0 scan.jpg)"'",
"fields": [
{"key": "name", "type": "text", "label": "Your name",
"page": 1, "x": 180, "y": 100, "w": 240, "h": 30, "required": true},
{"key": "email", "type": "email", "label": "Email",
"page": 1, "x": 180, "y": 150, "w": 240, "h": 30, "required": true},
{"key": "agree", "type": "checkbox", "label": "I agree",
"page": 1, "x": 180, "y": 200, "w": 22, "h": 22}
]
}'
const form = (await (await fetch(`${BASE}/api/v1/forms`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({
title: "Membership form",
image: scanAsBase64,
fields: [
{ key: "name", type: "text", label: "Your name",
page: 1, x: 180, y: 100, w: 240, h: 30, required: true },
{ key: "agree", type: "checkbox", label: "I agree",
page: 1, x: 180, y: 200, w: 22, h: 22 },
],
}),
})).json()).form;
import base64, requests
form = requests.post(f"{BASE}/api/v1/forms",
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"title": "Membership form",
"image": base64.b64encode(open("scan.jpg", "rb").read()).decode(),
"fields": [
{"key": "name", "type": "text", "label": "Your name",
"page": 1, "x": 180, "y": 100, "w": 240, "h": 30, "required": True},
{"key": "agree", "type": "checkbox", "label": "I agree",
"page": 1, "x": 180, "y": 200, "w": 22, "h": 22},
],
}, timeout=60).json()["form"]
# the same document, as a PDF anybody can type into
pdf = requests.get(form["links"]["pdf"],
headers={"Authorization": f"Bearer {TOKEN}"}, timeout=60).content
Response
{
"ok": true,
"form": {
"id": "ba59b6ce5221411285daea90f4be16b6",
"title": "Membership form",
"kind": "pdf",
"pages": 1,
"version": 2,
"your_role": "owner",
"form": {
"count": 3,
"open": true,
"responses": 0,
"fields": [
{
"key": "name",
"type": "text",
"label": "Your name",
"required": true,
"page": 1
},
{
"key": "email",
"type": "email",
"label": "Email",
"required": true,
"page": 1
},
{
"key": "agree",
"type": "checkbox",
"label": "I agree",
"required": false,
"page": 1
}
]
}
}
}
What a form asks#
GET
/api/v1/forms/{id}/fields
documents:read
The schema, without the geometry: the keys an answer comes back under, what kind each one is, and which are needed. This is what to validate against before submitting, and what to head a table with.
A key used in more than one place on the page is ONE question — the same answer written in two boxes — and appears once here.
PUT the same path with {"fields": […]} to
replace them, or {"fields": […], "keep": true} to add to
what is there.
curl -s https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/fields -H "Authorization: Bearer $TOKEN"
const { fields } = await (await fetch(
`${BASE}/api/v1/forms/${formId}/fields`,
{ headers: { Authorization: `Bearer ${TOKEN}` } })).json();
schema = requests.get(f"{BASE}/api/v1/forms/{form_id}/fields",
headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30).json()["fields"]
Response
{
"ok": true,
"fields": [
{
"key": "name",
"type": "text",
"label": "Your name",
"required": true,
"page": 1
},
{
"key": "agree",
"type": "checkbox",
"label": "I agree",
"required": false,
"page": 1
}
],
"form": {
"count": 3,
"open": true,
"responses": 4
}
}
A link anybody can fill it in through#
POST
/api/v1/forms/{id}/links
documents:write
Hands back a URL that opens the form for whoever holds it, with no account and nothing to install. That is the point of it: the person answering a form is usually not your customer, and asking them to sign up to answer a question is how a form goes unanswered.
Make one per audience — this term's class, the reviewer — because
each is revoked on its own and each counts its own submissions.
POST /api/v1/forms/{id}/shares with an
email does the same thing for one named person, and emails
them their own link.
label | optional | What this link is for. Shown to you, never to the person holding it. |
mode | optional | fill (the default here) collects answers; view only shows the form. |
downloads | optional | Whether they may download it. true by default. |
curl -s -X POST https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/links -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"label": "Sent to the mailing list"}'
const { link } = await (await fetch(
`${BASE}/api/v1/forms/${formId}/links`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ label: "Sent to the mailing list" }),
})).json();
link = requests.post(f"{BASE}/api/v1/forms/{form_id}/links",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"label": "Sent to the mailing list"}, timeout=30).json()["link"]
print(link["url"]) # send this to anybody
Response
{
"ok": true,
"link": {
"token": "swbRi03si9Q9B-Ohb_7GQvW4NW1WQ3TT",
"label": "Sent to the mailing list",
"mode": "fill",
"downloads": true,
"responses": 0,
"opens": 0,
"url": "https://docubend.com/p/swbRi03si9Q9B-Ohb_7GQvW4NW1WQ3TT",
"doc_id": "ba59b6ce5221411285daea90f4be16b6"
}
}
What came back#
GET
/api/v1/forms/{id}/responses
documents:read
Every submission, newest first, with its answers keyed the way the
form asks for them. document is the id of the FILLED
COPY — each submission is also an ordinary document in My documents,
with the answers set into the page where the form put them, so it can
be opened, printed, downloaded as a PDF or shared like anything
else.
Add .csv to the path for the lot as a spreadsheet.
Columns come from the form, in the form's own order, so a question
nobody answered still has a column and a question added later does not
shuffle the earlier rows.
POST the same path with {"values": {…}} to
submit answers yourself, and
DELETE /api/v1/forms/{id}/responses/{rid} to take one
back — which bins the filled copy with it unless you pass
?keepDocument=1.
limit | optional | How many, up to 1000. 200 by default. |
offset | optional | Where to start. |
curl -s https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/responses -H "Authorization: Bearer $TOKEN"
# or as a spreadsheet
curl -s https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/responses.csv -H "Authorization: Bearer $TOKEN" -o responses.csv
const { responses } = await (await fetch(
`${BASE}/api/v1/forms/${formId}/responses`,
{ headers: { Authorization: `Bearer ${TOKEN}` } })).json();
got = requests.get(f"{BASE}/api/v1/forms/{form_id}/responses",
headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30).json()
for r in got["responses"]:
print(r["respondent"] or "anonymous", r["values"])
# the form as that person filled it in, as a PDF
pdf = requests.get(f"{BASE}/api/v1/documents/{r['document']}.pdf",
headers={"Authorization": f"Bearer {TOKEN}"}, timeout=60).content
Response
{
"ok": true,
"total": 2,
"fields": [
{
"key": "name",
"type": "text",
"label": "Your name",
"required": true,
"page": 1
},
{
"key": "agree",
"type": "checkbox",
"label": "I agree",
"required": false,
"page": 1
}
],
"responses": [
{
"id": "116fb29315e4457ebb0322a787f95efc",
"doc_id": "ba59b6ce5221411285daea90f4be16b6",
"respondent": "grace@example.com",
"via": "link",
"submitted_at": 1788558221.83,
"summary": "Your name: Grace Hopper · I agree: yes",
"document": "6a9de148a04442bfa129e5c024143c5b",
"values": {
"name": "Grace Hopper",
"agree": true
}
}
]
}
Close it, or change how it behaves#
PATCH
/api/v1/forms/{id}
documents:write
Closing a form is not deleting it. Everyone holding a link can still
open it and see what it asked, and every answer already given is
untouched — it simply stops taking new ones. That is the difference
people mean, and it is why this is not DELETE.
open | optional | false stops it taking answers. |
multiple | optional | false allows one answer per person. |
notify | optional | Whether the owner is emailed on each submission. |
confirm | optional | What the person is told after they press Send. |
curl -s -X PATCH https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6 -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"open": false, "confirm": "Thank you — we will be in touch."}'
await fetch(`${BASE}/api/v1/forms/${formId}`, {
method: "PATCH",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify({ open: false }),
});
requests.patch(f"{BASE}/api/v1/forms/{form_id}",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"open": False}, timeout=30).json()
Response
{
"ok": true,
"form": {
"count": 3,
"open": false,
"multiple": true,
"confirm": "Thank you — we will be in touch.",
"responses": 4
}
}
Delete one#
DELETE
/api/v1/documents/{id}
documents:write
To the bin, where the account's retention setting keeps it — thirty days by default — and from where a person can put it back. Nothing here destroys a document immediately, on purpose.
curl -s -X DELETE https://docubend.com/api/v1/documents/ba59b6ce5221411285daea90f4be16b6 \
-H "Authorization: Bearer $TOKEN"
await fetch(`${BASE}/api/v1/documents/${docId}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${TOKEN}` },
});
requests.delete(f"{BASE}/api/v1/documents/{doc_id}",
headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30).json()
Response
{
"ok": true,
"binned": "ba59b6ce5221411285daea90f4be16b6"
}
What is left#
GET
/api/v1/usage
documents:read
Calls made today and storage used, against what each is capped at —
so a nightly job can find out before it hits a limit rather than by
hitting one. null means no limit.
The allowance is counted per account, not per credential, and resets at midnight UTC.
curl -s https://docubend.com/api/v1/usage \
-H "Authorization: Bearer $TOKEN"
const r = await fetch(`${BASE}/api/v1/usage`,
{ headers: { Authorization: `Bearer ${TOKEN}` } });
const usage = await r.json();
u = requests.get(f"{BASE}/api/v1/usage",
headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30).json()
if u["calls"]["left"] is not None and u["calls"]["left"] < 50:
print("nearly out of calls for today")
Response
{
"ok": true,
"day": "2026-08-14",
"calls": {
"today": 2,
"allowed": 2000,
"left": 1998
},
"storage": {
"used": 0,
"quota": 5368709120,
"left": 5368709120,
"largest_file": 536870912
}
}
What is here#
GET
/api/v1
documents:read
This page, as JSON: every route, the scopes, and where the token endpoint is. Useful for poking at the API with curl, and for a client that would rather read the surface than have it hard-coded.
curl -s https://docubend.com/api/v1 -H "Authorization: Bearer $TOKEN"
const r = await fetch(`${BASE}/api/v1`,
{ headers: { Authorization: `Bearer ${TOKEN}` } });
const surface = await r.json();
requests.get(f"{BASE}/api/v1",
headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30).json()
Response
{
"ok": true,
"version": 1,
"docs": "https://docubend.com/api",
"authentication": {
"grant": "client_credentials",
"token_endpoint": "https://docubend.com/auth/oauth/token",
"use": "Authorization: Bearer <token>",
"scopes": {
"…": "as above"
}
},
"read": {
"…": "every GET route"
},
"write": {
"…": "every POST, PUT and DELETE route"
}
}
List the forms#
GET
/api/v1/forms
documents:read
The documents that are forms, and only those.
?state=open or ?state=closed — worth a filter
because the question a nightly job asks is what is still collecting, and
answering it by fetching everything is the caller doing our work.
state | optional | open or closed. |
limit | optional | 1–200, 50 by default. |
offset | optional | Where to start. |
curl -s "https://docubend.com/api/v1/forms?state=open" \
-H "Authorization: Bearer $TOKEN"
const r = await fetch(`${BASE}/api/v1/forms?state=open`,
{ headers: { Authorization: `Bearer ${TOKEN}` } });
const { forms } = await r.json();
forms = requests.get(f"{BASE}/api/v1/forms", params={"state": "open"},
headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30).json()["forms"]
Response
{
"ok": true,
"total": 1,
"forms": [
{
"id": "ba59b6ce5221411285daea90f4be16b6",
"title": "Membership form",
"kind": "pdf",
"pages": 1,
"fields": 6,
"open": true,
"responses": 12,
"your_role": "owner",
"links": {
"self": "https://docubend.com/api/v1/documents/ba59b6ce5221411285daea90f4be16b6",
"…": "as elsewhere"
}
}
]
}
Shape read from the handler rather than captured from a live call. The fields are right; the values are illustrative.
One form#
GET
/api/v1/forms/{id}
documents:read
The document it is, plus what it asks and how many have answered.
The same row GET /api/v1/documents/{id} returns, with a
form object added and three extra links — its fields, its
responses, and its responses as a spreadsheet.
curl -s https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6 \
-H "Authorization: Bearer $TOKEN"
const r = await fetch(`${BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6`,
{ headers: { Authorization: `Bearer ${TOKEN}` } });
const { form } = await r.json();
console.log(form.form.count, "fields");
form = requests.get(f"{BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6",
headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30).json()["form"]
Response
{
"ok": true,
"form": {
"id": "ba59b6ce5221411285daea90f4be16b6",
"title": "Membership form",
"kind": "pdf",
"pages": 1,
"shapes": 14,
"version": 6,
"your_role": "owner",
"form": {
"fields": [
{
"key": "full_name",
"type": "text",
"label": "Full name",
"required": true,
"page": 0
}
],
"count": 6,
"open": true,
"responses": 12
},
"links": {
"self": "https://docubend.com/api/v1/documents/ba59b6ce5221411285daea90f4be16b6",
"fields": "https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/fields",
"responses": "https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/responses",
"csv": "https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/responses.csv",
"…": "pdf, svg, png, model, mermaid, open"
}
}
}
Shape read from the handler rather than captured from a live call. The fields are right; the values are illustrative.
Replace what a form is made of#
PUT
/api/v1/forms/{id}
documents:write
New pages, same form, same answers. Takes the same sources
as POST /api/v1/pdfs, and fields alongside them
if the fields are moving too.
A response already collected is never retro-fitted onto a layout it never saw: the filled copy freezes what was actually filled in, and the row stays true whatever happens to the form afterwards.
curl -s -X PUT https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Membership form 2027","pdf":"<base64>"}'
await fetch(`${BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6`, {
method: "PUT",
headers: { Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json" },
body: JSON.stringify({ title: "Membership form 2027", pdf: b64 }),
});
requests.put(f"{BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"title": "Membership form 2027", "pdf": b64}, timeout=60)
Response
{
"ok": true,
"form": {
"id": "ba59b6ce5221411285daea90f4be16b6",
"title": "Membership form 2027",
"kind": "pdf",
"pages": 1,
"version": 7,
"your_role": "owner",
"form": {
"count": 6,
"open": true,
"responses": 12
},
"…": "links, as above"
}
}
Shape read from the handler rather than captured from a live call. The fields are right; the values are illustrative.
Place the fields#
PUT
/api/v1/forms/{id}/fields
documents:write
Replace the fields on a form. {"keep": true} adds to
them instead.
Ten types, and the list is short on purpose: every one is something a PDF's own form dictionary can hold, because the promise is that what you design comes out as a real PDF form. A type with nowhere to go in the file format would be a type that quietly became a picture of itself.
fields | required | A list of {key, type, label, required, page, x, y, w, h}, plus options for the ones that choose. Up to 500. |
keep | optional | true appends rather than replacing. |
curl -s -X PUT https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/fields \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"fields":[{"key":"full_name","type":"text","label":"Full name","required":true,"page":0,"x":72,"y":190,"w":300,"h":26}]}'
await fetch(`${BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/fields`, {
method: "PUT",
headers: { Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json" },
body: JSON.stringify({ fields: [
{ key: "full_name", type: "text", label: "Full name",
required: true, page: 0, x: 72, y: 190, w: 300, h: 26 },
] }),
});
requests.put(f"{BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/fields",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"fields": [
{"key": "full_name", "type": "text", "label": "Full name",
"required": True, "page": 0, "x": 72, "y": 190, "w": 300, "h": 26},
]}, timeout=30)
Response
{
"ok": true,
"fields": [
{
"key": "full_name",
"type": "text",
"label": "Full name",
"required": true,
"page": 0,
"x": 72,
"y": 190,
"w": 300,
"h": 26
}
],
"count": 1
}
Shape read from the handler rather than captured from a live call. The fields are right; the values are illustrative.
Find the blanks on a scanned page#
GET
/api/v1/forms/{id}/suggest
documents:read
Where the fields probably go, read off the lines and boxes
already on the page. ?page= for one page.
Asked for, never assumed. This is a heuristic, and quietly
putting twenty guessed fields on somebody's document would be the wrong
kind of helpful — so nothing is placed until you send the result to
PUT …/fields, or post the form with
"detect": true.
curl -s "https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/suggest?page=1" \
-H "Authorization: Bearer $TOKEN"
const r = await fetch(`${BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/suggest?page=1`,
{ headers: { Authorization: `Bearer ${TOKEN}` } });
const { fields } = await r.json();
found = requests.get(f"{BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/suggest",
params={"page": 1},
headers={"Authorization": f"Bearer {TOKEN}"}, timeout=60).json()["fields"]
Response
{
"ok": true,
"fields": [
{
"key": "field_1",
"type": "text",
"label": "",
"page": 0,
"x": 96,
"y": 214,
"w": 280,
"h": 24
},
{
"key": "field_2",
"type": "checkbox",
"label": "",
"page": 0,
"x": 96,
"y": 262,
"w": 14,
"h": 14
}
]
}
Shape read from the handler rather than captured from a live call. The fields are right; the values are illustrative.
Every answer, as a spreadsheet#
GET
/api/v1/forms/{id}/responses.csv
documents:read
text/csv, one row per submission, one column per
field key. The same data …/responses returns as JSON, in the
shape a finance team opens without asking anybody for help.
curl -s https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/responses.csv \
-H "Authorization: Bearer $TOKEN" -o responses.csv
const r = await fetch(`${BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/responses.csv`,
{ headers: { Authorization: `Bearer ${TOKEN}` } });
await writeFile("responses.csv", await r.text());
csv = requests.get(f"{BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/responses.csv",
headers={"Authorization": f"Bearer {TOKEN}"}, timeout=60).text
open("responses.csv", "w").write(csv)
Response
submitted,from,full_name,joined
2026-09-01T09:14:02Z,jo@example.com,Jo Rivers,2026-09-01
2026-09-01T11:40:55Z,,Sam Okafor,2026-08-28
Shape read from the handler rather than captured from a live call. The fields are right; the values are illustrative.
One submission#
GET
/api/v1/forms/{id}/responses/{rid}
documents:read
The answers, who claimed to send them, when, and the id of the filled document it was stored as.
DELETE on the same path takes it away, and takes the filled
document with it — ?keepDocument=1 keeps that.
curl -s https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/responses/116fb29315e4457ebb0322a787f95efc \
-H "Authorization: Bearer $TOKEN"
const r = await fetch(
`${BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/responses/${rid}`,
{ headers: { Authorization: `Bearer ${TOKEN}` } });
const { response } = await r.json();
one = requests.get(
f"{BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/responses/{rid}",
headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30).json()
Response
{
"ok": true,
"response": {
"id": "116fb29315e4457ebb0322a787f95efc",
"from": "jo@example.com",
"via": "link",
"submitted": 1786890842.11,
"document": "7c1d0f4a9b2e4c6f8a0d3b5e7f9c1a2b",
"answers": {
"full_name": "Jo Rivers",
"joined": "2026-09-01"
}
}
}
Shape read from the handler rather than captured from a live call. The fields are right; the values are illustrative.
Submit answers yourself#
POST
/api/v1/forms/{id}/responses
documents:write
For a caller collecting answers somewhere else and filing them
here. as names who the answers are from, and it is recorded
as a claim rather than as an identity — a credential proves whose
account this is and proves nothing about whose answers these are.
answers | required | An object keyed by field key. |
as | optional | Who the answers are from. Recorded as a claim. |
curl -s https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/responses \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"as":"jo@example.com","answers":{"full_name":"Jo Rivers","joined":"2026-09-01"}}'
await fetch(`${BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/responses`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json" },
body: JSON.stringify({ as: "jo@example.com",
answers: { full_name: "Jo Rivers" } }),
});
requests.post(f"{BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/responses",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"as": "jo@example.com",
"answers": {"full_name": "Jo Rivers"}}, timeout=30)
Response
{
"ok": true,
"response": {
"id": "116fb29315e4457ebb0322a787f95efc",
"from": "jo@example.com",
"via": "api",
"submitted": 1786890842.11,
"document": "7c1d0f4a9b2e4c6f8a0d3b5e7f9c1a2b"
}
}
Shape read from the handler rather than captured from a live call. The fields are right; the values are illustrative.
The fill links that exist#
GET
/api/v1/forms/{id}/links
documents:read
Every public link on this form, with its label and when it was made. Each one is a separate door: withdrawing one does not disturb the rest.
curl -s https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/links \
-H "Authorization: Bearer $TOKEN"
const r = await fetch(`${BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/links`,
{ headers: { Authorization: `Bearer ${TOKEN}` } });
const { links } = await r.json();
links = requests.get(f"{BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/links",
headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30).json()["links"]
Response
{
"ok": true,
"links": [
{
"token": "n7Qk2pV0sWx4Ry8Tz1Ab3Cd5",
"mode": "fill",
"label": "Fill-in link",
"created": 1786889000,
"url": "https://docubend.com/f/n7Qk2pV0sWx4Ry8Tz1Ab3Cd5"
}
]
}
Shape read from the handler rather than captured from a live call. The fields are right; the values are illustrative.
Errors#
| Code | What it means |
|---|---|
| 400 | The request was not understood — usually no source field, or one that is not the right type. The message lists what this endpoint takes. |
| 401 | No token, an expired one (they last an hour), or one whose credential has been revoked. Ask for a new token — the body says so, and carries a link to this page. |
| 402 | This account's plan does not include what was asked for. The API itself is Pro and Enterprise. |
| 403 | The credential's scopes do not cover this call — a documents:read token cannot write. |
| 404 | No such document, or no such route. A document belonging to somebody else is a 404, not a 403. |
| 405 | That path is real, and does not take that method — the Allow header says which it does. A missing path is a 404 instead, so the two are never confused. |
| 409 | The document changed underneath a versioned update — or the PDF you sent is locked against copying and editing. That one carries locked: "restrictions" and opens with no password at all: post it again with ?unlock=1. |
| 413 | The file is larger than the plan allows (512 MB on Pro and Enterprise). |
| 415 | We cannot read that kind of file. The message lists the kinds we can. |
| 422 | We could read it and it was damaged, or it was a mermaid diagram type that cannot honestly be drawn as boxes and arrows — or the PDF will not open without a password. That one carries locked: "password": send the password with unlock=1. |
| 429 | The account's daily allowance is spent. A Retry-After header says how long until it resets. |
| 507 | That would put the account over its storage. |
The shape of a refusal, and the headers that carry information.
Limits#
| Free | Pro | Enterprise | |
|---|---|---|---|
| API access | — | yes | yes |
| API calls a day | — | 2,000 | 20,000 |
| Largest single file | 25 MB | 512 MB | 512 MB |
| Documents kept | nothing | 5 GB | 25 GB |
| Credentials at once | — | 20 | 20 |
A nightly report that keeps one URL#
Make the document once, keep its id, and PUT to it every
night. The link you gave people never changes, and last night's version
is still in the history.
import os, requests, datetime
BASE, DOC = "https://docubend.com", os.environ["REPORT_DOC_ID"]
TOKEN = token() # from "Get a token", above
report = f"""# Build report — {datetime.date.today():%d %B %Y}
**All green.** 412 tests, 0 failures.
| Suite | Time |
| --- | --- |
| unit | 41s |
| e2e | 2m 04s |
"""
r = requests.put(f"{BASE}/api/v1/pdfs/{DOC}",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"title": "Build report", "markdown": report}, timeout=60)
r.raise_for_status()
print("version", r.json()["document"]["version"])
A dashboard screenshot, filed every morning#
Post the image bytes, then hand somebody the open link —
it opens in the editor, where they can annotate it and share it.
const png = await capture(); // whatever makes your screenshot
const r = await fetch(
`${BASE}/api/v1/pdfs?title=${encodeURIComponent(`Traffic — ${today}`)}`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "image/png" },
body: png,
});
if (!r.ok) throw new Error(`docubend: ${r.status} ${await r.text()}`);
const { document: doc } = await r.json();
await postToSlack(`Traffic dashboard: ${doc.links.open}`);