A form can keep unpublished changes. Live submissions keep using the published version while you edit and test a draft in sandbox mode. When the draft is right, publish it to make it live, or discard it to keep the live version as it is.
Statuses
Every form has a status:
| Status | Meaning |
|---|---|
draft |
The form has never been published. It only takes sandbox submissions. |
changes_pending |
The form is live and has a saved draft that is not live yet. |
published |
The form is live as saved, with no pending changes. |
The forms endpoints and the MCP form tools return status, published_at (when the form was last published, or null if it never has been) and draft (the pending changes, or null).
What goes through a draft
These attributes are versioned. With draft, changes to them are kept aside until you publish:
namedescriptionmodeschemasettingswebhook_urlmail_provider_id
Everything else applies immediately. In particular is_active, the switch that turns a form on or off, takes effect straight away even when you save it with draft: true. So does turnstile_secret, the form’s Turnstile secret key. The settings.turnstile switch and site key are part of settings, so they go through the draft.
Save changes as a draft
Pass "draft": true when you update a published form. The changes are stored as the form’s draft and the live version is untouched.
- Drafts build on each other. Each draft save starts from the current draft, not from the live version.
- A draft that ends up identical to the live version is dropped, and the form goes back to
published. - Without
draft, changes go live immediately, as before. If there’s a pending draft, a save withoutdraftpublishes the draft together with your new changes.
The example below uses illustrative values.
curl -X PATCH https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID \
-H "Authorization: Bearer $QUICKFORMS_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"draft": true, "settings": {"success_message": "Thanks, we will reply within a day."}}'{
"tool": "update-form",
"arguments": {
"form_id": "01jc8xk2m5t9qw3e7r4y6u8v0p",
"draft": true,
"settings": { "success_message": "Thanks, we will reply within a day." }
}
}{
"data": {
"id": "01jc8xk2m5t9qw3e7r4y6u8v0p",
"name": "Contact",
"status": "changes_pending",
"published_at": "2026-11-01T09:00:00.000000Z",
"draft": {
"name": "Contact",
"settings": { "success_message": "Thanks, we will reply within a day.", "...": "..." },
"...": "..."
},
"settings": { "success_message": "Thanks! Your submission has been received.", "...": "..." },
"...": "..."
}
}The top-level attributes in the response are the live version. draft holds every versioned attribute as it will be once published.
In the dashboard, use Save. The form editor and the form page show the form with its draft applied.
Test a draft
Sandbox submissions run against the draft, so you can try changes before they go live. Live submissions always get the published version.
| How | What runs against the draft |
|---|---|
| Submit endpoint | A submission with the form’s sandbox key in the X-Quickforms-Sandbox header or a _sandbox field. See sandbox mode. |
| Embed widget | data-sandbox="qf_sandbox_..." on the container renders the draft and sends sandbox submissions. The iframe does the same with ?sandbox=qf_sandbox_.... |
| Public schema | GET /f/{form}/schema.json?sandbox=qf_sandbox_... returns the draft with Cache-Control: no-store. Without the key it returns the published schema. |
| Dashboard | Test submissions from Send a test on the form page. |
| REST API | POST /api/v1/forms/{form}/test-submissions. |
| MCP | send-test-submission. |
Forms in sandbox mode, and every form when QUICKFORMS_FORCE_SANDBOX=true, run all their submissions against the draft.
curl https://YOUR-QUICKFORMS-HOST/f/FORM_ID/schema.json?sandbox=qf_sandbox_... \
-H "Accept: application/json"Publish or discard
Publishing makes the draft live, clears it and sets published_at. Publishing a form that has never been live puts it live. Discarding throws the draft away and leaves the live version as it is.
| Where | Publish | Discard |
|---|---|---|
| Dashboard | Publish | Discard changes |
| REST API | POST /api/v1/forms/{form}/publish |
DELETE /api/v1/forms/{form}/draft |
| MCP | publish-form with form_id |
publish-form with form_id and discard: true |
Both REST endpoints return the form. The examples below use illustrative values.
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID/publish \
-H "Authorization: Bearer $QUICKFORMS_TOKEN" \
-H "Accept: application/json"
curl -X DELETE https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID/draft \
-H "Authorization: Bearer $QUICKFORMS_TOKEN" \
-H "Accept: application/json"{ "tool": "publish-form", "arguments": { "form_id": "01jc8xk2m5t9qw3e7r4y6u8v0p" } }{ "tool": "publish-form", "arguments": { "form_id": "01jc8xk2m5t9qw3e7r4y6u8v0p", "discard": true } }{
"data": {
"id": "01jc8xk2m5t9qw3e7r4y6u8v0p",
"name": "Contact",
"status": "published",
"published_at": "2026-11-06T10:30:00.000000Z",
"draft": null,
"settings": { "success_message": "Thanks, we will reply within a day.", "...": "..." },
"...": "..."
}
}Create an unpublished form
Pass "draft": true when you create a form, through POST /api/v1/forms or create-form, and the form starts with status draft. Until it is published:
- Live submissions to the submit endpoint get a
404with"This form has not been published yet.". GET /f/{form}/schema.jsonreturns the same404, so the embed widget can’t load it without a sandbox key.- Sandbox submissions are accepted, so you can test it end to end.
Updating a form that has never been published with draft: true saves the changes and keeps it unpublished. Updating it without draft publishes it. Without draft, a new form is published as soon as it is created, as before.
The example below uses illustrative values.
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/forms \
-H "Authorization: Bearer $QUICKFORMS_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"template": "contact", "name": "Website contact", "draft": true}'{
"tool": "create-form",
"arguments": { "template": "contact", "name": "Website contact", "draft": true }
}Then send it a test submission and publish it when it works.