# Double opt-in

Nothing sends a confirmation email on its own: one explicit call does, and this is what it does.

Read this first. The confirmation email is sent only by POST /v1/audiences/:id/contacts/:member/confirm. Nothing sends it for you. Turning double opt-in on and then adding somebody leaves them sitting in pending forever, unmailed and unconfirmed, until your code makes that call.

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:
```bash
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:
```json
{
  "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:
```bash
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:
```json
{
  "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:
```ts
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.

Double opt-in is about consent to be on a list. It is a different question from a topic opt-out, which is consent to keep receiving one category of mail. Most lists want both. See /docs/topics.

---

Base URL: https://emails.sh/v1. Auth: `Authorization: Bearer esh_...`.
Whole API in one file: https://emails.sh/llms.txt. All documentation: https://emails.sh/docs.md.
