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.

PATCH /v1/audiences/:id
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>"
  }'
double_opt_in_source says which setting decided
{
  "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.

POST /v1/audiences/:id/contacts/:member/confirm
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"
202 Accepted
{
  "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.

A signup handler that actually confirms
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 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.

RefusalWhat it means
422 confirmation_refusedAlready confirmed, already unsubscribed, suppressed, or past the third send.
422 no_sending_addressThe workspace has no verified address to send the confirmation from. Verify a domain first.
500 confirmation_unavailableThe 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.