# SoleOS — API & tooling reference

> Every HTTP surface SoleOS exposes (78 endpoints), who may call it, and how.
> Generated from lib/api-catalog.ts and checked against the codebase by
> lib/api-catalog.test.ts, so this file cannot describe an endpoint that does
> not exist or omit one that does.

Product overview (what SoleOS is, rather than what it exposes): https://soleos.app/llms.txt
Human-readable version of this page: https://soleos.app/docs

## Start here

Most integrations need exactly one of these:

- **Adding support to an app or game you ship** → `POST /api/support/v1/messages`, and a
  copy-pasteable client from the table further down. No SDK, no websocket, no auth library
  required; a plain one-shot POST with a JSON body is enough.
- **Reporting signups or purchases from your own backend** → the signal ingest gateway.
- **Reading portfolio data** → there is no public read API. The dashboard endpoints below are
  session-authenticated and workspace-scoped; they are documented so you understand the app, not
  so you can call them from outside it.

## Conventions

- Base URL: `https://soleos.app`
- Request and response bodies are JSON unless stated otherwise.
- Errors are `{ "error": "<machine_code>", "message": "<human readable>" }`. Branch on
  `error`, show `message`.
- `429` responses carry `Retry-After` in seconds. Honour it; the limits are per-identifier as
  well as per-IP, so ignoring it does not help.
- Anything marked **public** takes no cookies and no credentials beyond what is documented.
  Treat every public identifier as something an attacker also holds, because they do.

---

## Support API

Everything a shipped product talks to. Public by design: no cookies, no SDK, no auth library. The ingest key identifies an app and grants exactly two capabilities — open a conversation, and append to one whose end_user_ref the caller already knows.

### `GET POST OPTIONS /api/support/v1/messages`

The whole public support surface. POST opens a conversation for an (app, end_user_ref) pair or appends to the open one; GET polls for new messages. CORS-open, no cookies, no SDK required.

**Auth:** Public ingest key (`app_key`) + the caller's own `end_user_ref`

```
curl -X POST https://soleos.app/api/support/v1/messages \
  -H 'Content-Type: application/json' \
  -d '{
    "app_key": "pk_...",
    "end_user_ref": "hy2Nq8Rf3vKp1sLd7wZx4tCb",
    "message": "The game crashes on level 3",
    "client_message_id": "aX9...",
    "metadata": { "app_version": "1.4.2", "os_version": "Android 15",
                  "device_model": "SM-A155F", "locale": "tr-TR" }
  }'

# → 201 {"conversation_id":"...","message_id":42,"status":"open","duplicate":false}
#   200 with "duplicate":true when this client_message_id already landed

curl "https://soleos.app/api/support/v1/messages?app_key=pk_...&end_user_ref=hy2N...&since=42"
# → {"status":"open","messages":[...],"cursor":43,"poll_after_ms":15000,"app":{...}}
```

**Errors** — `{ "error": <code>, "message": <human readable> }`:

| code | status | retry? | meaning |
| --- | --- | --- | --- |
| `invalid_request` | 400 / 413 | never | Malformed JSON, or a body over 32KB. A client bug. |
| `invalid_app_key` | 403 | never | Unrecognised key. Check what you shipped. |
| `app_key_revoked` | 403 | never | The key was turned off in SoleOS. Hide the feature rather than retrying. |
| `invalid_end_user_ref` | 400 | never | Not 16-200 chars of [A-Za-z0-9._:-]. Fix the generator; do not retry. |
| `empty_message` | 400 | never | Nothing to send. |
| `message_too_long` | 413 | never | Over 4000 characters. Cap the input client-side so this cannot happen. |
| `rate_limited` | 429 | after `Retry-After` | Too fast. `Retry-After` carries the wait in seconds. |
| `server_error` | 5xx | with backoff | Keep the message queued and try again with backoff. |

**Note:** end_user_ref is a bearer secret: 16-200 chars of [A-Za-z0-9._:-], stored only as SHA-256. Send it as X-End-User-Ref where your client can set headers. Rate limited per ref, per IP and per app; ETag + since make a steady-state poll cost no rows and no body.

