# VibeMonetize API reference

Complete reference for every `/v1` endpoint. This file is generated by hand from the
single source of truth for shapes, `packages/core/src/routes.ts` (`API_ROUTES`); each
section notes the API route file (`apps/api/src/routes/*.ts`) that implements it. If
this document and `routes.ts` ever disagree, `routes.ts` wins — please fix the doc.

## Basics

- **Base URL**: your VibeMonetize API deployment (dev default: `http://localhost:8787`).
- **Auth**: `Authorization: Bearer <key>` header.
  - `sk_...` **secret key** — server-side only. Full access (registry CRUD, memberships,
    money). Accepted on every authenticated route.
  - `pk_...` **publishable key** — browser-safe. Only accepted on routes marked
    *publishable* below. A secret key also works on those routes; a publishable key is
    never accepted on a *secret*-marked route.
  - Keys can be **app-scoped**: a key minted for one app is rejected (`403 forbidden`)
    when a request names a different `appId`.
- **Bodies**: JSON, `Content-Type: application/json`.
- **IDs**: ULIDs (26-char Crockford base32), e.g. `01J0FAKEULID0000000000000A`.
- **Slugs**: lowercase alphanumeric plus `-`/`_`, max 64 chars (`^[a-z0-9][a-z0-9_-]*$`).
- **Money**: integer cents. `currency` is a lowercase 3-letter code (default `usd`).
- **Timestamps**: ISO 8601 strings.
- **Soft delete**: `DELETE` routes archive (set `archivedAt`), never hard-delete.
  Archived rows disappear from list/get routes.

## Error shape

Every error is `{ "code": string, "message": string }` (`packages/core/src/errors.ts`):

| code | HTTP | meaning |
|---|---|---|
| `validation_failed` | 400 | Body/query failed schema validation |
| `unauthorized` | 401 | Missing/invalid/revoked key |
| `token_expired` | 401 | Entitlement token expired |
| `payment_required` | 402 | Active plan required |
| `forbidden` | 403 | Key not scoped to this app / resource |
| `usage_limit_exceeded` | 403 | Meter cap reached |
| `not_found` | 404 | No such resource in your tenant |
| `conflict` | 409 | Duplicate slug, concurrent change, or blocked archive |
| `connect_onboarding_required` | 409 | Payout before Stripe Connect onboarding |
| `idempotency_conflict` | 409 | Idempotency key reused with different payload |
| `rate_limited` | 429 | Too many requests |
| `internal` | 500 | Server error |
| `stripe_error` | 502 | Upstream Stripe failure |

---

## Developer auth (`apps/api/src/routes/signup.ts`, `routes/devLogin.ts`)

How you get a developer account and the `sk_`/`pk_` keys every other route uses. None
of these routes take an `Authorization` header.

### `POST /v1/auth/signup/start` — request a signup code *(no auth; enabled in production)*

Body: `{ "email": string }`. Emails a 6-digit verification code (valid 10 minutes,
3 attempts; a newer code invalidates any earlier one). Always responds
`200 { "ok": true }` whether or not `email` already has an account, so the response
can't be used to enumerate accounts. `429 rate_limited` under request-volume abuse.

**No deliverable inbox** (local eval, self-hosting, CI, a fully autonomous agent): a
local or self-hosted api running with no `RESEND_API_KEY` configured, outside a
production build (`NODE_ENV` unset/non-`production`), adds a `devCode` field to the
`200` response carrying the exact code just generated — `{ "ok": true, "devCode":
"123456" }`. This is never present against the hosted production api (or any
production-configured deploy); do not rely on it there.

### `POST /v1/auth/signup/verify` — redeem the code for keys *(no auth; enabled in production)*

Body: `{ "email": string, "code": "6 digits" }`. On success, finds-or-creates the
developer for `email` (idempotent) and mints a fresh key pair:

```json
{ "developerId": "01J0...", "secretKey": "sk_...", "publishableKey": "pk_..." }
```

