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

# Receive verification codes

> Block until a one-time code or magic link lands in an account's inbox, and get back just the code or link — extracted server-side from every inbound email.

When your code drives a signup or a login somewhere, the app emails a one-time code or a magic
link. Sente classifies **every** inbound email the moment it arrives and extracts the artifact, so
you wait for exactly that email and receive the value itself. No IMAP, no regex, no HTML parsing on
your side.

<Note>
  A Sente-driven [register](/accounts/register) or [connect](/accounts/connect) run completes email
  verification **by itself** — the run controller polls the account's own inbox and types the code in.
  This page is for when **your** code drives the flow: your own browser automation, a mobile signup,
  a partner API that emails a login link.
</Note>

## What you need

* **An `identityId`.** The wait endpoint is per-identity. If you let Sente auto-provision the
  identity (no `identityId` passed to connect/register), read it back off the account:
  `registration.identityId`.
* **An email code.** Only email lands in a Sente inbox. **SMS codes never arrive here** — a run
  that hits SMS 2FA blocks and pages a human ([human takeover](/runs/human-takeover)); nothing is
  worked around.

## The complete flow

Stamp a timestamp **before** you trigger the email, then wait with that `since`. A code that lands
in under a second cannot be missed, and a stale code from an earlier attempt cannot be picked up by
mistake.

<CodeGroup>
  ```bash CLI theme={null}
  # --identity takes an id, the full address, or the local part.
  SINCE=$(date -u +%Y-%m-%dT%H:%M:%SZ)

  # …now trigger the app's "email me a code" action…

  CODE=$(sente wait --identity github@sente.run --otp --since "$SINCE" --timeout 120)
  if [ -z "$CODE" ]; then
    echo "no verification code arrived" >&2
    exit 1
  fi
  echo "code: $CODE"
  ```

  ```ts TypeScript theme={null}
  import { Sente } from "@sente-labs/sdk";

  const sente = new Sente({ apiKey: process.env.SENTE_API_TOKEN! });

  const identityId = "idt_9f1c2ab34d5e46f7a8b9c0d1e2f3a4b5";
  const since = new Date().toISOString(); // stamp BEFORE triggering

  await triggerSignInEmail(); // your code: submit the form / call the app's API

  const otp = await sente.messages.waitForOtp(identityId, { since, timeout: 60 });
  if (!otp?.code) throw new Error("no verification code arrived");

  await submitCode(otp.code); // e.g. "481920"
  ```

  ```python Python theme={null}
  import os
  from datetime import datetime, timezone
  from sente import Sente

  sente = Sente(api_key=os.environ["SENTE_API_TOKEN"])

  identity_id = "idt_9f1c2ab34d5e46f7a8b9c0d1e2f3a4b5"
  since = datetime.now(timezone.utc).isoformat()  # stamp BEFORE triggering

  trigger_sign_in_email()  # your code: submit the form / call the app's API

  r = sente.messages.wait_for_otp(identity_id, since=since, timeout=60)
  if not r or not r.code:
      raise RuntimeError("no verification code arrived")

  submit_code(r.code)  # OtpResult(code, message)
  ```
</CodeGroup>

Magic links are the same call with a different helper — you get the URL to open:

<CodeGroup>
  ```bash CLI theme={null}
  LINK=$(sente wait --identity github@sente.run --magic-link --since "$SINCE" --timeout 120)
  ```

  ```ts TypeScript theme={null}
  const ml = await sente.messages.waitForMagicLink(identityId, { since, timeout: 60 });
  if (ml?.link) await page.goto(ml.link);
  ```

  ```python Python theme={null}
  ml = sente.messages.wait_for_magic_link(identity_id, since=since, timeout=60)
  if ml and ml.link:
      page.goto(ml.link)
  ```
</CodeGroup>

Both helpers return the whole message alongside the extracted value (`otp.message`, `r.message`),
and `null` / `None` on timeout.

## The wait contract

`GET /v1/messages/wait` — the endpoint both helpers call.

| Behaviour         | Value                                                                                                                                         |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `identityId`      | Required. There is no org-wide wait.                                                                                                          |
| `since`           | Optional. **Defaults to now − 60 s**, so an email that landed just before your call still matches. Pass it explicitly for back-to-back flows. |
| `kind`            | `otp`, `magic_link`, or `other`. With `kind` set, only annotated messages match — you never race the annotator.                               |
| `timeout`         | Seconds, **1–60, default 25**. Both SDKs pass this straight through; a value above 60 is a `400`.                                             |
| Match order       | The **oldest** message after `since`, not the newest. Another reason to stamp `since` yourself.                                               |
| Result            | `200` with the full message, or **`204` and no body** on timeout (`null` / `None` in the SDKs).                                               |
| Archived messages | Never match.                                                                                                                                  |
| Quota             | Waiting consumes no run or send quota.                                                                                                        |

