Forms API

Create and manage forms, embed them on any site, collect responses (including files), export results and manage invitations. Every endpoint uses a forms key and respects every form feature: invite-only gating, open/close schedules, response limits, conditional visibility and required-field validation. A key only ever sees forms owned by the account that created it; any other form id returns 404.

Base   https://dev.bodek.us/v1/forms
Auth   Authorization: Bearer bf_live_…

The gateway maps https://dev.bodek.us/v1/forms/<rest> onto the Forms service at https://forms.bodek.us/api/v1/forms/<rest>. Every path on this page is shown relative to the gateway, so GET /v1/forms/{id} means https://dev.bodek.us/v1/forms/{id}. Two service-level paths are the exception: /v1/forms/me and /v1/forms/health map to /api/v1/me and /api/v1/health. You may also call the service origin directly with the same key — recommended for large file downloads (see Files & downloads).

Conventions

Envelope

Successful JSON responses wrap the payload in data; list endpoints add a meta object. Errors use error.code (stable, machine-readable), error.message (human-readable) and, where useful, error.details.

{ "data": { … } }
{ "data": [ … ], "meta": { "limit": 25, "offset": 0, "total": 134 } }
{ "error": { "code": "missing_required", "message": "Required fields are missing.",
             "details": { "fields": ["q0_a1"] } } }

Pagination

List endpoints take limit (1–100, default 25) and offset (default 0). meta.total is the number of matching records, so keep requesting with offset += limit until offset >= total.

curl "https://dev.bodek.us/v1/forms/ABC1234567/responses?limit=100&offset=200" \
  -H "Authorization: Bearer bf_live_XXXX"

Request bodies

Send JSON with Content-Type: application/json. A body that isn't a JSON object returns 400 invalid_json. Chunk uploads use multipart/form-data.

Bodies are size-capped: 2 MB for creating/updating a form, 6 MB for submitting a response, 256 KB for adding invitations and 64 KB for everything else. Larger bodies return 413 payload_too_large. offset is capped at 1,000,000 and search at 200 characters.

Identifiers & timestamps

Form ids are 10-character alphanumeric strings (e.g. ABC1234567); response and invitation ids are integers; upload ids are 16-character alphanumeric strings. Timestamps are returned as YYYY-MM-DD HH:MM:SS in UTC.

Headers

Every response carries X-Request-Id (quote it in bug reports) and the X-RateLimit-Limit-Minute, X-RateLimit-Remaining-Minute, X-RateLimit-Reset-Seconds, X-RateLimit-Limit-Day and X-RateLimit-Remaining-Day counters. See the overview.

Scopes

ScopeGrants
forms.readRead forms, stats, responses, exports, uploaded files and invitations.
forms.submitRead the embed config, upload files and submit responses. This is all a public/browser key needs.
forms.writeEverything above, plus create, update, delete, duplicate and open/close forms, delete responses, and add/remove invitations.

A key holding forms.write passes every forms.read and forms.submit check. Missing scopes return 403 insufficient_scope with the acceptable scopes in error.details.required.

Keep read keys private. forms.read exposes every response and uploaded file. Keys shipped to browsers (e.g. with the SDK) should hold only forms.submit and be restricted to your domains.

Browser vs. server use

Every endpoint marked read or write below is server-side only:

  • A request made from a web browser (it carries an Origin or Sec-Fetch-* header) gets 403 browser_not_allowed, whatever scopes the key has.
  • A key restricted to browser domains (origin mode Domains) is treated as publishable: it can only call the submit-level endpoints (embed, submit, uploads, file metadata of your own in-progress upload) and gets 403 publishable_key on the rest, even when the request comes from a server.

So the safe setup is two keys: a forms.submit key restricted to your domains for the browser, and a separate read/write key kept on your server. If a read or write key has ever been used in page code, revoke it and create a new one: anyone can copy it from the page source and use it from a server.

Management endpoints also require the key owner's account to be in good standing: a suspended account gets 403 account_suspended on every endpoint, and an account without an active subscription that includes Forms gets 402 subscription_required (or plan_upgrade_required) on read/write endpoints. The public embed, submit and upload endpoints keep working, like the hosted form page.

Endpoint index

Method & pathScopePurpose
GET /v1/formsreadList forms
POST /v1/formswriteCreate a form
GET /v1/forms/{id}readGet a form
PATCH /v1/forms/{id}writeUpdate a form
DELETE /v1/forms/{id}writeDelete a form
POST /v1/forms/{id}/duplicatewriteDuplicate a form
POST /v1/forms/{id}/closewriteStop accepting responses
POST /v1/forms/{id}/openwriteResume accepting responses
GET /v1/forms/{id}/statsreadCounts and per-answer breakdown
GET /v1/forms/{id}/embedsubmit or readPublic render config
POST /v1/forms/{id}/responsessubmitSubmit a response
GET /v1/forms/{id}/responsesreadList responses
GET /v1/forms/{id}/responses/exportreadCSV / JSON export
GET /v1/forms/{id}/responses/{response_id}readGet a response
DELETE /v1/forms/{id}/responses/{response_id}writeDelete a response
POST /v1/forms/{id}/uploadssubmitStart a chunked upload
POST /v1/forms/{id}/uploads/{key}/chunksubmitSend a chunk
POST /v1/forms/{id}/uploads/{key}/completesubmitFinalize an upload
GET /v1/forms/{id}/uploads/{key}/statussubmitUpload progress
DELETE /v1/forms/{id}/uploads/{key}submitAbort an upload
GET /v1/forms/{id}/files/{upload_id}submit or readFile metadata
GET /v1/forms/{id}/files/{upload_id}/contentreadDownload a file
GET /v1/forms/{id}/invitationsreadList invitations
POST /v1/forms/{id}/invitationswriteAdd invitations
DELETE /v1/forms/{id}/invitations/{invite_id}writeRemove an invitation
GET /v1/forms/meanyKey identity
GET /v1/forms/healthnoneLiveness check

