---
title: Start Checkout
api: POST /auth/checkout
description: "Create an account via Stripe checkout. Two modes: anonymous (server emails the link) or authenticated (fast-path inline URL)."
---

> **For AI agents:** the complete documentation index is at [llms.txt](https://docs.boringmarketing.com/llms.txt). Append `.md` to any page URL for its markdown version.

Initiates a Stripe checkout session to create a new account, reactivate a cancelled one, or open the billing portal for an active one. This is the only self-serve onboarding path — `POST /auth/register` returns `410 Gone`.

The endpoint has **two modes** selected by the presence of `X-API-Key`:

| Mode | Trigger | Behavior | Response |
|---|---|---|---|
| **Anonymous** | No `X-API-Key` header | Server creates the Stripe session and emails the URL to `body.email`. No URL is returned in the HTTP response. | `{status: "email_sent", session_id, poll_url, sent_at}` |
| **Authenticated** | `X-API-Key` header AND the key-owner's email matches `body.email` | Agent-native fast path. URL is returned inline, no email sent. | `{checkout_url, session_id, poll_url, flow}` |

<Warning>
**Anonymous mode response is intentionally byte-identical across all account states.** Whether `body.email` is a new user, an active subscriber, or a cancelled one, the shape and status are the same. This closes a response-shape enumeration oracle. Do not try to infer account state from the response — use `GET /auth/me` after the user has their key to check state.
</Warning>

## Request body

<ParamField body="email" type="string" required>
  Email address. In anonymous mode this is where the Stripe checkout link is delivered. In authenticated mode it must match the `X-API-Key` owner's email exactly.
</ParamField>

<ParamField body="name" type="string">
  Display name for the account. Optional.
</ParamField>

<ParamField body="tier" type="string" required>
  Target tier: `solo`, `studio`, or `agency`.
</ParamField>

## Anonymous mode (recommended for agents)

The typical agent signup flow. No pre-existing credentials are needed.

### Request

```bash
curl -s -X POST \
  https://api.boringmarketing.com/auth/checkout \
  -H "Content-Type: application/json" \
  -d '{
    "email": "dev@example.com",
    "name": "Dev User",
    "tier": "solo"
  }'
```

### Response (always this shape)

```json
{
  "status": "email_sent",
  "session_id": "bmc_k7f3...",
  "poll_url": "https://api.boringmarketing.com/auth/checkout/bmc_k7f3.../status",
  "sent_at": "2026-04-08T18:42:11.123Z"
}
```

The server sends the Stripe URL to `email`. Tell the user to check their inbox, click the link, and complete payment. Then poll `poll_url` (see [Checkout Status](https://docs.boringmarketing.com/api-reference/auth/checkout-status)) until it returns `completed`.

### How the user retrieves their API key

After payment completes, the poll endpoint returns `{status: "completed", dashboard_url}` — **not an API key**. Anonymous signups retrieve their key via the dashboard:

1. User opens `dashboard_url`.
2. User enters their email → receives a magic-link.
3. User clicks the link → lands in the dashboard.
4. User copies the key from **Settings → API Access**.

<Note>
**Path split.** Those 4 steps require a browser and an email client.

**If you're a human:** follow the list above.

**If you're an agent running unattended:** you cannot finish anonymous-mode checkout alone — there is no programmatic way to claim the key from the `session_id`. Either (a) surface `dashboard_url` to your operator and wait for them to paste the key back, or (b) use [authenticated mode](#authenticated-mode-fast-path) when you already hold an API key for the user.
</Note>

This indirection closes a security oracle that existed in an earlier version of the flow where the `session_id` alone was sufficient to claim a key.

## Authenticated mode (fast path)

Use this mode when an agent already has an API key for the user and needs to upgrade a tier, reactivate a cancelled subscription, or open the billing portal.

The `X-API-Key` header must be present AND `body.email` must match the key-owner's email exactly.

### Request

```bash
curl -s -X POST \
  https://api.boringmarketing.com/auth/checkout \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $BM_API_KEY" \
  -d '{
    "email": "you@example.com",
    "name": "You",
    "tier": "studio"
  }'
```

### Response

```json
{
  "checkout_url": "https://checkout.stripe.com/c/pay/cs_live_...",
  "session_id": "bmc_m2p8...",
  "poll_url": "https://api.boringmarketing.com/auth/checkout/bmc_m2p8.../status",
  "flow": "new"
}
```

The `flow` field is a UX hint:

| `flow` | Meaning |
|---|---|
| `new` | Fresh registration. Show "Welcome". |
| `reactivation` | Existing cancelled user reactivating. Show "Welcome back". |
| `active` | Already-active caller. The `checkout_url` is a billing portal URL for plan changes. |

### Auth failures

If `X-API-Key` is valid but `body.email` doesn't match the key-owner, the endpoint returns `401` with a generic error — deliberately indistinguishable from an invalid key. This prevents account enumeration via the checkout surface.

## Rate limits

Anonymous mode is protected by a two-layer limiter:

| Limiter | Limit | Scope |
|---|---|---|
| **Per-email** | 3 / hour | Redis-backed, hashed key |
| **Per-IP** | 5 / hour | slowapi |

A 429 response is identical whether the per-email or per-IP limit triggered — no oracle. If the user claims they didn't receive the first email, wait at least 60 seconds before retrying. A 4th POST within the hour will lock the address for the full window.

Authenticated mode is only bound by the per-IP limiter since the caller is already authenticated.

## Error responses

| Status | Error | Meaning |
|---|---|---|
| `401` | `invalid_api_key` | Authenticated mode: API key invalid OR header present but email does not match the key owner. The two cases are intentionally indistinguishable (closes an account-existence probe). |
| `422` | — | `tier` is not one of `solo`, `studio`, `agency`. Response body is a plain `{"detail": "..."}` with the rejected tier. |
| `429` | `rate_limit_exceeded` | Per-email (3/hr, anonymous only) or per-IP (5/hr) limit exceeded. Body includes `retry_after` (seconds); response carries a matching `Retry-After` header. |
| `502` | — | Upstream Stripe failure — could not create customer, checkout session, or billing portal session. Plain `{"detail": "..."}` body. Safe to retry. |
| `503` | — | **Stripe price for the requested tier is not configured on the server.** Contact support; the caller cannot self-recover. Body: `{"detail": "Stripe price not configured for tier '<tier>'. Contact support."}` |
| `503` | `email_delivery_failed` | Anonymous mode only: Resend returned a non-2xx when sending the checkout email. The `checkout_sessions` row **is** persisted with `email_send_status=failed`, and the per-email rate-limit slot is refunded so a Resend outage does not lock the caller out. Retry after a short wait. |

<Tip>
  **Prefer a browser flow?** Point humans at [dashboard.boringmarketing.com](https://dashboard.boringmarketing.com) — same Stripe checkout with a UI, and after sign-in the API key is visible on the Settings page.
</Tip>
