my-pane — Client API Guide

Audience. A coding agent building a client (iOS app, third-party integration, internal tooling) against my-pane-server. Read top-to-bottom. Everything you need to wire up auth, publish, and read the feed is here.

Source of truth. This guide describes the wire contract of the v1 API. If a sample in here ever disagrees with src/app/api/v1/** in the server repo, the server wins — but file an issue, this doc should match.

Canonical published URLs

Don't ship copies of this file. Fetch from the server. All three URLs are hosted by the same deployment as the API and update on every push:

WhatURLContent-Type
This guide, rendered${BASE}/docs/apitext/html
This guide, raw markdown${BASE}/docs/api.mdtext/markdown
Design system${BASE}/docs/designtext/html (single-file, self-contained)

${BASE} is your deployment origin — https://localhost:3000 in dev, your Vercel URL in prod. Screenshots referenced below resolve relative to the same origin (/docs/images/*.png).


1. System in one paragraph

my-pane is a per-user feed of typed "panes" (e.g. tweet, price, kpi, news). Publishers (agents, third-party services) push panes into a user's feed by calling POST /api/v1/feed/items with a bearer token issued to that user. Clients (e.g. the iOS app) pull the feed back via GET /api/v1/feed/items with the same kind of token. The feed is FIFO — default read order is oldest-first; you may request newest-first. There is exactly one feed per user, named "default". Items are retained forever.


2. Base URL and versioning

  • Local dev: https://localhost:3000 — served with a self-signed cert by next dev --experimental-https. iOS clients pointed at this host during development need to allow the self-signed cert (see §7.8). curl users need -k / --insecure.
  • Production: whatever NEXT_PUBLIC_APP_URL is in Vercel — always HTTPS, with a real CA-signed cert.
  • All endpoints are versioned in the path under /api/v1/…. When v2 ships, v1 stays online for a deprecation window.
  • Every response includes X-MyPane-Api-Version: 1. Clients should surface a "please update" warning if they see a major version newer than the one they were built against.

3. Authentication

There is one credential type: the bearer API token. There are no passwords. A token looks like:

mp_<base64url(24 random bytes)>

Tokens are issued at signup (and on demand from the dashboard). The server stores only SHA-256(token) — plaintext is shown once at creation and never recoverable. Losing it means generating a new one.

How to use a token

For every protected endpoint, send:

Authorization: Bearer mp_…

What if the user has no token yet?

There is no API-driven onboarding path that doesn't return a token — signup itself issues one. The expected flow for an iOS client is:

  1. New user → user pastes a token they got from the website's signup flow or the iOS app calls POST /api/v1/auth/signup directly (see below).
  2. Returning user → user pastes their existing token.

Either way, store the token in the iOS Keychain with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly (or equivalent). Do not store it in UserDefaults.

Session cookie

The server also issues an HttpOnly session cookie for use by its web dashboard. iOS clients should ignore it. All iOS calls use bearer tokens; never persist or send the session cookie from a native client.


4. Common conventions

Content type

Always application/json; charset=utf-8 for request and response bodies.

Error envelope

Every non-2xx response (including validation errors) returns:

{
  "error": {
    "code": "bad_request" | "unauthorized" | "forbidden" | "not_found" |
            "conflict" | "internal",
    "message": "Human-readable summary",
    "details": { … }      // optional, present for validation errors
  }
}

When code === "bad_request" and the request failed Zod validation, the details field is a flatten()'d Zod error:

{
  "error": {
    "code": "bad_request",
    "message": "Invalid signup payload",
    "details": {
      "formErrors": [],
      "fieldErrors": {
        "email":         ["Invalid email"],
        "dateOfBirth":   ["Date of birth must be a real past date (>= 1900)"]
      }
    }
  }
}

Status codes

StatusWhen
200 OKSuccessful read or login.
201 CreatedNew user, token, or feed item.
204 No ContentLogout, revoke, delete.
400 Bad RequestBody validation failed; unknown pane type; payload too large; bad cursor.
401 UnauthorizedMissing/invalid token or session.
403 ForbiddenCurrently unused; reserved.
404 Not FoundUser / token / feed item doesn't exist or isn't yours.
409 ConflictEmail already in use during signup.
500 InternalBug. Worth retrying once with exponential backoff.

Cursor pagination

GET /api/v1/feed/items returns:

{
  "items": [ … ],
  "nextCursor": "eyJpIjoiMDFLUk04WEpWWkdaVlhUNTI2RDZCRVBYRUMifQ" | null
}
  • nextCursor is opaque base64url — do not decode or parse it. Pass it back verbatim as ?cursor=… to get the next page.
  • nextCursor === null means you've reached the end of that page direction.
  • A new item published after you started paginating will appear when you start a fresh ascending read (because its ULID sorts after your last cursor). For "tail the feed in real time," see §7.4.

Item-size cap

Publish requests are capped at 380,000 bytes (the body, not just data). This mirrors DynamoDB's 400 KB item ceiling so behavior is identical in dev (SQLite) and prod (DynamoDB). Larger payloads return 400 bad_request with message Payload too large.

Timestamps

All createdAt / lastUsedAt / dateOfBirth-adjacent timestamps are ISO-8601 in UTC (2026-05-15T07:30:00.000Z). The one exception is dateOfBirth itself, which is YYYY-MM-DD (no time component — birthdays don't have timezones).


5. Endpoints

5.1 Health

GET /api/v1/health — public, no auth.

Returns whether the server is up and which DB driver it's running against (useful for distinguishing dev/staging/prod).

curl -sk https://localhost:3000/api/v1/health
{ "status": "ok", "driver": "sqlite", "time": "2026-05-15T07:30:00.000Z" }

Monitoring endpoints (root, unversioned)

For external uptime/load monitors there are two well-known root paths (unversioned on purpose — monitors shouldn't track the API version):

GET /health — public, no auth. Liveness and readiness: runs one cheap point-lookup through the DB layer. Returns 200 with { "status": "ok", ... } when the DB is reachable, 503 with { "status": "unhealthy", ... } when it isn't. (The /api/v1/health above is the lighter in-band check that does not touch the DB.)

curl -sk https://localhost:3000/health

GET /metricsgated. Prometheus text exposition format (request counts, error counts by status class, cumulative latency, uptime). Requires a bearer token (METRICS_TOKEN, falling back to ADMIN_PASSWORD); fails closed with 503 if neither is configured. Operational metrics are reconnaissance-useful, so this endpoint is not public.

curl -sk -H "Authorization: Bearer $METRICS_TOKEN" https://localhost:3000/metrics

5.2 Sign up

POST /api/v1/auth/signup — public.

Creates a user and issues their first API token. The token plaintext is returned only here, only once — copy it immediately.

Request

{
  "firstName": "Ben",
  "lastName": "Magro",
  "dateOfBirth": "1987-01-09",
  "email": "ben@example.com"
}

Validation:

  • firstName, lastName: 1–80 chars, required
  • dateOfBirth: YYYY-MM-DD, must be a real date in the past, year ≥ 1900
  • email: RFC-5322-ish, ≤ 254 chars

Response — 201 Created

{
  "user": {
    "id": "01KRM8ZPGA8PZD0Z7S24KSZQ1M",
    "email": "ben@example.com",
    "firstName": "Ben",
    "lastName": "Magro",
    "dateOfBirth": "1987-01-09",
    "createdAt": "2026-05-14T22:16:16.650Z"
  },
  "token": {
    "tokenId": "01KRM8ZPGSQT2MWH8CPFAPZHJ4",
    "name": "default",
    "createdAt": "2026-05-14T22:16:16.650Z",
    "token": "mp_RP7RGJx9kQwx…"        // <- save this. Shown once.
  },
  "refreshToken": {
    "refreshTokenId": "01KRM8ZPH…",
    "expiresAt": "2026-06-13T22:16:16.650Z",
    "token": "mp_rt_…"                 // <- save this too. Renews the session (§5.4.1).
  }
}

Errors

StatusCodeWhen
400bad_requestValidation failed (see details).
409conflictEmail already in use.
curl -k -X POST https://localhost:3000/api/v1/auth/signup \
  -H "Content-Type: application/json" \
  -d '{"firstName":"Ben","lastName":"Magro","dateOfBirth":"1987-01-09","email":"ben@example.com"}'

5.3 Log in with a token

POST /api/v1/auth/login — public.

For native clients you probably don't need this. It exists so the web dashboard can recognize a returning user. iOS clients hold the token in Keychain and send it as a bearer header on every request; they don't need a session cookie.

Request

{ "token": "mp_…" }

Response — 200 OK

Returns the same user shape as /auth/me, sets the mp_session HttpOnly cookie, and includes a refreshToken (see §5.4.1). Native clients should discard the cookie but keep the refresh token.

{
  "user": { "...": "..." },
  "refreshToken": {
    "refreshTokenId": "01J…",
    "expiresAt": "2026-07-30T22:16:16.650Z",
    "token": "mp_rt_…"
  }
}

Errors

StatusCodeWhen
401unauthorizedToken is unknown or revoked.

5.4 Log out

POST /api/v1/auth/logout — session cookie required.

Clears the session cookie. Optionally pass { "refreshToken": "mp_rt_…" } in the body to also kill that refresh token server-side (recommended — a leaked copy then can't be replayed). Returns 204 No Content.

Web-only. iOS clients don't call this; they "log out" by deleting the token from Keychain.


5.4.1 Refresh the session

POST /api/v1/auth/refresh — public (the refresh token IS the credential).

The short-lived session ("access token") issued at login / email-verification can be renewed without re-pasting the API token by exchanging the long-lived refresh token returned at that time.

Refresh tokens are single-use. Every successful refresh ROTATES the token: the presented one is invalidated and a brand-new one returned. Always replace your stored refresh token with the one from the response. A refresh token also expires (30 days) and can be revoked server-side at logout. If a client hits a 401, it should retire its refresh token (via /auth/logout with the token in the body) and send the user back through login.

Request

{ "refreshToken": "mp_rt_…" }

Response — 200 OK

Returns the same user shape as /auth/me, sets a fresh mp_session cookie, and returns the rotated refresh token. The previous refresh token is now dead.

{
  "user": { "...": "..." },
  "refreshToken": {
    "refreshTokenId": "01J…",
    "expiresAt": "2026-07-30T22:16:16.650Z",
    "token": "mp_rt_…"
  }
}

Errors

StatusCodeWhen
401unauthorizedRefresh token is unknown, already used, revoked, or expired.
429rate_limitedToo many refresh attempts from this IP.

5.5 Current user

GET /api/v1/auth/me — session cookie required.

Web-only. The session is set by /auth/login. iOS clients usually don't need this — they already know the user from when the token was provisioned. If a client genuinely needs profile info given only a bearer token, we'll add GET /api/v1/auth/me-by-token in v2.

Response — 200 OK

{
  "user": {
    "id": "01KRM8ZPGA8PZD0Z7S24KSZQ1M",
    "email": "ben@example.com",
    "firstName": "Ben",
    "lastName": "Magro",
    "dateOfBirth": "1987-01-09",
    "createdAt": "2026-05-14T22:16:16.650Z"
  },
  "plan": "free",
  "planExpiresAt": null,
  "pane_count": 12
}

Alongside the user profile, the response carries the caller's subscription and feed state:

FieldTypeNotes
plan"free" | "pro"Subscription tier. pro after a verified receipt (see §5.10). Defaults to free.
planExpiresAtstring | nullISO-8601 expiry of a pro entitlement, or null. Informational — the server does not auto-downgrade.
pane_countnumberThe user's active (non-expired, non-completed) pane count.

Clients may rely on the exact keys plan and pane_count being present at the top level. Other fields are additive and safe to ignore.

Delete the account

DELETE /api/v1/auth/mebearer token (a web session also works).

Permanently deletes the caller's account.

StatusWhen
204 No ContentDeleted.
401 UnauthorizedToken missing, invalid, or already revoked.
curl -sk -X DELETE https://getpanes.com/api/v1/auth/me \
  -H "Authorization: Bearer mp_…" -i

This is a hard delete, not a deactivation, and it is immediate and irreversible — there is no undo and no recovery window. (Apple's App Store guidelines require in-app account deletion to actually delete the account, so a "deactivate only" flow is not offered.) One operation removes:

  • the user record,
  • every feed item (pane) they own,
  • all API tokens and refresh tokens,
  • any pending email-verification record,
  • the plan / subscription linkage stored on the user (plan, planExpiresAt, originalTransactionId).

After a successful call the bearer token used to make it is already invalid — every subsequent request returns 401.

It does NOT cancel an Apple subscription. Apple owns that lifecycle; the server can only forget its own linkage. A client that offers account deletion should tell the user to cancel the subscription separately in the App Store (Settings → Apple Account → Subscriptions), otherwise they keep being billed.


5.6 List tokens

GET /api/v1/auth/tokens — session cookie required.

Web-only.

Response — 200 OK

{
  "tokens": [
    {
      "tokenId": "01KRM8ZPGSQT2MWH8CPFAPZHJ4",
      "name": "default",
      "createdAt": "2026-05-14T22:16:16.650Z",
      "lastUsedAt": "2026-05-15T07:21:04.187Z"
    }
  ]
}

Plaintext tokens are never returned here. There is no "show again" path.


5.7 Create token

POST /api/v1/auth/tokens — session cookie required.

Web-only. Used by the dashboard to mint additional tokens (e.g. one per integration). Native clients are expected to use the token they were provisioned with; if they need to rotate, the user does it in the web dashboard and pastes the new token into the app.

Request

{ "name": "yahoo-finance-agent" }

Response — 201 Created

{
  "tokenId": "01KRMC0X7PD9P5VK0WQJWNAHJ4",
  "name": "yahoo-finance-agent",
  "createdAt": "2026-05-15T07:30:00.000Z",
  "token": "mp_…"      // shown once
}

5.8 Revoke token

DELETE /api/v1/auth/tokens/{tokenId} — session cookie required.

Web-only.

Response — 204 No Content if deleted, 404 not_found otherwise.

After revocation, any request using that token returns 401. There is no grace period.


5.9 Pane type discovery

GET /api/v1/panes/types — public, no auth.

Returns one entry per registered pane type. Each entry has:

FieldTypeNotes
typestringThe pane's identifier (e.g. "price", "chart", …). Use this as type when publishing.
descriptionstringOne-line human description.
dataSchemaobjectJSON Schema (OpenAPI 3 dialect) for the pane's data payload, generated directly from the live Zod schema. This is the machine-readable source of truth — what's documented in §6 is the human-readable equivalent of the same shape.

Treat this endpoint as the canonical schema for data payloads. The human-readable catalog in §6 below is hand-curated from the same Zod definitions, but if there's ever a mismatch, what the live endpoint returns wins. Fetch it at app launch and cache it; re-fetch on long background-resume in case a new pane type or field has shipped.

Response shape (truncated for readability):

{
  "types": [
    {
      "type": "price",
      "description": "A live price quote with a sparkline and OHLC summary.",
      "dataSchema": { /* full JSON Schema — fetch the live endpoint */ }
    },
    {
      "type": "chart",
      "description": "A line chart of a single series over a window, with axis and OHLC summary.",
      "dataSchema": { /* … */ }
    }
    /* … 14 more pane types … */
  ]
}