“read” = forms.read or forms.write; “write” = forms.write; “submit” = forms.submit or forms.write; “submit or read” = any of the three. read and write endpoints are server-side only (see Browser vs. server use).

List forms

GET /v1/forms

Lists the key owner's forms, newest first. Scope forms.read.

QueryTypeDescription
searchstringFilter by title or form id (substring match).
limit / offsetintegerPagination (default 25, max 100).
curl "https://dev.bodek.us/v1/forms?search=signup" \
  -H "Authorization: Bearer bf_live_XXXX"
{
  "data": [
    { "id": "ABC1234567", "title": "Event signup",
      "created_at": "2026-09-01 10:12:00", "updated_at": "2026-09-03 08:00:41", "archived": false }
  ],
  "meta": { "limit": 25, "offset": 0, "total": 7 }
}

Note: meta.total is the owner's total number of forms and is not narrowed by search.

Create a form

POST /v1/forms

Scope forms.write. Body:

FieldTypeDescription
titlestring, requiredForm title (max 255 characters).
questionsarrayQuestions and content blocks in display order. See Question schema. Max 200 items.
settingsobjectSee Form settings.
curl -X POST https://dev.bodek.us/v1/forms \
  -H "Authorization: Bearer bf_live_XXXX" -H "Content-Type: application/json" \
  -d '{
    "title": "Event signup",
    "questions": [
      { "kind": "text", "preset": "title", "size": "xl", "bold": true,
        "html": "Join us for the Spring Mixer" },
      { "question_text": "About you",
        "answers": [
          { "type": "text",  "label": "Full name", "required": true },
          { "type": "email", "label": "Email",     "required": true, "is_respondent_email": true }
        ] },
      { "question_text": "Will you attend?",
        "answers": [
          { "type": "checkbox", "single": true, "options": ["Yes","No"], "required": true } ] }
    ],
    "settings": { "thank_you_message": "See you there!", "max_responses": 100 }
  }'

Returns 201 with the new form in the same shape as Get a form. Mix in kind: "text" items anywhere to add headings, instructions, or lists.

Errors: 422 missing_title, 422 invalid_questions (questions not an array), 422 create_failed (e.g. too many questions), 400 invalid_json.

Get a form

GET /v1/forms/{id}

Scope forms.read. Returns the full schema — items, answers (with each answer's field name), and settings. Every item in questions carries a kind: either "question" (has question_text and answers) or "text" (a display-only content block).

curl https://dev.bodek.us/v1/forms/ABC1234567 \
  -H "Authorization: Bearer bf_live_XXXX"
{
  "data": {
    "id": "ABC1234567",
    "title": "Event signup",
    "questions": [
      { "index": 0, "kind": "text", "preset": "title", "size": "xl", "align": "left",
        "bold": true, "color": "", "html": "Join us for the Spring Mixer", "show_if": null },
      { "index": 1, "kind": "question", "question_text": "About you", "show_if": null,
        "answers": [
          { "index": 0, "field": "q1_a0", "type": "text", "label": "Full name", "required": true,
            "options": [], "single": false, "allow_other": false, "multiple": false,
            "from": null, "to": null, "show_if": null },
          { "index": 1, "field": "q1_a1", "type": "email", "label": "Email", "required": true,
            "options": [], "single": false, "allow_other": false, "multiple": false,
            "from": null, "to": null, "show_if": null }
        ] }
    ],
    "settings": {
      "status": "open", "invite_only": false, "max_responses": 100,
      "opens_at": "", "closes_at": "", "thank_you_message": "See you there!",
      "show_question_numbers": false, "theme": null
    }
  }
}

Errors: 404 not_found (no such form for this key), 500 form_unreadable.

Update a form

PATCH /v1/forms/{id}

Scope forms.write. Partial update — send only what you want to change. At least one of the fields below is required.

FieldTypeBehaviour
titlestringReplaces the title. Must be non-empty.
questionsarrayReplaces the whole list, validated exactly like create. To edit one question, GET the form, modify the array and PATCH it back.
settingsobjectMerged key-by-key into the current settings; omitted keys keep their value. settings.theme is merged into the current theme too, so an uploaded logo is kept. Send "" / 0 to clear opens_at, closes_at or max_responses.
status"open" | "closed"Shortcut for settings.status.
curl -X PATCH https://dev.bodek.us/v1/forms/ABC1234567 \
  -H "Authorization: Bearer bf_live_XXXX" -H "Content-Type: application/json" \
  -d '{ "title": "Event signup (2026)",
        "settings": { "closes_at": "2026-10-01T17:00", "max_responses": 250 } }'

Returns 200 with the updated form, in the same shape as Get a form.

Round-tripping questions. The read schema doesn't expose a question's builder attachment, its question-level required flag, or an email answer's is_respondent_email flag. When a PATCHed question omits these keys, the value from the existing question at the same position is kept; send the key explicitly (e.g. "file_path": null) to clear it. Extra read-only keys such as index and field are ignored. Field names are positional (q{question}_a{answer}), so reordering or inserting questions changes which field existing response data belongs to — prefer appending.

Attachments and logos can't be pointed at other files. A file_path is only kept if it is an attachment this form already has, and settings.theme.logo_path only if it is a logo uploaded to this form; any other value is dropped (on create both are always cleared). Upload attachments and logos in the web builder.