### `POST OPTIONS /api/widget`

The v0 widget protocol (actions open/send/poll), kept as a translation layer over the v1 core so script tags already embedded on live sites keep working.

**Auth:** Public ingest key (`app_key`) + the caller's own `end_user_ref`

```
curl -X POST https://soleos.app/api/widget \
  -H 'Content-Type: application/json' \
  -d '{"action":"open","key":"pk_...","meta":{"page":"/pricing","lang":"en"}}'
# → {"token":"...","greeting":"...","accent":"#F2A93B","messages":[]}
```

**Note:** Frozen on purpose. `token` here IS a v1 `end_user_ref` — same value, same hash — so a browser holding one keeps its thread across the migration. New integrations should use v1.

### `GET /widget.js`

The v0 embeddable chat widget, served as dependency-free JS in a Shadow DOM.

**Auth:** None — public

```
<script src="https://soleos.app/widget.js" data-soleos="pk_..."></script>
```

### `GET /widget/v1.js`

The same widget speaking v1, with a persisted outbox that retries rather than dropping messages.

**Auth:** None — public

```
<script src="https://soleos.app/widget/v1.js" data-soleos="pk_..."></script>
```

**Note:** Migrating from /widget.js is a one-line src change: this script adopts the old script's stored token so returning visitors keep their history.

### `POST /api/support`

SoleOS's own in-dashboard feedback box — a signed-in SoleOS user messaging the founder. Stores to `soleos_feedback` and pings admins by email and Telegram.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

**Note:** Not the customer support inbox. End users of your products come in through /api/support/v1/messages.

---

## Support inbox (agent)

The signed-in half of the support inbox, behind /app/support. Reads go through the caller's RLS-scoped client so a teammate limited to one project cannot read another product's threads; writes go through the service role only after an RLS read has proved the caller may act.

### `GET PATCH /api/support/agent`

Inbox bootstrap: conversations, apps, canned replies, projects and settings in one request. PATCH updates the workspace's support settings (notification thresholds, agent locale, default reply mode).

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET POST /api/support/agent/thread`

One conversation. GET returns the full history and marks it read. POST takes an `action`: reply, status, read, translate, preview, draft, approve_draft, discard_draft, relabel_language.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

```
curl -X POST https://soleos.app/api/support/agent/thread \
  -H 'Content-Type: application/json' \
  -d '{"conversationId":"...","action":"reply","body":"On it.","translate":true}'
```

**Note:** `translate: true` sends in the user's language and stores both texts — body is what went over the wire, body_en is what you typed.

### `GET POST PATCH /api/support/agent/apps`

Register a product for support, mint or rotate its public ingest key, revoke it, and set its reply mode (human / auto_ack / ai_draft / ai_auto).

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET POST PATCH DELETE /api/support/agent/canned`

