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.
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. |
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 |
| 503 | queue_unavailable |
Render queue temporarily unreachable | Retry in a moment |
| 500 | internal |
The render row could not be written or 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