> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Proving an address

> Two flows arrive in the inbox and are the only things that can prove an email: the magic link and the six-digit code.

`/v1/boot` proves the `external_id` and nothing else — an email that rides along
is stored as an attribute, never as an identity edge. The two flows here are the
ones that can prove the address itself, because they arrive *in* it. Each answers
with a verified `uk_ct_…` session, sets `email_verified` on the contact, and
publishes `contact.email_verified`.

They are public surface reached with a **publishable key** in the request body,
from your own domain, and they hold the API's strictest posture: unconditional
`202` on the request, one `401` for every way a redeem can fail, strict per-IP
limits, and one-time tokens for anything that arrives by email.

<Note>
  Neither flow creates an account. The address has to belong to a contact already —
  one your backend created with [`POST /v1/contacts`](/en/concepts/contacts)
  or that a boot carried — because a flow that minted contacts from an inbox would
  be a second sign-up door beside your own. The request still answers `202` for an
  address nobody has, so the endpoint cannot be used to learn which addresses do.
</Note>

## Magic links

Passwordless sign-in for a page that has no session to offer: a ticket email, the
public help center, the portal.

<Steps>
  <Step title="Request">
    ```bash theme={null}
    curl -s $API/v1/contact-auth/magic-link \
      -H 'Content-Type: application/json' \
      -d '{ "publishable_key": "uk_pk_live_…", "email": "grace@example.com" }'
    ```

    Unconditional `202`. Valid one hour. Rate limited to 5 per hour per IP.
  </Step>

  <Step title="Redeem">
    ```bash theme={null}
    curl -s $API/v1/contact-auth/magic-link/redeem \
      -H 'Content-Type: application/json' \
      -d '{ "token": "uk_ml_…" }'
    ```

    Answers the contact and a verified `uk_ct_…` session.
  </Step>
</Steps>

The redeem is atomic in SQL, so the link works exactly once. Used, expired and
never-existed all answer the same `401` — three different truths, one response, on
purpose.

## Email codes

The same flow presented as six digits. Reach for it instead of a link when the
code will be typed on the device that asked for it: a code survives a mail client
that opens links in its own browser, and it works when the inbox is on a phone
and the sign-in is on a laptop.

<Steps>
  <Step title="Request">
    ```bash theme={null}
    curl -s $API/v1/contact-auth/email-code \
      -H 'Content-Type: application/json' \
      -d '{ "publishable_key": "uk_pk_live_…", "email": "grace@example.com" }'
    ```

    ```json 202 — always theme={null}
    { "challenge_token": "uk_ec_…", "expires_at": "2026-03-01T12:10:00Z" }
    ```

    Hold the challenge in the page. It is not a session and can never be used as
    one. Valid ten minutes. Rate limited to 5 per hour per IP.
  </Step>

  <Step title="Verify">
    ```bash theme={null}
    curl -s $API/v1/contact-auth/email-code/verify \
      -H 'Content-Type: application/json' \
      -d '{ "publishable_key": "uk_pk_live_…", "challenge_token": "uk_ec_…", "code": "418902" }'
    ```

    Answers the contact and a verified `uk_ct_…` session. The code arrived in the
    inbox, so it proves the address exactly like the link does.
  </Step>
</Steps>

The challenge comes back for **every** address, whether or not it has an
account — otherwise this endpoint would be the place somebody learns which
addresses do. The verify holds the same line: a wrong code, an expired one, a
spent one, and a challenge that never had a code behind it all answer the same
`401 invalid_code`.

<Note>
  Two rules make six digits safe to accept, and both matter:

  * **Each code carries five attempts.** A guess costs one whether or not it was
    right, and the code dies when they run out. Six digits is a million, which a
    rate limit alone does not close.
  * **Asking for a new code spends the outstanding one.** Otherwise every request
    would add another five guesses against another number.

  The code alone is not enough either: it is only valid against the challenge it
  was issued for, so a code read over somebody's shoulder opens nothing without
  the browser that asked for it.
</Note>

## The session

Both flows hand back a `uk_ct_…` contact session, valid 30 days, with
`verified: true`. Use it for the contact's own reads:

```bash theme={null}
curl -s $API/v1/contact/me     -H "Authorization: Bearer uk_ct_…"
curl -s -X POST $API/v1/contact/logout -H "Authorization: Bearer uk_ct_…"
```

Signing out deletes the session — the next lookup simply matches nothing. In the
SDK the pair is `requestEmailCode` / `signInWithEmailCode` and `requestMagicLink`
/ `redeemMagicLink` on the client; the portal's `/sign-in` is the code flow
drawn by us.