Failures: `400 validation_failed` (wrong code — deliberately identical to the
missing-challenge case, so verify can't be used as an oracle), `401 token_expired`
(code older than 10 minutes), `403 forbidden` (code superseded by a newer one, or
locked after 3 wrong attempts — request a new code).

Terminal shortcut: `npx vibemonetize signup --email you@example.com` drives both
calls (second invocation adds `--code 123456`) and writes the minted keys into
`.env.local`.

### `POST /v1/auth/dev-login` — dev/test bootstrap *(no auth; disabled in production)*

Body: `{ "email": string, "name"?: string }`. Finds-or-creates the developer and mints
a fresh key pair — same response shape as `signup/verify`, no email round-trip. Every
call mints new keys. Hard-disabled (404) outside dev/test environments.

---

## Apps (`apps/api/src/routes/apps.ts`)

An App is one deployed product. Features, meters, and (usually) plans hang off an app.

### `POST /v1/apps` — create app *(secret)*

Request: `{ slug, name, domains?: string[] }`
Response `201`: App object:

```json
{
  "id": "…", "developerId": "…", "slug": "songlens", "name": "Songlens",
  "domains": ["songlens.app"],
  "listedAt": null, "tagline": null, "description": null, "iconUrl": null, "category": null,
  "createdAt": "…", "updatedAt": "…", "archivedAt": null
}
```

Errors: `conflict` (duplicate slug). Store-listing fields (`listedAt`, `tagline`, …) are
managed only via the listing routes below.

### `GET /v1/apps` — list apps *(secret)* → `App[]`
### `GET /v1/apps/:appId` — get app *(secret)* → `App`
### `PATCH /v1/apps/:appId` — update app *(secret)*

Request (all optional): `{ slug?, name?, domains? }`. `domains` replaces the whole list.

### `DELETE /v1/apps/:appId` — archive app *(secret)* → archived `App`

---

## Features (`apps/api/src/routes/features.ts`)

A Feature is a gateable capability of one app, referenced everywhere by `(appId, slug)`.

### `POST /v1/apps/:appId/features` — create feature *(secret)*

Request: `{ appId, slug, name, description? }` — body `appId` must equal the route `:appId`.
Response `201`: `{ id, developerId, appId, slug, name, description, createdAt, updatedAt, archivedAt }`
Errors: `conflict` (duplicate slug within the app), `not_found` (app not yours).

### `GET /v1/apps/:appId/features` — list features *(secret)* → `Feature[]`
### `PATCH /v1/apps/:appId/features/:featureId` — update feature *(secret)*

Request (all optional): `{ slug?, name?, description? }`. Renaming a slug changes what
plan grants must reference — update plan grants and your `<Paywall feature="…">` usages together.

### `DELETE /v1/apps/:appId/features/:featureId` — archive feature *(secret)* → archived `Feature`

---

## Meters (`apps/api/src/routes/meters.ts`)

A Meter is a countable usage dimension (`period`: `month` = resets monthly,
`lifetime` = never resets, `credit` = consumed from purchased/granted credit balances).

### `POST /v1/apps/:appId/meters` — create meter *(secret)*

Request: `{ appId, slug, name, unit, period }` — body `appId` must equal route `:appId`;
`period` ∈ `month | lifetime | credit`.
Response `201`: Meter object (same envelope as features plus `unit`, `period`).

### `GET /v1/apps/:appId/meters` — list meters *(secret)* → `Meter[]`
### `PATCH /v1/apps/:appId/meters/:meterId` — update meter *(secret)*

Request (all optional): `{ slug?, name?, unit?, period? }`. Changing `period` changes how
existing usage balances are interpreted — avoid on meters with live traffic.

### `DELETE /v1/apps/:appId/meters/:meterId` — archive meter *(secret)* → archived `Meter`

---

## Bundles (`apps/api/src/routes/bundles.ts`)

A Bundle is a developer-scoped `Plan` container whose grants/limits fan out across
**multiple apps** — no other special casing (see the `Plans` section below for how a
plan attaches to one). See the [bundles guide](/guides/bundles) for the full
create-bundle → bundle-plan → checkout → cross-app-entitled walkthrough.

### `POST /v1/bundles` — create bundle *(secret)*

Request: `{ slug, name }`. Response `201`: `{ id, developerId, slug, name, createdAt,
updatedAt, archivedAt }`. Errors: `conflict` (duplicate slug for this developer),
`validation_failed`.

### `GET /v1/bundles` — list bundles *(secret)* → `Bundle[]`

No update/archive route yet — a bundle's `slug`/`name` are cosmetic; archiving one with
live bundle-plans would need the same active-membership guard `DELETE /v1/plans/:planId`
already has.

---

## Plans (`apps/api/src/routes/plans.ts`)

A Plan is what end users buy (or are granted). It belongs to exactly one of an **app**
(`appId`) or a **bundle** (`bundleId` — a developer-scoped container, created via
`POST /v1/bundles` above, whose grants fan out across multiple apps).
`interval` ∈ `month | year | one_time | lifetime | credits`.

- `grants: [{ appId, featureSlug }]` — features unlocked by the plan.
- `limits: [{ appId, meterSlug, cap, period }]` — usage caps granted by the plan
  (`period: "credit"` caps define how many credits the plan grants; credit caps are
  **additive** across membership rows, other caps take the max across sources).
- Paid plans get a Stripe Product+Price attached automatically at creation
  (`stripePriceId`). `$0` plans never touch Stripe and are **auto-enrolled** as comp
  memberships on first entitlement-token request (optionally gated on a verified email
  via `requireEmail`).

### `POST /v1/plans` — create plan *(secret)*

Request:

```json
{
  "appId": "…",            // exactly one of appId / bundleId non-null
  "bundleId": null,
  "slug": "pro",
  "name": "Pro",
  "priceCents": 1900,
  "currency": "usd",
  "interval": "month",
  "trialDays": 0,
  "requireEmail": false,
  "grants": [{ "appId": "…", "featureSlug": "unlimited_apps" }],
  "limits": [{ "appId": "…", "meterSlug": "generations", "cap": 1000, "period": "month" }]
}
```

Response `201`: hydrated Plan (request fields + `id`, `developerId`, `stripePriceId`, timestamps).
Errors: `conflict` (duplicate slug), `not_found` (app/bundle or a grant/limit appId not
yours), `validation_failed` (appId ⊕ bundleId violated), `stripe_error` (price creation
failed — no plan row is left behind).

### `GET /v1/plans` — list plans *(secret)*, query `?appId=` / `?bundleId=` → `Plan[]`
### `GET /v1/apps/:appId/plans` — list an app's plans *(secret)* → `Plan[]`

Same shape/result as `GET /v1/plans?appId=`, nested under the app the way
`/v1/apps/:appId/features` and `.../meters` already are — use whichever reads more
naturally for your client. App-scoped plans only (a bundle plan has no single app to
nest under; use `?bundleId=` on the query form for those).

### `GET /v1/plans/:planId` — get plan *(secret)* → hydrated `Plan`
### `PATCH /v1/plans/:planId` — update plan *(secret)*

Request (all optional): `{ name?, priceCents?, interval?, trialDays?, requireEmail?, grants?, limits? }`

- `slug`, `appId`/`bundleId`, and `currency` are **immutable** — archive + recreate instead.
- `grants` / `limits`, when provided, **replace the whole set**. Grant/limit changes take
  effect for **all current and future members** within one entitlement-token TTL (≤5 min).
- Changing `priceCents` or `interval` on a paid plan attaches a **brand-new Stripe
  price**; the old Stripe price is never modified. **Existing subscribers keep billing at
  the price they subscribed at** (grandfathering); only new checkouts see the new price.
  Setting `priceCents: 0` detaches the Stripe price (the plan becomes an auto-enroll free plan).

### `DELETE /v1/plans/:planId` — archive plan *(secret)*

Archived plans stop granting entitlements to everyone **and** can no longer be checked
out. To protect you from "Stripe keeps billing but access stopped", this returns
`409 conflict` while the plan still has active, unexpired memberships — cancel them
first (`POST /v1/memberships/:membershipId/cancel`).

---

## Memberships — subscription management (`apps/api/src/routes/memberships.ts`)

A Membership is the single shape entitlements resolve from: Stripe subscriptions
(`source: "subscription"`), one-time/lifetime/credit purchases (`"purchase"`), trials
(`"trial"`), and manual grants (`"comp"`). `status` ∈ `active | trialing | past_due |
canceled | expired`. All membership routes return the plan hydrated inline.

### `GET /v1/memberships` — list *(secret)*

Query (all optional, combinable):

| param | matches |
|---|---|
| `appId` | plans attached to that app, **plus** bundle plans whose grants/limits fan out to it |
| `endUserId` | one end user |
| `email` | the end user's verified email (RFC 002 verification flow) |
| `status` | membership status |

Response: array of

```json
{
  "id": "…", "developerId": "…", "endUserId": "…", "planId": "…",
  "status": "active", "source": "subscription",
  "stripeSubscriptionId": "sub_…", "startedAt": "…", "expiresAt": null,
  "createdAt": "…", "updatedAt": "…", "archivedAt": null,
  "plan": { …hydrated Plan… }
}
```

### `GET /v1/memberships/:membershipId` — get one *(secret)* → membership + plan

### `POST /v1/memberships` — grant a comp membership *(secret)*

Request: `{ planId, endUserId | anonId, expiresAt? }` — exactly one of
`endUserId`/`anonId`; an unknown `anonId` auto-provisions the end user. `expiresAt`
time-boxes the grant (omit for non-expiring).

Grants the plan with `source: "comp"`, no Stripe involved. Pointing this at a
`credits`-interval plan **grants that plan's credit caps** — credit caps stack per
membership row, so repeated grants add up. Response `201`: membership + plan.

### `POST /v1/memberships/:membershipId/cancel` — cancel *(secret)*, no body

- **Stripe-billed** membership (`stripeSubscriptionId` set): schedules
  `cancel_at_period_end` on Stripe. The response still shows `status: "active"` with
  `expiresAt` mirroring the period end — the customer keeps what they paid for; the
  Stripe webhook flips the status to `canceled` when the period actually ends.
- **Everything else** (comp/trial/purchase-grant): canceled immediately
  (`status: "canceled"`, `expiresAt: now`).
- Idempotent: canceling an already-canceled/expired membership returns it unchanged.

---

## Paywall remote config (`apps/api/src/routes/paywallConfig.ts`)

Freeform versioned JSON consumed by the react SDK's `<Paywall>` — edit copy/theme/gating
presentation without redeploying the app.

### `GET /v1/apps/:appId/paywall-config` *(secret)* — latest config

`404 not_found` until the first PUT (treat as "no config yet").
Response: `{ id, developerId, appId, version, config: {…}, publishedAt, createdAt, updatedAt, archivedAt }`

### `PUT /v1/apps/:appId/paywall-config` *(secret)* — replace + publish

Request: `{ "config": { …any JSON object… } }`. Replaces the config wholesale, bumps
`version`, sets `publishedAt`.

### `GET /v1/paywall-config?appId=…` *(publishable)* — published config (SDK read path)

---

## Checkout (`apps/api/src/routes/checkout.ts`)

### `POST /v1/checkout-sessions` *(publishable)*

Request: `{ planSlug + appId | planId, successUrl, cancelUrl, endUserId | anonId }`
(exactly one plan identifier, exactly one identity).
Response: `{ sessionId, url }` — redirect the user to `url` (Stripe Checkout).

**Prefer `planSlug`** (RFC 006): the slug you chose in the pricing editor (`"pro"`,
`"credits-100"`), resolved within an app scope — `appId` in the body (the react SDK
sends `<MonetizeProvider>`'s automatically), or the api key's own app scope when the
key is app-scoped. `planId` (the plan's ULID) remains as the escape hatch, and is the
only way to sell a cross-app bundle plan (bundle plans have no single app scope).

`month`/`year` plans create subscription-mode sessions; `one_time`/`lifetime`/`credits`
create payment-mode sessions. Completion is projected from Stripe webhooks into
memberships/purchases — entitlements appear within one token refresh after payment.
Errors: `not_found` (plan — for `planSlug`, the message names the slug and app
searched), `validation_failed` (`planSlug` without any app scope; both/neither plan
identifiers; $0 plan — free plans aren't bought, they auto-enroll), `forbidden`
(app-scoped key selling another app's or a bundle plan), `conflict` (duplicate slugs
in the app — use `planId`).

The plan lookup runs **before** the rest of the body's schema is enforced: a
well-formed but nonexistent `planId`/`planSlug` always surfaces as `not_found`, even if
`successUrl`/`cancelUrl`/the identity fields are ALSO invalid — so a slug/id typo is
never masked behind an unrelated field error.

### `POST /v1/webhooks/stripe` *(Stripe signature)* (`apps/api/src/routes/webhooks.ts`)

Stripe calls this; you don't. Verified against `STRIPE_WEBHOOK_SECRET`, idempotent by
event id, projected into memberships/purchases/ledger.

---

## Entitlements (`apps/api/src/routes/entitlementToken.ts`, `jwks.ts`)

### `POST /v1/entitlement-token` *(publishable)*

Request: `{ appId, endUserId | anonId }` (exactly one; unknown `anonId` auto-provisions
an end user and auto-enrolls eligible $0 plans).
Response: `{ token, expiresAt }` — a short-lived (≤5 min) JWT with claims
`{ sub, appId, plan, features: string[], meters: { [slug]: { cap, used, remaining, resetsAt } } }`.
SDKs verify it locally against the JWKS; never call the API per feature-check.

### `GET /v1/.well-known/jwks.json` *(no auth)* — JWKS for local verification

---

## Usage metering (`apps/api/src/routes/usageEvents.ts`)

### `POST /v1/usage-events` *(publishable)*

Request: `{ appId, meterSlug, delta? (default 1), idempotencyKey?, endUserId | anonId }`
Response: `{ accepted: true, remaining }` — `remaining` is post-increment, `null` when uncapped.
Errors: `usage_limit_exceeded` (403) when the meter is already exhausted. Retries that
reuse an `idempotencyKey` are counted once (the retry succeeds without double-counting).
An unknown `meterSlug` is `not_found` (404) and names the slug + `appId` searched, plus
lists that app's valid meter slugs (or says the app has none yet).

---

## End users — verified email (`apps/api/src/routes/endUsers.ts`)

RFC 002 flow to attach a verified email to an anonymous end user (and merge identities).

### `POST /v1/end-users/identify` *(publishable)*

Request: `{ appId, anonId, email }` → Response: `{ challengeId }` (a 6-digit code is
emailed; the response never reveals whether the email already existed).

### `POST /v1/end-users/verify` *(publishable)*

Request: `{ challengeId, code }` → Response: `{ endUserId }` — the verified (possibly
merged) end user id. Use this id for `email`-based membership lookups later.

---

## Analytics (`apps/api/src/routes/analytics.ts`, ingest in `events.ts`)

### `POST /v1/events` *(publishable)* — ingest

Request: `{ events: [{ appId, name, endUserId | anonId, sessionId?, occurredAt?, properties?, source? }] }`
(1–100 events; `name` is lowercase snake/dot case). Canonical names powering the funnel:
`page_view, signup, activation, paywall_impression, checkout_started, checkout_completed,
subscription_started, subscription_churned, usage_limit_reached`.
Response: `{ accepted: n }`.

### `GET /v1/analytics/funnel?appId=…&from=…&to=…` *(secret)*

Response: `{ appId, from, to, visitors, counts: { page_view: n, …one key per canonical event } }`

### `GET /v1/analytics/apps-ranking` *(secret)*

Trailing-30d portfolio triage. Response: array of
`{ appId, slug, name, visitors30d, signups30d, conversion, mrrCents, recommendation: "push"|"bundle"|"kill" }`.

---

## Developer money (`apps/api/src/routes/developerMoney.ts`)

RFC 001: end users pay the platform's Stripe account; your earnings accrue to a ledger
(charges minus 3% platform fee); you claim them via deferred Connect onboarding.

### `GET /v1/developer/balance` *(secret)*

Response: `{ availableCents, currency, entries: LedgerEntry[] }` (entries newest-first;
`type` ∈ `charge | refund | chargeback | platform_fee | payout`, signed cents).

### `POST /v1/developer/payouts` *(secret)* — body `{}`

Pays out the full available balance. Errors: `connect_onboarding_required` (409) until
onboarding is complete, `validation_failed` when the balance is zero.

### `POST /v1/developer/connect-onboarding` *(secret)*

Request: `{ returnUrl, refreshUrl }` → Response: `{ url }` (hosted Stripe onboarding link).

---

## Developer profile + Store (RFC 005) (`apps/api/src/routes/store.ts`)

### `GET /v1/developer/profile` *(secret)* → Developer (incl. `handle`, `displayName`, `avatarUrl`, `bio`)
### `PATCH /v1/developer/profile` *(secret)* — request (all optional): `{ handle?, displayName?, avatarUrl?, bio? }`
### `POST /v1/apps/:appId/listing` *(secret)* — list app in the public store

Request: `{ category, tagline?, description?, iconUrl? }` (`category` ∈ `ai_tools, music,
productivity, developer_tools, games, education, finance, health, social, other`).
Requires the app to have at least one registered domain and the developer a `handle`
(both enforced with a descriptive `validation_failed`). Response: `App`.

### `DELETE /v1/apps/:appId/listing` *(secret)* — unlist → `App`
### `GET /v1/store/apps?category=&limit=` *(no auth)* — public directory
### `GET /v1/store/apps/:handle/:slug` *(no auth)* — one listing
### `GET /v1/store/creators` / `GET /v1/store/creators/:handle` *(no auth)*
### `POST /v1/store/recompute-rankings` *(cron secret)* — internal ranking job

---

## Developer bootstrap (`apps/api/src/routes/devLogin.ts`)

### `POST /v1/auth/dev-login` *(no auth — dev/test environments only)*

Request: `{ email, name? }` → Response: `{ developerId, secretKey, publishableKey }`.
Idempotent per email; **each call mints a fresh key pair**. Hard-disabled (404) in
production — real key provisioning happens in the dashboard there.