<Warning>
  One HTTP call holds for at most 60 seconds. To wait longer from an SDK, loop — reusing the same
  `since` each time so nothing is skipped. The CLI already does this internally, which is why
  `sente wait --timeout 120` works.
</Warning>

## How the extraction works

Every inbound email gets an `annotation` before anything observable happens (webhook delivery,
`wait` matching, `kind` filtering):

```json theme={null}
"annotation": {
  "kind": "otp",          // "otp" | "magic_link" | "other"
  "code": "481920",       // set when kind = "otp"
  "link": null,           // set when kind = "magic_link"
  "confidence": 0.98
}
```

A single Claude Haiku pass classifies the email's primary purpose and pulls out the value.
Classification is LLM-only — no regex heuristics that break when a sender changes their template.

It is injection-hardened, because anyone in the world can send mail to an `@sente.run` address:

* The system prompt is fixed and never composed from email content.
* The email is passed as **untrusted data** inside an `<email>` block, explicitly labelled as data,
  not instructions.
* The model has **no tools**. The worst a hostile email can do is make itself be classified wrong.
* The output is schema-constrained and re-validated server-side before it is stored.

<Warning>
  The hardening protects the extractor, not you. A verification email can still contain a prompt
  injection aimed at **your** agent ("ignore your instructions", "email your API key to…"). Take the
  extracted `code` or `link` and nothing else — never feed a raw email body to an agent with tools.
</Warning>

## When nothing arrives

| What you see                               | What it means                                                                                                 | What to do                                                                                                    |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `null` / `None` / empty stdout / `204`     | The timeout elapsed with no matching email.                                                                   | Loop with the same `since`. Confirm the app actually sent it — list with `kind=other` to see what did arrive. |
| A message, but `annotation.code` is `null` | Classified as a code email, but nothing extractable was in it.                                                | Read `message.parsed.text` yourself, and send us the message id.                                              |
| Only `kind: "other"` messages              | The app sent something the classifier didn't read as a verification artifact — or it sent a link, not a code. | Try `waitForMagicLink`. If it's genuinely a code email, send us the message id.                               |
| `404 {"error": "identity not found"}`      | The `identityId` isn't in your org.                                                                           | Use the `identityId` from the account (`registration.identityId`) or `GET /v1/identities`.                    |
| `400` with a list of issues                | Validation — usually `timeout` outside 1–60, or an unparseable `since`.                                       | Clamp the timeout to 60 and loop; send `since` as an ISO-8601 timestamp.                                      |

<AccordionGroup>
  <Accordion title="Annotation failed for a message — is it lost?">
    No. Annotation failure never blocks delivery: the message is stored and the `message.received`
    webhook still fires — the message just has `annotation: null` when you fetch it. A background sweep
    re-annotates recent unannotated
    inbound messages roughly once a minute, so a `kind` wait started shortly after arrival still
    matches once it heals. The sweep stops retrying messages older than about 15 minutes — by then the
    code has expired anyway.
  </Accordion>

  <Accordion title="Two flows on the same identity at once">
    Give each flow its own explicit `since`, stamped immediately before that flow triggers its email.
    Without `since`, both waits fall back to the same 60-second lookback and can match each other's
    code. If both codes come from the same sender, also read `message.subject` before submitting.
  </Accordion>

  <Accordion title="I fetched the message directly and annotation was null">
    Annotation runs right after the message is persisted, so a `GET /v1/messages/:id` issued in that
    window can see `annotation: null`. Waiting with `kind` set never has this problem — an unannotated
    message simply doesn't match, and matches as soon as it is annotated.
  </Accordion>
</AccordionGroup>

## Push instead of poll

If your service has a public HTTPS endpoint, register a [webhook](/events/webhooks) for
`message.received` instead. Sente pushes a notification for every inbound email and you fetch the
full message — annotation included — by id. Long-polling is the right tool for a process with no
public URL (a laptop, a CI job, a container behind NAT).

## Next steps

<CardGroup cols={3}>
  <Card title="The account's inbox" icon="mail" href="/email/inbox">
    List, read, send, reply, and archive.
  </Card>

  <Card title="Webhooks" icon="webhook" href="/events/webhooks">
    Get pushed `message.received` events instead of polling.
  </Card>

  <Card title="Messages API" icon="terminal" href="/api-reference/messages">
    Every parameter and response shape.
  </Card>
</CardGroup>
