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
| Scope | Grants |
|---|---|
forms.read | Read forms, stats, responses, exports, uploaded files and invitations. |
forms.submit | Read the embed config, upload files and submit responses. This is all a public/browser key needs. |
forms.write | Everything 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.
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
OriginorSec-Fetch-*header) gets403 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_keyon 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 & path | Scope | Purpose |
|---|---|---|
GET /v1/forms | read | List forms |
POST /v1/forms | write | Create a form |
GET /v1/forms/{id} | read | Get a form |
PATCH /v1/forms/{id} | write | Update a form |
DELETE /v1/forms/{id} | write | Delete a form |
POST /v1/forms/{id}/duplicate | write | Duplicate a form |
POST /v1/forms/{id}/close | write | Stop accepting responses |
POST /v1/forms/{id}/open | write | Resume accepting responses |
GET /v1/forms/{id}/stats | read | Counts and per-answer breakdown |
GET /v1/forms/{id}/embed | submit or read | Public render config |
POST /v1/forms/{id}/responses | submit | Submit a response |
GET /v1/forms/{id}/responses | read | List responses |
GET /v1/forms/{id}/responses/export | read | CSV / JSON export |
GET /v1/forms/{id}/responses/{response_id} | read | Get a response |
DELETE /v1/forms/{id}/responses/{response_id} | write | Delete a response |
POST /v1/forms/{id}/uploads | submit | Start a chunked upload |
POST /v1/forms/{id}/uploads/{key}/chunk | submit | Send a chunk |
POST /v1/forms/{id}/uploads/{key}/complete | submit | Finalize an upload |
GET /v1/forms/{id}/uploads/{key}/status | submit | Upload progress |
DELETE /v1/forms/{id}/uploads/{key} | submit | Abort an upload |
GET /v1/forms/{id}/files/{upload_id} | submit or read | File metadata |
GET /v1/forms/{id}/files/{upload_id}/content | read | Download a file |
GET /v1/forms/{id}/invitations | read | List invitations |
POST /v1/forms/{id}/invitations | write | Add invitations |
DELETE /v1/forms/{id}/invitations/{invite_id} | write | Remove an invitation |
GET /v1/forms/me | any | Key identity |
GET /v1/forms/health | none | Liveness 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
Lists the key owner's forms, newest first. Scope forms.read.
| Query | Type | Description |
|---|---|---|
search | string | Filter by title or form id (substring match). |
limit / offset | integer | Pagination (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
Scope forms.write. Body:
| Field | Type | Description |
|---|---|---|
title | string, required | Form title (max 255 characters). |
questions | array | Questions and content blocks in display order. See Question schema. Max 200 items. |
settings | object | See 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
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
Scope forms.write. Partial update — send only what you want to change.
At least one of the fields below is required.
| Field | Type | Behaviour |
|---|---|---|
title | string | Replaces the title. Must be non-empty. |
questions | array | Replaces the whole list, validated exactly like create. To edit one question, GET the form, modify the array and PATCH it back. |
settings | object | Merged 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
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
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
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
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 } ] }
]
}
}
| Field | Notes |
|---|---|
responses.remaining | null when the form has no max_responses. |
questions[].answers[].counts | Only for checkbox, dropdown and rating. Checkbox answers with “other” enabled count it under __other__ and list up to 100 other_text values. |
average | Ratings 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 field | Type | Notes |
|---|---|---|
type | string | text, longtext, email, number, date, checkbox, dropdown, rating, file, signature. Unknown types are dropped. |
label | string | Max 200 chars. |
required | boolean | Enforced on submit unless the answer is hidden by show_if. |
options | string[] | checkbox / dropdown only. Max 100, each max 200 chars; duplicates removed. |
single | boolean | checkbox: radio-style single choice. |
allow_other | boolean | checkbox: adds an “Other” free-text option. |
from / to | integer | rating range, 0–100 (defaults 1–5). |
multiple | boolean | file: accept several files. |
is_respondent_email | boolean | email: address used for the response receipt. |
show_if | object | Conditional 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.
| Key | Type | Default | Notes |
|---|---|---|---|
status | "open" | "closed" | open | Also settable via open/close. |
close_message | string | "" | Shown when closed or past closes_at. Max 500. |
thank_you_message | string | "" | Shown after submitting. Max 1000. |
opens_at / closes_at | string | none | YYYY-MM-DDTHH:MM (optionally :SS), UTC. |
max_responses | integer | none | Stop accepting after this many responses. 0 clears it. |
invite_only | boolean | false | Submissions need a valid invite_token (see Invitations). |
notify_owner | "on" | "off" | on | Email the owner on each new response. |
response_receipt | boolean | false | Email a receipt to the respondent's email answer. |
show_question_numbers | boolean | false | Number questions on the rendered form. |
show_issued_date | boolean | true | Show the issued date on the hosted form. |
theme | object | none | See 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>"
}
| Field | Notes |
|---|---|
preset | UI convenience that seeds the other style fields. title, subtitle, paragraph, or custom. Send it explicitly (recommended: send every style field). |
size | One of sm, md, lg, xl. |
align | left, center, or right. |
bold | Boolean; bolds the whole block. |
color | #RRGGBB or empty. |
html | Rich 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_if | Optional 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.
| Field | Notes |
|---|---|
enabled | Master switch. If false and no custom values are set, the theme is omitted entirely. |
page_bg / card_bg | Page and question-card background, #RRGGBB or blank. |
text_color / accent | Body text and accent (buttons, highlights). The selected rating and submit button automatically pick a readable text color from the accent luminance. |
field_bg / field_text | Background and text color of input fields. Blank keeps the default. |
logo_align / logo_height | Logo placement (left/center/right) and pixel height (24–200). |
custom_css | Raw 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:
| Class | Element |
|---|---|
.bf-form | Form wrapper |
.q-form-logo | Logo container |
.q-form-title | Form title |
.q-render-card | Each question card |
.q-num | Question number badge |
.q-render-title | Question text |
.q-answer-label | Field label |
.bf-field | Any input, select, or textarea |
.q-rating-row / .q-rating-opt | Rating row and each option |
.q-text-block | Content / text block |
.q-submit | Submit button |
Embed config
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
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.
| Field | Type | Description |
|---|---|---|
fields | object | Map of field name → value (see Field types). |
invite_token | string | Required 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:
| Status | Code | When |
|---|---|---|
| 403 | invite_required | Invite-only form without a valid invite_token. |
| 409 | form_closed | Closed, outside its schedule, or at max_responses. |
| 409 | already_submitted | The invitation token was already used. |
| 422 | invalid_fields | Malformed email/date/signature or a failed file; details.fields lists them. |
| 422 | missing_required | Visible required fields are empty; details.fields lists them. |
| 413 | payload_too_large | JSON body over 6 MB. |
| 429 | rate_limited | Per-key or per-client-IP limit reached; see Retry-After. |
List responses
Scope forms.read. Submitted responses, newest first, with decoded answer data.
| Query | Type | Description |
|---|---|---|
search | string | Substring match against answer data and location. |
since | date/time | Only responses submitted at or after this instant. |
until | date/time | Only responses submitted at or before this instant. |
limit / offset | integer | Pagination (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
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
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
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.
| Query | Description |
|---|---|
format | csv (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
| Field | Type | Description |
|---|---|---|
field | string | The q{q}_a{a} the file is for. If given, it must be a file question on this form. |
name | string, required | Original file name (the extension is checked; any directory part is stripped). |
size | integer, required | Total size in bytes. |
mime | string | Accepted 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
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
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
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.
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
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" } }
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"
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.
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 } }
}
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.
Scope forms.write. Adds recipients; addresses already invited to this form are skipped.
| Field | Type | Description |
|---|---|---|
emails | string[] or string, required | Up to 500 addresses (a string may be comma/space/semicolon separated). |
personal_note | string | Optional 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).
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
| type | Value you send | Stored value |
|---|---|---|
text / longtext | string | trimmed string (max 2 000 / 50 000 chars) |
email | valid email string | string |
number | number or numeric string | number |
date | YYYY-MM-DD | string |
dropdown | one of options | string (anything else is stored as null) |
checkbox (single) | one of options | string |
checkbox (multi) | array of options (plus "__other__") | array |
rating | integer between from and to | integer |
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 string | upload id + file object |
file (multiple: true) | array of upload_id strings | array 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
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 } }
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"] } } }
| Status | Codes | Meaning |
|---|---|---|
| 400 | invalid_json, empty_chunk, chunk_rejected | Malformed request. |
| 401 | missing_authorization, invalid_token_format, invalid_token, token_revoked, token_expired, token_orphaned | Authentication failed. |
| 402 | subscription_required, plan_upgrade_required | The key owner's subscription doesn't cover Forms (read/write endpoints only). |
| 403 | wrong_service, insufficient_scope, origin_blocked, origin_required, origin_misconfigured, ip_blocked, browser_not_allowed, publishable_key, account_suspended, invite_required | Authenticated but not allowed. |
| 404 | not_found, file_unavailable | Unknown endpoint, or a form/response/upload/invitation that doesn't exist for this key. |
| 405 | method_not_allowed | Path exists but not for this HTTP method. |
| 409 | form_closed, already_submitted | The form isn't accepting, or the invite was used. |
| 410 | upload_gone, chunk_rejected | Upload session expired. |
| 416 | range_not_satisfiable | Bad Range header on a download. |
| 422 | missing_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_supported | Validation failed; see message and details. |
| 413 | payload_too_large | Request body over the endpoint's size cap. |
| 429 | rate_limited, too_many_uploads | Per-key per-minute/per-day cap, per-client-IP submission/upload cap (see Retry-After), or too many unfinished uploads on a form. |
| 500 | internal_error, form_unreadable | Server error — include X-Request-Id when reporting. |
| 502 | upstream_unreachable | The gateway couldn't reach the Forms service — retry. |
| 507 | storage_full | The form owner is out of storage. |