docubend
Documentation

Forms

A form is an ordinary document with field nodes on it — which is why a photograph of a paper form can become one.

Rendering a form as .pdf writes a genuine AcroForm — fields anybody can type into in Acrobat, Preview or a browser, with the answers readable by software afterwards.

The field types#

Ten, and the list is short on purpose: every one is something a PDF's own form dictionary can hold. A type with nowhere to go in the file format would be a type that quietly became a picture of itself.

TypeIn the PDF
texttext fieldOne line
textareatext fieldSix lines
number, date, email, teltext fieldOne line, validated on submission
checkbox, radiobuttonA checkbox is never missing — it is off
selectchoiceTakes options
signaturetext fieldThe place on the page where a person writes their name. Not a digital signature, and nothing here says it is.

Each field is {key, type, label, required, page, x, y, w, h}. The key is the name an answer comes back under and the heading of its CSV column, so it is held to what a JSON key, a CSV header and a PDF field name can all be: up to 64 characters of A–Z a–z 0–9 _ . - and spaces, starting with a letter or a digit. Up to 500 of them on one form.

Filling one in is not a capability any plan can withhold. The person answering usually has no account, often is not a customer, and is doing the account holder a favour. Building a form is the paid line; answering one is a door with no lock on it.

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
        }
      ]
    }
  }
}

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.

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
  }
}

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.

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.

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
      }
    }
  ]
}

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.

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
  }
}