Canned replies: list, create, edit, delete. Ordered by real use.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET POST PATCH /api/support/agent/tickets`

Tickets, filed per project from a conversation. POST with `auto: true` asks Claude to summarise the thread in English; without it, pass your own title and body.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

**Note:** Keys are per project and assigned by a database trigger under an advisory lock, e.g. PUFFZE-7.

### `GET /api/support/agent/count`

Unread badge count for the sidebar. One indexed count, no joins.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

---

## Signal ingest & webhooks

How events reach SoleOS from your own backends and from providers. Every one of these is signature- or secret-verified, and each refuses unsigned input rather than degrading into trusting it.

### `POST /api/connections/webhook`

Mint or rotate a project's webhook secret and hand back the ready-to-paste Supabase trigger SQL or Firebase function snippet.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

**Note:** The secret is shown once and stored as a SHA-256 hash.

### `POST /api/webhooks/viral-loop`

Viral Loop lifecycle webhook: `post.published` and `run.failed`.

**Auth:** Provider signature / shared secret header

```
X-ViralLoop-Signature: <hmac>   # verified against VIRAL_LOOP_WEBHOOK_SECRET
```

**Note:** Refuses everything with 503 when the secret is unset rather than degrading into trusting unsigned input.

### `POST /api/webhooks/resend`

Resend bounce and complaint webhook. Hard bounces and spam complaints are auto-suppressed so a dead list cannot poison the sending domain shared by every product.

**Auth:** Provider signature / shared secret header

```
Svix-Id / Svix-Timestamp / Svix-Signature   # verified against RESEND_WEBHOOK_SECRET
```

### `POST /api/billing/webhook`

Stripe → Supabase sync, keeping each workspace's plan and subscription status current so the paywall can trust the database.

**Auth:** Provider signature / shared secret header

```
Stripe-Signature: <sig>   # verified against STRIPE_WEBHOOK_SECRET
```

### `POST /api/telegram`

Telegram bot webhook. Handles /start <code> (links a chat to a workspace) and /stop.

**Auth:** Provider signature / shared secret header

```
X-Telegram-Bot-Api-Secret-Token: <secret>   # set when the webhook was registered
```

---

## Public site
### `POST /api/newsletter`

Newsletter capture. Writes through the service role because the table has no anon policy. Rate limited per IP.

**Auth:** None — public

### `GET POST /api/unsubscribe`

One-click unsubscribe (RFC 8058). Deliberately unauthenticated — an opt-out that first asks you to sign in is not an opt-out.

**Auth:** None — public

```
GET  /api/unsubscribe?token=<signed>   # human-facing confirmation page
POST /api/unsubscribe                  # List-Unsubscribe-Post from the mail client
```

**Note:** The token is HMAC-signed and scoped to one address and mail kind.

### `GET /docs.md`

This reference, as one plaintext file — every endpoint, its auth and runnable examples in a single fetch. What an AI agent should be pointed at instead of crawling /docs.

**Auth:** None — public

```
curl https://soleos.app/docs.md
```

**Note:** Rendered from the same lib/api-catalog.ts as the web page, so the two cannot say different things.

### `GET /llms.txt`

Product summary for AI assistants, generated from content/product-facts.json at build time so it cannot drift.

**Auth:** None — public

### `GET /feed.xml`

RSS 2.0 for the blog and answers series, so readers, aggregators and AI crawlers get every post the moment the engine publishes it.

**Auth:** None — public

### `GET /auth/callback`

PKCE code exchange — the landing point for magic links.

**Auth:** Signed state / cookie from the matching `start` leg

---

## Portfolio & projects

The dashboard's own API. All session-authenticated and workspace-scoped; a teammate's project allowlist is enforced by RLS, not by the route.

### `POST /api/projects`

Create a project. Enriches it with the site's declared theme colour, favicon, or an exact-name App Store artwork match — misses stay null rather than being invented.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `PATCH DELETE /api/projects/[id]`

Update a project (name, platforms, website, status) or delete it.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET POST /api/projects/[id]/refresh`

Re-pull one project's connected sources on demand instead of waiting for the daily cron.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET PUT /api/projects/[id]/share`

Public-share settings for one project. Writes mint an unguessable share token.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET POST /api/projects/[id]/site-audit`

Audit the project's website for SEO and AI-answer readiness.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET /api/projects/[id]/content`

Top content posts for one project — which hook actually earned the reach.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET /api/connections`

Every connection in the workspace with its provider, health and last pull. Drives the sidebar Sources widget.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `POST /api/connections/disconnect`

Remove a project's connection(s) for a provider. Snapshots already captured are left in place.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET POST PATCH DELETE /api/insights`

AI insights: GET the latest stored brief, POST to generate one, PATCH to mark an item done, DELETE to clear.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

**Note:** Requires ANTHROPIC_API_KEY and is dormant without it. Respects the per-workspace AI opt-out.

### `GET POST DELETE /api/goals`

