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
{
"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_headeris never sent to a new URL, so enter it again when you changeurl.
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}}.
{
"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,companyorphone. 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
422on that key, for example “Map something to Email.”. - Sending
mappingon 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.
[
{ "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. |
fieldmust be one of the form’s field names.valueis 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:
submission_id: one of the form’s submissions.data: your own field values.- The form’s latest submission.
- 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:
{ "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 |
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 |
||
first_name |
First name | |
last_name |
Last name |
It makes two requests, authenticated with the site ID and API key:
PUT /api/v1/customers/{id}with every other non-empty mapped value as an attribute.POST /api/v1/customers/{id}/eventswith the event name and, as its data,form_id,form_name,submission_idand 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.
{
"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.