Errors: 422 nothing_to_update, 422 missing_title, 422 invalid_questions, 422 invalid_settings, 422 invalid_status, 422 update_failed, 400 invalid_json, 404 not_found.

Delete a form

DELETE /v1/forms/{id}

Scope forms.write. Permanently deletes the form, all of its responses, its invitations and its stored files on Bodek storage. This cannot be undone.

curl -X DELETE https://dev.bodek.us/v1/forms/ABC1234567 \
  -H "Authorization: Bearer bf_live_XXXX"

{ "data": { "id": "ABC1234567", "deleted": true } }

Errors: 404 not_found, 422 delete_failed.

Duplicate a form

POST /v1/forms/{id}/duplicate

Scope forms.write. Copies the questions, settings, theme, logo and question attachments into a new form titled "<title> (Copy)". Responses and invitations are not copied. No body.

curl -X POST https://dev.bodek.us/v1/forms/ABC1234567/duplicate \
  -H "Authorization: Bearer bf_live_XXXX"

Returns 201 with the new form (same shape as Get a form) plus source_id:

{ "data": { "source_id": "ABC1234567", "id": "Xy7Qm2Lp0a",
            "title": "Event signup (Copy)", "questions": [ … ], "settings": { … } } }

Errors: 404 not_found, 422 duplicate_failed.

Open / close a form

POST /v1/forms/{id}/close
POST /v1/forms/{id}/open

Scope forms.write. Sets settings.status. A closed form rejects submissions with 409 form_closed. close accepts an optional body { "close_message": "…" } (max 500 characters) shown to respondents.

curl -X POST https://dev.bodek.us/v1/forms/ABC1234567/close \
  -H "Authorization: Bearer bf_live_XXXX" -H "Content-Type: application/json" \
  -d '{ "close_message": "Registration is full — thanks!" }'

{ "data": { "id": "ABC1234567", "status": "closed",
            "close_message": "Registration is full — thanks!",
            "accepting": false, "closed_reason": "Registration is full — thanks!" } }

accepting reflects everything that gates submissions, not only the status: an open form can still report accepting: false when it is outside its opens_at/closes_at window or has hit max_responses.

Errors: 404 not_found, 422 invalid_close_message, 422 update_failed.

Stats & summary

GET /v1/forms/{id}/stats

Scope forms.read. Response counts, the accepting state, stored-file totals and invitation counts. Add ?breakdown=1 for per-answer tallies (the same numbers as the Overview tab in the web app).

curl "https://dev.bodek.us/v1/forms/ABC1234567/stats?breakdown=1" \
  -H "Authorization: Bearer bf_live_XXXX"
{
  "data": {
    "id": "ABC1234567", "title": "Event signup", "status": "open",
    "accepting": true, "closed_reason": null,
    "public_url": "https://forms.bodek.us/q/ABC1234567",
    "responses": { "total": 42, "last_24h": 3, "last_7d": 17, "last_30d": 42,
                   "first_at": "2026-09-01 10:30:12", "last_at": "2026-09-23 15:39:39",
                   "max_responses": 100, "remaining": 58 },
    "uploads": { "count": 5, "bytes": 1048576 },
    "invitations": { "total": 10, "submitted": 4, "pending": 6, "reminded": 2 },
    "questions": [
      { "index": 2, "question_text": "Pick one", "answers": [
        { "field": "q2_a0", "type": "dropdown", "label": "", "answered": 2,
          "counts": { "A": 1, "B": 0, "C": 1 } },
        { "field": "q2_a1", "type": "rating", "label": "", "answered": 2,
          "counts": { "1": 0, "2": 1, "3": 0, "4": 1, "5": 0 }, "average": 3 } ] }
    ]
  }
}
FieldNotes
responses.remainingnull when the form has no max_responses.
questions[].answers[].countsOnly for checkbox, dropdown and rating. Checkbox answers with “other” enabled count it under __other__ and list up to 100 other_text values.
averageRatings only, rounded to 2 decimals.

breakdown=1 tallies at most the newest 20,000 responses (and at most 32 MB of answer data); when it had to stop early the result carries "breakdown_sampled": true. On very large forms prefer the plain call.

Question schema

This is what you send in questions on create/update. Items without kind: "text" are questions; each question has one or more answers (inputs).

{
  "question_text": "Contact details",       // max 1000 chars
  "show_if": { "q": 0, "a": 0, "op": "equals", "value": "Yes" },   // optional
  "answers": [                               // max 20 per question
    { "type": "text", "label": "Name", "required": true },
    { "type": "email", "label": "Email", "is_respondent_email": true },
    { "type": "checkbox", "label": "Topics", "options": ["A","B","C"], "allow_other": true },
    { "type": "checkbox", "single": true, "options": ["Yes","No"] },
    { "type": "dropdown", "options": ["Small","Medium","Large"] },
    { "type": "rating", "from": 1, "to": 5 },
    { "type": "file", "label": "CV", "multiple": true }
  ]
}
Answer fieldTypeNotes
typestringtext, longtext, email, number, date, checkbox, dropdown, rating, file, signature. Unknown types are dropped.
labelstringMax 200 chars.
requiredbooleanEnforced on submit unless the answer is hidden by show_if.
optionsstring[]checkbox / dropdown only. Max 100, each max 200 chars; duplicates removed.
singlebooleancheckbox: radio-style single choice.
allow_otherbooleancheckbox: adds an “Other” free-text option.
from / tointegerrating range, 0–100 (defaults 1–5).
multiplebooleanfile: accept several files.
is_respondent_emailbooleanemail: address used for the response receipt.
show_ifobjectConditional visibility (also allowed on the question and on content blocks): q/a are the source question/answer indexes, op is one of equals, not_equals, contains, gte, lte, answered, and value is compared against the source answer.