Goals CRUD. The arrival-date maths lives in lib/goals.ts and runs client-side against the same series the chart draws.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET POST PUT /api/inbox`

PUT provisions a project's widget key (used by the project page). GET and POST answer 410 — they moved to /api/support/agent/*.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

---

## Connecting a data source

Two shapes: OAuth flows (start → consent → callback → pick → link) and key-paste connectors. Credentials go to Supabase Vault, never to a column.

### `GET /api/connect/stripe/start`

Stripe Connect OAuth, leg 1. Read-only; state is HMAC-signed.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET /api/connect/stripe/callback`

Stripe Connect OAuth, leg 2. Verifies state, swaps the code, attaches the account.

**Auth:** Signed state / cookie from the matching `start` leg

### `GET /api/connect/revenuecat/start`

RevenueCat OAuth, leg 1: send the user to RevenueCat's approval screen with read-only scopes. State is HMAC-signed so the callback can trust which workspace it belongs to.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET /api/connect/revenuecat/callback`

RevenueCat OAuth, leg 2. Stashes the token bundle in Vault.

**Auth:** Signed state / cookie from the matching `start` leg

### `GET /api/connect/posthog/start`

PostHog OAuth, leg 1. PKCE S256 public client (CIMD — the client_id is a URL we host).

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET /api/connect/posthog/callback`

PostHog OAuth, leg 2: verify the PKCE verifier, exchange the code, and store the grant before redirecting back with an immediate first pull.

**Auth:** Signed state / cookie from the matching `start` leg

### `GET /api/connect/ga4/start`

Google Analytics 4 OAuth, leg 1. offline + consent so a refresh token is issued.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET /api/connect/ga4/callback`

GA4 OAuth, leg 2: verify the cookie, exchange the code, stash the token in Vault on the project's ga4 connection, then return the user to pick a property.

**Auth:** Signed state / cookie from the matching `start` leg

### `GET /api/connect/search-console/start`

Search Console OAuth, leg 1: Google consent for read-only Search Console, with offline + consent so a refresh token is issued.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET /api/connect/search-console/callback`

Search Console OAuth, leg 2: verify the cookie, exchange the code, stash the token in Vault, then return the user to pick a verified site.

**Auth:** Signed state / cookie from the matching `start` leg

### `GET /api/connect/supabase/start`

Supabase Management OAuth, leg 1. PKCE plus a signed context cookie.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET /api/connect/supabase/callback`

Supabase OAuth, leg 2: verify the signed context cookie, exchange the code for a management token, and stash it in Vault on the project's supabase connection.

**Auth:** Signed state / cookie from the matching `start` leg

### `POST /api/connections/revenuecat`

Workspace-level RevenueCat import from a v2 secret key; the key goes to Vault.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET /api/connections/revenuecat/projects`

List the RevenueCat projects the connected grant can see, so the user picks which one maps to this SoleOS project.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `POST /api/connections/revenuecat/pick`

Link a chosen RevenueCat project to a SoleOS project.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `POST /api/connections/revenuecat/link`

Per-project RevenueCat link using whichever account grant applies.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `POST /api/connections/posthog`

Validate a PostHog personal API key (query:read) and attach a traffic connection.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET /api/connections/ga4/properties`

List GA4 properties the account can read, flattened across accounts.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `POST /api/connections/ga4/link`

Record which GA4 property this project tracks. The puller reads config.propertyId from here on every run.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET /api/connections/search-console/sites`

List the verified Search Console sites the account owns, so the user picks which property maps to this project.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `POST /api/connections/search-console/link`

Record which verified Search Console site this project tracks. The puller reads it from the connection config on every run.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET /api/connections/supabase/projects`

List Supabase projects the connected account can see.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `POST /api/connections/supabase/install`

Install the signup trigger through the Management API — no copy-paste.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `POST /api/connections/app-store`

App Store Connect via a pasted API key (issuer id, key id, .p8).

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `POST /api/connections/play`

Google Play Console. Install counts come from the Play-managed GCS bucket.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `POST /api/connections/firebase`

Firebase, connected by pasting a service-account key — no manual Cloud Function to deploy. The key goes to Vault.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `POST /api/connections/bing`