Why no inline schema example? Hand-writing a JSON-Schema sample here would drift the moment a field is added or renamed. The previous version of this section did exactly that and ended up misleading clients (a stale example contributed to a real renderer bug downstream). The live endpoint is generated from the same Zod definitions that validate publishes — they can't drift from each other. Trust it.


5.10 Publish a feed item

POST /api/v1/feed/itemsbearer token required.

Publishes a single pane to the authenticated user's default feed. The server validates data against the registered schema for type and rejects unknown types.

Request

{
  "type": "tweet",
  "publisher": "X",
  "publisherInit": "X",
  "publisherColor": "#000000",
  "label": "From people you follow",
  "data": {
    "author": "Paul Graham",
    "handle": "@paulg",
    "text": "The most underrated skill in startups isn't writing code or talking to users. It's deciding what not to do.",
    "likes": "4.2K",
    "replies": 318
  }
}

Common request fields (apply to every pane type):

FieldRequiredNotes
typeyesOne of the registered types. Unknown → 400.
publisheryesDisplay name of the source (e.g. "Yahoo Finance"). 1–80 chars.
publisherInitno1–3 character avatar/badge initial.
publisherColornoCSS color (hex / rgb() / named).
labelnoShort subheading shown above the pane (e.g. "Markets"). ≤ 120 chars.
datayesType-specific object, must match the registry schema.
expiresAtnoISO-8601 datetime after which the pane is hidden from the feed. Omit for a pane that never expires.

