Double opt-in
Nothing sends a confirmation email on its own: one explicit call does, and this is what it does.
Double opt-in means a new member is not mailable until they click a link in an email confirming they meant it. It is the difference between a list you can defend and a list somebody typed a stranger's address into. Here it is two pieces: a setting that decides what status a new member lands in, and a call that sends the confirmation.
Turn it on
There is a workspace default, and each audience can override it. The per-audience setting is three-valued: true requires it, false does not, and null inherits the workspace default. GET on an audience tells you both the answer and where the answer came from.
curl -X PATCH https://emails.sh/v1/audiences/2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33 \
-H "Authorization: Bearer $EMAILSSH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"require_double_opt_in": true,
"confirmation_subject": "Confirm your subscription to Acme updates",
"confirmation_body": "<p>Hello, click to confirm you want Acme product updates: <a href=\"{{ confirm_url }}\">confirm</a>. The link works for {{ ttl_days }} days.</p>"
}'{
"id": "2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33",
"name": "Product updates",
"description": "Monthly changelog",
"contact_count": 1879,
"subscribed_count": 1842,
"require_double_opt_in": true,
"double_opt_in_source": "audience",
"created_at": "2026-06-01T10:00:00.000Z"
}- {{ confirm_url }}
- The signed link. A body without it is a confirmation email nobody can act on.
- {{ audience_name }}
- What they are confirming, so the email says what it is about.
- {{ ttl_days }}
- How long the link lasts, which is 30 days.
- {{ email }}
- The address being confirmed.
What happens on add
With it on, a new member lands in pending rather than subscribed, and a pending member is not mailed by a broadcast. The one exception is deliberate: a row that explicitly says status "subscribed" is taken at its word, which is how a list that was already confirmed somewhere else is migrated without asking everybody again.
That exception is a loaded gun. Use it for an import out of a system that genuinely held confirmations, and not for a spreadsheet.
Send the confirmation
Import or add the person, read back their membership id, and call confirm. That is the step nothing does for you.
curl -X POST https://emails.sh/v1/audiences/2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33/contacts/6f1d2c33-8a4e-4b17-9f02-51c7d9a3e480/confirm \
-H "Authorization: Bearer $EMAILSSH_API_KEY"{
"membership_id": "6f1d2c33-8a4e-4b17-9f02-51c7d9a3e480",
"email": "ada@example.com",
"sent": true,
"status": "pending",
"confirm_url": "https://emails.sh/c/confirm/6f1d2c33-8a4e-4b17-9f02-51c7d9a3e480?e=ada%40example.com&t=9f2c4e1b7a05"
}The whole signup handler, then, is three calls: import the address, take the membership id off the result, and confirm it. Wire it once and forget it.
const AUDIENCE = '2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33';
const headers = {
Authorization: `Bearer ${process.env.EMAILSSH_API_KEY}`,
'Content-Type': 'application/json'
};
export async function subscribe(email: string, firstName: string) {
// 1. Import. Under double opt-in this lands the member in "pending".
await fetch(`https://emails.sh/v1/audiences/${AUDIENCE}/contacts`, {
method: 'POST',
headers,
body: JSON.stringify({ contacts: [{ email, attributes: { first_name: firstName } }] })
});
// 2. Find the membership id. It is not the contact id and not the address.
const listed = await fetch(
`https://emails.sh/v1/audiences/${AUDIENCE}/contacts?status=pending&limit=1000`,
{ headers }
);
const { contacts } = (await listed.json()) as {
contacts: { id: string; email: string }[];
};
const member = contacts.find((c) => c.email === email);
if (!member) throw new Error(`${email} is not pending on this audience`);
// 3. Send the confirmation. Nothing else ever does.
const confirmed = await fetch(
`https://emails.sh/v1/audiences/${AUDIENCE}/contacts/${member.id}/confirm`,
{ method: 'POST', headers }
);
if (!confirmed.ok) {
const body = (await confirmed.json()) as { error?: { code?: string } | string };
const code = typeof body.error === 'string' ? body.error : body.error?.code;
throw new Error(`confirmation refused: ${code ?? confirmed.status}`);
}
}The link
The token is a signed HMAC rather than a row, so nothing has to be stored and nothing expires early. It lasts 30 days and a plain GET on the link confirms, because a mail client that prefetches a link is a fact of life and a confirmation is not a destructive action.
A membership may be sent at most 3 confirmations. Past that the call answers 422 confirmation_refused rather than letting a retry loop become a way to mail somebody repeatedly.
| Refusal | What it means |
|---|---|
422 confirmation_refused | Already confirmed, already unsubscribed, suppressed, or past the third send. |
422 no_sending_address | The workspace has no verified address to send the confirmation from. Verify a domain first. |
500 confirmation_unavailable | The send itself failed. Retry. |
When to bother
- A public signup form
- Always. It is the only thing standing between your list and a bored person typing in addresses that are not theirs.
- A checkbox during account creation
- Usually not. You already sent them a verification email and they already proved they own the address.
- An imported list
- Depends on where it came from. If the source held real confirmations, migrate them as subscribed. If it came from a spreadsheet, confirm them, and expect a smaller list afterwards than you hoped for.