Keep your forms in a quickforms.json file next to your site and deploy them from the command line or CI. Each form has a key, such as contact. Deploying creates the form with that key the first time and updates the same form after that, so the same file always gives the same form and it is safe to deploy on every build.
Everything goes through one endpoint, PUT /api/v1/forms/by-key/{key}, with a team API key. The CLI, the GitHub Action and the Netlify plugin all call it.
The file
quickforms.json has a forms object keyed by form key. Each value is a form definition.
{
"forms": {
"contact": {
"name": "Contact",
"description": "General enquiries from our website.",
"schema": {
"fields": [
{ "name": "name", "type": "text", "label": "Name", "required": true },
{ "name": "email", "type": "email", "label": "Email", "required": true },
{ "name": "message", "type": "textarea", "label": "Message", "required": true, "min": 10 }
]
},
"settings": { "notify": { "emails": ["you@example.com"] }, "allowed_origins": ["https://example.com"] },
"destinations": [
{ "name": "Team Slack", "type": "slack", "config": { "webhook_url": "${SLACK_WEBHOOK_URL}" } }
]
}
}
}This is the starter file quickforms init writes.
Keys
A key is 1 to 64 lowercase letters, numbers, dashes and underscores, starting with a letter or number, like contact or beta-waitlist. Keys are unique within a team, and a deleted form’s key can be used again. Anything else is a 422 on key.
Form definitions
A definition takes the same attributes as POST /api/v1/forms, and is validated the same way:
| Key | Description |
|---|---|
name |
Required when the form is first created. |
description |
What the form is for, used by the spam filter. |
mode |
live or sandbox. |
schema |
The fields. See schema. Required when the form is first created. |
is_active |
true or false. |
webhook_url |
Where the webhook goes. |
mail_provider_id |
One of the team’s mail providers. |
settings |
See form settings. Settings are merged into the form’s current settings, and lists are replaced. |
turnstile_secret |
Write-only Turnstile secret key. |
stripe_secret |
Write-only Stripe secret key for payments. |
destinations |
A list of destinations. |
Two more keys control the deploy. The CLI sets them for you from its flags:
| Key | Description |
|---|---|
draft |
true keeps the changes as a draft instead of making them live. A form created with draft stays unpublished until you publish it. |
prune_destinations |
true deletes the form’s destinations that are not in destinations. Off by default, so destinations added in the dashboard are kept. |
Destinations
Each item in destinations has a name (required, up to 80 characters, unique in the list) and a type (required), plus enabled, config, mapping and conditions, the same as POST /api/v1/forms/{form}/destinations. A form can have up to 20 destinations. See destinations for each type’s config.
Destinations are matched to the form’s existing ones by name. A name that exists is updated, a new name is created. The type of an existing destination doesn’t change. Leave destinations out to leave the form’s destinations alone.
Errors in a destination come back with its position, for example destinations.0.config.access_token. When any part of a definition is invalid nothing is saved.
Environment variables
The CLI replaces ${NAME} in any string with the environment variable NAME (uppercase letters, numbers and underscores) before sending the definition. Use it for secrets such as webhook URLs, API keys, turnstile_secret and stripe_secret, so they stay out of the file. A variable that is not set stops the deploy with an error.
The CLI
The CLI is a Node.js script with no dependencies. It needs Node 18 or later. It lives in the quickforms repository at packages/cli (package name @quickforms/cli, command quickforms). Run it from a checkout of the repository:
node packages/cli/bin/quickforms.mjs deploy --file path/to/quickforms.jsonOr install the folder globally to get the quickforms command:
npm install -g ./packages/cli
quickforms deployCommands
| Command | What it does |
|---|---|
quickforms init |
Write a starter quickforms.json. Fails if the file already exists. |
quickforms validate |
Check every form without saving anything (a dry run). |
quickforms deploy |
Create or update every form in the file, and its destinations. |
quickforms pull |
Write the team’s keyed forms to quickforms.json. |
Options
| Option | Default | Description |
|---|---|---|
--file |
quickforms.json |
The config file. |
--host |
$QUICKFORMS_URL |
Your quickforms URL. |
--token |
$QUICKFORMS_TOKEN |
A team API key. |
--only |
All forms | Comma-separated form keys to deploy or validate, for example --only contact,newsletter. |
--draft |
Off | validate and deploy: save changes as drafts instead of publishing. |
--prune |
Off | validate and deploy: remove destinations that are not in the file. |
--json |
Off | validate and deploy: print the results as JSON instead of one line per form. |
validate, deploy and pull need a host and API key, from the options or the QUICKFORMS_URL and QUICKFORMS_TOKEN environment variables.
export QUICKFORMS_URL=https://YOUR-QUICKFORMS-HOST
export QUICKFORMS_TOKEN=YOUR_API_KEY
export SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
quickforms validate
quickforms deploydeploy prints one line per form: created, updated or (for validate) valid, then the key, status and endpoint URL, plus how many destinations were added, updated or removed. With --json it prints a list with each form’s key, id, status, endpoint_url, hosted_url, sandbox_key and created.
If a request fails, the CLI prints the status code, the message and each validation error, and exits with code 1.
Pull
quickforms pull writes every form that has a key to the file, with its name, description, mode, schema, settings, webhook_url and destinations. Forms without a key are skipped. Masked secrets in destination config are left out, so add them back as ${ENV_VARS} before you deploy. It overwrites the file.
The endpoints
Both need a team API key, like the rest of the REST API.
PUT /api/v1/forms/by-key/{key}
Create or update the form with this key from a definition. Add ?dry_run=1 to validate everything without saving: the form and destinations are checked exactly as a real deploy would, then thrown away.
curl -X PUT https://YOUR-QUICKFORMS-HOST/api/v1/forms/by-key/contact \
-H "Authorization: Bearer $QUICKFORMS_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"name": "Contact", "schema": {"fields": [{"name": "email", "type": "email", "required": true}]}, "destinations": [{"name": "Team Slack", "type": "slack", "config": {"webhook_url": "https://hooks.slack.com/services/..."}}]}'{
"data": {
"id": "01jc8xk2m5t9qw3e7r4y6u8v0p",
"key": "contact",
"name": "Contact",
"status": "published",
"endpoint_url": "https://YOUR-QUICKFORMS-HOST/f/01jc8xk2m5t9qw3e7r4y6u8v0p",
"hosted_url": "https://YOUR-QUICKFORMS-HOST/f/01jc8xk2m5t9qw3e7r4y6u8v0p",
"...": "...",
"destinations": [
{ "id": "01jcd0...", "form_id": "01jc8xk2m5t9qw3e7r4y6u8v0p", "type": "slack", "service": "Slack", "name": "Team Slack", "enabled": true, "config": { "webhook_url": "••••••••..." }, "mapping": { "...": "..." }, "conditions": [], "...": "..." }
]
},
"meta": {
"dry_run": false,
"created": true,
"status": "published",
"destinations": { "created": ["Team Slack"], "updated": [], "deleted": [] }
}
}data is the form, the same as GET /api/v1/forms/{form}, plus its destinations. meta.created says whether the form was new, and meta.destinations lists the destination names created, updated and deleted. It responds 201 when it created the form and 200 when it updated one or on a dry run.
GET /api/v1/forms/by-key/{key}
The form with this key, with its destinations. 404 if the team has no form with that key.
curl https://YOUR-QUICKFORMS-HOST/api/v1/forms/by-key/contact \
-H "Authorization: Bearer $QUICKFORMS_TOKEN" \
-H "Accept: application/json"Dry runs
quickforms validate and ?dry_run=1 run the whole deploy, including schema and destination validation, without saving. Errors come back as a 422, the same as a real deploy. meta.dry_run is true.
Drafts
With draft (--draft in the CLI), changes to a live form are kept as a draft and status becomes changes_pending. The live version keeps taking submissions until you publish. Destinations are saved straight away, they are not part of a draft.
GitHub Action
The CLI folder is also a GitHub Action (packages/cli/action.yml). It sets up Node 20 and runs the CLI.
| Input | Required | Default | Description |
|---|---|---|---|
host |
Yes | Your quickforms URL. | |
token |
Yes | A team API key. Store it as a secret. | |
file |
No | quickforms.json |
The config file. |
command |
No | deploy |
deploy or validate. |
draft |
No | false |
Save changes as drafts instead of publishing. |
prune |
No | false |
Remove destinations that are not in the file. |
It has one output, forms: the JSON list of forms the CLI’s --json option prints, with their endpoint URLs.
Point uses at the packages/cli folder of the quickforms repository. This workflow validates on pull requests and deploys on pushes to main:
name: quickforms
on:
pull_request:
push:
branches: [main]
jobs:
forms:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- id: quickforms
uses: OWNER/quickforms/packages/cli@REF
with:
host: https://YOUR-QUICKFORMS-HOST
token: ${{ secrets.QUICKFORMS_TOKEN }}
command: ${{ github.event_name == 'pull_request' && 'validate' || 'deploy' }}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- run: echo '${{ steps.quickforms.outputs.forms }}'Replace OWNER/quickforms and REF with the repository and the branch, tag or commit you use. Environment variables used as ${NAME} in quickforms.json need to be set on the step.
Netlify build plugin
netlify-plugin-quickforms (in packages/netlify-plugin-quickforms) deploys quickforms.json before your site builds. It uses the CLI package, @quickforms/cli.
[[plugins]]
package = "netlify-plugin-quickforms"
[plugins.inputs]
file = "quickforms.json"
draftOnPreview = true| Input | Default | Description |
|---|---|---|
file |
quickforms.json |
The config file. |
draftOnPreview |
true |
Builds outside the production context (such as deploy previews) save changes as drafts instead of publishing. |
Set QUICKFORMS_URL and QUICKFORMS_TOKEN, and any ${NAME} variables the file uses, in your Netlify environment variables.
Before the build it:
- Deploys every form in the file. In a context other than
production, withdraftOnPreviewon, it deploys them as drafts. - Sets a
QUICKFORMS_<KEY>_URLenvironment variable for each form with its endpoint URL, so your build can point forms at the right address. The key is uppercased and anything other than letters and numbers becomes_:beta-waitlistbecomesQUICKFORMS_BETA_WAITLIST_URL. - Writes
quickforms.forms.jsonto the publish directory, a list of each form’skey,endpoint_urlandhosted_url. - Shows how many forms it deployed in the deploy summary.
If the deploy fails, the build fails with the error.