Response — 201 Created

{
  "item": {
    "itemId": "01KRMC3X8GBHJN2N1Y7YYBT3WQ",
    "userId": "01KRM8ZPGA8PZD0Z7S24KSZQ1M",
    "feedId": "default",
    "type": "tweet",
    "publisher": "X",
    "publisherInit": "X",
    "publisherColor": "#000000",
    "label": "From people you follow",
    "data": { "author": "Paul Graham", … },
    "createdAt": "2026-05-15T07:30:21.512Z",
    "tokenId": "01KRMC0X7PD9P5VK0WQJWNAHJ4",
    "status": "active",
    "expiresAt": null
  }
}

itemId is a ULID — lexicographically sortable by time. Use it for de-dup keys, stable IDs in your local store, and as the cursor anchor.

Errors

StatusCodeWhen
400bad_requestValidation failed for the envelope or data. Unknown type. Payload > 380 KB.
401unauthorizedMissing or invalid bearer token.

5.11 Read the feed

GET /api/v1/feed/itemsbearer token required.

Query parameters

NameTypeDefaultNotes
limitint201–100.
order"asc" | "desc""asc"asc = FIFO (oldest first), desc = newest first.
cursoropaque stringPass back the previous nextCursor.
afteritemIdIncremental sync. Return only items strictly newer than this itemId. See below.

Response — 200 OK

{
  "items": [
    {
      "itemId": "…",
      "userId": "…",
      "feedId": "default",
      "type": "code",
      "publisher": "GitHub",
      "publisherInit": "⌘",
      "publisherColor": "#0E0E0E",
      "label": "main · build #1421 ✓",
      "data": { "repo": "mypane/web", … },
      "createdAt": "2026-05-15T06:00:00.000Z",
      "tokenId": "…",
      "status": "active",
      "expiresAt": null
    }
    /* … */
  ],
  "nextCursor": "eyJpIjoiMDFL…" | null
}

