# Pay API `/v1` — the contract `uc-pay.js` is built on

The server side lives in the main site repo (`~/code/SITES/undercurrent.ai_main_site`,
branch `feat/pay-v1`, `lambda/billing/pay-*`) as routes on the **existing** billing
Lambdas — it is not a new payments implementation. This file is the contract the embed
kit depends on; the server's own README is authoritative for implementation detail.

| | |
|---|---|
| QA | `https://pay-qa.undercurrent.ai` — dedicated HTTP API `undercurrent-pay-qa`, us-east-2, Stripe **test mode** |
| prod | `https://pay.undercurrent.ai` — **not deployed**; prod is untouched |
| Auth | none for catalog/checkout/pay-link redirects; `X-Service-Token` to mint pay links |
| CORS | `*`, no credentials — nothing in this API reads a cookie |

**Doctrine.** The drop-in is a *link*, not a script: no Stripe.js, no publishable key, no
iframe, no cookies. A plain HTML form POSTs to `/v1/checkout` and follows the server's
`303`. Money is integer cents (`…Cents`) everywhere; there is no float on the wire. A
browser never grants anything — entitlements and credits change only on a verified
Stripe webhook.

---

## `GET /v1`

```json
{ "service": "uc-pay", "apiVersion": "v1", "env": "qa", "routes": ["…"] }
```

## `GET /v1/catalog[?brand=uc]`

Public. `Cache-Control: public, max-age=300`, `ETag` (send `If-None-Match` → `304`).
Unknown brand → `404 {"error":"unknown_brand"}`. Omit `brand` for every brand.

```json
{
  "apiVersion": "v1", "env": "qa", "catalogVersion": "<12 hex>", "currency": "usd",
  "brands": [{ "id": "uc", "name": "Undercurrent", "statementDescriptorSuffix": "UNDERCURRENT",
               "enabled": true, "unavailableReason": null }],
  "items": [{
    "sku": "uc_pro_monthly", "brand": "uc", "productId": "undercurrent_pro",
    "name": "Undercurrent Pro", "description": "…", "nickname": "Pro — per seat, monthly",
    "kind": "subscription",
    "unit": "seat",
    "unitAmountCents": 3200,
    "interval": "month",
    "entitlement": "pro",
    "requiresOrg": true,
    "quantity": { "min": 1, "max": 500 },
    "purchasable": true,
    "unavailableReason": null
  }],
  "donations": [{ "brand": "uc", "productId": "undercurrent_donation", "name": "Undercurrent Donation",
                  "minCents": 100, "maxCents": 2000000, "recurringSku": "uc_donation_monthly",
                  "recurringInterval": "month", "purchasable": true, "unavailableReason": null }]
}
```

| Field | Values |
|---|---|
| `kind` | `subscription` · `credits` · `one_time` (donations are listed separately under `donations`) |
| `unit` | `seat` · `usd` ($1 per unit, quantity = dollars) · `flat` |
| `unitAmountCents` | integer, or `null` when the price is not decided |
| `interval` | `month` · `year` · `null` (one-off) |
| `unavailableReason` | `amount_undecided` · `brand_not_in_account` · `fulfilment_pending` · `null` |

Sellable today (brand `uc`): `uc_community_monthly` 800, `uc_community_annual` 8160,
`uc_pro_monthly` 3200, `uc_pro_annual` 32640 (subscription, per seat, org required) and
`uc_credits_1usd` 100 (credits, quantity = dollars, org required, max 5000). Lair,
BreachRadar, Adjudica and Depot SKUs are listed but not sellable (amount undecided /
fulfilment pending); `cc` and `bertis` are `brand_not_in_account` (entity decision
pending).

## `POST /v1/checkout`

Body: `application/x-www-form-urlencoded` (a plain HTML form) **or** `application/json`.
Field names are snake_case in both.

| Field | Kinds | Notes |
|---|---|---|
| `brand` | all | required, e.g. `uc` |
| `kind` | all | required: `donation` · `subscription` · `credits` · `one_time` |
| `sku` | subscription, credits, one_time | required for those, from `/v1/catalog` |
| `quantity` | subscription (seats), credits (dollars), one_time | integer ≥ 1, default 1 |
| `amount` | donation | dollars as text: `25` or `25.50` |
| `amount_cents` | donation | integer cents — alternative to `amount` |
| `recurring` | donation | `month` (also `on` / `true` / `1`) → monthly, whole dollars only |
| `org` | subscription + credits (required), donation (optional — credits that org) | org slug |
| `email` | optional | prefills Checkout |
| `donor_name`, `message`, `public` | donation | optional |
| `success_url`, `cancel_url` | optional | absolute `https` URL whose **origin is on the brand's allow-list**; omitted → the pay host's `/v1/return` page |
| `src` | optional | `embed` (default) · `site` · `signal_bot` · `slack` · `invoice` · `storefront` — each maps to its own `integration_identifier` surface (`slack` is separate from the Signal bot) |
| `idempotency_key` | optional | or the `Idempotency-Key` header |
| `hp_company` | honeypot | must be empty; a filled one gets a fake success and nothing is created |

**Response mode.** JSON when the request's `Content-Type` is JSON, or `Accept` prefers
`application/json`, or the field `response=json` is present. Otherwise (a form post)
redirect mode.

| Outcome | Form post | JSON |
|---|---|---|
| Success | **`303`** `Location: https://checkout.stripe.com/c/pay/…` | **`200`** `{ "url", "paymentId", "provider": "stripe", "expiresAtIso" }` |
| Error | a small self-contained HTML page, same status, message + "Go back" link | `{ "error": "<code>", "message": "<human text>" }` |

