# Agent onboarding: from nothing to a paid status page on your own domain

This page is for an AI agent (or a script) driving ups.dev through the
[REST API](https://ups.dev/docs/api.md) or the MCP server at `/mcp`. Every step is an API
call. The human in the loop is asked for exactly three things, and each one is
spelled out by the API response at the moment it is needed:

1. a mailbox the agent can read (signup is magic-link only);
2. approval of a payment (a Stripe shared payment token);
3. one DNS record (custom domain).

## 1. Get an API token (no browser)

Sign-in is magic-link only: no password, no OAuth. The mailbox is the
credential, so use one the agent can read or that forwards to one it can.

```bash
# Step 1: ask for the link. Same response for new and existing addresses.
curl -X POST https://ups.dev/api/v1/registrations \
  -H "Content-Type: application/json" \
  -d '{"email_address":"monitor@example.com"}'
# → 202 {"next_step":"Read the email, take the token from the link's query string, and POST /api/v1/sessions ..."}

# Step 2: exchange the token from the emailed link (…/auth/magic_link?token=XYZ) for an API token.
curl -X POST https://ups.dev/api/v1/sessions \
  -H "Content-Type: application/json" \
  -d '{"token":"XYZ"}'
# → 201 {"api_token":{"token":"ups_live_..."}, "account":{...}, "next_step":"GET /api/v1/profile ..."}
```

The API token is shown once; store it. Links expire in 15 minutes. The exchange
consumes the link; a browser visit (the human signing in to the dashboard, or a
mail scanner prefetching the URL) does not, so a corporate mailbox can't kill
the signup. Both endpoints are rate limited per IP, and one link is sent per
address every two minutes.

```bash
curl -H "Authorization: Bearer ups_live_..." https://ups.dev/api/v1/profile
```

From here on, stay on the API (or `/mcp` with the same bearer token). Rotate or
add tokens with `POST /api/v1/api_tokens`, list with `GET`, revoke with
`DELETE /api/v1/api_tokens/:id`. Capability discovery is unauthenticated:
`GET /api/v1`.

## 2. Build the page

```bash
POST /api/v1/status_pages                      {"status_page":{"name":"Example","description":"..."}}
POST /api/v1/status_pages/:id/components       {"component":{"name":"Web app"}}
POST /api/v1/status_pages/:id/monitors         {"monitor":{"name":"Web","url":"https://example.com/","component_id":1,"check_type":"https"}}
```

The page is live at `https://ups.dev/<slug>` on the free plan (1 page, 5
components, 10 monitors).

## 3. Pay (agent-driven)

Paid plans are bought with a **Stripe shared payment token**: the customer's
agent platform (Stripe Link, for example) issues a token scoped to ups.dev's
Stripe profile with an amount cap the customer approves. ups.dev never sees
card details and no checkout page is involved.

```bash
GET  /api/v1/billing
# → plans (ids, amounts), payment.seller.network_business_profile, payment.agent_instructions

# Obtain the token for the plan amount, scoped to that seller profile. With Link:
npx @stripe/link-cli spend-request create \
  --payment-method-id <csmrpd_...> --amount 1000 \
  --credential-type shared_payment_token --network-id <profile_...> \
  --request-approval --context "ups.dev pro_monthly"
# (the customer approves the request in Link)

POST /api/v1/billing/subscription   {"plan":"pro_monthly","shared_payment_token":"spt_..."}
# → 201 subscription active, paid through current_period_end; payment.receipt_url; plan limits flip immediately
```

ups.dev charges the plan amount once per billing period (a PaymentIntent on
the token). Stripe doesn't let a token be saved for later charges, so renewal
depends on the token:

- **Recurring token** (issued with `usage_limits.recurring_interval` matching
  the plan): charged again automatically at `current_period_end`.
- **One-time token** (what `link-cli` issues today): covers one period. In the
  last 7 days of it, `GET /api/v1/billing` says so in `next_step` and
  `subscription.renewal.due`; get a fresh token and POST the same plan again.
  The new period starts where the paid one ends.

Unpaid 3 days past the end (`renewal.grace_ends_at`), the plan lapses to free
and custom domains stop serving until the account pays and re-verifies.

Errors are structured: `invalid_plan`, `token_required`, `token_rejected`
(spent, expired, revoked, or over its cap), `payment_declined`,
`payment_incomplete`, `already_subscribed`. Send an `Idempotency-Key` header to
make retries safe.

## 4. Custom domain (Pro and Business)

```bash
PUT  /api/v1/status_pages/:id/custom_domain          {"custom_domain":"status.example.com"}
# → dns: {type: CNAME, name: status.example.com, target: custom.ups.dev}; relay this to the domain owner
#   (tell them: most DNS consoles append the zone, so enter just the prefix, e.g. `status`)
POST /api/v1/status_pages/:id/custom_domain/verify   # once the record exists
# → verified: true, public_url: https://status.example.com/ (TLS is automatic)
```

Free accounts get `402 plan_required` on the PUT, with the plans that unlock
it. The guide to hand the human doing DNS:
[custom domain setup](https://ups.dev/docs/custom-domains.md). Internals in
CUSTOM_DOMAINS.md.

## Practical notes

- Token prefix tells you the environment: `ups_live_` in production, `ups_test_`
  elsewhere. Tokens are scoped to the account the minting user belongs to.
- Rate limit is 60 requests/minute per token (`429` when exceeded).
- The `api_token` query parameter also works, but prefer the `Authorization` header.
- MCP tools cover the same flow: `list_status_pages`, `create_component`,
  `create_monitor`, `get_billing`, `subscribe`, `set_custom_domain`,
  `verify_custom_domain`.

## Field notes from the first agent signup

An agent took status.nowreading.dev from nothing to a paid page on its own
domain on 2026-10-21. What tripped it up, so the next one doesn't trip:

- **The mailbox is the hard part.** Everything else is API calls. Make sure
  the agent can actually read the inbox before step 1, not after the link
  is sitting there unread.
- **Spend-request approval is time-boxed.** The first Link request expired
  before the customer approved it. Tell the customer it's coming and ask for
  a prompt approval; if the window closes, issue a fresh request.
- **New retry, new `Idempotency-Key`.** Stripe replays the cached result for a
  key it has seen, errors included. After fixing whatever failed, retry under
  a fresh key or you'll get the old failure back.
- **Errors arrive as JSON, never as a bare 5xx.** Billing failures are `503`
  with a structured `error` and a `stripe_request_id` (quote it when asking
  for help). A `502` from Cloudflare means the request never reached the
  error handling, so treat it as transport.
- **One-time tokens mean a calendar reminder.** The trial used one, so it
  renews by hand: watch for `subscription.renewal.due` in the last 7 days.
- **DNS consoles append the zone.** Most consoles (Google Cloud DNS included)
  add the zone name to whatever you type, so entering
  `status.example.com` yields `status.example.com.example.com`. Enter the
  bare label (`status`, `_ups-challenge.status`) and let the console finish it.