Pagination contract. Loop:

cursor = null
while True:
  page = GET /feed/items?limit=20&cursor=cursor
  yield page.items
  if page.nextCursor is None: break
  cursor = page.nextCursor

cursor is opaque — do not parse it.

Incremental sync with after. itemIds are ULID-sortable, so "newer" == lexicographically greater. A client that already holds history should store the greatest itemId it has seen (its high-water mark) and, on the next sync, pass it as ?after=<itemId> to fetch only the panes published since — in a single query — instead of paging the whole feed. after composes with order and cursor (if the new-item set exceeds limit, page it with cursor as usual). First-ever sync (no high-water mark) omits after and typically pulls the most recent page with order=desc + limit, loading older history lazily.

# incremental catch-up (oldest-new first, paged)
cursor = null
while True:
  page = GET /feed/items?after=<highWaterMark>&order=asc&limit=100&cursor=cursor
  yield page.items
  if page.nextCursor is None: break
  cursor = page.nextCursor
# then advance highWaterMark to the greatest itemId received

Errors

StatusCodeWhen
400bad_requestInvalid limit/order/cursor.
401unauthorizedMissing or invalid bearer token.
curl -sk 'https://localhost:3000/api/v1/feed/items?order=asc&limit=10' \
  -H "Authorization: Bearer mp_…"

5.12 Get a single item

GET /api/v1/feed/items/{itemId}bearer token required.

Returns the full item for the calling user. Returns 404 not_found if the item doesn't exist, belongs to someone else, has been completed, or has expired.

Response — 200 OK

{ "item": { /* same shape as in §5.11 */ } }

5.13 Delete an item

DELETE /api/v1/feed/items/{itemId}bearer token required.

Hard-deletes one item from the calling user's feed. Returns 204 No Content on success, 404 not_found if it didn't exist.


5.14 Mark an item completed

PATCH /api/v1/feed/items/{itemId}bearer token required.

Marks a pane as completed — the user has acted on it (tapped, swiped away, fulfilled the action). Completed panes are hidden from GET /feed/items and single-item GET /feed/items/{itemId}.

Request

{ "status": "completed" }

Response — 200 OK

Returns the updated item (including "status": "completed"). After this call the item will no longer appear in feed reads — hold the response if you need a local copy.

Errors

StatusCodeWhen
400bad_requestInvalid payload.
401unauthorizedMissing or invalid bearer token.
404not_foundItem doesn't exist or doesn't belong to this user.
curl -sk -X PATCH https://localhost:3000/api/v1/feed/items/01KRMC3X8GBHJN2N1Y7YYBT3WQ \
  -H "Authorization: Bearer mp_…" \
  -H "Content-Type: application/json" \
  -d '{"status":"completed"}'

5.15 Verify a purchase receipt

POST /api/v1/verify-receiptbearer token required.

Submits an Apple StoreKit signed transaction (a compact JWS) for verification. On success the server records the user's subscription: it sets plan to pro, stores Apple's originalTransactionId, and captures the entitlement expiry. The updated plan is then reflected by GET /api/v1/auth/me (§5.5).

Request

{ "signedTransaction": "eyJhbGciOiJFUzI1NiIs….<payload>.<signature>" }
FieldTypeNotes
signedTransactionstringThe StoreKit 2 Transaction.jwsRepresentation (compact JWS).

Response — 200 OK

{
  "plan": "pro",
  "planExpiresAt": "2026-08-08T12:00:00.000Z",
  "originalTransactionId": "2000000012345678"
}

Errors

StatusCodeWhen
400bad_requestMalformed JSON, or the transaction fails verification.
401unauthorizedMissing or invalid bearer token.

Verification backend. Which verifier runs is selected by the APPLE_VERIFY_DRIVER server env var. The default (stub) decodes the JWS payload without checking its signature and is for dev/CI only. The production driver (apple) — real App Store Server API verification — is a follow-up card and is not implemented yet. Plan state, the storage schema, and this wire contract are all final; only the signature-checking internals change when it lands.

No server-side entitlement enforcement. The server never gates pane writes on plan — publishing works identically on free and pro. plan is a signal for the client to unlock features; enforcement is the client's job. planExpiresAt is informational and does not trigger an auto-downgrade.


5.16 RevenueCat webhook

POST /api/v1/webhooks/revenuecatserver-to-server; not for client use.

This endpoint is called by RevenueCat, not by the app. When Pro subscriptions are purchased/renewed/expired through RevenueCat, RC POSTs a lifecycle event here and the server updates the user's plan (reflected by GET /api/v1/auth/me, §5.5). The iOS app's only responsibility is to set the RevenueCat appUserID to the user's my-pane id — that's how app_user_id on the event maps to a user.

Auth. RevenueCat sends a static shared secret (configured in the RevenueCat dashboard) verbatim as the Authorization header. The server compares it (constant-time) against the REVENUECAT_WEBHOOK_SECRET env var; a missing or mismatched header returns 401. If the env var is unset the endpoint rejects every request (fails closed).

Request — RevenueCat's event envelope (only the fields the server reads are shown):

{
  "event": {
    "type": "INITIAL_PURCHASE",
    "app_user_id": "<my-pane user id>",
    "product_id": "pro_monthly",
    "expiration_at_ms": 1754654400000,
    "original_transaction_id": "2000000012345678"
  }
}

Event → plan mapping

event.typeEffect
INITIAL_PURCHASE, RENEWAL, PRODUCT_CHANGE, UNCANCELLATION, NON_RENEWING_PURCHASEplanpro; planExpiresAt = ISO of expiration_at_ms (or null); records original_transaction_id.
EXPIRATIONplanfree; clears planExpiresAt.
CANCELLATIONNo change — access continues until EXPIRATION at period end (logged only).
any other typeNo-op ack.

Response — 200 OK — a small ack, e.g. { "ok": true, "plan": "pro", "planExpiresAt": "…" }. An unknown app_user_id or unmapped event type is also acked with 200 (so RevenueCat stops retrying) and logged server-side.