Form settings

Accepted in settings on create and update (update merges). Invalid values are normalised to their defaults.

KeyTypeDefaultNotes
status"open" | "closed"openAlso settable via open/close.
close_messagestring""Shown when closed or past closes_at. Max 500.
thank_you_messagestring""Shown after submitting. Max 1000.
opens_at / closes_atstringnoneYYYY-MM-DDTHH:MM (optionally :SS), UTC.
max_responsesintegernoneStop accepting after this many responses. 0 clears it.
invite_onlybooleanfalseSubmissions need a valid invite_token (see Invitations).
notify_owner"on" | "off"onEmail the owner on each new response.
response_receiptbooleanfalseEmail a receipt to the respondent's email answer.
show_question_numbersbooleanfalseNumber questions on the rendered form.
show_issued_datebooleantrueShow the issued date on the hosted form.
themeobjectnoneSee Branding & theme.

On read, settings returns status, invite_only, max_responses, opens_at, closes_at, thank_you_message, show_question_numbers and theme.

Content blocks

A content block is a styled, display-only item — a heading, a paragraph, or a bullet/numbered list — that renders inline among your questions but collects no answer. Content blocks never appear in responses, CSV exports, or stats, and are skipped by required-field and conditional-logic evaluation on submit.

A content block is any item in questions with "kind": "text":

{
  "kind": "text",
  "preset": "title",        // "title" | "subtitle" | "paragraph" | "custom"
  "size": "xl",             // "sm" | "md" | "lg" | "xl"
  "align": "left",          // "left" | "center" | "right"
  "bold": true,
  "color": "#1a2230",       // "" or a #RRGGBB hex; blank inherits the form color
  "html": "<b>Welcome</b> — please read <a href=\"https://x.tld\">the terms</a>.<ul><li>One</li><li>Two</li></ul>"
}
FieldNotes
presetUI convenience that seeds the other style fields. title, subtitle, paragraph, or custom. Send it explicitly (recommended: send every style field).
sizeOne of sm, md, lg, xl.
alignleft, center, or right.
boldBoolean; bolds the whole block.
color#RRGGBB or empty.
htmlRich content. Sanitized server-side against a strict allowlist — only p, div, br, b, strong, i, em, u, s, ul, ol, li, a, span survive, all attributes are stripped except href on links (http/https/mailto only, forced to rel="noopener noreferrer nofollow"). A block with no visible text is dropped. Max 20 000 characters.
show_ifOptional conditional-visibility rule, same shape as for questions.

Branding & theme

Forms carry an optional settings.theme object for custom branding: a logo at the top of the form plus page, card, text and accent colors. When enabled is false (or the object is absent) the form uses the clean standard theme.

On read (Get a form and the embed config) the theme is returned with a resolved absolute logo_url:

"theme": {
  "enabled": true,
  "page_bg": "#f4f6f9",
  "card_bg": "#ffffff",
  "text_color": "#1a2230",
  "accent": "#0d6efd",
  "field_bg": "#ffffff",
  "field_text": "#1a2230",
  "custom_css": ".bf-form{font-family:'Inter',sans-serif}",
  "logo_url": "https://forms.bodek.us/questionnaires/ABC1234567/creator/logo_….png",
  "logo_align": "left",
  "logo_height": 64
}

On write send the same fields. Logos are uploaded through the form builder UI, not the JSON API; PATCH merges the theme so an existing logo is kept. Colors must be #RRGGBB (3-digit hex is expanded); invalid colors are dropped. Heights are clamped to 24–200 px.

FieldNotes
enabledMaster switch. If false and no custom values are set, the theme is omitted entirely.
page_bg / card_bgPage and question-card background, #RRGGBB or blank.
text_color / accentBody text and accent (buttons, highlights). The selected rating and submit button automatically pick a readable text color from the accent luminance.
field_bg / field_textBackground and text color of input fields. Blank keeps the default.
logo_align / logo_heightLogo placement (left/center/right) and pixel height (24–200).
custom_cssRaw CSS (max 20 000 chars) applied to the form, for brand matching and custom fonts (@import / @font-face are allowed). Sanitized server-side: HTML-tag breakouts and the javascript:/vbscript:/expression()/-moz-binding/behavior: vectors are stripped. Applies even when enabled is false.

Custom CSS targets these stable classes, present on both the hosted form and embedded (SDK) forms:

ClassElement
.bf-formForm wrapper
.q-form-logoLogo container
.q-form-titleForm title
.q-render-cardEach question card
.q-numQuestion number badge
.q-render-titleQuestion text
.q-answer-labelField label
.bf-fieldAny input, select, or textarea
.q-rating-row / .q-rating-optRating row and each option
.q-text-blockContent / text block
.q-submitSubmit button

Embed config

GET /v1/forms/{id}/embed

Scope forms.submit, forms.read or forms.write. A public-safe render config (used by the SDK): items, the accepting state and reason, invite requirements, the direct submit URL, and the theme. It contains no response data, so it's safe for browser keys.

curl https://dev.bodek.us/v1/forms/ABC1234567/embed \
  -H "Authorization: Bearer bf_live_XXXX"
{
  "data": {
    "id": "ABC1234567",
    "title": "Event signup",
    "questions": [ … same as Get a form … ],
    "accepting": true,
    "closed_reason": null,
    "invite_only": false,
    "submit_url": "https://forms.bodek.us/api/v1/forms/ABC1234567/responses",
    "thank_you": "See you there!",
    "show_question_numbers": false,
    "theme": null
  }
}

