feat: add customizable contact double opt-in flow (#350)

* feat: add customizable contact double opt-in flow

* test: add double opt-in service coverage

* fix: address review comments for double opt-in PR

- Make pending status conditional on doubleOptInEnabled flag
- Backfill legacy unsubscribeReason for reliable pending detection
- Add doubleOptInContent to contact book listing select
- Fix duplicate toast on DOI editor subject save failure
- Harden searchParams parsing against string[] values
- Make default DOI template use link mark for clickable URL
- Make public API create+update atomic via transaction
- Prevent contact upsert failure when DOI email send fails
- Fix empty string template variable replacement

Co-authored-by: opencode <opencode@anthropic.com>

* fix: harden double opt-in confirmation safeguards

Preserve explicit unsubscribe intent in DOI flows and prevent confirmation links from re-subscribing opted-out contacts. Also sanitize subscribe-page error messaging and use timing-safe hash comparison for link verification.

* ui stuff

* fix: require doubleOptInUrl in double opt-in templates

* feat: add configurable from address for double opt-in emails

* feat: add resend confirmation flow for pending contacts

* fix: move subscribe confirmation to explicit POST flow

* test: add contact book public API endpoint coverage

* docs: add double opt-in documentation and update OpenAPI spec

Add a user guide for the double opt-in feature covering setup, contact
statuses, email customization, template variables, and best practices.
Update the OpenAPI spec to include doubleOptIn fields in all contactBook
request/response schemas.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: opencode <opencode@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
KM Koushik
2026-03-01 00:34:20 +11:00
committed by GitHub
parent edcd32a4ea
commit e3e9635a5f
27 changed files with 3500 additions and 288 deletions
+68
View File
@@ -1095,6 +1095,28 @@
"description": "The emoji associated with the contact book",
"example": "📙"
},
"doubleOptInEnabled": {
"type": "boolean",
"description": "Whether double opt-in is enabled for new contacts",
"example": true
},
"doubleOptInFrom": {
"type": "string",
"nullable": true,
"description": "From address used for double opt-in emails (must use a verified domain)",
"example": "Newsletter <hello@example.com>"
},
"doubleOptInSubject": {
"type": "string",
"nullable": true,
"description": "Subject line used for double opt-in confirmation email",
"example": "Please confirm your subscription"
},
"doubleOptInContent": {
"type": "string",
"nullable": true,
"description": "Email editor JSON content used for double opt-in confirmation"
},
"createdAt": {
"type": "string",
"description": "The creation timestamp"
@@ -1142,6 +1164,23 @@
"properties": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"doubleOptInEnabled": {
"type": "boolean",
"description": "Whether double opt-in is enabled for new contacts"
},
"doubleOptInFrom": {
"type": "string",
"nullable": true,
"description": "From address used for double opt-in emails (must use a verified domain)"
},
"doubleOptInSubject": {
"type": "string",
"description": "Subject line used for double opt-in confirmation email"
},
"doubleOptInContent": {
"type": "string",
"description": "Email editor JSON content used for double opt-in confirmation"
}
},
"required": ["name"]
@@ -1165,6 +1204,10 @@
"additionalProperties": { "type": "string" }
},
"emoji": { "type": "string" },
"doubleOptInEnabled": { "type": "boolean", "description": "Whether double opt-in is enabled for new contacts" },
"doubleOptInFrom": { "type": "string", "nullable": true, "description": "From address used for double opt-in emails" },
"doubleOptInSubject": { "type": "string", "nullable": true, "description": "Subject line used for double opt-in confirmation email" },
"doubleOptInContent": { "type": "string", "nullable": true, "description": "Email editor JSON content used for double opt-in confirmation" },
"createdAt": { "type": "string" },
"updatedAt": { "type": "string" }
},
@@ -1210,6 +1253,10 @@
"additionalProperties": { "type": "string" }
},
"emoji": { "type": "string" },
"doubleOptInEnabled": { "type": "boolean", "description": "Whether double opt-in is enabled for new contacts" },
"doubleOptInFrom": { "type": "string", "nullable": true, "description": "From address used for double opt-in emails" },
"doubleOptInSubject": { "type": "string", "nullable": true, "description": "Subject line used for double opt-in confirmation email" },
"doubleOptInContent": { "type": "string", "nullable": true, "description": "Email editor JSON content used for double opt-in confirmation" },
"createdAt": { "type": "string" },
"updatedAt": { "type": "string" },
"_count": {
@@ -1279,6 +1326,23 @@
"properties": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"doubleOptInEnabled": {
"type": "boolean",
"description": "Whether double opt-in is enabled for new contacts"
},
"doubleOptInFrom": {
"type": "string",
"nullable": true,
"description": "From address used for double opt-in emails (must use a verified domain)"
},
"doubleOptInSubject": {
"type": "string",
"description": "Subject line used for double opt-in confirmation email"
},
"doubleOptInContent": {
"type": "string",
"description": "Email editor JSON content used for double opt-in confirmation"
}
}
}
@@ -1301,6 +1365,10 @@
"additionalProperties": { "type": "string" }
},
"emoji": { "type": "string" },
"doubleOptInEnabled": { "type": "boolean", "description": "Whether double opt-in is enabled for new contacts" },
"doubleOptInFrom": { "type": "string", "nullable": true, "description": "From address used for double opt-in emails" },
"doubleOptInSubject": { "type": "string", "nullable": true, "description": "Subject line used for double opt-in confirmation email" },
"doubleOptInContent": { "type": "string", "nullable": true, "description": "Email editor JSON content used for double opt-in confirmation" },
"createdAt": { "type": "string" },
"updatedAt": { "type": "string" }
},
+1 -1
View File
@@ -39,7 +39,7 @@
},
{
"group": "Guides",
"pages": ["guides/webhooks", "guides/use-with-react-email"]
"pages": ["guides/webhooks", "guides/double-opt-in", "guides/use-with-react-email"]
}
]
},
+171
View File
@@ -0,0 +1,171 @@
---
title: Double Opt-In
description: "Verify new subscribers with a confirmation email before adding them to your contact book"
---
## Overview
Double opt-in requires new contacts to confirm their email address before they become subscribed. When enabled on a contact book, newly added contacts receive a confirmation email with a verification link. Only after clicking the link do they become fully subscribed.
**Why use double opt-in?**
- Ensures email addresses are valid and owned by the subscriber
- Reduces bounce rates and spam complaints
- Improves deliverability and sender reputation
- Helps comply with email marketing regulations (GDPR, CAN-SPAM)
## How it works
<Steps>
<Step title="Contact is added">
When a contact is added to a contact book with double opt-in enabled (via
dashboard, API, or CSV import), they are created with a **Pending** status
instead of being immediately subscribed.
</Step>
<Step title="Confirmation email is sent">
A confirmation email is automatically sent to the contact with a unique
verification link. The link is signed with HMAC-SHA256 and expires after 7
days.
</Step>
<Step title="Contact confirms">
The contact clicks the verification link in the email and confirms their
subscription on the confirmation page.
</Step>
<Step title="Contact is subscribed">
The contact's status changes from **Pending** to **Subscribed** and they
will now receive your emails.
</Step>
</Steps>
## Enabling double opt-in
### Via the dashboard
1. Go to [Contacts](https://app.usesend.com/contacts) and select a contact book
2. Click on the **Double Opt-In** tab
3. Toggle double opt-in on
4. Customize the confirmation email (optional)
5. Save your changes
### Via the API
Create a contact book with double opt-in enabled:
```bash
curl -X POST https://app.usesend.com/api/v1/contactBooks \
-H "Authorization: Bearer us_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Newsletter Subscribers",
"doubleOptInEnabled": true,
"doubleOptInFrom": "Newsletter <hello@yourdomain.com>",
"doubleOptInSubject": "Please confirm your subscription"
}'
```
Or enable it on an existing contact book:
```bash
curl -X PATCH https://app.usesend.com/api/v1/contactBooks/{contactBookId} \
-H "Authorization: Bearer us_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"doubleOptInEnabled": true
}'
```
## Contact statuses
When double opt-in is enabled, contacts have three possible statuses:
| Status | Description |
| ---------------- | ---------------------------------------------------------------- |
| **Subscribed** | Contact has confirmed their subscription and will receive emails |
| **Pending** | Contact has been added but hasn't confirmed yet |
| **Unsubscribed** | Contact has explicitly unsubscribed |
<Warning>
Contacts with **Pending** status will not receive campaign emails. They will
only receive the double opt-in confirmation email.
</Warning>
## Customizing the confirmation email
You can customize three aspects of the confirmation email:
### From address
Set a custom sender address for confirmation emails. The address must use one of your verified domains.
```json
{
"doubleOptInFrom": "Newsletter <hello@yourdomain.com>"
}
```
If not set, the confirmation email will be sent from the first available verified domain.
### Subject line
Customize the email subject. The default is "Please confirm your subscription".
```json
{
"doubleOptInSubject": "Confirm your subscription to our newsletter"
}
```
### Email template
The confirmation email body can be customized using the useSend email editor (via the dashboard) or by providing the editor JSON content via the API.
#### Template variables
The following variables can be used in the confirmation email template:
| Variable | Description |
| -------------------- | -------------------------------- |
| `{{doubleOptInUrl}}` | The confirmation link (required) |
| `{{email}}` | The contact's email address |
| `{{firstName}}` | The contact's first name |
| `{{lastName}}` | The contact's last name |
<Warning>
The `{{doubleOptInUrl}}` variable is **required** in the email template. The
confirmation email cannot be saved without it. This ensures every confirmation
email contains a working verification link.
</Warning>
## Resending confirmation emails
If a contact hasn't confirmed their subscription, you can resend the confirmation email from the dashboard:
1. Go to your contact book and find the pending contact
2. Click the **Resend** button next to the contact
Each resend generates a new confirmation link with a fresh 7-day expiration window.
## Best practices
<AccordionGroup>
<Accordion title="Set up a verified domain first">
Double opt-in requires at least one verified domain to send confirmation
emails. Make sure to [verify your domain](https://app.usesend.com/domains)
before enabling double opt-in.
</Accordion>
<Accordion title="Keep the confirmation email simple">
The confirmation email should be clear and concise. Include a prominent
confirmation button and a brief explanation of what the subscriber is
confirming.
</Accordion>
<Accordion title="Use a recognizable from address">
Use a from address that your subscribers will recognize, such as
`newsletter@yourdomain.com` or `hello@yourdomain.com`. This reduces the
chance of the confirmation email being marked as spam.
</Accordion>
<Accordion title="Monitor pending contacts">
Regularly check for contacts stuck in Pending status. If many contacts
aren't confirming, consider improving your confirmation email or resending
confirmations.
</Accordion>
</AccordionGroup>