Skip to content

Destinations

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.

A destination is somewhere a form’s submissions are sent, besides email and the form webhook. Add them on the form’s Destinations tab in the dashboard, with the API or over MCP.

Type Service What it does
slack Slack Posts each submission to a Slack channel through an incoming webhook.
discord Discord Posts each submission to a Discord channel through a channel webhook.
http HTTP request Sends a JSON request to any URL: a Zapier, Make or n8n catch hook, or your own API.
hubspot HubSpot Creates or updates a contact by email.
attio Attio Creates or updates a person by email address.
customerio Customer.io Identifies the person and tracks an event, so a campaign can start from the submission.
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 match gets a delivery, in the order the destinations were created. Destinations are held for spam review and 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.
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 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 or MCP, like any other delivery.

Sandbox

In 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

GET /api/v1/destinations/{destination}json
{
  "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.
conditions When to send. See 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}}.

mappingjson
{
  "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.

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

conditionsjson
[
  { "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 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.

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"}}'
{
  "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 match.
requests Each request’s method, url, headers and body. Secret headers show as [redacted].
mapped The rendered 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.

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

Body with body: mappingjson
{ "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.

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 for the full setup.

API

All endpoints take a team API key as Authorization: Bearer <api_key>. See 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.

Destination types

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

One entry in datajson
{
  "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

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" }]
  }'
{
  "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, 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.

Type to search…

↑↓ navigate↵ selectEsc close