| Status | `error` | When |
|---|---|---|
| 400 | `invalid_request` | a field is missing or malformed |
| 400 | `return_url_not_allowed` | `success_url` / `cancel_url` origin is not on the brand's allow-list |
| 404 | `unknown_brand` · `unknown_sku` | not in the catalog |
| 422 | `not_purchasable` (+ `reason`) | in the catalog but not sellable yet |
| 409 | `in_progress` | the same idempotency key is already in flight |
| 503 | `provider_unavailable` | Stripe not connected, or the price is not in Stripe yet. **Every checkout on QA today** — never a bare 500 |
| 502 | `provider_error` | Stripe answered with an error |

```bash
# JSON
curl -sS https://pay-qa.undercurrent.ai/v1/checkout \
  -H 'content-type: application/json' \
  -d '{"brand":"uc","kind":"donation","amount":"25"}'

# Exactly what a plain HTML form sends
curl -sS -i https://pay-qa.undercurrent.ai/v1/checkout \
  --data-urlencode brand=uc --data-urlencode kind=credits \
  --data-urlencode sku=uc_credits_1usd --data-urlencode quantity=25 --data-urlencode org=demo-org
```

## Pay links

- **`POST /v1/pay`** — header `X-Service-Token`; JSON body = the checkout fields plus
  `expires_in_days` (1–365, default 30) and `label` →
  **`201`** `{ "code": "K7MXQ-3PAHD", "url": "https://pay-qa.undercurrent.ai/v1/pay/K7MXQ-3PAHD", "expiresAtIso": "…" }`.
- **`GET /v1/pay/{code}`** → **`303`** to Checkout. The session is cached per code while it
  has more than 10 minutes of life left, so link unfurlers (Signal, Slack) do not mint a
  session per preview. `?format=json` or `Accept: application/json` → `{ "url", … }`.
  `404 not_found` · `410 expired` · `503 provider_unavailable` (an HTML page in browser mode).
- Code alphabet `23456789ABCDEFGHJKMNPQRSTVWXYZ` (no I, L, O, U, 0, 1), 10 characters shown
  as `XXXXX-XXXXX`; lookup is case-insensitive and ignores the dash.

## `GET /v1/return?status=success|canceled&brand=uc[&payment_id=…]`

A neutral HTML page — "Thank you — you can close this tab" / "Checkout canceled — no
charge was made". The default `success_url` / `cancel_url` when a caller gives none.

---

## What the embed relies on

| The embed | depends on |
|---|---|
| every form | `POST /v1/checkout` accepting form-encoded snake_case fields and answering `303` |
| `<uc-subscribe>` tiers, `<uc-pay-button>` price/disable | `GET /v1/catalog?brand=` → `items[].{sku,name,unitAmountCents,interval,unit,purchasable,unavailableReason}`, CORS `*` |
| `<uc-donate>` | `amount` as plain `25` / `25.50` (the script normalises `$1,250.5` → `1250.50` before posting) |
| `<uc-credits>` | `kind=credits`, `sku=uc_credits_1usd`, `quantity` = whole dollars |
| `<uc-pay-link>` | `GET /v1/pay/{CODE}` → `303` |

## Divergences from WS1's long-term OpenAPI

WS1's `pay-v1.openapi.yaml` (`~/NEXUS/reports/undercurrent/payments_aws_blueprint_REPORT/`,
`1.0.0-draft.1`) landed while 0.1.0 was being cut. It describes the *target* service; this
file describes what `feat/pay-v1` implements on QA today, and the embed follows the
implementation. Recorded, not silently resolved:

| Topic | OpenAPI draft | Implemented `/v1` (what uc-pay 0.1.0 sends) | Note |
|---|---|---|---|
| Checkout body | nested `item: { lookupKey, quantity }`, `purpose`, camelCase (`orgSlug`, `donorPublic`, `idempotencyKey`) | flat snake_case: `sku`, `quantity`, `kind`, `org`, `public`, `idempotency_key` | Flat names are what a plain HTML form can post without bracket notation (`item[lookupKey]`). Worth keeping flat in the long-term contract for the form path |
| Return targets | `successPath` / `cancelPath` (required, site-relative) | `success_url` / `cancel_url` (optional, absolute, origin allow-listed per brand) | Relative paths cannot express "return to *this* third-party site"; absolute + allow-list is what embedding on other sites needs |
| Donations | a catalog item + `purpose=donation` (quantity = dollars) | `kind=donation` + `amount` (dollars text) or `amount_cents` | Same money, different spelling |
| JSON success | `CheckoutStart` = `{ kind: "redirect", url, orderRef }` (+ `invoice`, `instructions`) | `{ url, paymentId, provider, expiresAtIso }` | The embed never reads the JSON — forms always use the 303 |
| Catalog | `brand` required; `{ brand, products[].prices[] (interval one_time/monthly/annual), rails, taxMode }` | `brand` optional; `{ brands[], items[] (interval month/year/null, purchasable, unavailableReason), donations[] }` | uc-pay reads `items[].{sku,name,unitAmountCents,interval,unit,purchasable,unavailableReason}` |
| Pay codes | 14–20 symbols, no dash | 10 symbols, shown `XXXXX-XXXXX` | uc-pay 0.1.0 only turns 10-symbol codes into links; widen the check when the server moves |
| Health | `GET /v1/health` | `GET /v1` | |
| Surface tag | `surface` enum (`web`, `bot`, `invoice`, …) | `src` (`embed`, `site`, `signal_bot`, `slack`, `invoice`, `storefront`) | `slack` added 2026-09-30 for slack_bridge `/uc pay` |
| Not-sellable SKU | not modelled | `422 not_purchasable` + `reason` | |

Donations are never "tax-deductible" (there is no 501(c)(3)); no page, receipt or reply may
imply it.
