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
- Someone submits the form. The submission is validated, checked by the spam filter and stored as usual.
- quickforms sends the confirmation email to the address in the form’s email field, and the submission’s
confirmation_statusbecomespending. Nothing else is sent. - 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. - The submission becomes
confirmed,confirmed_atis set, and its notification email, autoresponder and webhook are created and delivered. - The person lands on your
redirect_url, or on a hosted page that showsconfirmed_message.
If nobody clicks the link in time, the submission expires. See expiry.
Switch it on
Double opt-in lives in settings.confirmation. Set it with the REST API or with the create-form and update-form MCP tools.
{
"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:
{
"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). It is a delivery with channel confirmation, so it appears in the submission’s deliveries with its own attempts and status.
It has:
- The
subjectandbody, 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_labelthat 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.
- 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_statusstaysnull.
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:
keepmarks the submissionexpired. It is kept, and its held emails and webhooks are never sent.deletedeletes the submission.
Resending 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, or the confirmation input on the list-submissions MCP tool.
Webhooks include confirmed_at in the submission object of the payload. 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 for request and response examples.
Testing in sandbox mode
In sandbox mode 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.