docubend
Contents

API reference

Every call, on one page. If you are starting from nothing, the quickstart gets you to a PDF in about five minutes.

Two things before you start. Keep the token exchange on your server. A client secret in a browser bundle, a mobile app or a public repository is a credential somebody else has — this grant is for your software talking to us, not for a page talking to us on a visitor's behalf. And no URL is fetched on your behalf: nothing under /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#

FieldTypeEndpointWhat it is
markdownstring/pdfsMarkdown. Headings, bold, lists, tables, links, images, quotes and code fences. Set as real text, not a picture of it.
htmlstring/pdfsThe same closed vocabulary as Markdown, if you already have HTML.
imagebase64 string/pdfsA PNG or JPEG. Or post the bytes as the request body with the matching Content-Type.
pdfbase64 string/pdfsAn existing PDF, imported page by page so it becomes editable. "mode": "image" keeps each page as a picture instead.
mermaidstring/diagramsA mermaid flowchart or graph. Laid out on arrival, because mermaid carries no coordinates.
svgstring/diagramsAn SVG, read into real shapes.
drawiostring/diagramsA .drawio file's XML.
vsdxbase64 string/diagramsA Visio .vsdx file.
modelobjectbothA document model of your own — the same shape .json gives back.
Format outContent typeWhat it is
.pdfapplication/pdfThe whole document, rendered now. ?page=2 for one page.
.pngimage/pngOne page as a picture. ?page=, and ?dpi= up to 200 (110 by default).
.svgimage/svg+xmlOne page as vector SVG. ?page=.
.mmdtext/vnd.mermaidA diagram as mermaid text. Only what mermaid can say — the rest is counted in the response headers.
.drawioapplication/xmlA diagram as a .drawio file, openable in diagrams.net.
.jsonapplication/jsonThe 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.

Body fields
titleoptionalWhat to call it. Defaults to Untitled PDF, or use ?title= when posting raw bytes.
markdown / html / image / pdf / modelone requiredThe source. See what you can send.
imagesoptionalFor Markdown and HTML: {"chart.png": "<base64>"}, matching the names used in ![](chart.png).
modeoptionalFor a pdf source: image keeps each page as a picture instead of importing its text.
unlockoptionalFor 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.
passwordoptionalThe 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
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."
  }'
JavaScript
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);
Python
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

201
{
  "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
curl -s "https://docubend.com/api/v1/pdfs?title=Traffic%20dashboard" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: image/png" \
  --data-binary @dashboard.png
JavaScript
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();
Python
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

201
{
  "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.

Body fields
titleoptionalDefaults to Untitled diagram.
mermaid / svg / drawio / vsdx / modelone requiredThe source.
curl
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"
  }'
JavaScript
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();
Python
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

201
{
  "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
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]"}'
JavaScript
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();
Python
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

200
{
  "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.

Query parameters
kindoptionalpdf or diagram.
limitoptionalUp to 200. 50 by default.
offsetoptionalWhere to start. total in the response is the count before the window is applied.
curl
curl -s "https://docubend.com/api/v1/documents?kind=diagram&limit=2" \
  -H "Authorization: Bearer $TOKEN"
JavaScript
const r = await fetch(
  `${BASE}/api/v1/documents?kind=diagram&limit=2`,
  { headers: { Authorization: `Bearer ${TOKEN}` } });
const { documents } = await r.json();
Python
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

200
{
  "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.

Query parameters
includeoptionalmodel to include the document's JSON under data.
curl
curl -s https://docubend.com/api/v1/documents/ba59b6ce5221411285daea90f4be16b6 \
  -H "Authorization: Bearer $TOKEN"
JavaScript
const r = await fetch(`${BASE}/api/v1/documents/${docId}`,
  { headers: { Authorization: `Bearer ${TOKEN}` } });
const { document: doc } = await r.json();
Python
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

200
{
  "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.

Formats
.pdfapplication/pdfThe whole document, rendered now. ?page=2 for one page.
.pngimage/pngOne page as a picture. ?page=, and ?dpi= up to 200 (110 by default).
.svgimage/svg+xmlOne page as vector SVG. ?page=.
.mmdtext/vnd.mermaidA diagram as mermaid text. Only what mermaid can say — the rest is counted in the response headers.
.drawioapplication/xmlA diagram as a .drawio file, openable in diagrams.net.
.jsonapplication/jsonThe document's own model — the same JSON the editor works on.
curl
# 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
JavaScript
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()));
Python
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

200
%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
curl -s https://docubend.com/api/v1/documents/6b35e4159e2e4b0e80ebae85f41cd6fd.mmd \
  -H "Authorization: Bearer $TOKEN" -D headers.txt
JavaScript
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");
Python
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

200
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.

Body fields
fieldsoptionalWhere 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.
detectoptionaltrue to look for the blanks instead of placing them yourself.
titleoptionalWhat it is called in My documents.
curl
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}
    ]
  }'
