# Double opt-in (/recipes/double-opt-in)

With double opt-in, a signup doesn't become a marketing recipient until they've clicked a confirmation link. That extra click is your defence against mistyped addresses, shared role inboxes, and submissions from people who never meant to opt in.

Everything hinges on `{{subscribeUrl}}` — a link Bitelio generates per contact and injects into any email you send. When the recipient clicks it, their `subscribed` flag becomes `true` and Bitelio emits a `contact.subscribed` event.

## Setup

import {Step, Steps} from 'fumadocs-ui/components/steps';

<Steps>
  <Step>
    ### Create two templates

    * A **Transactional** template holding the confirmation message, with `{{subscribeUrl}}` inside:

      ```html
      <p>Hi {{firstName}}, please confirm your email to start receiving updates:</p>
      <p><a href="{{subscribeUrl}}">Confirm my email</a></p>
      ```

    * A **Marketing** template for the welcome message that only goes out *once* they've confirmed.

    <Callout title="The confirmation must be transactional" type="warn">
      If a marketing template is aimed at a contact who isn't subscribed, the send is [silently skipped](/concepts/contacts#emails-by-subscription-state). That's exactly why the confirmation email needs to be transactional: transactional sends ignore subscription state.
    </Callout>
  </Step>

  <Step>
    ### Trigger the signup from your backend

    From your server, register the contact with a secret key (`sk_*`, `contacts:edit`), then track the event with the same project’s public key (`pk_*`). Tracking accepts only the public key. Never expose the secret key in browser code.

    ```bash
    curl https://api.bitelio.com/v1/contacts \
      -H "Authorization: Bearer sk_your_secret_key" \
      -H "Content-Type: application/json" \
      -d '{ "email": "ada@example.com", "subscribed": false, "data": { "firstName": "Ada" } }'

    curl https://api.bitelio.com/v1/track \
      -H "Authorization: Bearer pk_your_public_key" \
      -H "Content-Type: application/json" \
      -d '{ "event": "signup.pending", "email": "ada@example.com", "subscribed": false }'
    ```

    Note that `subscribed: false` appears in both requests. Calling `/v1/track` on its own would still create the missing contact — but as a subscribed one, which undermines the whole double opt-in scheme. Hence the explicit contact creation first.
  </Step>

  <Step>
    ### Workflow A: send the confirmation

    Under **Workflows → New workflow**:

    * **Trigger**: `EVENT` on `signup.pending`
    * `SEND_EMAIL` step → transactional confirmation template

    Turn it on.
  </Step>

  <Step>
    ### Workflow B: welcome them after confirmation

    Under **Workflows → New workflow**:

    * **Trigger**: `EVENT` on `contact.subscribed`
    * `SEND_EMAIL` step → marketing welcome template

    Turn it on. Because `contact.subscribed` is emitted on every opt-in — a `{{subscribeUrl}}` click, the preferences page, or an API update — this single workflow covers both fresh confirmations and contacts who resubscribe later.
  </Step>
</Steps>

## Reminder if they don't confirm

Add a `WAIT_FOR_EVENT` step to Workflow A, right after the send:

* **Event**: `contact.subscribed`
* **Timeout**: `86400` (24 hours)

If the timeout fires, send one reminder — again from a transactional template. Resist adding more: to mailbox providers (and to the recipient), a stream of "please confirm" emails reads as spam.

## What's next

<Cards>
  <Card title="Unsubscribe & preferences pages" href="/guides/unsubscribe-pages">
    How `{{subscribeUrl}}` works and what the hosted pages do.
  </Card>

  <Card title="Templates" href="/concepts/templates">
    Marketing vs. Transactional vs. Headless templates, and when each applies.
  </Card>
</Cards>
