HTTP API
Forms
A form is a public endpoint your own website posts into: a contact form, a newsletter signup, a lead capture box. It is the only part of the Sedgemark API that accepts a write without a key.
How it works
You define a form in the dashboard the same way you define a collection: a name, a slug, and a list of typed fields. Sedgemark gives you back two endpoints and an inbox. Your site posts to the endpoints; you read what arrives in the inbox.
No authentication. Returns the form’s fields and an anti-spam token.
No authentication. Never send an API key here.
These endpoints run in your visitors’ browsers. They are unauthenticated by design, and attaching an API key to a request from a browser publishes that key to everyone who loads the page.
Fetching a form’s descriptor
The descriptor tells you what the form currently expects. Building your markup from it, rather than hardcoding field names, means adding a field in the dashboard does not require a code change on the site.
{
"slug": "contact",
"name": "Contact us",
"fields": [
{
"name": "Email",
"slug": "email",
"field_type": "email",
"required": true,
"config": {},
"constraints": {
"control": "input",
"input_type": "email",
"input_mode": "email",
"autocomplete": "email",
"max_length": 320,
"min": null,
"max": null,
"step": null,
"values": []
}
}
],
"honeypot_field": "website",
"min_submit_seconds": 3,
"token": "eyJmIjoiYzE0Zi4uLiIsImlhdCI6MTc1NDMxNDI0NX0.9Qk3..."
} | Key | Notes |
|---|---|
| slug | The form’s slug, echoing what you asked for. |
| name | Display name from the dashboard. |
| fields | Each with name, slug, field_type, required, config, and a constraints block. Post values keyed by slug. |
| honeypot_field | The name of the trap input to render hidden, or null if the form has none. |
| min_submit_seconds | How long a submission must take. 0 means no timing requirement. |
| token | The signed anti-spam token, or null when min_submit_seconds is 0. |
The constraints block on each field carries everything needed to render the
right control and validate the value in the browser: control, input_type, input_mode, autocomplete, max_length, min, max, step, and values for an enum. You never have to infer an input
type from a field type yourself.
Submitting
Post JSON, keyed by field slug. No key, no session, no CSRF token, just the values.
const res = await fetch(
'https://acme.sedgemark.app/api/v1/forms/contact/submit',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'visitor@example.com',
message: 'Hello!',
}),
}
)
const result = await res.json()
// { ok: true, id: "…", submitted_at: "2026-08-04T15:04:05.000Z" }
Or from a server, where no Origin header means the allowlist never applies:
curl -X POST https://acme.sedgemark.app/api/v1/forms/contact/submit \
-H "Content-Type: application/json" \
-d '{"email":"visitor@example.com","message":"Hello!"}' HTTP/1.1 201 Created
{
"ok": true,
"id": "5a1f3c88-2b47-4e19-90d6-7c8e9f0a1b2c",
"submitted_at": "2026-08-04T15:04:05.000Z",
"ignored": ["utm_source"]
} | Key | Notes |
|---|---|
| ok | Always true on a 201. |
| id | The stored submission’s id, or null if the honeypot was tripped. See below. |
| submitted_at | ISO 8601 timestamp. Absent from the honeypot response, which is exactly { ok: true, id: null } and nothing else. |
| ignored | Present only when the payload carried keys the form does not define, and truncated to the first 20. See Unknown keys. |
Unknown keys are dropped, not rejected
A payload key that matches no field is ignored and echoed back in the ignored array. It is not an error. This is deliberate on two counts: real
browser posts routinely carry tracking parameters like utm_source or
framework fields your form did not ask for, and rejecting unknown keys would turn a
public endpoint into a way for a stranger to enumerate your field names one guess at a
time.
Missing required fields still fail. You define which fields are required, so that is not a disclosure.
Three caps bound all of this:
| Cap | Behavior |
|---|---|
| 64 KB body | A larger body is rejected with 413 while being read, before it is parsed. |
| 100 keys | A payload with more than 100 keys is rejected, not trimmed: 400 with a single error whose field is _payload. That is not a real field slug, so a handler mapping errors onto inputs will find nothing to attach it to: render it as a form-level message. |
| 20 ignored keys | The `ignored` array echoes at most the first 20 unknown keys. The rest are still dropped silently; a short `ignored` array is not proof there were no others. |
Field types
Forms have their own list of 12 field types, overlapping with but
not identical to a collection’s. There is deliberately no rich_text, relation, media or json:
none of them make sense on a surface a stranger can post to.
| Field type | Render as | Input type | Max length | Server rule |
|---|---|---|---|---|
| text | text | 1,000 | Single-line: control characters stripped, newlines/tabs collapsed to spaces, then trimmed. | |
| long_text | None | 10,000 | Multi-line: control characters stripped (line breaks preserved), then trimmed. | |
| 320 | Exactly one "@", a dot in the domain, no whitespace. | |||
| url | url | 2,048 | Must parse as a URL with an http: or https: scheme. | |
| phone | tel | 32 | Non-dialable characters stripped, then must contain 7-20 digits. | |
| integer | number | None | Must be an integer within -2147483648..2147483647. | |
| decimal | number | 40 | Must be a finite number. | |
| boolean | checkbox | None | Accepts true/false, "true"/"false", "on"/"off", "1"/"0", 1/0. | |
| date | date | None | Must be YYYY-MM-DD and a real date. | |
| datetime | datetime-local | None | Must parse as a date; stored normalized to UTC. | |
| enum | None | 100 | Must be exactly one of config.values (no coercion, no case-folding). | |
| file | file | None | An array of upload ids from POST /api/v1/forms/{slug}/upload — never file bytes. At most config.max_files (ceiling 10), each single-use and valid for 60 minutes. |
decimal’s max length applies to the submitted string. A number input ignores a maxlength attribute, so validate
it rather than rendering it.
phone renders as type="tel". There is no type="phone" in HTML; writing one silently degrades to a plain
text input.
File uploads
A file field is the one field type whose value is not what the visitor
typed. Files travel through their own endpoint first, and the value you submit is a list of upload
ids, never the bytes themselves.
None
One file per request. The file goes in the request body raw: this endpoint does not accept multipart/form-data, and sending a FormData to it will fail. The
field slug and the original filename ride in the query string:
POST https://acme.sedgemark.app/api/v1/forms/contact/upload?field=resume&filename=cv.pdf
Content-Type: application/pdf
<the raw bytes of the file> HTTP/1.1 201 Created
{
"ok": true,
"id": "8f14e45f-cea5-4c2b-9d31-77b0a1f6c3de",
"filename": "cv.pdf",
"mime_type": "application/pdf",
"size": 248113,
"expires_at": "2026-03-01T19:00:00.000Z"
}
Then send the returned id (or several) as the field’s value on the
ordinary submit call:
POST https://acme.sedgemark.app/api/v1/forms/contact/submit
Content-Type: application/json
{
"_token": "<from the descriptor>",
"email": "sam@example.com",
"resume": ["8f14e45f-cea5-4c2b-9d31-77b0a1f6c3de"]
} Reading a multipart body means buffering all of it before anything can measure it, which on an endpoint any stranger can reach is a memory-pressure primitive rather than a convenience. Taking the file as the raw body lets the size cap be enforced while reading, with no parser involved. It also means uploads run while the visitor is still filling in the rest of the form, which is what makes a progress bar possible at all.
In a browser
A File from an <input type="file"> is already a
valid body: pass it straight to fetch:
// 1. Send each file to the upload endpoint, as raw bytes.
const ids = await Promise.all(
[...input.files].map(async (file) => {
const url = new URL(`${BASE}/api/v1/forms/contact/upload`)
url.searchParams.set('field', 'resume')
url.searchParams.set('filename', file.name)
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: file, // the File itself, not FormData
})
if (!res.ok) throw new Error((await res.json()).error)
return (await res.json()).id
})
)
// 2. Send the ids with the rest of the form.
await fetch(`${BASE}/api/v1/forms/contact/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ _token: token, email: emailInput.value, resume: ids }),
}) The upload endpoint honours the same allowed-origins rule as submitting, so a browser can call it from any origin the form already permits.
What a field accepts
A file field’s config.accept is a list of group keys, never MIME strings. A form
can narrow what the platform permits; it can never widen it.
| Group | Name | Extensions |
|---|---|---|
images | Images | .jpg, .jpeg, .png, .gif, .webp |
documents | Documents | .pdf, .doc, .docx, .txt, .md |
spreadsheets | Spreadsheets | .csv, .xls, .xlsx |
archives | Archives | .zip, .gz, .tar |
audio_video | Audio & video | .mp4, .webm, .mp3, .wav, .ogg |
An absent or empty accept means every group. Extension and declared
content type are checked independently, because requiring them to agree rejects a great
many genuine files: a .csv from a machine with Excel installed arrives as application/vnd.ms-excel.
A browser handed any of the three inline parses it as a scripted document, and a form upload is entirely stranger-supplied. The media library still accepts them, because an editor who uploads one chose to; nothing about a public form needs a vector image badly enough to reverse that.
Limits and lifetime
| Setting | Default | Ceiling |
|---|---|---|
| config.max_files | 1 | 10 |
| config.max_size_mb | 10 MB | 25 MB |
The size ceiling is half the media library’s, for the reason that runs through this whole page: this endpoint is unauthenticated. Both values are re-clamped when they are read, so a field configured before a ceiling changed behaves rather than advertising a cap the endpoint would refuse.
An upload that is never claimed by a submission is deleted after 60 minutes, so a visitor who attaches a file and abandons the form costs you nothing. Claiming happens inside the submission’s own transaction, so a submission never lands pointing at a file that is not there.
Reading files back
Uploaded files are never served over /api/v1, by any key. They are attachments to a private submission,
not library assets: reach them from the submissions inbox in the dashboard, or over MCP
with a submissions:read key. See Reading submissions.
Handling errors
Validation collects every failing field in one pass, so a visitor sees all their mistakes at once instead of one per attempt:
HTTP/1.1 400 Bad Request
{
"ok": false,
"error": "Validation failed",
"errors": [
{ "field": "email", "message": "email must be a valid email address" },
{ "field": "message", "message": "message is required" }
]
} An anti-spam failure looks different: a single message plus a machine-readable code:
HTTP/1.1 400 Bad Request
{
"ok": false,
"error": "Submitted too quickly, please try again",
"code": "submitted_too_fast"
} | Code | Meaning | Retryable |
|---|---|---|
| token_invalid | The token was missing, malformed, tampered with, or issued for a different form. | No, refetch this form’s descriptor. |
| token_expired | The token is older than 24 hours. | Yes, refetch the descriptor and resubmit. |
| submitted_too_fast | The token was not yet min_submit_seconds old when the submission arrived. | Yes, wait, then resubmit. |
Other statuses you may see: 403 when the request’s origin is not on the
form’s allowlist, 413 for an oversized body, 429 when over
the rate limit, and 404, covered next.
An unknown slug, a form switched to inactive, a form your current plan no longer covers,
and forms being disabled for the workspace all return the same 404 with the
same body. A 404 here never confirms that a slug exists, so a stranger cannot probe for
your form names or read your billing state off a status code.
Anti-spam
Two mechanisms, both optional and configured per form in the dashboard.
The honeypot field
If honeypot_field is set, render an input with that exact name, hidden from
real visitors: off-screen, tabindex="-1", autocomplete="off". A bot that fills every input it finds will fill
this one.
When it is filled, the API returns 201 { "ok": true, "id": null }, a deliberate fake success. No row is
stored and no webhook fires. Show the visitor your normal thank-you message; never render
it as an error, and never tell the caller why. If you need to distinguish it, check for id === null.
The timing token
If min_submit_seconds is greater than 0, the submission must
carry the descriptor’s token back as a _token key in the
body, and that token must already be at least that many seconds old.
The token records when it was issued. A token minted inside your submit handler is zero
seconds old and is rejected with submitted_too_fast 100% of the time. Fetch the
descriptor at page load, hold the token, and attach it on submit.
It stays valid for 24 hours and is reusable, so one fetch per page load is the right amount.
When min_submit_seconds is 0 the token is null and
any _token you send is ignored, which is what makes server-side and
static-build submission work with no round trip at all.
Neither mechanism is a CSRF token or a security boundary. They raise the cost of naive automated spam; they do not stop a determined attacker with a script.
CORS and allowed origins
Each form has an origin allowlist. Leave it empty and any browser origin may submit; fill it in and only those exact origins may.
A request with no Origin header (curl, a server, a build step) is always accepted, whatever the
allowlist says. CORS is a browser-integrity control: it stops another website
submitting to your form from a visitor’s browser. It does not stop anyone who can
run a script.
Treat the allowlist as hygiene, and treat every submission as untrusted input regardless of where it claims to come from.
- Matching is an exact string match on the serialized origin. There is no wildcard and no subdomain matching.
-
A rejected origin gets
403with no CORS headers, so the browser reports it as a CORS failure rather than leaking the response. -
An accepted origin is reflected verbatim, never
*, whenever an allowlist is configured. -
Cookies are never involved:
Access-Control-Allow-Credentialsis not sent anywhere. -
Both endpoints answer
OPTIONSpreflights, cached for 24 hours. A JSON POST is never a simple request, so every browser submit costs a preflight and the POST against the shared rate limit.
A complete example
Plain HTML and fetch, with the honeypot hidden and the token fetched at the
right moment:
<form id="contact-form">
<label>
Email
<input name="email" type="email" inputmode="email" maxlength="320" required />
</label>
<label>
Message
<textarea name="message" maxlength="10000"></textarea>
</label>
<!-- Honeypot. The name must match the form's honeypot_field exactly.
Real visitors never see it; bots fill it in. -->
<input
name="website"
type="text"
tabindex="-1"
autocomplete="off"
style="position:absolute;left:-9999px"
/>
<button type="submit">Send</button>
</form>
<script>
const BASE = 'https://acme.sedgemark.app'
// Fetch the descriptor NOW, at page load, not inside the submit handler.
// The token carries its issue time, and the submit is rejected unless the
// token is already min_submit_seconds old. One minted at submit time fails
// every single time. Skip this entirely when min_submit_seconds is 0.
const tokenReady = fetch(BASE + '/api/v1/forms/contact')
.then((r) => r.json())
.then((d) => d.token)
.catch(() => null)
document.getElementById('contact-form').addEventListener('submit', async (e) => {
e.preventDefault()
const body = Object.fromEntries(new FormData(e.target))
const token = await tokenReady
if (token) body._token = token
const res = await fetch(BASE + '/api/v1/forms/contact/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const result = await res.json()
if (result.ok) {
// Show the thank-you. This includes a tripped honeypot, where id is null.
} else {
// result.errors is [{ field, message }]: mark up every one of them.
}
})
</script>
If you are working in TypeScript, @sedgemark/client does all of this for
you: calling sm.form(slug) at render time primes the token, and submit() attaches it and surfaces every failing field at once. The
downloadable generated client goes further still, with a validator that mirrors the
server exactly and per-field specs a generic form component can render from. See TypeScript client.
Reading submissions
Submissions are never readable over /api/v1.
There is no endpoint for them at any scope. Contact forms collect personal data, and the
delivery API is the surface most likely to have a key sitting in a build pipeline.
- The dashboard inbox: browse, bulk-delete, and export to CSV.
- The MCP tools
list_submissionsandget_submission, which require a key with thesubmissions:readgrant specifically. A key granted onlyforms:readcan list this form’s fields and still cannot see a single submission. See MCP server. - A webhook: subscribe to
form.submittedto be notified as each one arrives. Note that the payload carries the submission’s id, not its values, so this tells you something came in; reading it still means one of the two routes above. See Webhooks.