Start here
Connecting your own website to DodoForm takes one credential and one request. No form to create first, no UUID to look up, no fields to define. Grab an API key from /dashboard/api and POST your form data as-is:
curl -X POST https://www.dodoform.com/api/v1/inbox \
-H "Authorization: Bearer df_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"form": "Website contact",
"data": {
"name": "Jane Doe",
"email": "jane@acme.com",
"message": "I would like a demo."
}
}'Fields are detected automatically
The first call creates a form called "Website contact" and turns your JSON keys into fields — email becomes an email field, message a long-text field, and so on. Later calls reuse that form, and any new key you send becomes a new column.
Structured data costs 0 AI credits — detection is rule-based, not AI.
That's the whole integration. Everything below is for when you want more control — sending to a specific existing form, extracting fields from unstructured text, or handing a narrowly-scoped credential to a third party.
Authentication — two key types
DodoForm has two kinds of API key. Either one can send records — pick whichever suits you and move on. The difference is only how much they can reach.
| Key | Looks like | Created in | Works on |
|---|---|---|---|
| Workspace key | df_live_… | /dashboard/api | Every endpoint — sending records to any form in the workspace, plus /api/v1/me and /api/parse. Simplest choice. |
| Form key | ddf_live_… | /dashboard/forms/{id}/api | Sending records to one form only. Use when handing a credential to a third party. |
| Browser key | df_pk_… | /dashboard/api | /api/v1/inbox only, from the site origins you list. Safe to ship in client-side code — for static sites with no backend. Pro and above. |
Not sure which to use?
If your code runs on a server — an API route, serverless function, or backend — use a workspace key from /dashboard/api. It works on every endpoint, including record ingestion for any form you own.
If your site is static with no backend, a secret key would be exposed in the page source. Use a browser key instead. Reach for a form key only when you want a server credential that can touch exactly one form.
Authorization: Bearer df_live_your_workspace_key_hereKeys are shown once. Copy the secret at creation — it is stored only as a SHA-256 hash and cannot be retrieved again. Revoke and recreate if lost.
Use the www host. www.dodoform.com serves the API; the bare apex domain does not.
Plan requirement. Creating API keys of either type requires the Max plan or above. See pricing. If you don't want to upgrade, use the no-key option below.
Endpoints
This is the complete v1 surface. Anything not listed here does not exist — in particular there is no /api/v1/submissions and no /api/v1/forms; both return 404.
Finds or creates a form by name, infers fields from your payload, then stores the record. Accepts a workspace key (df_live_…) from your server, or a browser key (df_pk_…) from client-side code. A form-scoped key already has a fixed destination, so it isn't accepted here.
Body
| Name | Type | Required | Description |
|---|---|---|---|
| form | string | Optional | Destination form name, matched case-insensitively. Created on first use. Defaults to "API inbox". |
| data | object | Optional | Your values, keyed however your app names them. Keys become fields. 0 AI credits. |
| raw | string | Optional | Unstructured text for AI extraction into the form's existing fields. Costs AI credits. |
Send data or raw — at least one is required.
{
"submission_id": "9c1f...",
"status": "auto_approved",
"structured_data": { "fname": "Jane Doe", "femail": "jane@acme.com" },
"min_confidence": 1,
"form": "Website contact",
"form_id": "a1b2c3d4-...",
"form_created": true,
"fields_added": ["fname", "femail", "fmessage"],
"max_fields_per_form": 60
}How keys become fields
Stable. "Work Email", work_email and work-emailall map to the same column, so changing casing won't duplicate it.
Typed.The value decides when it's clear (a real address → email; true → toggle; 2026-07-28 → date), otherwise the key name hints.
Guarded. Keys must start with a letter and stay under 40 characters. Reserved names (id, __proto__) are refused, and a form accepts at most 60 auto-created fields.
Transparent. Anything skipped comes back in ignored_keys with a reason — nothing is dropped silently.
Auto-created forms use a plan slot
A form created here counts toward your plan's form limit, exactly like one built by hand. Send many different form names and you will hit that limit and get 402 form_limit_reached (the response lists your existing form names). Reuse one name per integration.
Provisioning is rate limited to 30 requests/hour per workspace. High-volume traffic should post to the form-scoped endpoint below, which allows far more.
No API key? Two options that work on any plan
API keys need the Max plan. If you just want your own site's form to file into DodoForm, you don't need one.
1. Embed the form (zero code)
Publish the form and drop its embed snippet into your page. Responses land in DodoForm with no integration work at all. After publishing you get a ready-made <iframe> to copy, alongside the public link and QR code.
2. Post to the public endpoint (keep your own UI)
Your custom form posts to the same endpoint the public form uses. No key, and sending pre-structured values costs 0 AI credits. Use the form's public slug, not its UUID.
curl -X POST https://www.dodoform.com/api/process-submission \
-H "Content-Type: application/json" \
-d '{
"slug": "your-form-slug",
"structured_data": {
"name": "Jane Doe",
"email": "jane@acme.com",
"message": "I was charged twice this month."
}
}'Bot protection applies
This endpoint is public, so it is rate limited per IP and per form. If the form owner has Cloudflare Turnstile enabled, a valid turnstile_token is required and server-to-server calls will be rejected — use a form key and the /api/v1/… endpoint for backend-to-backend traffic.
What costs AI credits
Responses are unlimited on every plan. AI work is metered in credits, and credits are only consumed when a call actually produces a useful result — failed requests are never charged.
| Request | Credits |
|---|---|
Submissions with data (including /api/v1/inbox field detection) | 0 |
Submissions with raw | 1+, scaled by input size |
/api/parse | 1+, scaled by input size |
/api/v1/me, retries deduplicated by Idempotency-Key | 0 |
Your remaining credits appear in the dashboard top bar. Monthly allowances are listed on the pricing page.
Rate limits
The submissions endpoint is limited per form, not per plan. The default is 120 requests/minute, enforced over a rolling hourly window (so 7,200/hour). Form owners can change it in the form's API settings.
Every response carries your current budget:
X-RateLimit-Limit: 7200
X-RateLimit-Remaining: 7199Exceeding it returns 429 with { "error": "rate_limited", "retry_after_seconds": 60 } and a Retry-After header. Back off and retry; pair with an Idempotency-Key so retries stay safe.
Webhooks (outbound)
Webhooks send data out, they don't take data in
DodoForm webhooks notify your systems when a submission arrives. There is no inbound webhook URL to receive data — to push records into DodoForm, use the submissions endpoint above. Webhooks are available on every plan, including Free.
Configure them per form in the dashboard. This is the exact payload DodoForm sends:
{
"event": "submission.created",
"form_id": "a1b2c3d4-...",
"form_slug": "contact-us",
"submission_id": "9c1f...",
"created_at": "2026-07-28T09:15:00.000Z",
"structured_data": { "name": "Jane Doe", "email": "jane@acme.com" },
"metadata": { "source": "api", "key_id": "..." },
"status": "auto_approved",
"review": { "needs_review": false, "min_confidence": 1 }
}Quick start
Drop-in handlers for your site's contact form. Only the API key is configured — no form id.
// Only one secret to configure.
const KEY = process.env.DODOFORM_API_KEY; // df_live_...
export async function handleContactForm(fields) {
const res = await fetch("https://www.dodoform.com/api/v1/inbox", {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
// Makes retries safe — a repeat never double-files.
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
form: "Website contact",
data: fields, // pass your form object straight through
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(`DodoForm ${res.status}: ${err.error} — ${err.detail ?? ""}`);
}
return res.json();
}import os, uuid, requests
KEY = os.environ["DODOFORM_API_KEY"] # df_live_...
def send_to_dodoform(fields: dict):
res = requests.post(
"https://www.dodoform.com/api/v1/inbox",
headers={
"Authorization": f"Bearer {KEY}",
"Content-Type": "application/json",
"Idempotency-Key": str(uuid.uuid4()),
},
json={"form": "Website contact", "data": fields},
timeout=15,
)
res.raise_for_status()
return res.json()Troubleshooting
Everything returns 404
You're probably calling /api/v1/submissions or /api/v1/forms — neither exists. The path must include your form ID: /api/v1/schemas/{FORM_ID}/submissions. Also check you're on www.dodoform.com; the bare domain doesn't serve the API.
Which key should I use?
If your code runs on a server, use a workspace key from /dashboard/api — it works everywhere, including /api/v1/inbox. If your site is static with no backend, use a browser key (df_pk_) instead, because a secret key would be visible in your page source.
I don't want to create a form or look up a UUID
Then use POST /api/v1/inbox with just your API key and a form name. It creates the form and detects fields from your JSON on the first call.
402 form_limit_reached from /api/v1/inbox
Each distinct form name creates a form, and forms count against your plan limit. Reuse one name per integration; the error response lists the form names you already have.
Some of my keys didn't become fields
Check ignored_keys in the response. Keys must start with a letter, stay under 40 characters, avoid reserved names like id, and fit within 60 auto-created fields per form.
400 invalid_form_id
Form ids are UUIDs like a1b2c3d4-e5f6-.... If yours is 24 hex characters or a readable slug, it came from somewhere else — copy the UUID from the dashboard URL.
403 key_schema_mismatch
A form key is locked to one form, and a workspace key only reaches forms in its own workspace. Check the form id matches the key.
Records arrive but fields are empty
The keys inside data must be field IDs, not labels. "Email Address" is a label; the ID is something like femail. The form's API tab lists every ID with a copy button.
Submissions show as needs_review
That's expected in raw mode when confidence is below the form's review threshold. Approve them in the dashboard, or send structured data to skip review entirely.
I want to create a form via the API
POST to /api/v1/inbox with a form name — the form is created on the first call and its fields are inferred from your payload. There's no endpoint for designing a form (adding logic, pages, or themes); do that in the dashboard.
403 origin_not_allowed
A browser key only works from the site origins listed on the key. Add the exact origin, including the scheme, at /dashboard/api — https://example.com and http://example.com are different, and *.example.com does not cover the apex domain.
403 raw_not_allowed_for_browser_key
Browser keys accept structured data only, because raw runs AI extraction and spends credits — and a public key could be abused to drain them. Send data from the browser, or call from your server with a secret key.
403 captcha_required or captcha_failed
This key has bot protection enabled. Render the Cloudflare Turnstile widget with DodoForm's site key and send its token as captcha_token. Tokens are single-use and expire in about five minutes, so fetch a fresh one per submission.
My browser key sends everything to the wrong form
A browser key is bound to one form chosen when you created it, and a form value in the request body is ignored on purpose — otherwise anyone could create forms from your page source. Create a separate key per destination form.
New fields aren't appearing from my browser key
Browser keys never add fields to an existing form, so a bot can't litter it with junk columns. The values are still stored and appear under "Additional data" on the submission. Add the field in the builder, or send the first payload with a secret key.
Still stuck?
Send us the endpoint you called and the exact error body — that's usually enough to spot it immediately.