Errors

StatusCodeWhen
400bad_requestBody is not valid JSON.
401unauthorizedMissing/invalid Authorization, or REVENUECAT_WEBHOOK_SECRET unset.

Dashboard setup (deferred). Going live requires setting REVENUECAT_WEBHOOK_SECRET on the server and configuring the matching webhook URL + Authorization value in the RevenueCat dashboard. Until then the endpoint exists but rejects (401) every call.


6. Pane type catalog

Every pane carries the common request fields from §5.10 (type, publisher, optional publisherInit, publisherColor, label) plus a type-specific data payload. Below is the wire shape of every data payload, with a realistic example you can pass straight through to POST /feed/items.

⚠ Many fields are pre-formatted display strings (e.g. "+12.4%", "$220–280K", "4:21"). The publisher decides how to format; the client renders verbatim. Avoid client-side parsing of these.

Choosing a variant

Every pane type accepts an optional variant string in its data payload. This controls which visual treatment the client renders. If you omit variant, the client falls back to the default (shown first in each section below).

{
  "type": "weather",
  "data": {
    "variant": "data-brick",
    "temp": 26,
    "condition": "Partly cloudy"
  }
}

6.1 price — Live Price Quote

Default variant: v1 (the "replacement stock pane" from design section 04b)

price v1

Renders as an OHLC receipt: ticker + name + currency tag on the left, current price + signed change on the right, an OPEN-line sparkline, then an OHLC grid.

Full payload (everything the new design uses):

{
  "symbol": "NVDA",
  "name": "NVIDIA Corp.",
  "currency": "USD",
  "currencySymbol": "$",
  "value": 887.21,
  "change": 21.04,
  "changePct": 2.43,
  "open": 866.17,
  "dayHigh": 891.04,
  "dayLow": 862.30,
  "spark": [866.17, 871, 868, 875, 882, 886, 880, 877, 884, 889, 887.21],
  "axis": ["09:30", "12:00", "16:00"],
  "windowLabel": "Since open",
  "windowShort": "today"
}

Minimum payload (older publishers — the client falls back to a leaner card):

{
  "symbol": "NVDA",
  "name": "NVIDIA Corp.",
  "value": 887.21,
  "change": 21.04,
  "changePct": 2.43,
  "spark": [820, 832, 851, 866, 880, 887]
}

Fields

FieldReqNotes
symbolyesTicker. 1–16 chars.
nameyesCompany / asset name. 1–120 chars.
valueyesCurrent price. Number, not string.
changeyesAbsolute change since open (signed number).
changePctyesPercent change since open (signed number).
sparkyesSparkline points, 2–256, oldest→newest.
currencySymbolnoUnicode symbol prefixed to numeric values. Defaults to "$". Max 8 chars. Use "€", "£", "¥", "₿", "Ξ", "◎", etc.
currencynoISO-ish code shown as a small label near the ticker (e.g. "USD", "AUD"). Lets the client distinguish $887.21 · USD from $887.21 · AUD. 2–8 chars.
glyphnoAsset glyph for non-fiat assets (e.g. "Ξ" for ETH). Replaces the ticker badge when present. 1–4 chars.
opennoDay's open. Renders in the OHLC grid.
dayHighnoDay's high.
dayLownoDay's low.
axisno2–12 evenly-spaced labels under the sparkline. Typical intraday: ["09:30", "12:00", "16:00"].
windowLabelnoLong-form window name (e.g. "Since open").
windowShortnoShort-form window name (e.g. "today").

No "live" concept. There's no realtime/streaming flag. Publishers re-publish (or PATCH) to update the price; the client treats every read as a snapshot.

Ethereum example (with glyph):

{
  "symbol": "ETH",
  "name": "Ethereum",
  "currency": "USD",
  "currencySymbol": "$",
  "glyph": "Ξ",
  "value": 3580.00,
  "change": 45.00,
  "changePct": 1.27,
  "open": 3520.00,
  "dayHigh": 3612.00,
  "dayLow": 3498.00,
  "spark": [3510, 3530, 3555, 3570, 3580],
  "axis": ["00:00", "08:00", "16:00"],
  "windowLabel": "Since open",
  "windowShort": "today"
}

AUD example:

{
  "symbol": "BHP",
  "name": "BHP Group",
  "currency": "AUD",
  "currencySymbol": "$",
  "value": 42.18,
  "change": -0.32,
  "changePct": -0.75,
  "spark": [42.5, 42.6, 42.3, 42.2, 42.18]
}

6.2 chart — Line Chart

Default variant: v1 (chart-first treatment from design section 04b)

chart v1

Same identity header as price (ticker + name + currency on the left, value

  • signed change on the right) and then a larger chart with a dashed OPEN line and axis labels along the bottom.

Full payload:

{
  "symbol": "TSLA",
  "name": "Tesla, Inc.",
  "currency": "USD",
  "currencySymbol": "$",
  "value": 248.50,
  "change": -8.21,
  "changePct": -3.20,
  "open": 256.71,
  "dayHigh": 272.00,
  "dayLow": 244.10,
  "points": [256.71, 262, 258, 265, 271, 268, 254, 251, 246, 252, 248.50],
  "period": "5D",
  "axis": ["MON", "TUE", "WED", "THU", "FRI"],
  "windowLabel": "5-day",
  "windowShort": "5 days"
}

Fields (same shape as price except sparkpoints, plus period is required):

FieldReqNotes
symbol, name, value, change, changePctyesSame semantics as price.
pointsyesChart series, 2–2048, oldest→newest. Higher resolution than spark.
periodyesShort window code: "1D", "5D", "1M", etc. 1–8 chars.
currencySymbol, currency, glyph, open, dayHigh, dayLow, axis, windowLabel, windowShortnoSame semantics as price. axis for a 5-day chart is typically ["MON", "TUE", "WED", "THU", "FRI"].

6.3 kpi — Single Big Number

Default variant: v1

kpi v1

{
  "metric": "Revenue",
  "value": "$14,892",
  "delta": "+12.4%",
  "deltaPos": true,
  "sub": "vs. avg Mon · 412 charges"
}
  • value is a string (pre-formatted).
  • delta, deltaPos, sub are all optional.