JavaScript
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;
Python
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

201
{
  "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
curl -s https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/fields   -H "Authorization: Bearer $TOKEN"
JavaScript
const { fields } = await (await fetch(
  `${BASE}/api/v1/forms/${formId}/fields`,
  { headers: { Authorization: `Bearer ${TOKEN}` } })).json();
Python
schema = requests.get(f"{BASE}/api/v1/forms/{form_id}/fields",
    headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30).json()["fields"]

Response

200
{
  "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
  }
}

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.

Query parameters
limitoptionalHow many, up to 1000. 200 by default.
offsetoptionalWhere to start.
curl
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
JavaScript
const { responses } = await (await fetch(
  `${BASE}/api/v1/forms/${formId}/responses`,
  { headers: { Authorization: `Bearer ${TOKEN}` } })).json();
Python
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

200
{
  "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.

Body fields
openoptionalfalse stops it taking answers.
multipleoptionalfalse allows one answer per person.
notifyoptionalWhether the owner is emailed on each submission.
confirmoptionalWhat the person is told after they press Send.
curl
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."}'
JavaScript
await fetch(`${BASE}/api/v1/forms/${formId}`, {
  method: "PATCH",
  headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
  body: JSON.stringify({ open: false }),
});
Python
requests.patch(f"{BASE}/api/v1/forms/{form_id}",
    headers={"Authorization": f"Bearer {TOKEN}"},
    json={"open": False}, timeout=30).json()

Response

200
{
  "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
curl -s -X DELETE https://docubend.com/api/v1/documents/ba59b6ce5221411285daea90f4be16b6 \
  -H "Authorization: Bearer $TOKEN"
JavaScript
await fetch(`${BASE}/api/v1/documents/${docId}`, {
  method: "DELETE",
  headers: { Authorization: `Bearer ${TOKEN}` },
});
Python
requests.delete(f"{BASE}/api/v1/documents/{doc_id}",
    headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30).json()

Response

200
{
  "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
curl -s https://docubend.com/api/v1/usage \
  -H "Authorization: Bearer $TOKEN"
JavaScript
const r = await fetch(`${BASE}/api/v1/usage`,
  { headers: { Authorization: `Bearer ${TOKEN}` } });
const usage = await r.json();
Python
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

200
{
  "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
curl -s https://docubend.com/api/v1 -H "Authorization: Bearer $TOKEN"
JavaScript
const r = await fetch(`${BASE}/api/v1`,
  { headers: { Authorization: `Bearer ${TOKEN}` } });
const surface = await r.json();
Python
requests.get(f"{BASE}/api/v1",
    headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30).json()

Response

200
{
  "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.

Query parameters
stateoptionalopen or closed.
limitoptional1–200, 50 by default.
offsetoptionalWhere to start.
curl
curl -s "https://docubend.com/api/v1/forms?state=open" \
  -H "Authorization: Bearer $TOKEN"
JavaScript
const r = await fetch(`${BASE}/api/v1/forms?state=open`,
  { headers: { Authorization: `Bearer ${TOKEN}` } });
const { forms } = await r.json();
Python
forms = requests.get(f"{BASE}/api/v1/forms", params={"state": "open"},
    headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30).json()["forms"]

Response

200
{
  "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
curl -s https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6 \
  -H "Authorization: Bearer $TOKEN"
JavaScript
const r = await fetch(`${BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6`,
  { headers: { Authorization: `Bearer ${TOKEN}` } });
const { form } = await r.json();
console.log(form.form.count, "fields");
Python
form = requests.get(f"{BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6",
    headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30).json()["form"]

Response

200
{
  "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
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>"}'
JavaScript
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 }),
});
Python
requests.put(f"{BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6",
    headers={"Authorization": f"Bearer {TOKEN}"},
    json={"title": "Membership form 2027", "pdf": b64}, timeout=60)

Response

200
{
  "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.

Body fields
fieldsrequiredA list of {key, type, label, required, page, x, y, w, h}, plus options for the ones that choose. Up to 500.
keepoptionaltrue appends rather than replacing.
curl
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}]}'
JavaScript
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 },
  ] }),
});
Python
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