Bing Webmaster Tools, connected by pasting an API key. Adds search impressions and clicks for Bing-family engines.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET /api/connections/viral-loop/apps`

List Viral Loop apps the workspace key can see, auto-matched to SoleOS projects.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `POST /api/connections/viral-loop/link`

Confirm the whole Viral Loop match set at once — the match is portfolio-wide.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

---

## Billing, team & workspace
### `POST /api/billing/checkout`

Create or reuse the workspace's Stripe customer and open a Checkout Session with a 7-day trial. Returns a URL to redirect to.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `POST /api/billing/portal`

Open the Stripe billing portal. Returns a URL to redirect to.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET /api/billing/status`

Current workspace plan and subscription status.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET POST PATCH DELETE /api/team`

List the team, invite by email, change a role, revoke. Every mutation is owner-only and re-checks the caller's role server-side.

**Auth:** Signed-in session — workspace owner only

### `GET PATCH /api/workspace/prefs`

Workspace-level notification preferences — currently whether integration health changes are worth telling you about.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `POST /api/workspace/ai`

The per-workspace AI opt-out promised on /ai. Turning it off makes every AI feature dormant rather than degraded.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET POST DELETE /api/telegram/link`

Telegram link management: GET status, POST mints a one-time code and t.me deep link, DELETE unlinks.

**Auth:** Signed-in session (Supabase auth cookie), workspace-scoped

### `GET POST /api/admin/workspaces`

Platform admin: list and approve waitlisted workspaces. Defaults to the owner; override with SOLEOS_ADMIN_EMAILS.

**Auth:** Signed-in session — platform admin only

---

## Scheduled & internal

Not for external callers. Authenticated with PULL_SECRET or CRON_SECRET; Vercel Cron invokes them on the schedule in vercel.json.

### `GET POST /api/pull`

The metric pullers. One route, one puller per provider, with per-connection error isolation so a failing connection is marked and the run continues.

**Auth:** `Authorization: Bearer $PULL_SECRET` (or `$CRON_SECRET`)

```
curl -X POST https://soleos.app/api/pull -H "Authorization: Bearer $PULL_SECRET"
```

**Note:** Cron: daily 06:00 UTC.

### `GET POST /api/digest`

Weekly portfolio digest email: MRR and week delta, top movers, nearest milestone ETAs, signal counts.

**Auth:** `Authorization: Bearer $PULL_SECRET` (or `$CRON_SECRET`)

**Note:** Cron: Mondays 08:00 UTC.

### `GET POST /api/retry-pending`

Re-check connections that could not validate at connect time (e.g. a Play grant that had not propagated).

**Auth:** `Authorization: Bearer $PULL_SECRET` (or `$CRON_SECRET`)

**Note:** Cron: every 6 hours.

### `GET POST /api/indexnow`

Submit every public URL to IndexNow so Bing-family engines pick up new pages in minutes.

**Auth:** `Authorization: Bearer $PULL_SECRET` (or `$CRON_SECRET`)

**Note:** Cron: daily 07:30 UTC.

### `GET POST /api/cron/blog-generator`

Answers-engine cron: at most one grounded post per run.

**Auth:** `Authorization: Bearer $PULL_SECRET` (or `$CRON_SECRET`)

**Note:** Cron: 06:50 and 17:50 UTC.

### `GET POST /api/cron/site-audit`

Weekly Site Health sweep, so the score becomes a tracked series rather than a one-off number.

**Auth:** `Authorization: Bearer $PULL_SECRET` (or `$CRON_SECRET`)

**Note:** Cron: Mondays 05:20 UTC.

### `GET /api/cron/support-digest`

The support notification batching tick. Sends nothing most runs; the threshold logic lives in lib/support/notify.ts.

**Auth:** `Authorization: Bearer $PULL_SECRET` (or `$CRON_SECRET`)

**Note:** Cron: every 5 minutes.

### `GET /api/cron/backtest`

Walk-forward backtest of the projection engine against every project's real MRR history.

**Auth:** `Authorization: Bearer $PULL_SECRET` (or `$CRON_SECRET`)

**Note:** Run manually.

---

