---
title: "Destinations"
description: "Send each submission to Slack, Discord, HubSpot, Attio, Customer.io, Google Sheets or any HTTP endpoint, with field mapping, conditions, retries and a delivery log."
---

> 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.

# Destinations

A destination is somewhere a form's submissions are sent, besides [email](https://docs.getquickforms.com/email/) and the form [webhook](https://docs.getquickforms.com/webhooks/). Add them on the form's **Destinations** tab in the [dashboard](https://docs.getquickforms.com/dashboard/), with the [API](#api) or over [MCP](#mcp).

| Type | Service | What it does |
| --- | --- | --- |
| `slack` | [Slack](#slack) | Posts each submission to a Slack channel through an incoming webhook. |
| `discord` | [Discord](#discord) | Posts each submission to a Discord channel through a channel webhook. |
| `http` | [HTTP request](#http-request) | Sends a JSON request to any URL: a Zapier, Make or n8n catch hook, or your own API. |
| `hubspot` | [HubSpot](#hubspot) | Creates or updates a contact by email. |
| `attio` | [Attio](#attio) | Creates or updates a person by email address. |
| `customerio` | [Customer.io](#customerio) | Identifies the person and tracks an event, so a campaign can start from the submission. |
| `google_sheets` | [Google Sheets](#google-sheets) | Adds a row to a spreadsheet for every submission. |

A form can have up to 20 destinations.

## How destinations are delivered

Destinations go through the same delivery pipeline as emails and the webhook:

- When a submission is accepted, each enabled destination whose [conditions](#conditions) match gets a delivery, in the order the destinations were created. Destinations are held for spam review and [double opt-in](https://docs.getquickforms.com/double-opt-in/) like everything else.
- Live deliveries are queued and sent in the background. Each delivery is logged with the destination's name as its target, the service, and the requests it makes (secret values shown as `[redacted]`, and Slack and Discord webhook URLs cut down to their host).
- On each attempt the requests are rebuilt from the destination's current settings, so a fixed token or mapping applies to retries and redeliveries.
- A destination that makes more than one request (Customer.io) makes them in order and stops at the first failure.
- Each request has a 5 second connect timeout and a 15 second request timeout.

### Retries

| Result | What happens |
| --- | --- |
| 2xx | The delivery succeeds. |
| `429` or 5xx | Retried after 10s, 1m, 5m, 30m, 2h and 6h, the same schedule as [webhooks](https://docs.getquickforms.com/webhooks/#retries-and-duplicates). |
| Timeout or connection error | Retried on the same schedule. |
| Any other 4xx | Fails without a retry. A bad token or mapping won't fix itself. The error reads, for example, "HubSpot responded with HTTP 401". |
| [Private address](#private-addresses) | Fails without a retry. |
| Destination deleted | Fails without a retry: "This destination was deleted." |

Every attempt records its status code, a response excerpt, the error, the duration and when the next retry is due. Redeliver a live delivery from the dashboard, the [API](https://docs.getquickforms.com/api/) or [MCP](https://docs.getquickforms.com/mcp/), like any other delivery.

### Sandbox

In [sandbox](https://docs.getquickforms.com/sandbox/) submissions, destinations are never sent. Each one is stored as a `simulated` delivery with the exact requests it would have made, secrets redacted. Sandbox deliveries cannot be redelivered.

## A destination

```json title="GET /api/v1/destinations/{destination}"
{
  "data": {
    "id": "01jcd3n5p7q9r1s3t5v7w9x1y3",
    "form_id": "01jc8xk2m5t9qw3e7r4y6u8v0p",
    "type": "hubspot",
    "service": "HubSpot",
    "name": "Sales CRM",
    "enabled": true,
    "config": { "access_token": "••••••••••••••1234" },
    "mapping": { "email": "{{email}}", "firstname": "{{name}}", "message": "{{message}}" },
    "conditions": [{ "field": "topic", "operator": "equals", "value": "sales" }],
    "created_at": "2026-11-06T10:24:00.000000Z",
    "updated_at": "2026-11-06T10:24:00.000000Z"
  }
}
```

The IDs and times are illustrative.

| Key | Description |
| --- | --- |
| `type` | The destination type. Set when it is created and can't be changed. |
| `name` | Up to 80 characters. Shown as the delivery's target in the log. |
| `enabled` | Defaults to `true`. Disabled destinations are skipped. |
| `config` | The type's settings. See each type below. |
| `mapping` | What to send: a key for each value, and a template for it. See [mapping](#mapping). |
| `conditions` | When to send. See [conditions](#conditions). |

### Config and secrets

Each type lists its settings, which are strings of up to 2000 characters. Settings of type `url` must be an `http` or `https` URL, and `select` settings must be one of their options. A setting with a default can be left out.

Secret settings (tokens, keys and webhook URLs) are:

- Encrypted at rest along with the rest of the config.
- Write-only: responses show them masked, with only the last four characters.
- Kept when you update a destination and leave them out or send them blank, unless you change the destination's URL. A saved `auth_header` is never sent to a new URL, so enter it again when you change `url`.

## Mapping

The mapping is an object of key to template. The keys are what the service calls each value (a HubSpot property, an Attio attribute, a JSON key, a sheet column), and the templates fill in the submission with `{{placeholders}}`.

```json title="mapping"
{
  "email": "{{email}}",
  "firstname": "{{name}}",
  "lifecycle_stage": "lead"
}
```

- When you create a destination without a mapping, quickforms works out a starting one from the form's fields. For the CRM types it looks for an email field and common field names such as `first_name`, `company` or `phone`. Each type below lists its suggested keys.
- You can add your own keys too, such as custom properties.
- Keys use letters, numbers, spaces, `_`, `.` and `-`, up to 100 characters. A mapping can have up to 100 keys, and each template up to 2000 characters.
- Keys with an empty template are dropped when you save.
- Some keys are required by the type. Saving without them returns a `422` on that key, for example "Map something to Email.".
- Sending `mapping` on an update replaces the whole mapping. Leave it out to keep the saved one.

### Placeholders

`{{field_name}}` is replaced with that field's answer. Spaces inside the braces are allowed. Unknown and empty fields become blank, and each rendered value is trimmed.

| Answer | Becomes |
| --- | --- |
| Text, number, choice | The value. |
| Checkbox | `Yes` or `No`. |
| Several choices | The values joined with `, `. |
| File | Its [signed download link](https://docs.getquickforms.com/file-uploads/#download-links). |

These are available too:

| Placeholder | Value |
| --- | --- |
| `{{_form_name}}` | The form's name. |
| `{{_form_id}}` | The form's ID. |
| `{{_submission_id}}` | The submission's ID. |
| `{{_submitted_at}}` | When it was submitted, as ISO 8601. |
| `{{_summary}}` | Every answer, one per line, as `Label: value`. |
| `{{_link_label}}` | The label of the [personal link](https://docs.getquickforms.com/personal-links/) it came through, if any. |

## Conditions

Conditions decide whether a destination sends for a submission. Each one is an object with a `field`, an `operator` and, for some operators, a `value`. All conditions must match. An empty list always matches.

```json title="conditions"
[
  { "field": "topic", "operator": "equals", "value": "sales" },
  { "field": "email", "operator": "is_set" }
]
```

| Operator | Matches when |
| --- | --- |
| `equals` | The answer equals `value`, ignoring case. For several choices, one of them is exactly `value`. |
| `not_equals` | The opposite of `equals`. |
| `contains` | The answer contains `value`, ignoring case. For several choices, the values are joined with `, ` first. An empty `value` never matches. |
| `is_set` | The answer is not empty. A ticked checkbox is set. |
| `is_empty` | The answer is empty. An unticked checkbox is empty. |

- `field` must be one of the form's field names.
- `value` is up to 500 characters.
- A destination can have up to 10 conditions.

[Routing rules](https://docs.getquickforms.com/routing-rules/) use the same conditions.

## Preview

A preview is a dry run: it shows exactly what a destination would send, with secrets redacted. Nothing is sent.

### Request

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/destinations/DESTINATION_ID/preview \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"data": {"name": "Grace Hopper", "email": "grace@example.com", "message": "Hi"}}'
```

### 200 response

```json
{
  "data": {
    "would_send": true,
    "requests": [
      {
        "method": "POST",
        "url": "https://api.hubapi.com/crm/v3/objects/contacts/batch/upsert",
        "headers": { "Content-Type": "application/json", "Authorization": "[redacted]" },
        "body": {
          "inputs": [
            {
              "idProperty": "email",
              "id": "grace@example.com",
              "properties": { "email": "grace@example.com", "firstname": "Grace Hopper", "message": "Hi" }
            }
          ]
        }
      }
    ],
    "mapped": { "email": "grace@example.com", "firstname": "Grace Hopper", "message": "Hi" },
    "submission_id": null
  }
}
```

The submission it uses, in order of preference:

1. `submission_id`: one of the form's submissions.
2. `data`: your own field values.
3. The form's latest submission.
4. Example values made up from the form's fields.

| Key | Description |
| --- | --- |
| `would_send` | Whether the [conditions](#conditions) match. |
| `requests` | Each request's `method`, `url`, `headers` and `body`. Secret headers show as `[redacted]`. |
| `mapped` | The rendered [mapping](#mapping). |
| `submission_id` | The stored submission used, or `null` for `data` and example values. |

## Private addresses

On the hosted platform, quickforms refuses to send to URLs that resolve to a private or reserved network address, such as cloud metadata endpoints and internal services. This applies to the URLs you enter: Slack and Discord webhook URLs and the HTTP request URL. Every IPv4 and IPv6 address the host resolves to is checked, and the request is sent to those checked addresses. The delivery fails with "The destination URL resolves to a private or reserved address." and is not retried.

Redirects are not followed. A destination that answers with a `3xx` status counts as a failed delivery, so use the final URL.

Self-hosting? `QUICKFORMS_WEBHOOK_ALLOW_PRIVATE_HOSTS=true` allows private addresses for destinations and webhooks. It is on by default when `APP_ENV=local`.

## Slack

Posts a message to a Slack channel.

| Setting | Type | Required | Description |
| --- | --- | --- | --- |
| `webhook_url` | Secret | Yes | An incoming webhook URL from a Slack app with incoming webhooks turned on (`https://hooks.slack.com/services/...`). |

| Mapping key | Required | Default |
| --- | --- | --- |
| `text` | Yes | `*New submission: {{_form_name}}*` and `{{_summary}}` on the next line. |

The request is a `POST` to the webhook URL with `{"text": "..."}`.

## Discord

Posts a message to a Discord channel.

| Setting | Type | Required | Description |
| --- | --- | --- | --- |
| `webhook_url` | Secret | Yes | A channel webhook URL, from the channel's settings under Integrations, Webhooks (`https://discord.com/api/webhooks/...`). |
| `username` | Text | No | The bot name. Defaults to `quickforms`. |

| Mapping key | Required | Default |
| --- | --- | --- |
| `content` | Yes | `**New submission: {{_form_name}}**` and `{{_summary}}` on the next line. |

The request is a `POST` to the webhook URL with `content` and `username`. Messages are cut to fit Discord's 2000 character limit, and mentions in them don't ping anyone.

## HTTP request

Sends a JSON request to any URL: a Zapier, Make or n8n catch hook, your own API or an internal service. See also [Zapier and n8n](https://docs.getquickforms.com/zapier-and-n8n/).

| Setting | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | URL | Yes | Where to send it. |
| `method` | `POST`, `PUT` or `PATCH` | Yes | Defaults to `POST`. |
| `body` | `mapping` or `event` | Yes | `mapping` (the default) sends just the mapped fields. `event` sends the same JSON as the [form webhook](https://docs.getquickforms.com/webhooks/#request). |
| `auth_header` | Secret | No | Sent as the `Authorization` header, for example `Bearer abc123`. |
| `sign` | `no` or `yes` | No | `yes` adds the `Quickforms-Signature` header, signed with the form's webhook secret. Defaults to `no`. |

The default mapping has a key for each of the form's fields, with its placeholder. With `body` set to `mapping`, the body is that object as JSON:

```json title="Body with body: mapping"
{ "name": "Ada Lovelace", "email": "ada@example.com", "message": "Interested in a demo" }
```

Requests are sent with `Content-Type: application/json` and `User-Agent: quickforms-destinations/1.0`. With `sign` on, verify the signature the same way as a [webhook signature](https://docs.getquickforms.com/webhooks/#verify-the-signature).

## HubSpot

Creates or updates a HubSpot contact by email.

| Setting | Type | Required | Description |
| --- | --- | --- | --- |
| `access_token` | Secret | Yes | A HubSpot private app access token with the `crm.objects.contacts.write` scope. |

| Mapping key | Label | Required |
| --- | --- | --- |
| `email` | Email | Yes |
| `firstname` | First name | |
| `lastname` | Last name | |
| `company` | Company name | |
| `jobtitle` | Job title | |
| `phone` | Phone number | |
| `website` | Website URL | |
| `message` | Message | |

Map to any other contact property by its internal name. The request is a `POST` to `https://api.hubapi.com/crm/v3/objects/contacts/batch/upsert`, with the email as the ID and every non-empty mapped value as a property.

## Attio

Creates or updates an Attio person by email address.

| Setting | Type | Required | Description |
| --- | --- | --- | --- |
| `api_key` | Secret | Yes | An access token from Workspace settings, Developers, with read-write access to records. |

| Mapping key | Label | Required |
| --- | --- | --- |
| `email` | Email address | Yes |
| `first_name` | First name | |
| `last_name` | Last name | |
| `job_title` | Job title | |
| `description` | Description | |

`first_name` and `last_name` make up the person's name. Every other key is sent as an attribute slug in your workspace, so you can map to your own attributes. Empty values are left out. The request is a `PUT` to `https://api.attio.com/v2/objects/people/records?matching_attribute=email_addresses`.

## Customer.io

Identifies the person with the Track API, then records an event with the submission, so a campaign can start from it.

| Setting | Type | Required | Description |
| --- | --- | --- | --- |
| `site_id` | Text | Yes | Your Track API site ID. |
| `api_key` | Secret | Yes | Your Track API key. |
| `region` | `us` or `eu` | Yes | Defaults to `us`. `eu` sends to `track-eu.customer.io`. |
| `event_name` | Text | No | Defaults to `form_submitted`. |

| Mapping key | Label | Required |
| --- | --- | --- |
| `id` | Person ID (usually the email) | Yes |
| `email` | Email | |
| `first_name` | First name | |
| `last_name` | Last name | |

It makes two requests, authenticated with the site ID and API key:

1. `PUT /api/v1/customers/{id}` with every other non-empty mapped value as an attribute.
2. `POST /api/v1/customers/{id}/events` with the event name and, as its data, `form_id`, `form_name`, `submission_id` and the submission's answers.

## Google Sheets

Adds a row to a spreadsheet for every submission, using a Google Cloud service account. The mapping is the row: one entry per column, in order.

| Setting | Type | Required | Description |
| --- | --- | --- | --- |
| `service_account` | Secret | Yes | The service account's JSON key. |
| `spreadsheet_id` | Text | Yes | The ID in the sheet's URL. |
| `sheet` | Text | No | The sheet (tab) name. Defaults to `Sheet1`. |

See [Google Sheets](https://docs.getquickforms.com/google-sheets/) for the full setup.

## API

All endpoints take a team API key as `Authorization: Bearer <api_key>`. See [API](https://docs.getquickforms.com/api/).

| Method | Endpoint | Description |
| --- | --- | --- |
| `GET` | `/api/v1/destination-types` | The destination types, with their settings and suggested mapping keys. |
| `GET` | `/api/v1/forms/{form}/destinations` | A form's destinations, oldest first. |
| `POST` | `/api/v1/forms/{form}/destinations` | Create a destination. Returns `201`. |
| `GET` | `/api/v1/destinations/{destination}` | One destination. |
| `PATCH` | `/api/v1/destinations/{destination}` | Update a destination. Send only what changes. |
| `DELETE` | `/api/v1/destinations/{destination}` | Delete a destination. Returns `204`. |
| `POST` | `/api/v1/destinations/{destination}/preview` | A [dry run](#preview). |

### Destination types

`GET /api/v1/destination-types` returns each type with what you need to build a form for it.

```json title="One entry in data"
{
  "type": "slack",
  "label": "Slack",
  "description": "Post each submission to a Slack channel through an incoming webhook.",
  "config_fields": [
    {
      "label": "Incoming webhook URL",
      "type": "secret",
      "required": true,
      "help": "From a Slack app with incoming webhooks turned on (https://hooks.slack.com/services/...).",
      "key": "webhook_url"
    }
  ],
  "mapping_targets": [{ "label": "Message", "required": true, "key": "text" }],
  "custom_mapping": true
}
```

Setting types are `text`, `url`, `secret`, `select` and `textarea`. `select` settings list their `options`, and settings with a default include `default`.

### Create a destination

### Request

```bash
curl -X POST https://YOUR-QUICKFORMS-HOST/api/v1/forms/FORM_ID/destinations \
  -H "Authorization: Bearer $QUICKFORMS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "type": "slack",
    "name": "Sales channel",
    "config": { "webhook_url": "https://hooks.slack.com/services/T000/B000/XXXX" },
    "conditions": [{ "field": "topic", "operator": "equals", "value": "sales" }]
  }'
```

### 201 response

```json
{
  "data": {
    "id": "01jcd3n5p7q9r1s3t5v7w9x1y3",
    "form_id": "01jc8xk2m5t9qw3e7r4y6u8v0p",
    "type": "slack",
    "service": "Slack",
    "name": "Sales channel",
    "enabled": true,
    "config": { "webhook_url": "•••••••••••••••••••••••••••••••••••••••••••XXXX" },
    "mapping": { "text": "*New submission: {{_form_name}}*\n{{_summary}}" },
    "conditions": [{ "field": "topic", "operator": "equals", "value": "sales" }],
    "created_at": "2026-11-06T10:24:00.000000Z",
    "updated_at": "2026-11-06T10:24:00.000000Z"
  }
}
```

`type`, `name` and `config` are required when creating. The webhook URL and IDs are illustrative.

### Errors

| Status | When |
| --- | --- |
| `404` | The form or destination isn't in the API key's team. |
| `422` | An unknown `type` ("The selected type is invalid."), a missing or invalid setting, a missing required mapping key, an invalid mapping key or condition, or the form already has 20 destinations ("This form already has the most destinations allowed."). |

## MCP

The `manage-destination` tool does the same over [MCP](https://docs.getquickforms.com/mcp/), with an `action`:

| Action | Takes |
| --- | --- |
| `types` | Nothing. Lists the destination types. |
| `list` | `form_id` |
| `create` | `form_id`, `type`, `name`, `config`, and optionally `enabled`, `mapping` and `conditions` |
| `update` | `destination_id` and the keys to change |
| `delete` | `destination_id` |
| `preview` | `destination_id`, and optionally `data` (sample field values; defaults to the latest submission) |

## Adding a destination type

Self-hosting? A destination type is a PHP class that implements `App\Destinations\Driver`, listed under `destinations.drivers` in `config/quickforms.php`. The driver declares its settings and suggested mapping keys, works out a default mapping for a form, and returns the HTTP requests to make for a submission. Keep secret headers apart in the request so previews and the delivery log redact them.

Source: https://docs.getquickforms.com/destinations/index.mdx