6.4 tweet — Short Post

Default variant: v1

tweet v1

{
  "author": "Paul Graham",
  "handle": "@paulg",
  "text": "The most underrated skill in startups …",
  "likes": "4.2K",
  "replies": 318,
  "url": "https://x.com/paulg/status/…"
}
  • text: ≤ 800 chars.
  • likes is a string (pre-formatted), replies is an int.
  • url optional.

6.5 news — Headline

Default variant: v1

news v1

{
  "headline": "Fed signals two cuts before year-end as core inflation eases",
  "standfirst": "Powell told a Senate panel the path is now \"clearly disinflationary,\" opening the door to a September cut.",
  "read": "3 min read",
  "url": "https://bloomberg.com/…"
}

6.6 job — Job Listing

Default variant: v1

job v1

{
  "role": "Staff Product Designer",
  "company": "Linear",
  "location": "San Francisco · Hybrid",
  "salary": "$220–280K",
  "why": "Why you · ex-Stripe · design systems · 8 yrs",
  "url": "https://linear.app/careers/…"
}

6.7 image — Image Card

Default variant: v1

image v1

{
  "title": "2 bed · Mission · $4,200/mo",
  "sub": "0.4 mi from work · 740 sqft · pets ok",
  "aspect": "4 / 5",
  "url": "https://cdn.example.com/img.jpg",
  "alt": "Photo of a sunlit Mission apartment living room"
}
  • aspect is a CSS aspect-ratio string (e.g. "4 / 5", "16 / 9").
  • The client renders a placeholder of the right aspect when url is absent.

6.8 video — Video Card

Default variant: v1

video v1

{
  "title": "The map of all mathematics",
  "sub": "Veritasium · 4:21",
  "duration": "4:21",
  "aspect": "16 / 9",
  "url": "https://youtu.be/…",
  "thumbnailUrl": "https://i.ytimg.com/vi/…/maxresdefault.jpg"
}

6.9 audio — Podcast / Audio Clip

Default variant: v1

audio v1

{
  "title": "Acquired · the 13F edition",
  "sub": "Episode 312 · 12 min snippet",
  "duration": "12:08",
  "url": "https://cdn.podcast.com/…/clip.mp3",
  "artworkUrl": "https://cdn.podcast.com/…/cover.jpg"
}

6.10 map — Commute / Route Heads-Up

Default variant: v1

map v1

{
  "title": "Commute +12 min",
  "sub": "I-280 has cleared — switch routes to save 12 min",
  "from": "Home",
  "to": "Office",
  "minutes": 38
}

6.11 poll — Short Poll

Default variant: v1

poll v1

{
  "question": "Are sketch sessions in your team's weekly flow?",
  "opts": [
    { "label": "Yes, every week", "pct": 38 },
    { "label": "Sometimes",       "pct": 41 },
    { "label": "Never",           "pct": 21 }
  ],
  "n": 1240
}
  • opts: 2–8 entries, each with a label and pct in [0, 100].
  • Percentages don't have to sum to 100 — rounding is common.

6.12 quote — Pull-Quote / Highlight

Two visual variants from design section 04m. Data shape is identical — only the rendering changes. Omit variant to let the client pick.

VariantTreatment
sidebar-rule (V2)Italic body (~15.5px) indented behind a thin sunburst rule. Calm, blog-like. Good for longer pulls.
statement (V4)No quote marks, no container. Display body (~18px/500) with a hairline rule and a small mono source tag.

quote v2 — sidebar-rule quote v4 — statement

Example (sidebar-rule):

{
  "variant": "sidebar-rule",
  "text": "Make new mistakes. Make glorious, amazing mistakes. Make mistakes nobody's ever made before.",
  "author": "Neil Gaiman",
  "source": "Make Good Art"
}

Example (statement):

{
  "variant": "statement",
  "text": "Make new mistakes. Make glorious, amazing mistakes.",
  "author": "Neil Gaiman",
  "source": "Make Good Art"
}

Fields

FieldReqNotes
textyesThe quote body. 1–600 chars.
variantno"sidebar-rule" or "statement". Omit to let the client default.
authorno≤ 120 chars.
sourcenoBook / article / talk the quote came from. ≤ 160 chars.

Pre-existing items that have no variant (or an old "v1" value) continue to read fine; the client should treat unknown / missing values as "client's choice" and pick its default treatment.


6.13 code — Commit / Build Event

Default variant: v1

code v1

{
  "repo": "mypane/web",
  "commit": "feat(panes): add live feed cycling + persistence",
  "author": "lola",
  "files": "+342 −18",
  "log": "✓ build  ✓ tests (412)  ✓ deploy → preview",
  "url": "https://github.com/mypane/web/commit/…"
}

6.14 weather — Weather Forecast

Variants: hero (default) · data-brick · hourly

Variant 1 — hero (default)

Hero card with large temperature and conditions summary.

weather hero

{
  "variant": "hero",
  "temp": 26,
  "tempLabel": "max today",
  "condition": "Partly cloudy",
  "description": "Light winds, NE 15–20 km/h from afternoon."
}

Variant 2 — data-brick

Compact grid showing UV, rain chance, and wind stats.

weather data-brick

{
  "variant": "data-brick",
  "temp": 26,
  "condition": "Partly cloudy",
  "rainChancePct": 10,
  "uvIndex": 7,
  "uvLabel": "HIGH",
  "wind": "NE 18"
}

Variant 3 — hourly

Scrollable hourly temperature strip with weather icons.

weather hourly

{
  "variant": "hourly",
  "temp": 26,
  "condition": "Partly cloudy",
  "hourly": [
    { "time": "8 AM",  "temp": 22, "icon": "cloud" },
    { "time": "11 AM", "temp": 24, "icon": "sc" },
    { "time": "2 PM",  "temp": 26, "icon": "sc", "isCurrent": true },
    { "time": "5 PM",  "temp": 24, "icon": "sc" },
    { "time": "8 PM",  "temp": 19, "icon": "moon" }
  ]
}
  • hourly: 2–24 entries. Mark one with "isCurrent": true to highlight it.
  • icon values: "sun", "cloud", "sc" (sun+cloud), "rain", "storm", "moon".