If you render your own UI, check accepting first and show closed_reason when it is false. submit_url points at the service origin; posting to /v1/forms/{id}/responses through the gateway is equivalent.

Submit a response

POST /v1/forms/{id}/responses

Scope forms.submit. Field names are q{question}_a{answer} (see each answer's field in the schema). Gating, response limits, conditional visibility and required-field checks all run server-side.

FieldTypeDescription
fieldsobjectMap of field name → value (see Field types).
invite_tokenstringRequired for invite-only forms; each token submits once.
curl -X POST https://dev.bodek.us/v1/forms/ABC1234567/responses \
  -H "Authorization: Bearer bf_live_XXXX" -H "Content-Type: application/json" \
  -d '{
    "fields": { "q1_a0": "Jane Doe", "q1_a1": "jane@example.com", "q2_a0": "Yes" },
    "invite_token": "OPTIONAL_IF_INVITE_ONLY"
  }'

{ "data": { "response_id": 114, "form_id": "ABC1234567", "thank_you": "See you there!" } }

Returns 201. The owner notification and respondent receipt emails are sent according to the form's settings.

Validation. Answers are checked against the form: dropdown and choice values must be one of the options ("__other__" only when the answer allows “Other”), numbers must be finite, dates must be real calendar dates, and text answers must be strings (text is capped at 2,000 characters, long text at 50,000). A single-choice value that isn't an option, or an object/array where a string is expected, returns 422 invalid_fields.

Abuse limits. Besides the key's own rate limits, each client IP may submit to a given form at most 30 times per 10 minutes and 300 times per day (failed attempts count); beyond that the API returns 429 rate_limited with a Retry-After header. Keys restricted to server IPs (origin mode IPs) are exempt, so use one if your backend relays submissions for many users.

File answers. Upload the file(s) first (see File uploads), then send the returned upload_id as the field value — a string for single-file answers, an array for multiple: true. Alternatively, send the whole submission as multipart/form-data with fields[q1_a0]=… parts and the file in a part named file_q{q}_a{a} (small files only; prefer chunked uploads).

"fields": {
  "q2_a0": "9fA3kZ1mQ0pX7bT2",
  "q3_a0": ["Up1d000000000001", "Up1d000000000002"]
}

Errors:

StatusCodeWhen
403invite_requiredInvite-only form without a valid invite_token.
409form_closedClosed, outside its schedule, or at max_responses.
409already_submittedThe invitation token was already used.
422invalid_fieldsMalformed email/date/signature or a failed file; details.fields lists them.
422missing_requiredVisible required fields are empty; details.fields lists them.
413payload_too_largeJSON body over 6 MB.
429rate_limitedPer-key or per-client-IP limit reached; see Retry-After.

List responses

GET /v1/forms/{id}/responses

Scope forms.read. Submitted responses, newest first, with decoded answer data.

QueryTypeDescription
searchstringSubstring match against answer data and location.
sincedate/timeOnly responses submitted at or after this instant.
untildate/timeOnly responses submitted at or before this instant.
limit / offsetintegerPagination (default 25, max 100).

since/until accept ISO-8601 (2026-09-01, 2026-09-01T14:00:00Z, 2026-09-01T16:00:00+02:00) or a Unix timestamp. A bare date means midnight UTC, so until=2026-09-30 excludes the 30th — use until=2026-09-30T23:59:59Z. Invalid values return 422 invalid_date. A handy incremental-sync pattern is to store the newest created_at you've seen and poll with since= that value.

curl "https://dev.bodek.us/v1/forms/ABC1234567/responses?since=2026-09-01T00:00:00Z&limit=100" \
  -H "Authorization: Bearer bf_live_XXXX"
{
  "data": [
    {
      "id": 114,
      "created_at": "2026-09-23 15:39:38",
      "location": "Brooklyn, New York, US",
      "response_data": {
        "1": {
          "0": { "value": "Jane Doe" },
          "1": { "value": "jane@example.com" },
          "2": { "value": "9fA3kZ1mQ0pX7bT2",
                 "file": { "id": "9fA3kZ1mQ0pX7bT2", "name": "resume.pdf", "size": 8388608,
                           "mime": "application/pdf",
                           "url": "https://forms.bodek.us/review/ABC1234567/uploads/9fA3kZ1mQ0pX7bT2" } }
        },
        "2": { "0": { "value": "C" }, "1": { "value": 4 } }
      }
    }
  ],
  "meta": { "limit": 25, "offset": 0, "total": 1 }
}

response_data is keyed by question index, then answer index, so response_data["1"]["2"] is field q1_a2. (When indexes are contiguous from 0 the JSON encoder emits an array instead of an object — index it the same way.) Each entry has a value; hidden or unanswered fields are null. Checkbox answers with “other” add other; signatures add a signature object (method, name, image data URL, signed_at). File answers carry a file object (single) or files array (multiple); their url only works in the owner's browser session — from code, use the download endpoint.

Get a response

GET /v1/forms/{id}/responses/{response_id}

Scope forms.read. One response, in the same shape as a list item.

curl https://dev.bodek.us/v1/forms/ABC1234567/responses/114 \
  -H "Authorization: Bearer bf_live_XXXX"

{ "data": { "id": 114, "created_at": "2026-09-23 15:39:38", "location": "…",
            "response_data": { … } } }

