# REST API (https://dodomain.io/docs/api)

The /api/v1 surface (authentication, endpoints, and rate limits).

Base URL: `https://app.dodomain.io`. Every request and response body is JSON.

Everything on this page is also published machine-readably as an
[OpenAPI 3.1 document](https://dodomain.io/docs/openapi) at `https://dodomain.io/docs/openapi.json`, generated from the same
schemas these endpoints validate with, and regenerated by a CI gate that fails on any drift.

## Authentication [#authentication]

Three credential kinds:

- **Secret key** (`dd_sk_...`): server-to-server. Create an app with the `create_app` tool (or from the app page) to get one; send it as `Authorization: Bearer dd_sk_...` from your server only. A missing, malformed, or unknown key returns `401 unauthorized`. The public key (`dd_pk_...`) only identifies the app and grants nothing.
- **OAuth access token**: the credential AI assistants use, sent as `Authorization: Bearer <token>`. Access is limited to the approved scopes and teams. For a grant covering several teams, choose one with the `DoDomain-Team` header. App creation requires `apps:write` and the Admin role or higher on that team; an app-scoped secret key cannot create apps. See [Scopes and consent](https://dodomain.io/docs/connecting-ai-assistants#scopes-and-consent).
- **Session token** (`dd_sess_...`): a single-session capability returned by `POST /api/v1/sessions`. Session-scoped endpoints are public and authorize by the unguessable token itself, which is how the hosted flow and widget work from the browser. Sessions expire after 24 hours; expired sessions answer `410 expired`.

## Endpoints [#endpoints]

| Method   | Path                                                  | What it does                                                                                                                                      |
| -------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST`   | `/api/v1/sessions`                                    | Create a connect session for a domain (secret key or OAuth with `sessions:write`). Returns `token`, `expiresAt`, `connectUrl`.                    |
| `GET`    | `/api/v1/sessions/:token`                             | Read one session's state by its token: domain, requested records, status, detected provider, expiry.                                              |
| `GET`    | `/api/v1/sessions/:id`                                | Read one session by the **id** webhooks carry (secret key). Works after expiry, and reports a derived `expired` flag.                             |
| `POST`   | `/api/v1/sessions/:token/detect`                      | Detect the domain's DNS provider and the connect path it will get.                                                                                |
| `POST`   | `/api/v1/sessions/:token/verify`                      | Trigger a live DNS check of the session's records against authoritative nameservers. On full match the connection is finalized and webhooks fire. |
| `GET`    | `/api/v1/sessions/:token/cloudflare/start`            | Start the Cloudflare one-click OAuth flow for the session.                                                                                        |
| `POST`   | `/api/v1/domains/check`                               | Pre-flight a domain before connecting: provider, the zone that owns the records, connect tier, whether one-click is available, nameservers.       |
| `GET`    | `/api/v1/apps`                                        | List the apps in your team (never returns secret keys).                                                                                           |
| `POST`   | `/api/v1/apps`                                        | Create an app (OAuth `apps:write`, Admin or Owner). Returns public app fields with status `201`, never a secret key.                              |
| `GET`    | `/api/v1/connections`                                 | List verified connections and their live DNS health, filterable by app or domain.                                                                 |
| `GET`    | `/api/v1/connections/:connectionId`                   | Read one connection and its live DNS health (disconnected ones included).                                                                         |
| `DELETE` | `/api/v1/connections/:connectionId`                   | Disconnect a connection (stops its monitoring). The customer's DNS records stay in place.                                                         |
| `POST`   | `/api/v1/connections/:connectionId/reverify`          | Queue an on-demand DNS recheck of a broken connection.                                                                                            |
| `GET`    | `/api/v1/webhook-endpoints`                           | List your app's webhook endpoints (never returns signing secrets).                                                                                |
| `POST`   | `/api/v1/webhook-endpoints`                           | Register an endpoint. Returns the signing secret **once**.                                                                                        |
| `PATCH`  | `/api/v1/webhook-endpoints/:endpointId`               | Repoint an endpoint at a new URL. The signing secret is unchanged.                                                                                |
| `DELETE` | `/api/v1/webhook-endpoints/:endpointId`               | Stop delivering to an endpoint.                                                                                                                   |
| `POST`   | `/api/v1/webhook-endpoints/:endpointId/rotate-secret` | Mint a new signing secret for one endpoint. Returned **once**; effective immediately.                                                             |
| `POST`   | `/api/v1/keys/rotate`                                 | Rotate the calling app's own secret key. Returned **once**; the old key dies immediately.                                                         |

## Creating an app [#creating-an-app]

`POST /api/v1/apps` takes `{"name": "My product"}`. The name is trimmed and must be
1–80 characters. The `201` response has the same public fields as an item returned
by `GET /api/v1/apps`, including `id`, `name`, and `publicKey`. Pass its `id` as
`appId` to `POST /api/v1/sessions`; OAuth callers must name an app in their acting team.
No dashboard step is needed to create the app and session.

The plan's app limit is enforced with `402 quota_exceeded`. Missing scope, an
insufficient team role, and app-key authentication return `403` with
`details.code` of `SCOPE_MISSING`, `TEAM_ROLE_REQUIRED`, and `OAUTH_REQUIRED`
respectively. Invalid names return `400 invalid_request`. These failures use
RFC 9457 `application/problem+json` and retain the API's `error`, `message`, and
`details` fields. The secret key is never returned; rotate one with the `rotate_api_key` tool
only if your own backend needs it.

## Roles for OAuth callers [#roles-for-oauth-callers]

An OAuth token acts as the person who approved it, so the team role matrix applies. `POST /api/v1/apps`, `POST /api/v1/sessions` and `DELETE /api/v1/connections/:connectionId` need the Admin role or higher on the acting team, read live on every request: a member, or a user demoted after approving, gets `403 forbidden` with `details.code: "TEAM_ROLE_REQUIRED"`. The consent screen drops the matching write scopes (`apps:write`, `sessions:write`, `connections:write`) when the person lacks that role and approves the rest, so a member connects an app read-only; an approval that still names a write scope the person cannot use is refused. App secret keys are app-scoped and have no role.

## Creating a session [#creating-a-session]

`POST /api/v1/sessions` takes the domain and the records the end user must create. The response tells you what the session will actually be verified against:

```json
{
  "id": "cmd...",
  "token": "dd_sess_...",
  "expiresAt": "2026-08-13T09:00:00.000Z",
  "connectUrl": "https://app.dodomain.io/connect/dd_sess_...",
  "records": [{ "type": "CNAME", "host": "app", "fqdn": "app.customer.com" }]
}
```

`records` echoes each record you sent alongside `fqdn`, the fully-qualified name DoDomain will look up on authoritative DNS. Hosts are composed **relative to the session's `domain`**, and `@` (or an empty host) means the domain itself.

<Callout type="warn" title="host is relative to domain, not to the registrable apex">
  If your session `domain` is already a subdomain (`links.acme.com`), then `host: "links"` composes
  to `links.links.acme.com`. To attach a record to `links.acme.com` itself, send `host: "@"`. Check
  `records[].fqdn` in the response: it is the name that will be verified.
</Callout>

When something about an accepted request is worth flagging, the response also carries `warnings`. Warnings never change the status code (the session is created either way) and the key is absent when there is nothing to say:

```json
{
  "warnings": [
    {
      "code": "duplicate_host_label",
      "message": "host \"links\" repeats the leading label of domain \"links.acme.com\". This session will be verified at \"links.links.acme.com\". Use host \"@\" if you meant \"links.acme.com\".",
      "host": "links",
      "fqdn": "links.links.acme.com"
    }
  ]
}
```

Branch on `code`, not on the message text. New codes may be added; treat an unrecognised one as informational.

## Reading a session [#reading-a-session]

`GET /api/v1/sessions/:token` is the debugging endpoint: it echoes back exactly what we stored for a session, so you can confirm the API received what you think you sent. It is authorized by the session token itself (no secret key), and returns `410 expired` once the session's 24 hours are up.

```json
{
  "id": "cmd...",
  "domain": "customer.com",
  "records": [{ "type": "CNAME", "host": "app", "value": "cname.sendly.io" }],
  "recipe": null,
  "status": "pending",
  "tier": null,
  "detectedProvider": null,
  "returnUrl": null,
  "expiresAt": "2026-08-13T09:00:00.000Z"
}
```

| Field              | Meaning                                                                                                                                                                                                                                                                 |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `domain`           | The session's domain, exactly as you sent it. Record hosts are relative to this.                                                                                                                                                                                        |
| `records`          | The records you requested, verbatim. (Their composed `fqdn`s are in the create response, above.)                                                                                                                                                                        |
| `recipe`           | Whatever you passed as `recipe` at creation, echoed back. Nothing server-side consumes it.                                                                                                                                                                              |
| `status`           | `pending` → `detected` → `verifying` → `verified`, plus `authorizing`/`writing` (one-click in progress), `failed`, and `expired`.                                                                                                                                       |
| `tier`             | The connect path detection chose: `1` Cloudflare (one-click over OAuth), `2` the provider speaks Domain Connect (one-click only where it serves DoDomain's template for the session's records, otherwise guided setup), `3` guided manual. `null` until detection runs. |
| `detectedProvider` | The DNS provider detection identified. `null` until detection runs.                                                                                                                                                                                                     |
| `returnUrl`        | Where the hosted flow offers to send the user back, or `null`.                                                                                                                                                                                                          |

<Callout title="null detection fields mean 'not detected yet', never 'detection failed'">
  `tier` and `detectedProvider` are written only by `POST /api/v1/sessions/:token/detect`, which the
  hosted flow and widget call when the end user opens the connect link. Until then both are `null`
  and `status` is `pending`: that pair *is* the "detection has not run" signal. A detection that
  fails answers the detect call with a non-2xx and persists nothing, so it never leaves a
  half-detected session behind. If you drive the API yourself and want these fields populated, call
  detect.
</Callout>

## Reading a session by id (server-side) [#reading-a-session-by-id-server-side]

`GET /api/v1/sessions/:token` needs the session token and stops answering once the session expires. When you are reconciling from a webhook (every payload carries `sessionId`, never the token) or you want to know how a session ended, call the same path with the session **id** and your secret key instead:

```bash
curl https://app.dodomain.io/api/v1/sessions/cmd... \
  -H "Authorization: Bearer dd_sk_..."
```

One path, two arms: a `dd_sess_...` segment is the public token read above, anything else is this authenticated by-id read. A key only sees its own app's sessions; a session that exists but belongs to another app answers the same `404 not_found` as one that doesn't exist.

```json
{
  "id": "cmd...",
  "appId": "cmd...",
  "domain": "customer.com",
  "records": [{ "type": "CNAME", "host": "app", "fqdn": "app.customer.com" }],
  "recipe": null,
  "status": "verifying",
  "tier": 3,
  "detectedProvider": "cloudflare",
  "connectionId": null,
  "createdAt": "2026-08-16T09:00:00.000Z",
  "expiresAt": "2026-08-17T09:00:00.000Z",
  "expired": false
}
```

| Field          | Meaning                                                                                                                                                        |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `records`      | The **composed** records, each with the `fqdn` we actually look up, unlike the token endpoint's verbatim echo of what you sent.                                |
| `connectionId` | The connection this session produced, once it has one; `null` before that. The same id `DELETE`/`reverify` are keyed by.                                       |
| `expired`      | Derived from `expiresAt` at read time, so it is true the instant the session runs out, before the background sweep gets around to writing `status: "expired"`. |
| `status`       | The persisted status, reported as-is. Read `expired` for whether the session is over; read `status` for how far the end user got.                              |

Unlike the token endpoint, this one keeps answering `200` after expiry: reading a session's final state is most of the reason to call it.

A session that never produced a connection (it ended `expired` or `failed`) is deleted 120 days after its `expiresAt`; from then on this endpoint answers `404`. A session that produced a connection is kept for as long as the connection is.

## Pre-flighting a domain [#pre-flighting-a-domain]

`POST /api/v1/domains/check` with `{ "domain": "app.customer.com" }` tells you how a domain would connect before you create a session: the DNS provider, the zone that owns the records, the connect `tier`, the nameservers, the manual guide, and `oneClickAvailable`. It creates nothing.

`tier: 2` means the provider speaks Domain Connect; it does **not** mean one-click. One-click exists only where the provider serves DoDomain's templates, and some providers that speak the protocol (IONOS among them) serve none, so their users get guided setup. Porkbun goes the other way: it is `tier: 3`, yet DoDomain's own Porkbun integration connects it in one click. Read `oneClickAvailable` for the answer:

| `oneClickAvailable` | Meaning                                                                                                                                                                                                                                                                                                                                                               |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `true`              | Tier 1 (Cloudflare OAuth), a tier-2 provider that serves at least one of DoDomain's templates (probed live during this call), or a provider DoDomain connects through its own API (Porkbun). A tier-2 setup still gets one click only if its records fill a template that provider serves; a Porkbun setup only if it has at most 25 records of types Porkbun writes. |
| `false`             | Guided setup: the user adds the records with provider-specific steps and DoDomain verifies them. Also `false` when the template probe could not finish: it never guesses yes.                                                                                                                                                                                         |

One-click is also a plan feature: a session created by a Free team uses guided setup whatever this field says.

## Re-checking DNS yourself [#re-checking-dns-yourself]

`POST /api/v1/sessions/:token/verify` is a supported thing to call from your own code, not just something the hosted flow does. It runs a live authoritative DNS check of the session's records and, when every record matches, finalizes the connection and fires `connection.verified` + `session.completed`, the same path a user clicking **Verify** takes.

```bash
curl -X POST https://app.dodomain.io/api/v1/sessions/dd_sess_.../verify
```

```json
{
  "verified": false,
  "records": [
    {
      "fqdn": "app.customer.com",
      "type": "CNAME",
      "present": false,
      "note": "no CNAME found at app.customer.com",
      "outcome": "absent"
    }
  ]
}
```

It is authorized by the session token (no secret key), it is safe to call repeatedly, and calling it on an already-verified session does not double-count your quota. `outcome` distinguishes `absent` (we looked, it isn't there) from `indeterminate` (the lookup itself failed) and `domain_not_found` (the domain doesn't resolve at all): only a real authoritative match ever finalizes.

You usually don't need to poll it. DoDomain already re-checks open sessions in the background, so a record added after the user closes the connect window still completes on its own (see [Webhooks](https://dodomain.io/docs/webhooks#sessions-that-finish-after-the-user-walks-away)). Call `verify` when you want an answer _now_, for example while a support agent is on the phone with the customer.

## Sharing the setup with a DNS admin [#sharing-the-setup-with-a-dns-admin]

The person who opens a connect link is often not the person who can edit DNS. The hosted page offers to create a **setup link** for that person, and the same call is yours to make server-side:

```bash
curl -X POST https://app.dodomain.io/api/v1/sessions/dd_sess_.../shared-setup-link
```

```json
{
  "setupUrl": "https://app.dodomain.io/setup/dd_sl_...",
  "expiresAt": "2026-08-13T09:00:00.000Z"
}
```

`setupUrl` opens a page that shows your app's branding, the domain, the exact records to add and a **Verify** button, and nothing else. No one-click flow, no `returnUrl` back into your product, and never the `dd_sess_` token itself, which is what makes it safe to forward to someone who should not hold the whole session.

- Authorized by the session token, exactly like `verify`: no secret key.
- The link lives exactly as long as the session (`expiresAt` is the session's) and answers `410 expired` with it.
- **Calling it again rotates the link.** The previous `setupUrl` stops resolving the moment the new one is returned; there is never more than one live setup link per session.
- A verify pressed from the setup page finalizes the connection and fires `connection.verified` + `session.completed` exactly like a verify from the hosted page. The only trace of which door was used is on the session's attempt log ("via shared link", returned by `list_sessions`).

Sending the link is up to your user (or you) in this version: DoDomain shows it to copy and does not email it.

## Managing webhook endpoints [#managing-webhook-endpoints]

Everything the dashboard's **Webhook endpoints** card does is also a REST call, so delivery targets can live in your CI or infrastructure-as-code instead of in someone's browser history. These endpoints take the app's **secret key only**: an OAuth token (the credential AI assistants connect with) is refused with `403 forbidden` and `details.code: "SECRET_KEY_REQUIRED"`, because no consent scope covers managing your credentials.

`POST /api/v1/webhook-endpoints` with `{"url": "https://your-app.com/webhooks/dodomain"}` answers `201`:

```json
{
  "id": "cme...",
  "appId": "cmd...",
  "url": "https://your-app.com/webhooks/dodomain",
  "createdAt": "2026-08-17T09:00:00.000Z",
  "pausedAt": null,
  "secret": "whsec_..."
}
```

<Callout type="warn" title="secret is shown once">
  `secret` is the signing secret you verify `x-dodomain-signature` with. It appears in exactly two
  responses (the create above and `rotate-secret` below) and never again: `GET
  /api/v1/webhook-endpoints` returns the same object **without** the `secret` field. Store it when
  you receive it. Lost it? Rotate, and update your handler.
</Callout>

`PATCH /api/v1/webhook-endpoints/:endpointId` takes the same `{"url": ...}` body and moves an endpoint without re-keying it: a receiver that changed hosts keeps verifying with the secret it already has. `POST /api/v1/webhook-endpoints/:endpointId/rotate-secret` is the explicit re-key: it returns the same object shape as create, with a new `secret`. Rotation is **immediate and total**: signatures switch to the new secret at once, including retries of deliveries that were queued before you rotated, so deploy the new secret to your handler promptly.

`DELETE /api/v1/webhook-endpoints/:endpointId` answers `{"id": "...", "deleted": true}`. Past deliveries keep their history, but a failed delivery to a deleted endpoint can no longer be redriven.

Every endpoint object also carries `pausedAt`: `null` while the endpoint is delivering, or the time DoDomain [paused it automatically](https://dodomain.io/docs/webhooks#paused-endpoints) after 7 days with no successful delivery and at least 5 dead-lettered deliveries. `POST /api/v1/webhook-endpoints/:endpointId/resume` resumes it and answers with the endpoint, `pausedAt` back to `null`; calling it on an endpoint that isn't paused changes nothing. A `PATCH` that changes the `url` resumes the endpoint as well.

Failures use the same codes the rest of the API does:

| Status | Code              | When                                                                                              |
| ------ | ----------------- | ------------------------------------------------------------------------------------------------- |
| `400`  | `invalid_request` | The URL isn't `https://`, points at localhost/a private address, or your app already sends to it. |
| `402`  | `quota_exceeded`  | The app is at its endpoint cap (10 per app). Remove one before adding another.                    |
| `404`  | `not_found`       | No such endpoint **under your app**. Another app's endpoint id answers the same 404, by design.   |

## Rotating your secret key [#rotating-your-secret-key]

`POST /api/v1/keys/rotate` rotates the secret key that authenticated the call and returns the replacement once:

```json
{
  "appId": "cmd...",
  "publicKey": "dd_pk_...",
  "secretKey": "dd_sk_...",
  "rotatedAt": "2026-08-17T09:00:00.000Z",
  "previousKeyExpiresAt": null
}
```

The public key is echoed unchanged: it identifies the app in the widget and is never rotated.

**By default there is no overlap window**: with no body (or `{"overlapHours": 0}`) the key you called with stops authenticating the moment this returns, so this response is the only copy of the new key. Write it to your secret store before your next API call. That default is deliberate: if a key leaked, rotating it must _be_ the revoke, never a grace period.

**Opt-in zero-downtime rotation:** send `{"overlapHours": 1}` or `{"overlapHours": 24}` and BOTH keys authenticate until `previousKeyExpiresAt` (returned as an ISO timestamp), so your servers can pick up the new key with no failed requests in between. Two things to know about the window:

- **Exactly one previous key is ever kept.** Rotating again (with or without a window) replaces the previous slot, so key n-1 stops working instantly regardless of how much window it had left. Chain rotations no faster than your rollout.
- **A zero-overlap rotate ends any live window.** Rotating with the default `overlapHours: 0` (or no body) kills the previous key along with installing the new one: it is the escape hatch when a key you gave a window to turns out to be compromised.

The dashboard's API-keys card shows a live window's expiry and when the old key last authenticated a request, so you can see when your cutover is actually complete before the window ends.

The key the [Vercel integration](https://dodomain.io/docs/vercel) puts in your Vercel project is separate from the app's own key: rotation leaves it working, and calling this endpoint with it answers `403 forbidden` with `details.code: "INTEGRATION_KEY_NOT_ROTATABLE"`. Disconnecting the integration is its revoke.

A rotation from a scheduled job, using GitHub Actions and the `gh` CLI to write the new key back:

```bash
#!/usr/bin/env bash
set -euo pipefail

response="$(curl -fsS -X POST https://app.dodomain.io/api/v1/keys/rotate \
  -H "Authorization: Bearer ${DODOMAIN_SECRET_KEY}")"

new_key="$(printf '%s' "$response" | jq -er '.secretKey')"

# Store FIRST, verify SECOND: the old key is already dead at this point.
gh secret set DODOMAIN_SECRET_KEY --body "$new_key"

curl -fsS https://app.dodomain.io/api/v1/webhook-endpoints \
  -H "Authorization: Bearer ${new_key}" > /dev/null
echo "rotated $(printf '%s' "$response" | jq -r '.appId')"
```

<Callout title="Keys can rotate themselves, nothing more">
  A secret key may rotate **itself**, which is what makes scheduled rotation possible. There is
  deliberately no API to create, list, or delete keys: key inventory stays behind a signed-in
  dashboard session, so a leaked key can never mint a second, hidden credential that survives you
  rotating the one you know about. If a key is compromised, rotating it *is* the revoke.
</Callout>
## Rate limits and quotas [#rate-limits-and-quotas]

Secret-key requests are rate-limited per app on a fixed one-minute window, by plan. Over the cap, requests fail with `429 rate_limited`, a `Retry-After` header (seconds), and `details.retryAfterSeconds` in the body.

Every response to a call authenticated with a secret key or an OAuth token, success and `429` alike, tells you where you stand: `RateLimit-Policy: "app";q=60;w=60` and `RateLimit: "app";r=59;t=30` (`team` instead of `app` for an OAuth token), plus `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (Unix epoch seconds).

| Plan  | API requests / min | Verified connections / mo |
| ----- | ------------------ | ------------------------- |
| Free  | 60                 | 50                        |
| Pro   | 300                | 500                       |
| Scale | 1,200              | 2,500                     |

The monthly connections number is a billing quota, not a rate limit: on **every** plan it is enforced at session creation (`402 quota_exceeded` once your team is at its cap). Connections you already have keep working: only new sessions are refused. On Free, the same `402` also comes back once your unfinished sessions reach your remaining allowance plus 10, and the allowance is shared by the Free teams owned by any of this team's owners (see [Pricing](https://dodomain.io/docs/pricing)). When many sessions are being created for the same allowance at once, a creation can also return `429 rate_limited` with `Retry-After: 1` and `details.retryAfterSeconds: 1` (no `details.limit`): nothing was created, so retry it.

**What counts against it:** exactly one unit, the first time a session's connection is finalized. Re-verifying, a connection breaking and recovering, and disconnecting and reconnecting the same session all add nothing: the quota counts connections made, not verifications run. A connection created in an earlier month that recovers today counts against neither month again.

Session-scoped endpoints (`/api/v1/sessions/:token/...`) are public (the token is the credential), so they carry their own abuse caps instead of the plan's. All three are per minute and answer with the same `429 rate_limited` shape:

| Cap                                       | Per minute | What it bounds                                                                    |
| ----------------------------------------- | ---------- | --------------------------------------------------------------------------------- |
| One client address, one session           | 30         | A single caller hammering a single session.                                       |
| One session, all addresses together       | 120        | A session link replayed from many addresses.                                      |
| One client address, all sessions together | 1,200      | One address cycling through tokens, such as a scanner or a busy backend of yours. |

An IPv6 client counts by its `/64` prefix, not its individual address. Normal use, including the hosted flow's automatic re-check every 15 seconds, sits far below all three.

The third cap is the one to know about if you call `POST /api/v1/sessions/:token/verify` from your backend (see [Re-checking DNS yourself](#re-checking-dns-yourself)). Re-checking each live session every 15 seconds costs 4 requests a minute per session, so one server address can keep about 300 sessions on that cadence before it meets the cap. Past that, lengthen the interval or lean on the [webhooks](https://dodomain.io/docs/webhooks), which fire when a session completes without any polling.

<Callout title="Two different 429s">
  Rate-limit responses carry a `details.reason` field: branch on it rather than the message text to
  tell a per-minute window limit apart from other request-rate protections.
</Callout>
