---
title: "Forms as code"
description: "Keep forms and their destinations in a quickforms.json file and deploy them with the CLI, a GitHub Action or a Netlify build plugin."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.getquickforms.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Forms as code

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}`](#the-endpoints), 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.

```json title="quickforms.json"
{
  "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`](https://docs.getquickforms.com/api/#post-apiv1forms), 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](https://docs.getquickforms.com/spam-filter/). |
| `mode` | `live` or `sandbox`. |
| `schema` | The fields. See [schema](https://docs.getquickforms.com/schema/). Required when the form is first created. |
| `is_active` | `true` or `false`. |
| `webhook_url` | Where the [webhook](https://docs.getquickforms.com/webhooks/) goes. |
| `mail_provider_id` | One of the team's [mail providers](https://docs.getquickforms.com/email/#bring-your-own-keys). |
| `settings` | See [form settings](https://docs.getquickforms.com/api/#form-settings). Settings are merged into the form's current settings, and lists are replaced. |
| `turnstile_secret` | Write-only [Turnstile](https://docs.getquickforms.com/spam-protection/#cloudflare-turnstile) secret key. |
| `stripe_secret` | Write-only Stripe secret key for [payments](https://docs.getquickforms.com/payments/). |
| `destinations` | A list of [destinations](#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](https://docs.getquickforms.com/drafts-and-publishing/) 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`](https://docs.getquickforms.com/api/#post-apiv1formsformdestinations). A form can have up to 20 destinations. See [destinations](https://docs.getquickforms.com/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:

```bash title="Run from the repository"
node packages/cli/bin/quickforms.mjs deploy --file path/to/quickforms.json
```

Or install the folder globally to get the `quickforms` command:

```bash title="Install from the repository"
npm install -g ./packages/cli
quickforms deploy
```

### Commands

| 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](#dry-runs)). |
| `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.

```bash
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 deploy
```

`deploy` 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](https://docs.getquickforms.com/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.

### Request

```bash
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/..."}}]}'
```

### 201 response

```json
{
  "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}`](https://docs.getquickforms.com/api/#get-apiv1formsform), 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.

```bash
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](https://docs.getquickforms.com/drafts-and-publishing/). 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`:

```yaml title=".github/workflows/quickforms.yml"
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`.

```toml title="netlify.toml"
[[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:

1. Deploys every form in the file. In a context other than `production`, with `draftOnPreview` on, it deploys them as drafts.
2. Sets a `QUICKFORMS_<KEY>_URL` environment 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-waitlist` becomes `QUICKFORMS_BETA_WAITLIST_URL`.
3. Writes `quickforms.forms.json` to the publish directory, a list of each form's `key`, `endpoint_url` and `hosted_url`.
4. Shows how many forms it deployed in the deploy summary.

If the deploy fails, the build fails with the error.

Source: https://docs.getquickforms.com/forms-as-code/index.mdx