6.15 list — List Card

Variants: display-drop-caps (default) · uniform-bullets · hanging-mono · checklist

Variant 1 — display-drop-caps (default)

Large numbered drop-cap initials for each item.

list drop-caps

{
  "variant": "display-drop-caps",
  "items": [
    "Fed signals two cuts before year-end",
    "NVDA crosses $887 on AI guidance",
    "Linear ships v2 of the editor",
    "4 new senior design roles",
    "PR #1422 merged overnight"
  ]
}

Variant 2 — uniform-bullets

Simple bullet points with uniform styling.

list uniform-bullets

{
  "variant": "uniform-bullets",
  "items": [
    "Tailwind v4 launched with zero-config",
    "Arc browser open-sources sidebar API",
    "Figma Variables: everything you missed"
  ]
}

Variant 3 — hanging-mono

Monospaced hanging numbers for task-style lists.

list hanging-mono

{
  "variant": "hanging-mono",
  "items": [
    "Design review — Pane refinements",
    "Ship feed caching to prod",
    "Write up Q3 OKR proposals"
  ]
}

Variant 4 — checklist

Interactive squircle checkboxes. Items are objects with text and a done boolean — completed items render struck-through.

list checklist

{
  "variant": "checklist",
  "items": [
    { "text": "Reply to Maya re: design crit", "done": true },
    { "text": "Ship landing-page copy edits", "done": false },
    { "text": "Review PR #1422", "done": false },
    { "text": "Book Friday standup recap", "done": false },
    { "text": "Renew domain · expires Mon", "done": false }
  ]
}

Note: checklist items are { text: string, done: boolean } objects, not plain strings. Mixing the two shapes within a single payload is not supported.


6.16 html — Custom HTML (escape hatch)

Default variant: v1

A fully custom, purely visual pane: the publisher ships arbitrary HTML (plus optional CSS) that the client renders inside the pane card, filling the available space. Use this only when no structured type fits — unlike every other type, the server does not model the content, it only bounds the payload size.

No JavaScript. There is deliberately no js field, and the client renders with JavaScript disabled: any inline <script> or on* handler in your markup will not execute. HTML panes are static visual content only — you cannot run code on a reader's device. If you need interactivity, model it as a structured pane type instead.

