---
title: "Double opt-in"
description: "Ask people to confirm their email address before a submission's emails and webhooks are sent."
---

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

# Double opt-in

With double opt-in, a submission only sends one thing at first: a confirmation email with a button. The notification email, the autoresponder and the webhook are held until the person clicks the button. Use it for newsletters, waitlists and anything else where you want to know the email address is real and belongs to the person who submitted.

## How it works

1. Someone submits the form. The submission is validated, checked by the [spam filter](https://docs.getquickforms.com/spam-filter/) and stored as usual.
2. quickforms sends the confirmation email to the address in the form's email field, and the submission's `confirmation_status` becomes `pending`. Nothing else is sent.
3. The person clicks the button in the email, then the confirm button on the page it opens. The link is signed and expires after `expires_hours`.
4. The submission becomes `confirmed`, `confirmed_at` is set, and its notification email, autoresponder and webhook are created and delivered.
5. The person lands on your `redirect_url`, or on a hosted page that shows `confirmed_message`.

If nobody clicks the link in time, the submission expires. See [expiry](#expiry).

## Switch it on

Double opt-in lives in `settings.confirmation`. Set it with the [REST API](https://docs.getquickforms.com/api/#form-settings) or with the `create-form` and `update-form` [MCP tools](https://docs.getquickforms.com/mcp/).

```json title="PATCH /api/v1/forms/FORM_ID"
{
  "settings": {
    "confirmation": {
      "enabled": true,
      "email_field": "email",
      "subject": "Confirm your place on the {{name}} waitlist",
      "expires_hours": 72,
      "on_expiry": "delete",
      "redirect_url": "https://yoursite.com/confirmed"
    }
  }
}
```

The subject and redirect URL above are illustrative. Keys you leave out keep their defaults.

| Key | Default | Description |
| --- | --- | --- |
| `enabled` | `false` | Send a confirmation email and hold everything else until it is confirmed. |
| `email_field` | `null` | The email field holding the address to confirm. Defaults to the form's first email field. |
| `subject` | `"Please confirm your email address"` | Up to 200 characters. Supports `{{field}}` placeholders. |
| `body` | A short request to confirm | Plain text, up to 5000 characters. Supports `{{field}}` placeholders. |
| `button_label` | `"Confirm my email"` | The button text, up to 60 characters. |
| `expires_hours` | `48` | How long the link works, from 1 to 720 hours. |
| `on_expiry` | `keep` | What happens to a submission that is never confirmed: `keep` marks it `expired`, `delete` deletes it. |
| `redirect_url` | `null` | An http or https URL to send people to after they confirm. |
| `confirmed_message` | `"Thanks, your email address is confirmed."` | Shown on the hosted confirmed page when there is no `redirect_url`. Up to 500 characters. Supports `{{field}}` placeholders. |

The form needs an email field to switch double opt-in on, and `email_field` must name one of the form's email fields. Otherwise saving is a `422`:

```json title="422 response"
{
  "message": "Double opt-in needs an email field to send the confirmation to.",
  "errors": { "settings.confirmation.email_field": ["Double opt-in needs an email field to send the confirmation to."] }
}
```

## The confirmation email

The confirmation email goes out through the form's email provider, like the notification email and the autoresponder (see [email providers](https://docs.getquickforms.com/email/)). It is a delivery with channel `confirmation`, so it appears in the submission's deliveries with its own attempts and status.

It has:

- The `subject` and `body`, with `{{field_name}}` placeholders filled from the submission.
- One open confirmation per form and address at a time. A newer submission with the same address replaces the older one: the new email goes out, the older link stops working and the older submission becomes `expired`. If the new email is held back by the per-recipient limit, the older link keeps working.
- A button with `button_label` that links to the confirmation link.
- A footer: "If you did not sign up, you can ignore this email. The link expires in 48 hours." The number is the form's `expires_hours`.

Replies go to the form's first notification address, when it has one.

## The confirmation link

The button links to `GET /c/{submission}` with `expires` and `signature` query parameters. That page shows a confirm button, which posts to the same signed address. Opening the link doesn't confirm anything on its own, so mail scanners that follow every link can't confirm for someone. The link is signed, so it can't be changed or guessed, and it stops working after `expires_hours`.

| Situation | Result |
| --- | --- |
| Valid link, submission pending | `GET` shows the confirm button. `POST` confirms the submission, releases its emails and webhooks, then redirects or shows the confirmed page. |
| Valid link, already confirmed | Nothing is sent again. The person is redirected or sees the confirmed page, so clicking twice is harmless. |
| Link changed or signature wrong | `403` hosted page: "This confirmation link is not valid." |
| Link expired, the submission expired, or the form is offline | `410` hosted page: "This confirmation link has expired." |

After confirming, the person gets a `303` redirect to `redirect_url` when it is set. Without one they see a hosted page with `confirmed_message`, with `{{field}}` placeholders filled from the submission.

## Spam and blank addresses

- **Flagged submissions get no confirmation email.** A submission that fails the spam check or breaks a smart rule is held as usual, and no confirmation email is sent. When you mark it as not spam, the confirmation email is sent first, and the rest waits for the person to confirm. See [flagged submissions](https://docs.getquickforms.com/spam-filter/#flagged-submissions).
- **A blank optional email field means normal delivery.** If the email field is optional and left blank, there is no address to confirm, so the submission is delivered straight away like on a form without double opt-in. Its `confirmation_status` stays `null`.

## Expiry

`quickforms:expire-confirmations` runs every hour from the scheduler. It finds pending submissions whose latest confirmation email is older than the form's `expires_hours` and, depending on `on_expiry`:

- `keep` marks the submission `expired`. It is kept, and its held emails and webhooks are never sent.
- `delete` deletes the submission.

[Resending](#confirm-or-resend) the confirmation email sends a new link and starts the clock again, and it also works on an expired submission.

## Submission fields

Submissions from the REST API and MCP have two fields for double opt-in:

| Field | Description |
| --- | --- |
| `confirmation_status` | `null` when the form doesn't use double opt-in (or there was no address to confirm), otherwise `pending`, `confirmed` or `expired`. |
| `confirmed_at` | When the submission was confirmed, otherwise `null`. |

Filter a form's submissions by status with `?confirmation=pending`, `confirmed` or `expired` on the [submissions endpoint](https://docs.getquickforms.com/api/#get-apiv1formsformsubmissions), or the `confirmation` input on the `list-submissions` MCP tool.

Webhooks include `confirmed_at` in the `submission` object of the [payload](https://docs.getquickforms.com/webhooks/#request). On a form with double opt-in, the webhook is only sent after confirmation, so it is set.

## Confirm or resend

You can confirm a submission on the person's behalf, for example when they confirmed another way, or send them a fresh confirmation email.

| | REST API | MCP |
| --- | --- | --- |
| Confirm and release the held deliveries | `POST /api/v1/submissions/{submission}/confirm` | `confirm-submission` with `action: "confirm"` |
| Send a new confirmation email with a new link | `POST /api/v1/submissions/{submission}/resend-confirmation` | `confirm-submission` with `action: "resend"` |

Confirming a confirmed submission does nothing. Resending works on pending and expired submissions, and sets an expired one back to `pending`. Anything else is a `422` with the error on `submission`:

- "This submission expired before it was confirmed." when confirming an expired submission. Resend it instead.
- "This submission is not waiting for confirmation." when confirming a submission that doesn't use double opt-in.
- "Only unconfirmed submissions can be sent a confirmation email." when resending to a confirmed submission or one that doesn't use double opt-in.
- "This submission has no valid email address to confirm." when the email field has no valid address.
- "This form is offline, so confirmation emails cannot be sent for it." when resending on a suspended or deleted form.

See the [REST API reference](https://docs.getquickforms.com/api/#post-apiv1submissionssubmissionconfirm) for request and response examples.

## Testing in sandbox mode

In [sandbox mode](https://docs.getquickforms.com/sandbox/#double-opt-in) nothing is emailed, but the flow still works. The submit response lists the confirmation email as a `simulated` delivery, and its payload holds the confirmation link in `button.url`. Open that link and press the button to confirm the submission: the notification email, autoresponder and webhook are then created as `simulated` deliveries too.

Source: https://docs.getquickforms.com/double-opt-in/index.mdx