200
{
  "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
curl -s "https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/suggest?page=1" \
  -H "Authorization: Bearer $TOKEN"
JavaScript
const r = await fetch(`${BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/suggest?page=1`,
  { headers: { Authorization: `Bearer ${TOKEN}` } });
const { fields } = await r.json();
Python
found = requests.get(f"{BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/suggest",
    params={"page": 1},
    headers={"Authorization": f"Bearer {TOKEN}"}, timeout=60).json()["fields"]

Response

200
{
  "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
curl -s https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/responses.csv \
  -H "Authorization: Bearer $TOKEN" -o responses.csv
JavaScript
const r = await fetch(`${BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/responses.csv`,
  { headers: { Authorization: `Bearer ${TOKEN}` } });
await writeFile("responses.csv", await r.text());
Python
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

200
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
curl -s https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/responses/116fb29315e4457ebb0322a787f95efc \
  -H "Authorization: Bearer $TOKEN"
JavaScript
const r = await fetch(
  `${BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/responses/${rid}`,
  { headers: { Authorization: `Bearer ${TOKEN}` } });
const { response } = await r.json();
Python
one = requests.get(
    f"{BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/responses/{rid}",
    headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30).json()

Response

200
{
  "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.

Body fields
answersrequiredAn object keyed by field key.
asoptionalWho the answers are from. Recorded as a claim.
curl
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"}}'
JavaScript
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" } }),
});
Python
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

200
{
  "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.

Ask somebody to fill it in#

POST /api/v1/forms/{id}/shares documents:write

An invitation by email. Needs share and forms on the plan and a confirmed address on the account — we would be putting a message in somebody else's inbox with yours in the reply line.

GET lists who it went to; DELETE ?email= withdraws one.

curl
curl -s https://docubend.com/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/shares \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"email":"jo@example.com"}'
JavaScript
await fetch(`${BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/shares`, {
  method: "POST",
  headers: { Authorization: `Bearer ${TOKEN}`,
             "Content-Type": "application/json" },
  body: JSON.stringify({ email: "jo@example.com" }),
});
Python
requests.post(f"{BASE}/api/v1/forms/ba59b6ce5221411285daea90f4be16b6/shares",
    headers={"Authorization": f"Bearer {TOKEN}"},
    json={"email": "jo@example.com"}, timeout=30)

Response

200
{
  "ok": true,
  "shares": [
    {
      "email": "jo@example.com",
      "mode": "fill",
      "added": 1786890000
    }
  ]
}

Shape read from the handler rather than captured from a live call. The fields are right; the values are illustrative.

Errors#

CodeWhat it means
400The request was not understood — usually no source field, or one that is not the right type. The message lists what this endpoint takes.
401No 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.
402This account's plan does not include what was asked for. The API itself is Pro and Enterprise.
403The credential's scopes do not cover this call — a documents:read token cannot write.
404No such document, or no such route. A document belonging to somebody else is a 404, not a 403.
405That 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.
409The 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.
413The file is larger than the plan allows (512 MB on Pro and Enterprise).
415We cannot read that kind of file. The message lists the kinds we can.
422We 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.
429The account's daily allowance is spent. A Retry-After header says how long until it resets.
507That would put the account over its storage.

The shape of a refusal, and the headers that carry information.

Limits#

FreeProEnterprise
API accessyesyes
API calls a day2,00020,000
Largest single file25 MB512 MB512 MB
Documents keptnothing5 GB25 GB
Credentials at once2020

Every limit, including the ones that are not per plan.

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.

Python
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.

JavaScript
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}`);