## Surfaces that are not Next.js routes

### Signal ingest gateway

`POST {SUPABASE_URL}/functions/v1/ingest?source=<source>&project=<slug>`

A Supabase Edge Function, not a Next.js route. One endpoint receives every lifecycle signal — signups from your own backend, purchases and churn from RevenueCat — and turns them into events, notifications and emails.

**Auth:** `Authorization: Bearer $SOLEOS_INGEST_SECRET`, or the per-project webhook secret minted by /api/connections/webhook

```
curl -X POST "$SUPABASE_URL/functions/v1/ingest?source=app&project=puff-zero" \
  -H "Authorization: Bearer $SOLEOS_INGEST_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{"type":"signup","payload":{"desc":"new user"}}'
```

**Note:** Idempotent by construction: dedupe_key is the source plus the provider's event id (or a body hash), and a unique index makes a provider's retries no-ops. Source lives at supabase/functions/ingest/index.ts.

---

## Client snippets

Copy-pasteable clients for the v1 support API. Each generates and persists a stable
`end_user_ref` on first run and attaches app version, OS version, device model and locale
automatically. Snippets rather than packages on purpose: a file you paste beats a dependency
somebody has to keep publishing.

| Platform | File | Dependencies |
| --- | --- | --- |
| GameMaker (GML) | `packages/support-v1/gamemaker/soleos_support.gml` | none — http_request + the Async HTTP event |
| Unity (C#) | `packages/support-v1/unity/SoleOSSupport.cs` | none — UnityWebRequest + JsonUtility |
| iOS / macOS (Swift) | `packages/support-v1/swift/SupportClient.swift` | none — Foundation + Security |
| Android / JVM (Kotlin) | `packages/support-v1/kotlin/SupportClient.kt` | none beyond what an Android app ships |
| JavaScript / React Native | `packages/support-v1/javascript/support-client.js` | none |
| Web, as a script tag | `served at /widget/v1.js` | none |

All five persist an outbox **before** attempting delivery, flush oldest-first, back off on 5xx
and 429, and drop only a 4xx that can never succeed. Every message carries a
`client_message_id`, so retrying is safe: the server recognises a repeat and stores it once.

---

## Repo toolkit

### Everyday

`npm run dev`
  Next.js dev server (Turbopack by default in 16).

`npm run build`
  Production build. The real gate — it typechecks and compiles every route.

`npm run lint`
  ESLint, including the React hooks rules this codebase actually enforces.

`npx tsc --noEmit`
  Typecheck without building.

`npm run pull`
  Trigger the metric pullers against your local server.

### Tests

`node --experimental-strip-types --test lib/**/*.test.ts`
  The unit tests. No build step, no test framework — Node's own runner over TypeScript.
  Note: Test files import with explicit `.ts` specifiers so type-stripping works; that is why tsconfig excludes them.

`psql "$DB_URL" -v ON_ERROR_STOP=1 -f supabase/tests/support_rls.sql`
  The RLS suite: proves anon cannot touch the support tables, that workspaces are isolated, that viewers cannot write, and that the rate limiter really runs out.
  Note: Plain SQL assertions, so running them adds no extension to the database. Verified non-vacuous: introducing a deliberate anon leak makes it fail.

### Database

`supabase start`
  Local Postgres + Auth + Storage + Realtime stack (needs Docker).

`supabase db reset`
  Rebuild the local database from every migration, then seed.

`supabase db push --dry-run`
  Show which migrations would be applied to the linked remote project.

`supabase db push`
  Apply pending migrations to the remote project.
  Note: Apply the migration BEFORE deploying code that reads new tables — /api/widget is embedded on live customer sites and the reverse order breaks it for the length of a build.

### Scripts

`npx tsx scripts/sentry-coverage.ts`
  Which products report to Sentry, which are silent, and which are misnamed.

`npx tsx scripts/stripe-setup.ts`
  Create the Stripe products and prices the pricing page promises.

`npx tsx scripts/appstore-backfill.tsx`
  Backfill App Store Connect history for a project.

`node scripts/backfill-icons.mjs`
  Fill in missing project icons from favicons and App Store artwork.

`npx tsx scripts/email-preview.tsx`
  Render the transactional emails to HTML without sending them.

`npx tsx scripts/gen-logos.ts`
  Regenerate the provider logo sprite.

`psql "$DB_URL" -f scripts/seed-live.sql`
  Seed real projects and connections (generated from RevenueCat); see also seed-inventory.sql, seed-analytics-connections.sql, backfill-mrr.sql, backfill-traffic.sql.

---

## Environment variables

| Variable | Required | What it does |
| --- | --- | --- |
| `NEXT_PUBLIC_SUPABASE_URL` | required | Supabase project URL. Public. |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | required | Supabase anon key. Public, and assumed hostile — RLS and table grants are what protect data, not this key. |
| `SUPABASE_SECRET_KEY` | required | Service-role key. Server only. Bypasses RLS, so every route using it must do its own scoping. |
| `PULL_SECRET` | required | Bearer secret for the cron and pull endpoints. `requireSecret` throws in production if it is missing, so a misconfiguration is loud. |
| `CRON_SECRET` | optional | Alternative bearer secret, sent by Vercel Cron when set. |
| `SOLEOS_INGEST_SECRET` | optional | Bearer secret for the signal ingest gateway. |
| `ANTHROPIC_API_KEY` | optional | Claude, for AI insights, support drafts, translation and ticket summaries. Every one of those is dormant without it rather than broken. |
| `RESEND_API_KEY` | optional | Transactional email. Sending is skipped rather than failed when unset. |
| `RESEND_WEBHOOK_SECRET` | optional | Svix secret for the bounce/complaint webhook. Unset means the webhook refuses input rather than trusting it. |
| `STRIPE_SECRET_KEY` | optional | Stripe API key for billing. |
| `STRIPE_WEBHOOK_SECRET` | optional | Verifies Stripe webhook signatures. |
| `STRIPE_CONNECT_CLIENT_ID` | optional | Stripe Connect OAuth client for connecting a founder's own Stripe account. |
| `TELEGRAM_BOT_TOKEN` | optional | Telegram bot for signal notifications. |
| `TELEGRAM_WEBHOOK_SECRET` | optional | Echoed by Telegram in X-Telegram-Bot-Api-Secret-Token; the only thing authenticating that webhook. |
| `VIRAL_LOOP_API_KEY` | optional | Viral Loop content-engine connector. |
| `VIRAL_LOOP_WEBHOOK_SECRET` | optional | HMAC secret for Viral Loop lifecycle webhooks. |
| `GOOGLE_OAUTH_CLIENT_ID / _SECRET` | optional | Shared Google client for GA4 and Search Console. |
| `SUPABASE_OAUTH_CLIENT_ID / _SECRET` | optional | Supabase Management OAuth, for the native Supabase connector. |
| `SOLEOS_ADMIN_EMAILS` | optional | Comma-separated platform admins. Defaults to the owner. |
| `NOTIFY_EMAIL` | optional | Fallback recipient for signal and support mail. |
| `SENTRY_ORG / SENTRY_PROJECT / SENTRY_AUTH_TOKEN` | optional | Source-map upload. Only runs when the token is present, so local builds stay clean. |
| `NEXT_PUBLIC_DEMO` | optional | Forces the public demo experience with sample data and no auth. |

Optional variables make a feature dormant rather than broken — a missing key never takes the app
down with it.

---

## Security model, in short

- The Supabase anon key is public and assumed hostile. What protects data is row-level security
  plus explicit table grants, not the key.
- Service-role code bypasses RLS, so every route that uses it does its own scoping — an id taken
  from a request body is never trusted until a scoped read has proved the caller may act on it.
- Public identifiers (`app_key`) identify; secrets (`end_user_ref`, webhook secrets, bearer
  secrets) authorise. Secrets are stored hashed wherever the server does not need to replay them.
- Webhooks refuse unsigned input rather than degrading into trusting it when a secret is unset.

Full statement: https://soleos.app/security