FieldTypeNotes
htmlstring — required, ≤ 200 000 charsBody markup, or a complete self-contained document (if it already contains an <html> tag it's rendered as-is). Scripts do not run.
cssstring — optional, ≤ 100 000 charsInjected into a <style> block.
responsiveboolean — optional, default trueWhen true the client injects a mobile viewport + a width:100% / word-wrap base so content reflows to the pane width instead of overflowing. When false it renders at the content's natural size.
{
  "type": "html",
  "publisher": "Acme",
  "publisherInit": "A",
  "publisherColor": "#1A4FCC",
  "label": "Custom",
  "data": {
    "html": "<h1>Q3 revenue</h1><p>Up 24% QoQ.</p>",
    "css": "h1{font:600 22px -apple-system;margin:0 0 8px}p{font:15px -apple-system;color:#5C544A;margin:0}",
    "responsive": true
  }
}
  • Keep documents self-contained — with JS off, remote scripts and dynamic loading won't run, so inline your styles and use data URIs for small assets.
  • The payload counts against the overall feed-item size limit like any other pane, so large embedded assets can push you over it.

Forward-compatibility rule

When the server adds a new pane type:

  • Servers won't notify you. Hit GET /api/v1/panes/types periodically (e.g. at app launch + on resume after long backgrounding) to refresh the list.
  • If you receive an item whose type you don't recognize, render a graceful fallback ("Unsupported pane — update the app to see this") and preserve the row in your local store. Do not crash, do not drop the row.

7. Recommended iOS client flows

7.1 Onboarding — new user signs up from the app

  1. App collects firstName, lastName, dateOfBirth (UIDatePickeryyyy-MM-dd), email.
  2. POST /api/v1/auth/signup with that body.
  3. On 201, immediately store response.token.token in Keychain. This is the only time the server returns it.
  4. Store response.user in your local profile model.
  5. Drop the user into the feed view.

7.2 Onboarding — returning user already has a token

The user pastes their mp_… token into a single text field. Validate by hitting any authed endpoint (the cheapest is GET /feed/items?limit=1).

  • 200 → token is good. Save to Keychain.
  • 401 → wrong/revoked token. Show error, ask again.

7.3 Authenticated request — minimal Swift

let token = Keychain.read(.apiToken)!     // your wrapper
var req = URLRequest(url: url)
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")

Always read the version header so you can warn on a major-version skew:

if let v = httpResponse.value(forHTTPHeaderField: "X-MyPane-Api-Version"),
   Int(v) ?? 1 > supportedMajorVersion {
   showUpdateNudge()
}

7.4 Reading the feed

At app launch (cold start):

  1. Read your last-seen cursor from local storage (or nil).
  2. Loop GET /feed/items?order=asc&cursor=<last> until nextCursor is null.
  3. Store every item in your local DB keyed by itemId. ULIDs are sortable — you can render the list ordered by itemId ascending without re-sorting.
  4. Persist the last nextCursor you got that was non-null only if you actually consumed everything beyond it; otherwise persist the cursor of the last item you've shown. (Cursor is opaque, so just store the string.)

On foreground / pull-to-refresh:

Same loop. The server has no push channel yet — clients poll. A reasonable default cadence: pull on foreground, on UIApplicationDidBecomeActiveNotification, and at most once per 60s while in foreground. Don't poll while backgrounded.

For the "morning batch" UX from the design, you don't need real-time — agents publish overnight, the user reads it in one swoop. Polling on app open is plenty.

7.5 Publishing from the iOS app

Native clients normally read, not publish. Publishing is for agents running elsewhere (servers, Lambdas, Zapier-style integrations). But if the app does publish (e.g. a "share to my pane" extension):

  1. Validate data against the schema you cached from GET /panes/types.
  2. POST /api/v1/feed/items with the user's bearer token.
  3. On 201, insert the returned item into your local feed at the right sorted position (by itemId). No re-fetch needed.

7.6 Codable models

The minimum a Swift client needs:

struct ApiError: Codable, Error {
  struct Body: Codable { let code: String; let message: String }
  let error: Body
}

struct FeedItem: Codable, Identifiable {
  let itemId: String
  let userId: String
  let feedId: String
  let type: String
  let publisher: String
  let publisherInit: String?
  let publisherColor: String?
  let label: String?
  let data: [String: AnyCodable]   // type-specific; decode per `type`
  let createdAt: Date              // ISO-8601 with fractional seconds
  let tokenId: String

  var id: String { itemId }
}

struct FeedPage: Codable {
  let items: [FeedItem]
  let nextCursor: String?
}

Configure the JSONDecoder with .iso8601WithFractionalSeconds:

let dec = JSONDecoder()
dec.dateDecodingStrategy = .custom { decoder in
  let s = try decoder.singleValueContainer().decode(String.self)
  let fmt = ISO8601DateFormatter()
  fmt.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  guard let d = fmt.date(from: s) else {
    throw DecodingError.dataCorruptedError(in: try decoder.singleValueContainer(),
                                           debugDescription: "Bad ISO date: \(s)")
  }
  return d
}

For data, decode per-type into a concrete enum:

enum PaneData {
  case price(Price)
  case chart(Chart)
  case tweet(Tweet)
  // …
  case unknown(rawType: String, raw: [String: AnyCodable])
}

Always handle .unknown — see §6 forward-compat rule.

7.7 Error handling

Treat 401 globally — when you get one, the token is gone or revoked. Wipe Keychain and route to the login screen.

Treat 5xx as retryable with exponential backoff (1s, 2s, 4s, 8s, give up). Treat 4xx as terminal — surface the error.message to the user; don't retry.

Treat network errors the same as 5xx (retryable, transient).


8. Gotchas, do's and don'ts

❌ Don't✅ Do
Store the token in UserDefaults, plist, or shared container.Keychain with …ThisDeviceOnly accessibility.
Try to "log in with email/password" — no such endpoint exists.The token is the credential, full stop.
Decode the nextCursor to peek inside it.Treat it as an opaque string.
Sort the feed by createdAt strings.Sort by itemId (ULID) — sub-ms ties break deterministically.
Assume publisher is a known set.It's an arbitrary string the publisher chose. Render it.
Crash on an unknown type.Render an "unsupported" placeholder, keep the row.
Send Content-Type: application/json but no body on a GET.GETs don't need a body or content-type.
Hard-code https://localhost:3000.Read base URL from a build-time config (Info.plist).
Re-format pre-formatted strings like "+12.4%" or "$220–280K".Render them as-is. The publisher decided the format.
Use the session cookie from iOS.Bearer token only.

9. What v1 deliberately doesn't have

Plan for these existing but knowing they're not in v1:

  • No multiple feeds per user. Items already carry feedId: "default" on the wire — when v2 (or v1.1, as long as it's additive) introduces multi-feed, the field stays where it is.
  • No "seen / unread" state. Completion (PATCH"status":"completed") covers acted-on items; purely visual read/unread tracking still lives on the client.
  • No push channel. Poll.
  • No magic-link / email recovery. If the user loses their token and session, the only fix today is signing up again.
  • No image upload endpoint. Pane types that reference media (image, video, audio) take a url the publisher already hosts.
  • No webhooks for new items. Coming when multi-feed lands.
  • No rate limiting. Be polite.

When these arrive, they'll be additive endpoints (or new optional fields) — no breaking changes within v1.


10. Quick-reference endpoint table

MethodPathAuthWhat
GET/api/v1/healthLiveness + which DB driver.
GET/healthLiveness + readiness (verifies DB reachable). 503 if down.
GET/metricsbearer (METRICS_TOKEN)Prometheus metrics. Gated; 503 if unconfigured.
POST/api/v1/auth/signupCreate user + return initial token.
POST/api/v1/auth/loginWeb only: token → session cookie (+ refresh token).
POST/api/v1/auth/logoutsessionWeb only. Optional body kills a refresh token.
POST/api/v1/auth/refreshExchange a refresh token for a renewed session (rotates the token).
GET/api/v1/auth/mesessionWeb only. Also returns plan + pane_count.
DELETE/api/v1/auth/mebearerPermanently delete the account (hard delete, cascades). 204.
POST/api/v1/verify-receiptbearerVerify an App Store receipt; upgrades plan.
POST/api/v1/webhooks/revenuecatshared secretRevenueCat → server. Maps subscription events to plan. Not for client use.
GET/api/v1/auth/tokenssessionWeb only: list tokens.
POST/api/v1/auth/tokenssessionWeb only: create token.
DELETE/api/v1/auth/tokens/{tokenId}sessionWeb only: revoke.
GET/api/v1/panes/typesPane types + JSON Schema.
POST/api/v1/feed/itemsbearerPublish a pane.
GET/api/v1/feed/itemsbearerRead the feed (paginated).
GET/api/v1/feed/items/{itemId}bearerSingle item.
PATCH/api/v1/feed/items/{itemId}bearerMark a pane completed.
DELETE/api/v1/feed/items/{itemId}bearerDelete a single item.

11. End-to-end smoke (curl)

A scripted version lives at scripts/smoke.ts in the server repo — run npm run smoke against a live dev server. The same flow by hand:

BASE=https://localhost:3000

# 1. Sign up — capture the token from the response
TOKEN=$(curl -s -X POST "$BASE/api/v1/auth/signup" \
  -H "Content-Type: application/json" \
  -d '{"firstName":"Test","lastName":"User","dateOfBirth":"1990-01-15","email":"test+'$(date +%s)'@example.com"}' \
  | jq -r .token.token)
echo "TOKEN=$TOKEN"

# 2. Publish a tweet pane
curl -s -X POST "$BASE/api/v1/feed/items" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type":"tweet",
    "publisher":"X",
    "publisherInit":"X",
    "publisherColor":"#000000",
    "label":"From people you follow",
    "data":{"author":"PG","handle":"@paulg","text":"Hello world.","likes":"1.2K","replies":3}
  }' | jq

# 3. Read it back in FIFO order
curl -s "$BASE/api/v1/feed/items?order=asc&limit=10" \
  -H "Authorization: Bearer $TOKEN" | jq