Form submit API
Iframe-side form-fill endpoints (embed session JWT only): submit filled form data to validate against the manifest and enqueue a form render (202), and upload an image via multipart to get back a public URL.
Endpoints for the form-fill embed. Submit a filled-in form to enqueue a render, and upload an image so a form field can carry a URL instead of a raw file. Both are called from inside the iframe and authenticate with the same session JWT used to load it.
POST /v1/embed/form-submit/:sessionId
POST /v1/embed/form-submit/:sessionId/upload-imageAuthentication
Both endpoints take the embed session JWT — never a project API key:
Authorization: Bearer <session_token>The token is verified against the iframe request origin (which must be allow-listed), and the :sessionId in the path must match the token's session. Sessions must be minted with scope.mode = "fill"; submit additionally requires the submitForm permission.
POST /v1/embed/form-submit/:sessionId
Validates the submitted data against the template version's variable manifest, merges any JWT-claimed prefill under the user-supplied data (user data wins), and enqueues a render with source = "form" so the existing render-worker picks it up unchanged. Returns 202 Accepted with a poll URL.
Collect-only mode. When the session was minted with
form.captureMode: "collect", this endpoint does not create a render or store the field data. It validates the data and emits a signedform.submittedwebhook to the project's subscribers, then returns202 { "submitted": true, "sessionId": "…" }(noid/pollUrl— there is no render to poll). The delivery is awaited into the queue, so202means "durably enqueued". If no active webhook is subscribed toform.submitted, the submit fails with409 no_webhook_subscriberrather than silently dropping the data.
Auth
Authorization: Bearer <session_token>— a fill-mode session JWT withpermissions.submitForm = true.- The request origin must be allow-listed (used as the token audience).
:sessionIdmust equal the token's session id.
Path parameters
| Field | Type | Description | Default |
|---|---|---|---|
sessionId |
string | The embed session id. Must match the bearer token's session. | — |
Quick Start
curl
curl -X POST https://api.craftkit.dev/v1/embed/form-submit/$SESSION_ID \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"data": {
"customer.name": "Acme Corp",
"customer.email": "hello@acme.com"
}
}'Node.js
const res = await fetch(`https://api.craftkit.dev/v1/embed/form-submit/${sessionId}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${sessionToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
data: {
'customer.name': 'Acme Corp',
'customer.email': 'hello@acme.com',
},
}),
});
const { id, status, pollUrl } = await res.json();Python
import requests
res = requests.post(
f"https://api.craftkit.dev/v1/embed/form-submit/{session_id}",
headers={"Authorization": f"Bearer {session_token}"},
json={
"data": {
"customer.name": "Acme Corp",
"customer.email": "hello@acme.com",
}
},
)
job = res.json()Request body
{
"data": {
"customer.name": "Acme Corp",
"customer.email": "hello@acme.com"
},
"datasetSelection": {
"booking": "bk_123"
}
}| Field | Type | Description | Default |
|---|---|---|---|
data |
object | Variable values to render. Required. May use flat dot-keys ("customer.name") — they are expanded to nested objects ({ customer: { name } }) before manifest validation, so they match the renderer's lookups. Validated against the template version's manifest. |
— |
datasetSelection |
object | Optional string→string map persisted on the render row (for example, which source records the form was filled from). | null |
Prefill merge. Any
form.prefillcarried in the session JWT is applied under yourdata— your values always win. Prefill keys not present in the template manifest are dropped silently.
Dotted keys. The form embed emits flat dot-keyed state. Reserved property names (
__proto__,constructor,prototype) anywhere in a key are stripped during expansion to prevent prototype pollution.
Response — 202 Accepted
{
"id": "0193c2c3-...",
"status": "queued",
"pollUrl": "https://api.craftkit.dev/v1/embed/renders/0193c2c3-...",
"downloadUrl": null,
"errorMessage": null,
"createdAt": "2026-06-05T10:00:00.000Z"
}| Field | Type | Description |
|---|---|---|
id |
string | Render id (UUID). |
status |
string | queued initially. Progresses to rendering then succeeded | failed. |
pollUrl |
string | Embed-scoped poll URL (/v1/embed/renders/:id). Poll it with the session JWT — not the partner API key. |
downloadUrl |
string | null | null until the render succeeds. |
errorMessage |
string | null | Populated on failure. |
createdAt |
string | ISO-8601 timestamp. |
Response (collect-only mode) — 202 Accepted
When the session's form.captureMode is collect, there is no render. The form.submitted webhook carries the data instead:
{
"submitted": true,
"sessionId": "0193c2c3-..."
}| Field | Type | Description |
|---|---|---|
submitted |
boolean | Always true. Means the form.submitted delivery was durably enqueued. |
sessionId |
string | The embed session id the submission belongs to. |
Errors
| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 400 | bad_request |
Request origin is not in the allowed origins list | Add the embed origin to the partner's allow-list |
| 400 | invalid_json |
Body wasn't valid JSON | Check Content-Type and JSON.stringify |
| 400 | invalid_request |
Body didn't match { data, datasetSelection? } |
See the request body table; inspect issues |
| 400 | invalid_input_data |
data didn't match the template manifest |
Inspect issues.fieldErrors for offending keys |
| 401 | unauthorized |
Missing/malformed Authorization header, or the session token failed verification |
Send Authorization: Bearer <session_token> from an allow-listed origin |
| 403 | wrong_mode |
Session is not scope.mode = "fill" |
Mint a fill-mode session |
| 403 | permission_denied |
Session lacks the submitForm permission |
Mint the session with permissions.submitForm = true |
| 404 | session_not_found |
:sessionId doesn't match the bearer token |
Use the session id the token was minted for |
| 404 | template_not_resolved |
Session scope carries no template id, or the external id isn't a UUID | Mint with scope.templateExternalId set to the Craftkit template UUID |
| 404 | template_not_found |
No such template in this project | Check the template id and the session's project |
| 404 | unpublished_template |
The template has no published version | Publish a version first |
| 409 | no_webhook_subscriber |
(collect mode) No active webhook is subscribed to form.submitted |
Subscribe a webhook to form.submitted in the dashboard |
| 503 | queue_unavailable |
Render queue (or, in collect mode, the delivery queue) temporarily unreachable | Retry in a moment |
| 500 | internal |
The render row could not be written/enqueued (or the delivery could not be enqueued) | Retry; if it persists, contact support |
POST /v1/embed/form-submit/:sessionId/upload-image
Accepts a multipart/form-data upload with a single file field (an image) and stores it in object storage under the session's namespace. Returns the public URL so the form submit payload can carry a URL instead of a raw file. Called by the form embed before it POSTs the JSON form data.
Auth
Authorization: Bearer <session_token>— a fill-mode session JWT.- The request origin must be allow-listed.
:sessionIdmust equal the token's session id.
The submitForm permission is not required for upload — only fill mode.
Path parameters
| Field | Type | Description | Default |
|---|---|---|---|
sessionId |
string | The embed session id. Must match the bearer token's session. | — |
Request body
multipart/form-data with one field:
| Field | Type | Description | Default |
|---|---|---|---|
file |
file | The image to upload. MIME type must start with image/. Max size 10 MB. |
— |
The stored object key is embed-uploads/{projectId}/{sessionId}/{uuid}.{ext}. The extension comes from the original filename, falling back to a MIME-type mapping (jpg, png, gif, webp, svg) or .bin.
Quick Start
curl
curl -X POST https://api.craftkit.dev/v1/embed/form-submit/$SESSION_ID/upload-image \
-H "Authorization: Bearer $SESSION_TOKEN" \
-F "file=@logo.png"Node.js
const form = new FormData();
form.append('file', fileBlob, 'logo.png');
const res = await fetch(
`https://api.craftkit.dev/v1/embed/form-submit/${sessionId}/upload-image`,
{
method: 'POST',
headers: { Authorization: `Bearer ${sessionToken}` },
body: form,
},
);
const { url } = await res.json();Python
import requests
with open("logo.png", "rb") as f:
res = requests.post(
f"https://api.craftkit.dev/v1/embed/form-submit/{session_id}/upload-image",
headers={"Authorization": f"Bearer {session_token}"},
files={"file": ("logo.png", f, "image/png")},
)
url = res.json()["url"] Response — 200 OK
{
"url": "https://cdn.craftkit.dev/embed-uploads/proj_01j.../sess_01j.../9f1c....png"
}| Field | Type | Description |
|---|---|---|
url |
string | Public URL of the uploaded image. Pass it back as the value of the matching image variable in the data of your form submit. |
Errors
| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 400 | bad_request |
Request origin is not in the allowed origins list | Add the embed origin to the partner's allow-list |
| 400 | invalid_multipart |
Body wasn't valid multipart/form-data |
Send the file as multipart, not JSON |
| 400 | missing_file |
No file field in the form data |
Include a file part |
| 401 | unauthorized |
Missing/malformed Authorization header, or the session token failed verification |
Send Authorization: Bearer <session_token> from an allow-listed origin |
| 403 | wrong_mode |
Session is not scope.mode = "fill" |
Mint a fill-mode session |
| 404 | session_not_found |
:sessionId doesn't match the bearer token |
Use the session id the token was minted for |
| 413 | file_too_large |
File exceeds the 10 MB limit | Compress or resize the image |
| 415 | invalid_type |
The file's MIME type is not image/* |
Upload an image file |
Related
- Builder & renders API — create templates and read the
formrenders this endpoint produces - Embed catalogs — the variable catalog that becomes the form's fields
- Embed quickstart — mint a fill session and load the iframe
- Render a template — the partner-API-key equivalent of submitting data
Last revised: 2026-06-26