Errors: 404 not_found (unknown form, or the response isn't on this form).

Delete a response

DELETE /v1/forms/{id}/responses/{response_id}

Scope forms.write. Permanently deletes one response (freeing a slot if the form has max_responses). Files it referenced remain in storage.

curl -X DELETE https://dev.bodek.us/v1/forms/ABC1234567/responses/114 \
  -H "Authorization: Bearer bf_live_XXXX"

{ "data": { "id": 114, "form_id": "ABC1234567", "deleted": true } }

Errors: 404 not_found.

Export responses

GET /v1/forms/{id}/responses/export

Scope forms.read. Exports up to 10 000 responses (newest first) in one call. Accepts the same search, since and until filters as List responses.

QueryDescription
formatcsv (default) or json.

CSV is identical to the web app's “Export CSV”: UTF-8 with a BOM (opens cleanly in Excel), columns response_id, submitted_at, ip, location, user_id followed by one column per answer labelled "Question — Label". Multi-select values are joined with ; , “other” text is appended as Other: …, file answers become their URLs, and signatures become Signed by <name> (method, time). Response headers include X-Total-Count and X-Export-Truncated.

Formula-injection protection. Answers are typed by the public, so any text cell that starts with =, +, -, @, a tab or a carriage return is prefixed with a single quote ('=SUM(A1)) so spreadsheet apps show it as text instead of running it. Plain numbers such as -5 are left as they are. The JSON export returns the raw values; escape them yourself before putting them in HTML or a spreadsheet.

curl -o responses.csv "https://dev.bodek.us/v1/forms/ABC1234567/responses/export?since=2026-09-01" \
  -H "Authorization: Bearer bf_live_XXXX"

response_id,submitted_at,ip,location,user_id,"About — Name","About — Email",…
114,"2026-09-23 15:39:38",203.0.113.7,"Brooklyn, New York, US",,"Jane Doe",jane@example.com,…

JSON returns list-shaped items in data and, instead of paging, meta.total, meta.returned and meta.truncated:

{ "data": [ { "id": 114, … } ],
  "meta": { "format": "json", "total": 2, "returned": 2, "truncated": false } }

If truncated is true, narrow the window with since/until or page through List responses. Errors: 422 invalid_format, 422 invalid_date, 404 not_found.

File uploads

Files are uploaded out-of-band, in resumable chunks, before the response is submitted. Each upload yields a short upload_id you then reference from the matching q{q}_a{a} field. Uploads are charged to the form owner's storage and stored on the owner's configured backend. The flow is init → chunk(s) → complete, all under scope forms.submit. Max file size is 256 MB. Allowed extensions: images (jpg, png, gif, webp, bmp, heic, svg), documents (pdf, doc(x), xls(x), ppt(x), txt, rtf, csv), archives (zip, 7z, tar, gz), audio (mp3, wav, m4a, aac, ogg) and video (mp4, m4v, mov, webm).

1. Start

POST /v1/forms/{id}/uploads
FieldTypeDescription
fieldstringThe q{q}_a{a} the file is for. If given, it must be a file question on this form.
namestring, requiredOriginal file name (the extension is checked; any directory part is stripped).
sizeinteger, requiredTotal size in bytes.
mimestringAccepted for compatibility but ignored: the stored type is derived from the extension, and SVG/HTML/XML files are stored as application/octet-stream.

Returns an upload_key (a secret for the following calls), the public upload_id, the server's chunk_size, total_chunks, missing_chunks (indices still needed) and resumed. Re-initialising the same file (same name and size) resumes the existing session.

curl -X POST https://dev.bodek.us/v1/forms/ABC1234567/uploads \
  -H "Authorization: Bearer bf_live_XXXX" -H "Content-Type: application/json" \
  -d '{ "field": "q2_a0", "name": "resume.pdf", "size": 8388608, "mime": "application/pdf" }'

{ "data": { "upload_key": "7bb478f615a53bde6a82679d94be7d2d", "upload_id": "9fA3kZ1mQ0pX7bT2",
            "chunk_size": 5242880, "total_chunks": 2, "missing_chunks": [0,1], "resumed": false } }

Uploads can only be started while the form is accepting responses. Each form may have at most 50 unfinished upload sessions, and their reserved sizes count against the owner's storage. Each client IP may start at most 200 uploads per form per 10 minutes (1,000 per day; keys in origin mode IPs are exempt).

Errors: 422 upload_rejected (bad name/type/size/field), 409 form_closed, 429 too_many_uploads (too many unfinished sessions), 429 rate_limited, 507 storage_full (owner out of storage).

2. Send chunks

POST /v1/forms/{id}/uploads/{key}/chunk
PUT /v1/forms/{id}/uploads/{key}/chunk?index={n}

Send one chunk either as multipart/form-data (POST) with a chunk file part and the zero-based chunk_index, or as the raw request body (PUT or POST) with the index in ?index=N. Slice the file into chunk_size pieces (the last may be shorter). Every chunk must be exactly chunk_size bytes except the last, which must be the remainder; any other length is rejected with 400 chunk_rejected. Chunks are idempotent and may be retried or sent in any order.

split -b 5242880 -d -a 3 resume.pdf part_
curl -X POST https://dev.bodek.us/v1/forms/ABC1234567/uploads/KEY/chunk \
  -H "Authorization: Bearer bf_live_XXXX" \
  -F "chunk_index=0" -F "chunk=@part_000"

# or raw bytes:
curl -X PUT "https://dev.bodek.us/v1/forms/ABC1234567/uploads/KEY/chunk?index=1" \
  -H "Authorization: Bearer bf_live_XXXX" -H "Content-Type: application/octet-stream" \
  --data-binary @part_001

{ "data": { "received": true, "complete": false } }

Errors: 400 empty_chunk, 400 chunk_rejected (bad index or size), 410 chunk_rejected (session expired), 404 not_found (unknown key).

3. Complete

POST /v1/forms/{id}/uploads/{key}/complete

Finalize once every chunk is received. The server reassembles, verifies the size and stores the file on the owner's backend.

curl -X POST https://dev.bodek.us/v1/forms/ABC1234567/uploads/KEY/complete \
  -H "Authorization: Bearer bf_live_XXXX"

{ "data": { "upload_id": "9fA3kZ1mQ0pX7bT2", "name": "resume.pdf", "size": 8388608,
            "url": "https://forms.bodek.us/review/ABC1234567/uploads/9fA3kZ1mQ0pX7bT2" } }

Errors: 422 incomplete_upload with details.missing_chunks, 507 storage_full.

Progress & abort

GET /v1/forms/{id}/uploads/{key}/status

Returns status, upload_id, total_chunks and the remaining missing_chunks — use it to resume after an interruption. 410 upload_gone if the session no longer exists.

DELETE /v1/forms/{id}/uploads/{key}

Abort an in-progress upload and discard its chunks. Returns { "aborted": true }.

Finally, submit the response with "q2_a0": "9fA3kZ1mQ0pX7bT2". An upload can be attached to one response only; unattached uploads expire.

Files & downloads

GET /v1/forms/{id}/files/{upload_id}

Scope forms.submit, forms.read or forms.write. A stored upload's metadata. The url is the web app's owner-session link (works in the owner's browser only). Submit-only keys, publishable (domain-restricted) keys and browser requests can only see uploads that aren't attached to a submitted response yet; other uploads return 404 not_found. Server-side read keys see every upload on the form.

{ "data": { "id": "9fA3kZ1mQ0pX7bT2", "name": "resume.pdf", "size": 8388608,
            "mime": "application/pdf",
            "url": "https://forms.bodek.us/review/ABC1234567/uploads/9fA3kZ1mQ0pX7bT2" } }
GET /v1/forms/{id}/files/{upload_id}/content

Scope forms.read (server-side only). Downloads the file bytes. The response is always an attachment (Content-Disposition: attachment) with the stored Content-Type (active types such as HTML, SVG and XML are sent as application/octet-stream), plus X-Content-Type-Options: nosniff, a sandboxing Content-Security-Policy, Content-Length and Accept-Ranges: bytes. Send a Range header (e.g. bytes=0-1048575, or bytes=-500 for the last 500 bytes) to receive 206 Partial Content. If the owner stores files in their own S3/B2 bucket with “redirect” downloads enabled, you get a 302 to a signed URL valid for 2 minutes — let your client follow it (curl -L).

curl -L -o resume.pdf \
  https://forms.bodek.us/api/v1/forms/ABC1234567/files/9fA3kZ1mQ0pX7bT2/content \
  -H "Authorization: Bearer bf_live_XXXX"
Large files: downloads work through the gateway (https://dev.bodek.us/v1/forms/…/content, including Range and Content-Disposition), but the gateway buffers each response and times out after 60 seconds. For big files call the service origin directly as shown above (https://forms.bodek.us/api/v1/forms/…, same key), or fetch in Range slices.

Errors: 404 not_found (unknown upload, not on this form, or not finished), 404 file_unavailable (stored bytes missing), 416 range_not_satisfiable.

Invitations

Invitations give each recipient a personal link (and invite_token). They're required when settings.invite_only is true and optional otherwise; each token can submit once, and submitting marks the invitation submitted_at.

GET /v1/forms/{id}/invitations

Scope forms.read. Newest first, paginated with limit/offset; meta.counts summarises the whole list.

{
  "data": [
    { "id": 8, "email": "jane@example.com", "personal_note": "Hope you can make it!",
      "invite_token": "178b0896372ded56f9b78af4bb4ec8f0",
      "invite_url": "https://forms.bodek.us/q?q=ABC1234567&i=178b0896372ded56f9b78af4bb4ec8f0",
      "created_at": "2026-09-23 15:40:04", "sent_at": null, "last_reminder_at": null,
      "reminder_count": 0, "submitted_at": null, "response_id": null }
  ],
  "meta": { "limit": 25, "offset": 0, "total": 1,
            "counts": { "total": 1, "submitted": 0, "pending": 1, "reminded": 0 } }
}
The API doesn't send e-mail. Invitations are created and managed through the API, but delivering them is up to you: send each recipient their invite_url from your own mail system, or use Send invitations in the Bodek Forms web app. (Sending from the API would let any key make Bodek e-mail arbitrary addresses, so the former invitations/send, invitations/remind and invitations/{invite_id}/send endpoints have been removed and return 404.)

Use invite_url in your own emails, or pass invite_token when submitting through the API or data-bodek-invite in the SDK.

POST /v1/forms/{id}/invitations

Scope forms.write. Adds recipients; addresses already invited to this form are skipped.

FieldTypeDescription
emailsstring[] or string, requiredUp to 500 addresses (a string may be comma/space/semicolon separated).
personal_notestringOptional note (max 2,000 characters), shown in the invitation e-mail if you later send it from the web app.
curl -X POST https://dev.bodek.us/v1/forms/ABC1234567/invitations \
  -H "Authorization: Bearer bf_live_XXXX" -H "Content-Type: application/json" \
  -d '{ "emails": ["jane@example.com", "sam@example.com"], "personal_note": "Hope you can make it!" }'

{ "data": { "added": 2, "skipped_duplicates": 0, "skipped_invalid": 0, "sent": 0 } }

skipped_invalid counts entries that weren't valid addresses or were repeated within the request. sent is always 0. A form can have at most 10,000 invitations. Errors: 422 missing_emails, 422 too_many_emails, 422 too_many_invitations, 422 sending_not_supported (the old "send": true flag).

DELETE /v1/forms/{id}/invitations/{invite_id}

Scope forms.write. Removes an invitation; its link stops working. Returns { "id": 8, "deleted": true }.

Errors (all): 404 not_found for an unknown form or invitation id.

Field types

typeValue you sendStored value
text / longtextstringtrimmed string (max 2 000 / 50 000 chars)
emailvalid email stringstring
numbernumber or numeric stringnumber
dateYYYY-MM-DDstring
dropdownone of optionsstring (anything else is stored as null)
checkbox (single)one of optionsstring
checkbox (multi)array of options (plus "__other__")array
ratinginteger between from and tointeger
signature{ "method": "auto"|"manual", "name": "…", "image": "data:image/png;base64,…" } (object or JSON string, image ≤ 500 KB)name (or "Signed") + signature object
file (single)an upload_id stringupload id + file object
file (multiple: true)array of upload_id stringsarray of ids + files array

For checkboxes with “other” enabled, also send q{q}_a{a}_other_text. Conditional questions/answers carry a show_if rule; hidden fields are skipped and never required. Items with kind: "text" are content blocks, not fields — they take no value.

Storage backends

Every form owner has a storage backend that holds uploaded files. The default is Bodek storage, which shares one combined 5 GB allowance with Bodek Files. Owners can instead connect their own Amazon S3 or Backblaze B2 bucket (configured in form settings) for their own limit or unlimited capacity. The choice is transparent to API clients — you always reference files by upload_id and download them through /files/{upload_id}/content.

When an owner using Bodek storage is over their limit, uploads are refused with 507, any form that collects files stops accepting submissions, and the owner is emailed. Owners on their own bucket are bounded only by the limit they set (if any).

Embeddable SDK

Drop a form onto any site with one div and one script. Use a publishable forms key with only the forms.submit scope, restricted to your domains in the console. (The SDK only calls the embed, upload and submit endpoints; a key with forms.read would let anyone who views your page source read your responses. Read and write endpoints refuse browser requests and domain-restricted keys; see Browser vs. server use.)

<div data-bodek-form="ABC1234567" data-bodek-key="bf_live_XXXX"></div>
<script src="https://forms.bodek.us/sdk/bodek-forms.js"></script>

The widget injects a self-contained default stylesheet (so embedded forms match the hosted look out of the box), renders inputs by type, draws content blocks and applies your branding (logo and colors) via CSS variables, applies conditional logic client-side (re-validated server-side), and submits. File answers are uploaded automatically in resumable chunks the moment they're picked, with a progress card per file and support for multi-file answers; submission is blocked until uploads finish, and empty required fields are flagged with a red border. Your custom_css is injected last so it overrides the defaults; target the same classes documented under Branding. For invite-only forms add data-bodek-invite="<token>".

Prefer your own UI? Fetch the embed config, render the questions, upload files with the chunked flow, then submit.

Key identity & health

GET /v1/forms/me

Any valid forms key, no particular scope. Returns the calling key's id, name, scopes and owner — handy for verifying a key at startup. (On the service origin it is https://forms.bodek.us/api/v1/me.)

curl https://dev.bodek.us/v1/forms/me -H "Authorization: Bearer bf_live_XXXX"

{ "data": { "key_id": 36, "service": "forms", "name": "Website signup",
            "scopes": ["forms.submit"], "user_id": 3 } }
GET /v1/forms/health

No key required. Liveness check:

curl https://dev.bodek.us/v1/forms/health

{ "data": { "status": "ok", "service": "forms", "time": "2026-09-23T15:38:49+00:00" } }

Errors

All errors share one shape:

{ "error": { "code": "insufficient_scope",
             "message": "This key needs the scope: forms.write",
             "details": { "required": ["forms.write"] } } }
StatusCodesMeaning
400invalid_json, empty_chunk, chunk_rejectedMalformed request.
401missing_authorization, invalid_token_format, invalid_token, token_revoked, token_expired, token_orphanedAuthentication failed.
402subscription_required, plan_upgrade_requiredThe key owner's subscription doesn't cover Forms (read/write endpoints only).
403wrong_service, insufficient_scope, origin_blocked, origin_required, origin_misconfigured, ip_blocked, browser_not_allowed, publishable_key, account_suspended, invite_requiredAuthenticated but not allowed.
404not_found, file_unavailableUnknown endpoint, or a form/response/upload/invitation that doesn't exist for this key.
405method_not_allowedPath exists but not for this HTTP method.
409form_closed, already_submittedThe form isn't accepting, or the invite was used.
410upload_gone, chunk_rejectedUpload session expired.
416range_not_satisfiableBad Range header on a download.
422missing_title, invalid_questions, invalid_settings, invalid_status, nothing_to_update, create_failed, update_failed, delete_failed, duplicate_failed, invalid_close_message, invalid_fields, missing_required, invalid_date, invalid_format, upload_rejected, incomplete_upload, missing_emails, too_many_emails, too_many_invitations, sending_not_supportedValidation failed; see message and details.
413payload_too_largeRequest body over the endpoint's size cap.
429rate_limited, too_many_uploadsPer-key per-minute/per-day cap, per-client-IP submission/upload cap (see Retry-After), or too many unfinished uploads on a form.
500internal_error, form_unreadableServer error — include X-Request-Id when reporting.
502upstream_unreachableThe gateway couldn't reach the Forms service — retry.
507storage_fullThe form owner is out of storage.