---
name: api.internet.dev
description: A REST API serving users, organizations, credits, marketplace, inventory, documents, events, posts, and more over PostgreSQL. All endpoints return JSON.
---

# SKILL — api.internet.dev

A REST API serving users, organizations, credits, marketplace, inventory, documents, events, posts, and more over PostgreSQL. All endpoints return JSON.

## Base URL

```
https://api.internet.dev
```

## Authentication

Authenticated endpoints require an `x-api-key` header. Get a key by creating an account:

```bash
curl -X POST https://api.internet.dev/api/users/authenticate \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com", "password": "your-password"}'
```

The response includes a `user.key` field. Use it in subsequent requests:

```bash
curl https://api.internet.dev/api/users/viewer \
  -H "x-api-key: YOUR_KEY"
```

## Response Format

Success responses return entity fields at the top level:

```json
{ "user": { "id": "...", "email": "..." }, "existing": true }
{ "data": { ... }, "success": true }
```

Error responses always use:

```json
{ "error": true, "message": "description." }
```

## Usage Metadata

Every response — success and error — includes a `_usage` field with token cost, remaining balance, and rate limit info. This is injected automatically. Authenticated requests also include `tokens.remaining`.

```json
{
  "data": { ... },
  "_usage": {
    "tokens": { "cost": 0, "remaining": 12500 },
    "rate_limit": { "limit": 120, "remaining": 87, "interval_ms": 60000 }
  }
}
```

The same data is set as response headers: `X-Tokens-Cost`, `X-Tokens-Remaining`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Interval-Ms`.

## Token System (Credits)

The platform issues tokens (credits) to users as an internal currency. Tokens are earned through subscription allotments, monthly distributions, bonuses, and user-to-user transfers. They are spent on API usage charges and as a payment method in the marketplace.

Every endpoint has a token cost (currently all 0 for launch). When an endpoint costs tokens:

- **402 Payment Required** is returned if your balance is insufficient
- The response includes `tokens_required`, `tokens_balance`, and `payment_url`
- Check your balance: `GET /api/credits/balance`
- See all pricing: `GET /api/credits/pricing`
- Every response tells you your remaining balance via `_usage.tokens.remaining`

## Commerce Checkout Flow

The marketplace checkout flow is a separate e-commerce system that supports multiple payment methods:

- **Stripe (USD)** — real-money purchases via `POST /api/marketplace/checkout` (default)
- **Tokens** — instant purchases using your token balance via `POST /api/marketplace/checkout` with `payment_type: 'tokens'`
- **Entitlement** — free claims for tier-qualified users via `POST /api/inventory/claim`
- **Guest Checkout (Stripe)** — no-account purchases via `POST /api/marketplace/guest/checkout`

Using tokens in the checkout flow is one of several ways to purchase items. The commerce system works independently of the credit system — organizations can sell items via Stripe USD or guest checkout without tokens being involved at all.

## Rate Limits

Rate limiting uses a fixed-window counter per category per key/IP. All endpoints in the same category share one counter. Limits scale with user tier — higher tiers get proportionally more throughput.

**Categories (base limits for UNVERIFIED users):**

| Category | Base Requests | Per | Applied To |
|----------|---------------|-----|------------|
| read | 120 | 60s | GET endpoints and POST reads |
| write | 30 | 60s | Create, update, delete operations |
| financial | 5 | 60s | Credit send, deduct, distribute |
| auth | 5 | 60s | Authentication, password reset, verification, checkout |
| upload | 2 | 60s | Private attachment and slide image upload operations |
| render | 1 | 60s | Slide export submissions |
| beacon | 600 | 60s | Analytics and telemetry ingestion |
| unlimited | no limit | — | Webhooks |

**Tier multipliers (applied to base request count):**

| Tier | Level | Multiplier | Reads/min | Writes/min |
|------|-------|------------|-----------|------------|
| UNVERIFIED | 0 | 1x | 120 | 30 |
| VERIFIED | 10 | 6x | 720 | 180 |
| PAYING | 20 | 12x | 1,440 | 360 |
| GENERAL_CO_WORKING | 30 | 120x | 14,400 | 3,600 |
| PARTNER | 40 | 1,200x | 144,000 | 36,000 |
| ADMIN | 100 | unlimited | unlimited | unlimited |

Every response includes `_usage.rate_limit` with `limit`, `remaining`, and `interval_ms`, plus the headers `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Interval-Ms`.

When rate limited, a `429 Too Many Requests` is returned:

```json
{ "error": true, "message": "rate limit exceeded. try again later.", "retry_after_ms": 45000 }
```

---

## Endpoints

### Status

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /api/status | No | Health check |

### Users

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | /api/users/authenticate | No | Login or register with email and password. Pass `source` to tag the user's account with your app domain and use your custom email templates. Returns `emailed: false` when no email was sent (exempt domain or delivery failure) |
| POST | /api/users/authenticate-agent | No | Create paid agent account with Stripe payment method |
| POST | /api/users/authenticate-apple-client | No | Apple OAuth sign-in |
| GET | /api/users | Admin (level 100) | List all users |
| GET | /api/users/list-by-source | Admin (level 100) | List users by signup source |
| POST | /api/users/get-by-id | Admin (level 100) | Get user by ID |
| POST | /api/users/delete | API key | Delete a user |
| POST | /api/users/update | API key | Update user profile. `data.exemptions` is server-managed and preserved across updates (including `forcePush`) |
| POST | /api/users/update-viewer-password | API key | Change password |
| POST | /api/users/update-viewer-username | API key | Change username |
| POST | /api/users/regenerate-key | No | Generate new API key |
| POST | /api/users/reset-password | No | Reset password via verification code. Pass `source` to use your organization's custom reset password email |
| POST | /api/users/verify | No | Verify email with code |
| POST | /api/users/verify-check-by-id | Admin (level 100) | Check if email is verified |
| POST | /api/users/verify-resend | API key | Resend verification email. Pass `source` to use your organization's custom verification email |
| POST | /api/users/sync-wallet | API key | Associate wallet address |
| POST | /api/users/desync-wallet | API key | Remove wallet address |

### Users — Public

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | /api/users/public/get-by-id | No | Public user lookup by ID |
| POST | /api/users/public/get-by-username | No | Public user lookup by username |

### Users — Viewer

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /api/users/viewer | API key | Get authenticated user profile |
| POST | /api/users/viewer/generate-add-payment-method-url | API key | Stripe billing portal link |
| GET | /api/users/viewer/get-current-payment-method | API key | Current Stripe payment method |
| GET | /api/users/viewer/likes | Verified (level 10+) | Items liked by user |
| GET | /api/users/viewer/organizations | Verified (level 10+) | User's organizations |
| POST | /api/users/viewer/pay-provider-amount-cents | API key | Manual Stripe payment |

### Users — Office

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /api/users/office | Admin (level 100) | List office applications |
| POST | /api/users/office/apply | Verified (level 10+) | Apply for workspace access |
| POST | /api/users/office/delete | Admin (level 100) | Remove application |
| POST | /api/users/office/status | Verified (level 10+) | Check application status |
| POST | /api/users/office/update | Admin (level 100) | Update application |

### Users — Subscriptions

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /api/users/subscriptions | No | List all subscriptions |
| POST | /api/users/subscriptions/check | API key | Verify subscription is active |
| POST | /api/users/subscriptions/get-by-user-id | API key | Get subscription for a user |
| POST | /api/users/subscriptions/get-current-invoices | API key | Fetch Stripe invoices |
| POST | /api/users/subscriptions/unsubscribe | API key | Cancel subscription |

### Organizations

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /api/organizations | Verified (level 10+) | List all organizations |
| POST | /api/organizations/create | Verified (level 10+) | Create organization |
| POST | /api/organizations/delete | Org admin | Delete organization |
| POST | /api/organizations/get-by-id | Verified (level 10+) | Get organization by ID |
| POST | /api/organizations/update | Org admin | Update organization settings and custom email templates |
| POST | /api/organizations/toggle-public-access | Org admin | Toggle public visibility |
| POST | /api/organizations/waitlist-toggle | Org admin | Set `data.waitlist_support`. Body: `domain`, optional boolean `waitlist_support` (omit to flip) |
| POST | /api/organizations/membership | Verified (level 10+) | List memberships |
| POST | /api/organizations/membership/automatic | Verified (level 10+) | Auto-join by email domain |
| POST | /api/organizations/users | Org member | List users in organization |
| POST | /api/organizations/users/add | Org admin | Add user to organization |
| POST | /api/organizations/users/remove | Org member | Remove user |
| POST | /api/organizations/users/promote | Org admin | Promote user |
| POST | /api/organizations/users/demote | Org admin | Demote user |

### Organizations — Custom Email Templates

When users sign up or reset their password through your app, the API sends them an email. By default these emails come from `API.INTERNET.DEV <no-reply@mail.internet.dev>`. If you run your own website through an organization, you can customize the sender, subject, and body for each email type so your users see your branding instead.

To connect a user action to your organization's email templates, pass your organization's `domain` as the `source` field in the relevant endpoint (`/api/users/authenticate`, `/api/users/verify-resend`, `/api/users/reset-password`). The API looks up your organization by that domain and uses your custom templates if they exist.

There are three email types you can customize through `POST /api/organizations/update`:

| Data Key | When It's Sent | Triggered By |
|----------|---------------|--------------|
| `email` | A new user needs to verify their email address | `/api/users/authenticate` (new account), `/api/users/verify-resend`, `/api/users/verify` (expired code) |
| `email_reset_password` | A user requests a password reset | `/api/users/reset-password` |
| `email_send_password` | A new user is created via OAuth and needs their generated password | OAuth sign-in (Google, Apple, Bluesky) |

Each email object has three fields:

| Field | Description | Example |
|-------|-------------|---------|
| `from` | Sender name and address | `"YourApp <no-reply@yourapp.com>"` |
| `subject` | Subject line | `"Verify your email for YourApp"` |
| `text` | Body text (the verification link or password is appended automatically) | `"Thanks for signing up! Click the link below to verify your email."` |

### Organizations — Email Exemptions

Some application domains use this API only as an auth backend (user and session storage plus Google, Apple, and Bluesky sign-in). Users who authenticate through such a domain are tagged `EXEMPT_ALL_EMAILS` on their account and the API will not send them **any** email — no verification links, generated passwords, password resets, receipts, credit confirmations, subscription or organization notices. `vmax.ai` is currently the only exempt domain; exemptions are configured server-side, not through the API.

What this means when you integrate:

- Responses that include an `emailed` field (`/api/users/authenticate`, `/api/users/authenticate-interstitial`, `/api/users/verify-resend`) return `emailed: false` for an exempt user. Nothing was sent; do not tell the user to check their inbox.
- `emailed: false` is also returned when delivery to the mail provider fails, so treat it as "no email arrived" rather than as an error.
- The tag follows the user's most recent sign-in. Authenticating through an exempt domain sets it; authenticating through any other supported application domain lifts it. Users created through OAuth on an exempt domain receive no generated password and are expected to keep signing in through OAuth.
- Custom email templates (above) are unaffected for non-exempt domains.
- `data.exemptions` on the user object is read-only through the API: `POST /api/users/update` ignores a client-supplied `exemptions` key and keeps the stored tags, including with `forcePush: true`.
- Password sign-up is a dead end on an exempt domain — the verification link is suppressed, so the account stays UNVERIFIED (level 0). Exempt-domain users should sign in through OAuth or be admitted through the waitlist (created VERIFIED).

If you are building a product that uses this API only as a user, API key, OAuth, waitlist, and upload backend, read `public/skills/use-as-external-vendor/SKILL.md` — it is the full contract: every exemption, how it is granted and lifted, the waitlist → Google sign-in flow, and what still applies to exempt users.

### Waitlist

Org-scoped email capture. Visitors drop an email against your organization's `domain`; your staff admit them, which creates a VERIFIED account (with your domain's exemptions, if any). Requires `data.waitlist_support: true` on the organization (`POST /api/organizations/waitlist-toggle`).

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | /api/waitlist/add | No | Body: `domain`, `email`. Always `{ success: true }` for new and duplicate emails (no enumeration). `400` malformed email, `404` unknown org, `403` waitlist off |
| GET | /api/waitlist/list-by-domain | Verified user on the org's email domain, org admin, or admin (level 100) | Query: `domain`, `limit` (default 50, max 200), `offset`. Returns `{ data, limit, offset }`, newest first |
| POST | /api/waitlist/accept-by-email | Verified user on the org's email domain, org admin, or admin (level 100) | Body: `domain`, `email`. Creates or reuses the account, marks the entry `accepted`. Returns `{ data, exemptions, success }`; `already_accepted: true` on repeat |
| POST | /api/waitlist/revoke-by-email | Verified user on the org's email domain, org admin, or admin (level 100) | Body: `domain`, `email`. Returns the entry to `pending` (account kept). Returns `{ data, success }`; `already_pending: true` on repeat |
| POST | /api/waitlist/accept-all | Org admin or admin (level 100) | Body: `domain`. Accepts every pending entry. Returns `{ accepted, total, success }` |
| POST | /api/waitlist/status | API key | Body: `domain`, optional `email`. Reports the caller's own entry (or, for a verified user on the org's email domain / org admin / admin, any `email`). Returns `{ data, accepted, success }`; `data: null, accepted: false` when there is no entry |

"Verified user on the org's email domain" means an account whose email ends in `@<domain>` (for the `vmax.ai` org: `someone@vmax.ai`); no membership row is needed. Accepting does not gate OAuth sign-in — after sign-in, call `/api/waitlist/status` with the user's own key and admit the session only when `accepted` is `true`.

### Credits

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /api/credits | API key | List credit transactions |
| GET | /api/credits/balance | API key | Check token balance |
| GET | /api/credits/pricing | No | View all route costs and limits |
| POST | /api/credits/check | API key | Verify account ownership |
| POST | /api/credits/create-account-by-user-id | Admin (level 100) | Initialize credit account |
| POST | /api/credits/deduct | API key | Subtract credits |
| POST | /api/credits/distribute-bonus | API key | Award bonus credits |
| POST | /api/credits/distribute-to-user-id | Admin (level 100) | Monthly credit distribution |
| POST | /api/credits/get-balance-by-email | Admin (level 100) | Balance lookup by email |
| POST | /api/credits/get-balance-by-user-id | Admin (level 100) | Balance lookup by user ID |
| POST | /api/credits/send | API key | Transfer tokens to another user by email or username |
| POST | /api/credits/vendor-distribution-to-email | Admin (level 100) | Third-party credit distribution |
| POST | /api/credits/verify-apple-transaction-distritbution | API key | Verify Apple IAP and distribute |

### Posts

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | /api/posts | No | List posts (body: `type`, plus `organization_id`/`user_id`/`username`/`email`) |
| GET | /api/posts/[id] | No | Get post by ID (private TXT_DEV/DIAGRAM_PAGE posts require API key + ownership) |
| POST | /api/posts/create | Verified (level 10+) | Create a post with `type` and `fields`. Optional `markdown` field parses markdown into Slate editor nodes |
| POST | /api/posts/delete | API key | Delete a post (owner or admin) |
| POST | /api/posts/update | Verified (level 10+) | Update a post's `data`, `slug`, or `src` (owner or admin). Optional `markdown` in updates parses markdown into Slate editor nodes |
| POST | /api/posts/admin-stats | Admin (level 100) | Post analytics |
| POST | /api/posts/all-threads | No | All discussion threads |
| POST | /api/posts/all-thread-replies | No | All thread replies |
| GET | /api/posts/public/[slug] | No | Public post by slug |
| POST | /api/posts/public/organizations/[id] | No | Public posts for organization |
| GET | /api/posts/reception/[id] | Verified (level 10+), owner or admin | Reception stats (visits, referrers, backlinks) for a tracked post |

### Posts — Creating and Publishing

Posts store content in a flexible JSONB `data` field. You can put any content you want in it, including a `body` field with plain text or markdown content.

**Creating a post** requires `type` and `fields`:

- `type` — A string identifying the post type. Use `GENERAL` for general-purpose posts, `TXT_DEV` for txt.dev posts, or any custom string.
- `fields` — An object that becomes the post's `data`. Common fields: `title`, `body`, `public`, `description`.
- `domain` — Optional. Your organization's domain if creating under an org (requires org membership).
- `src` — Optional. A unique source identifier.

The API also initializes an `editorContent` field with an empty Slate editor state. If you're creating posts from an agent or script (not the txt.dev editor), you can ignore `editorContent` and use `body` for your content instead.

**Publishing a post** is a two-step process:

1. Create the post (it starts as private by default).
2. Update it with `data.public: true` and a `slug` for the public URL.

For `TXT_DEV` and `DIAGRAM_PAGE` post types, only posts with `data.public: true` are visible in public listings. Other post types are always visible when listed. The `slug` must be unique across all posts.

**Updating a post** deep-merges `updates.data` into the existing post data, so you only need to send the fields you want to change. `slug` and `src` are replaced entirely if provided.

### Posts — Markdown Support

Both the create and update endpoints accept an optional `markdown` field. When provided, the API parses the markdown into Slate editor nodes so the post renders with full formatting in the txt.dev editor.

**Supported markdown:**
- Headings: `#` (heading 1), `##` and beyond (heading 2)
- Bold: `**text**` or `__text__`
- Italic: `*text*` or `_text_`
- Inline code: `` `code` ``
- Block quotes: `> text`
- Bulleted lists: `- `, `* `, `+ `
- Numbered lists: `1. `, `2. `
- Images: `![alt](url)` (whole line becomes an image block)
- Links: `[text](url)`

The raw markdown is also stored in `data.body` for reference. If both `markdown` and `fields.body` are provided, the markdown value takes precedence.

### Posts — Reception Tracking

Track how publicly-shared posts perform: visits per day, referring domains, and backlinks from other sites.

**Enable tracking** by setting `reception_tracking: true` in the post's data:

```bash
curl -X POST https://api.internet.dev/api/posts/update \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_KEY" \
  -d '{"id": "POST_ID", "updates": {"data": {"reception_tracking": true}}}'
```

**Read stats** via the reception endpoint:

```bash
curl https://api.internet.dev/api/posts/reception/POST_ID \
  -H "x-api-key: YOUR_KEY"
```

Response:

```json
{
  "success": true,
  "data": {
    "post_id": "abc-123",
    "visits": { "2026-05-26T00:00:00.000Z": 47, "2026-05-25T00:00:00.000Z": 62 },
    "referrers": { "news.ycombinator.com": { "visits": 312, "seen_at": "2026-05-26T14:32:01.000Z" } },
    "backlinks": { "https://example.com/blog/roundup": { "domain": "example.com", "seen_at": "2026-05-24T00:00:00.000Z" } }
  }
}
```

**Client requirement**: For referrer recording to work, the Next.js SSR server must forward the browser's `Referer` header as `X-Referrer` on the existing post-fetch API call. This adds one header to an existing request — no new HTTP requests, no client-side JavaScript.

### Slide decks

Create personal or organization decks from Markdown or structured JSON, edit with version checks, retain revision history, upload private images, and render standalone HTML, PDF, or PNG through background jobs. Times New Roman is the default, with a system-font option. Explicit publication shares one audience revision without notes or hidden slides.

Read the **[slide skill](/skills/slides/SKILL.md)** for the full workflow, **[document schema](/skills/slides/references/schema.json)**, and **[JavaScript client](/skills/slides/scripts/client.mjs)**. All routes cost zero tokens. Personal decks need only a verified user's key; vmax.ai customers should omit `domain` and do not need membership in the vmax.ai organization or a credit account.

- `GET /api/slides/capabilities` reports supported formats, fonts, templates, and limits.
- `POST /api/slides/create` accepts an optional `document`, `markdown`, or `template`, plus an `idempotency_key`.
- `GET /api/slides` and `GET /api/slides/[id]` list/read authorized decks.
- `POST /api/slides/update` saves a replacement or slide operation batch using `expected_version`.
- `/api/slides/revisions`, `/api/slides/assets`, and `/api/slides/jobs` provide history, private media, and durable job status/actions; see the [endpoint reference](/skills/slides/references/api.md).
- `POST /api/slides/exports/create` queues a specific revision; poll the job for five-minute artifact URLs.
- `POST /api/slides/publish`, `/unpublish`, and `/delete` require personal-owner or org-admin management access.
- `GET /api/slides/public/[id]` returns only an active publication's audience JSON.

### Documents

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | /api/documents | Org member | List documents (body: `type`, `domain`) |
| GET | /api/documents/[id] | No | Get document by ID |
| POST | /api/documents/create | Org member | Create document |
| POST | /api/documents/delete | Org member | Delete document |
| POST | /api/documents/update | Org member | Update document |

### Events

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /api/events | No | List events (requires `domain` query param; public users see approved public events only) |
| GET | /api/events/[id] | Verified (level 10+) | Get event by ID |
| POST | /api/events/create | Org member | Create event |
| POST | /api/events/delete | Org member | Delete event |
| POST | /api/events/update | Org member | Update event |
| POST | /api/events/conflicts | Org member | Check scheduling conflicts |

### Likes

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /api/likes/[id] | No | Get like record |
| POST | /api/likes/create | Verified (level 10+) | Like an item |
| POST | /api/likes/delete | API key | Remove a like |
| POST | /api/likes/update | API key | Update a like |

### Data

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /api/data | Verified (level 10+) | List data objects |
| POST | /api/data/delete | API key | Delete data object |
| POST | /api/data/generate-presigned-url | Org member | AWS S3 upload URL (15MB max) |
| POST | /api/data/generate-presigned-url-gcs | Org member | Google Cloud Storage upload URL |
| POST | /api/data/admin/upload | Admin (level 100) | Presigned S3 PUT URL with custom `folder` and `file` (distribution uploads; see `/skills/admin-powers/SKILL.md`) |
| POST | /api/data/admin/delete | Admin (level 100) | Delete S3 object at custom `folder`/`file` (see `/skills/admin-powers/SKILL.md`) |
| GET | /api/data/localizations/[id] | No | Get localization by ID |
| POST | /api/data/localizations/create | Verified (level 10+) | Create localization |
| POST | /api/data/localizations/delete | API key | Delete localization |
| POST | /api/data/localizations/update | API key | Update localization |
| POST | /api/data/localizations/get-by-entity-id | No | Localizations for entity |
| POST | /api/data/prices/by-label | No | Price lookup by label |
| GET | /api/data/things | No | Get thing by ID |
| POST | /api/data/things/create | Verified (level 10+) | Create thing |
| POST | /api/data/things/delete | API key | Delete thing |
| POST | /api/data/things/update | API key | Update thing |
| GET | /api/data/things/list | No | List things |
| GET | /api/data/things/list/search | No | Search things |

### Inventory

Universal inventory primitive for physical goods, gacha/loot systems, cash shops, virtual world items, event tickets, collectibles, and more. Stock is tracked via individual units (`inventory_units`) — each with its own JSONB `data` for serial numbers, rarity, coordinates, license keys, or any context-specific metadata. Each listing returns `available_count`. Items can be `private` (org-members only). Orgs can gate their storefront via `organization.data.inventory_private`.

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /api/inventory | No (private: members) | List items with `available_count` |
| GET | /api/inventory/[id] | No (private: members) | Get item with `available_count` and `units` array |
| POST | /api/inventory/claim | Verified (level 10+) + tier gate | Claim 1 available unit for free |
| POST | /api/inventory/create | Org admin | Create item. `unit_count` or `units` array creates initial units |
| POST | /api/inventory/delete | Org admin | Soft-delete item and all associated units |
| POST | /api/inventory/update | Org admin | Update item. Set `private: true` to hide from non-members |
| POST | /api/inventory/search | No (private: members) | Search with `domain`, `search`, `filters`, `priceRange` |
| POST | /api/inventory/units/add | Org admin | Add units to an item (`count` or `units` array) |
| POST | /api/inventory/units/update | Org admin | Update unit `data` or `status` |
| POST | /api/inventory/units/remove | Org admin | Soft-delete a unit |

### Marketplace

Handles cart, checkout (Stripe USD or tokens), orders, and discount codes. Token checkout enables instant in-app purchases for cash shops and gacha systems. Stripe checkout handles real-money purchases with shipping for physical goods.

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /api/marketplace/cart | Verified (level 10+) | Get cart contents |
| POST | /api/marketplace/cart/add | Verified (level 10+) | Add item to cart |
| POST | /api/marketplace/cart/remove | Verified (level 10+) | Remove item from cart |
| POST | /api/marketplace/cart/clear | Verified (level 10+) | Clear cart |
| POST | /api/marketplace/cart/update | Verified (level 10+) | Update cart item |
| POST | /api/marketplace/checkout | Verified (level 10+) | Process payment via Stripe or tokens (payment_type) |
| GET | /api/marketplace/orders | Verified (level 10+) | List orders |
| GET | /api/marketplace/orders/[id] | Verified (level 10+) | Get order by ID |
| POST | /api/marketplace/orders/update | Verified (level 10+) | Update order status |
| POST | /api/marketplace/orders/send-receipt | API key or guest email | Send receipt email for a completed order |
| POST | /api/marketplace/orders/resend-receipt | API key | Resend receipt email for a completed order |
| GET | /api/marketplace/discounts | Org admin | List discount codes |
| GET | /api/marketplace/discounts/[id] | Org admin | Get discount by ID |
| POST | /api/marketplace/discounts/create | Org admin | Create discount code |
| POST | /api/marketplace/discounts/delete | Org admin | Delete discount code |
| POST | /api/marketplace/discounts/update | Org admin | Update discount code |

### Data — Admin Distribution Uploads

The `/api/data/admin/*` endpoints let a level-100 user publish binaries, archives, or installers to the `intdev-global` S3 bucket under a custom folder and filename. Uploads are `public-read` and re-uploading with the same `folder` + `file` overrides the previous object. These endpoints do not write to the `files` table — they are direct S3 control for distribution use cases. See `public/skills/admin-powers/SKILL.md` for folder/file validation rules, response shape, and end-to-end examples.

### Marketplace — Guest Checkout

Guest checkout lets customers purchase with just a credit card — no account, email, or name required. Organizations opt in via `guest_checkout_enabled: true` in their org data. Guest sessions use a short-lived JWT (24h). See `public/skills/marketplace-guest/SKILL.md` for the full guide.

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | /api/marketplace/guest/session | No | Create guest session JWT (requires `domain`) |
| GET | /api/marketplace/guest/cart | Guest session | View guest cart |
| POST | /api/marketplace/guest/cart/add | Guest session | Add item to guest cart |
| POST | /api/marketplace/guest/cart/remove | Guest session | Remove item from guest cart |
| POST | /api/marketplace/guest/cart/update | Guest session | Update guest cart item quantity |
| POST | /api/marketplace/guest/cart/clear | Guest session | Clear guest cart |
| POST | /api/marketplace/guest/checkout | Guest session | Guest checkout via Stripe PaymentMethod |
| GET | /api/marketplace/guest/orders/[id] | No | Look up guest order by UUID (PII not returned) |
| GET | /api/marketplace/guest/orders/[id]/status | No | Lightweight guest order status |

### Downloads

Limited-time file download links backed by per-link S3 clones. Sellers upload a source object under `catalog/…` once; each purchase mints a unique clone under `clones/<uuid>.<ext>` with its own expiration, max-downloads counter, and burn-after-reading grace window. The original `catalog/` object is never shared or deleted. Set `inventory_item.data.download_asset = { key, bucket?, file_name, mime_type, max_downloads?, expires_in_seconds? }` to enable purchase.

The raw `token` is a capability and is **only** returned once — in the mint response of `POST /api/downloads/purchase` or `POST /api/downloads/create`. List, status, and revoke responses return the sanitized link row **without** the token. Revoke/status looks up by `id` or `token` but does not echo the token back.

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | /api/downloads/purchase | API key or guest session | Two-step Stripe (create → persist PI id → confirm) → clone S3 → mint one link per reserved unit. Response carries the plaintext tokens |
| POST | /api/downloads/create | Org admin | Admin issuance with no Stripe (comp copies). `source_s3_key` must live under `catalog/<org-id>/…` or match an `inventory_item.data.download_asset.key` for the caller's org. The `bucket` override is rejected |
| GET | /api/downloads/[token] | No | Atomic redeem, 302 to short-lived presigned URL; `Referrer-Policy: no-referrer` + `Cache-Control: no-store`. 410 Gone on expired/revoked/exhausted/unknown |
| POST | /api/downloads/revoke | Org admin | Revoke a link; worker deletes the clone on next tick |
| GET | /api/downloads | Org admin | List download links for an org (token omitted) |
| GET | /api/downloads/status | Org admin or issuer | Inspect a single link's status + counters (token omitted) |

### Licenses

Offline-friendly Crockford base32 license keys shaped `INTXX-DEVXX-XXXXX-XXXXX-XXXXX` (hardcoded `INT` / `DEV` brand prefix + 19 random chars, ~95 bits entropy). Keys are stored as `sha256` hashes (for lookup) plus AES-256-CTR ciphertext (for owner retrieval). The plaintext is only returned at mint time and via the owner-scoped `GET /api/licenses/[id]`. Set `inventory_item.data.license_product_id`, `license_activations_allowed?`, `license_duration_seconds?` to enable purchase. See `public/skills/licenses/SKILL.md` for third-party integration.

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | /api/licenses/purchase | API key or guest session | Two-step Stripe (create → persist PI id → confirm) → mint one license per reserved unit; returns plaintext keys |
| POST | /api/licenses/create | Org admin | Mint up to 100 keys without Stripe. Lands against a zero-cost `admin_grant` order so the audit trail is identical to paid keys. Optional `recipient_user_id` pins ownership; otherwise keys mint with `user_id = null` (useful for bulk giveaways) |
| POST | /api/licenses/validate | No | Validate a key. Uniform `{ valid: false }` for unknown/revoked/refunded; reason codes for legitimate matches. Concurrent same-machine-id validates are idempotent (`valid_known_machine` instead of false `activation_limit`) |
| POST | /api/licenses/revoke | Org admin or issuer | Flip to revoked with optional reason. Idempotent — a second revoke returns the existing row unchanged with `already_revoked: true` |
| POST | /api/licenses/regenerate | Org admin or issuer | In one transaction: insert a fresh row with clean `machine_ids` / `activations_current`; flip the old row to `revoked` with `data.replaced_by_id`. Only `active` or `expired` rows can be rotated |
| GET | /api/licenses | API key (owner) or org admin via `?domain=…` | List licenses owned by user; org admins pass `?domain=…` to list all licenses issued by the org |
| GET | /api/licenses/[id] | API key (owner) or org admin | Owner-scoped detail; decrypts the plaintext for re-display. Non-owners (and non-org-admins) receive a uniform 404 so license id existence cannot be probed |

### KV Store

Level-100 admins can store product configuration and state as string values indexed by a globally unique namespace plus an exact key. Create a namespace before writing entries. An admin can access only namespaces they created, and a namespace cannot be deleted until all of its entries are deleted.

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /api/kv/namespaces | Admin (level 100) | List the caller's owned namespaces |
| POST | /api/kv/namespaces/create | Admin (level 100) | Create a namespace with `{ namespace }` |
| POST | /api/kv/namespaces/delete | Admin owner | Delete an empty namespace with `{ namespace }` |
| POST | /api/kv/entries/create | Admin owner | Create an entry with `{ namespace, key, value }` |
| POST | /api/kv/entries/get | Admin owner | Fetch one entry with `{ namespace, key }` |
| POST | /api/kv/entries/update | Admin owner | Replace an existing value with `{ namespace, key, value }`; does not upsert |
| POST | /api/kv/entries/delete | Admin owner | Delete one entry with `{ namespace, key }` |

Namespace input is trimmed and lowercased, then limited to 128 letters, numbers, dots, underscores, and hyphens beginning with a letter or number. Keys are preserved exactly, remain case-sensitive, and may be up to 512 characters. Values must be strings and may be empty or up to 256 KiB in UTF-8. Entry listing is not available in v1.

```bash
curl -X POST https://api.internet.dev/api/kv/entries/get \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_ADMIN_API_KEY" \
  -d '{"namespace":"product.settings","key":"Theme/Dark"}'
```

### Analytics

Create a titled project with `/api/analytics/sites/create`, share it with other verified users at viewer level 10 or admin level 100, then add the hosted script to every page. It automatically records the current path and client-side navigation. Route labels begin with `/`; custom event labels do not.

```html
<script defer src="https://api.internet.dev/analytics/v1.js" data-site-id="SITE_ID"></script>
```

If the host already owns a persistent per-installation UUID, pass it with the optional `data-client-id` attribute. The script includes it in every automatic and manual beacon.

```html
<script defer src="https://api.internet.dev/analytics/v1.js" data-site-id="SITE_ID" data-client-id="550e8400-e29b-41d4-a716-446655440000"></script>
```

Record custom events or page routes after the script has loaded. Slash-prefixed labels are normalized by removing query strings and hashes.

```js
window.viewsPage.track('signup_completed');
window.viewsPage.track('/pricing?campaign=summer'); // records /pricing
window.viewsPage.track('app_crashed', { release: '1.2.3', message: 'renderer exited' });
```

`window.analyticsPage` remains an alias for existing integrations. For consent-controlled installations, set `data-auto-track="false"`, then call `window.viewsPage.track(window.location.pathname)` after consent. The script sends no cookies or local storage and silently ignores network failures. Sites with a Content Security Policy must allow `https://api.internet.dev` in both `script-src` and `connect-src`.

Native clients can generate and persist a UUID per installation, then supply it as `client_id` on every hit. The API derives a site-scoped visitor UUID and never stores the supplied id.

```bash
curl -X POST https://api.internet.dev/api/analytics/track \
  -H "Content-Type: application/json" \
  -d '{"site_id":"SITE_ID","client_id":"550e8400-e29b-41d4-a716-446655440000","label":"app_opened"}'
```

Caller `data` may be any JSON value. The API stores it under the event's root `data` property when its serialized UTF-8 representation is at most 4096 bytes. Larger values still record the event with `{ "error": "DATA_SIZE_LIMIT_EXCEEDED" }`.

Set `attachment: true` when recording an event that will receive a private attachment. The event is recorded first and the response includes a 15-minute capability:

```bash
curl -X POST https://api.internet.dev/api/analytics/track \
  -H "Content-Type: application/json" \
  -d '{"site_id":"SITE_ID","label":"app_crashed","data":{"release":"1.2.3"},"attachment":true}'
```

Use the returned `event_id` and `attachment_token` in a follow-up request:

```bash
curl -X POST https://api.internet.dev/api/analytics/events/EVENT_ID/attachment/create \
  -H "Content-Type: application/json" \
  -d '{"attachment_token":"ATTACHMENT_TOKEN","file":"crash.dmp","type":"application/octet-stream"}'
```

The follow-up endpoint allows one attachment per event and returns a private S3 PUT URL valid for 15 minutes. Attachments should be at most 15 MiB, but the direct PUT size is currently advisory. The `upload` rate-limit category allows 2 attempts/min for unauthenticated callers and applies normal tier scaling when an API key is present. Limited requests return `429`; the previously recorded event is unaffected. PUT the file directly with the declared content type. Attachments remain private and can later be downloaded only by authorized project viewers.

In a Next.js root layout, load the hosted script with `next/script`:

```tsx
import Script from 'next/script';

<Script src="https://api.internet.dev/analytics/v1.js" data-site-id="SITE_ID" strategy="afterInteractive" />
```

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | /api/analytics/sites/create | Verified (level 10+) | Create a project with a required `name` |
| POST | /api/analytics/sites/update | Project admin (100), owner, or platform admin | Update project `name` |
| POST | /api/analytics/sites/delete | Owner only | Soft-delete a site |
| GET | /api/analytics/sites | Verified (level 10+) | List owned and shared sites with effective `access_level` and `is_owner` |
| GET | /api/analytics/sites/[id] | Project viewer (10+), owner, or platform admin | Fetch a single site by ID |
| GET | /api/analytics/sites/members | Project admin (100), owner, or platform admin | List direct members using `?site_id=…` |
| POST | /api/analytics/sites/members/add | Owner only | Add an existing verified user by `email` at numeric `level` 10 or 100 |
| POST | /api/analytics/sites/members/update | Owner only | Change a member's numeric level using `site_id`, `user_id`, and `level` |
| POST | /api/analytics/sites/members/remove | Owner only | Remove a member using `site_id` and `user_id` |
| POST | /api/analytics/track | No | Browser/native beacon. Body: `site_id`, `label?`, `client_id?`, `data?`, `attachment?`. Context is capped at 4 KiB. Set `attachment: true` to receive a short-lived attachment capability. |
| POST | /api/analytics/events | Project viewer (10+), owner, or platform admin | List newest event details. Body: `site_id`, `from`, `to`, optional exact `label`, opaque `cursor`, and `limit` (default 50, max 100). |
| POST | /api/analytics/events/[id]/attachment/create | Attachment token | Provision one private attachment upload. Body: `attachment_token`, `file`, and `type`. Rate limit: 2/min for unauthenticated callers, tier-scaled for authenticated users. |
| POST | /api/analytics/events/[id]/attachment | Project viewer (10+), owner, or platform admin | Redirect to a five-minute private attachment download while its event retains the upload pointer. |
| POST | /api/analytics/stats | Project viewer (10+), owner, or platform admin | Hit and approximate unique-visitor graphs. Body: `site_id`, `from`, `to`, `labels?` (SQL-style `*` wildcards, ≤ 20 patterns), `include_labels?`. |

Project access levels are independent of the user's global account level. Viewers can list/open a shared project and view metrics. Admins can additionally change project settings and list memberships. Only the owner can add, change, or remove memberships or delete the project; platform admins do not bypass those owner-only operations.

Set `include_labels: true` to include up to 100 non-empty labels observed in the requested date range, ranked by hits. Each result contains `{ label, hits, visitors }`. Labels beginning with `/` are pages; other labels are custom events.

Private attachments are deleted in best-effort batches after their events are 30 days old. This is a storage threshold, not an authorization deadline, so cleanup lag may keep an attachment available longer. Event context is retained with the analytics hit.

### Webhooks

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | /api/webhooks/stripe | No | Stripe webhook handler |

### Telemetry

Product-agnostic telemetry ingestion and admin reporting for any client — desktop apps, games, websites, services. Events are ingested anonymously or with optional user attribution via `x-api-key` header. Product-specific fields (app version, OS, GPU, screen size, etc.) go inside the `data` object.

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | /api/telemetry/events | No (optional API key) | Ingest a batch of telemetry events for a registered product. Deduplicates on `event_id`. Pass `product_id`, `client_id`, and an `events` array (max 25). Each event needs `event_id`, `event_name`, `occurred_at`, and a `data` object for product-specific fields. Returns 202 on success |
| POST | /api/telemetry/metrics/daily | Admin (level 100) | Query Daily Active User (DAU) metrics for a product over a date range. Pass `product_id`, `from`, `to` (YYYY-MM-DD). Returns daily series and version breakdown |
| POST | /api/telemetry/metrics/query | Admin (level 100) | Generic aggregation over telemetry events. Pass `product_id`, `from`, `to`, plus optional `event_name`, `filters`, `bucket` (`none`/`day`), `dimension` (`event_name` or `{ property: <data key> }`), `metric` (`events`/`unique_clients`), `limit`. Returns `rows` of `{bucket?, dimension?, value}` |
| POST | /api/telemetry/subscriptions/summary | Admin (level 100) | Query subscription funnel for a product over a date range. Pass `product_id`, `from`, `to`. Returns checkout event counts and current subscription status breakdown |

**Supported products:** `terminal_graph`

**Supported event names:** `app_opened`, `app_became_active`, `app_updated`, `account_signed_in`, `subscription_checkout_started`, `subscription_checkout_completed`, `feature_used`, `milestone_reached`, `error_occurred`

### Utility

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | /api/aes | No | AES encryption endpoint |
| POST | /api/404 | No | 404 handler |

---

## User Tiers

| Level | Tier | Monthly Credits |
|-------|------|-----------------|
| 0 | UNVERIFIED | 0 |
| 10 | VERIFIED | 0 |
| 20 | PAYING | 1,500 |
| 30 | GENERAL_CO_WORKING | 45,000 |
| 40 | PARTNER | 45,000 |
| 100 | ADMIN | 45,000 |

## Examples

### Create account and authenticate

```bash
curl -X POST https://api.internet.dev/api/users/authenticate \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com", "password": "your-password"}'
```

### Get authenticated user profile

```bash
curl https://api.internet.dev/api/users/viewer \
  -H "x-api-key: YOUR_KEY"
```

### Check token balance

```bash
curl https://api.internet.dev/api/credits/balance \
  -H "x-api-key: YOUR_KEY"
```

### View pricing for all endpoints

```bash
curl https://api.internet.dev/api/credits/pricing
```

### Create a post

```bash
curl -X POST https://api.internet.dev/api/posts/create \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_KEY" \
  -d '{
    "type": "GENERAL",
    "fields": {
      "title": "My First Post",
      "body": "Hello world"
    }
  }'
```

### Create a post with markdown (rendered in editor)

When you pass a `markdown` field, the API parses it into Slate editor nodes. The post will render with full formatting in the txt.dev editor.

```bash
curl -X POST https://api.internet.dev/api/posts/create \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_KEY" \
  -d '{
    "type": "TXT_DEV",
    "fields": {
      "title": "Getting Started Guide",
      "description": "A quick guide to getting started"
    },
    "markdown": "# Getting Started\n\nWelcome to the platform.\n\n## Features\n\n- **Authentication** with OAuth\n- **Organizations** for teams\n- *Inline code* and `snippets`\n\nFor more details, visit [our docs](https://api.internet.dev)."
  }'
```

### Create a post with raw markdown in body (not parsed)

If you want to store markdown as plain text without Slate parsing, put it in `fields.body` instead:

```bash
curl -X POST https://api.internet.dev/api/posts/create \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_KEY" \
  -d '{
    "type": "GENERAL",
    "fields": {
      "title": "Getting Started Guide",
      "body": "# Getting Started\n\nWelcome to the platform.",
      "description": "A quick guide to getting started with the API"
    }
  }'
```

### Publish a post

Publishing makes a post publicly accessible. Set `public` to `true` and provide a `slug` for the public URL.

```bash
curl -X POST https://api.internet.dev/api/posts/update \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_KEY" \
  -d '{
    "id": "POST_ID",
    "updates": {
      "slug": "getting-started-guide",
      "data": { "public": true }
    }
  }'
```

The post is now accessible at `/api/posts/public/getting-started-guide`.

### Update a post

Updates are deep-merged into the existing post data, so you only need to send the fields you want to change.

```bash
curl -X POST https://api.internet.dev/api/posts/update \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_KEY" \
  -d '{
    "id": "POST_ID",
    "updates": {
      "data": {
        "title": "Updated Title",
        "body": "Updated content with **markdown** support."
      }
    }
  }'
```

### Update a post's content with markdown (rendered in editor)

```bash
curl -X POST https://api.internet.dev/api/posts/update \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_KEY" \
  -d '{
    "id": "POST_ID",
    "updates": {
      "markdown": "# Updated Content\n\nThis replaces the editor content with **formatted** text.\n\n- First point\n- Second point\n\n> A blockquote for emphasis."
    }
  }'
```

### List your posts

```bash
curl -X POST https://api.internet.dev/api/posts \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_KEY" \
  -d '{"type": "GENERAL", "user_id": "YOUR_USER_ID"}'
```

### List a user's public posts by username

```bash
curl -X POST https://api.internet.dev/api/posts \
  -H "Content-Type: application/json" \
  -d '{"type": "TXT_DEV", "username": "someone"}'
```

### Get a public post by slug

```bash
curl https://api.internet.dev/api/posts/public/getting-started-guide
```

### Delete a post

```bash
curl -X POST https://api.internet.dev/api/posts/delete \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_KEY" \
  -d '{"id": "POST_ID"}'
```

### Send tokens to another user

```bash
# By username (preferred for agents)
curl -X POST https://api.internet.dev/api/credits/send \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_KEY" \
  -d '{"username": "recipient", "amount": 500}'

# By email (preferred for humans)
curl -X POST https://api.internet.dev/api/credits/send \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_KEY" \
  -d '{"email": "recipient@example.com", "amount": 500}'
```

### Checkout with tokens

```bash
curl -X POST https://api.internet.dev/api/marketplace/checkout \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_KEY" \
  -d '{"items": [{"sku": "ITEM-001", "quantity": 1}], "payment_type": "tokens", "domain": "your-org.com"}'
```

### Claim a tier-gated item

```bash
curl -X POST https://api.internet.dev/api/inventory/claim \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_KEY" \
  -d '{"sku": "ITEM-001", "domain": "your-org.com"}'
```

### Customize your organization's verification email

```bash
curl -X POST https://api.internet.dev/api/organizations/update \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_KEY" \
  -d '{
    "domain": "your-org.com",
    "data": {
      "email": {
        "from": "YourApp <no-reply@yourapp.com>",
        "subject": "Verify your email for YourApp",
        "text": "Thanks for signing up! Click the link below to verify your email."
      }
    }
  }'
```

### Customize your organization's reset password email

```bash
curl -X POST https://api.internet.dev/api/organizations/update \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_KEY" \
  -d '{
    "domain": "your-org.com",
    "data": {
      "email_reset_password": {
        "from": "YourApp <no-reply@yourapp.com>",
        "subject": "Reset your YourApp password",
        "text": "We received a request to reset your password. Click the link below to sign in and change it."
      }
    }
  }'
```

### Customize your organization's send password email

```bash
curl -X POST https://api.internet.dev/api/organizations/update \
  -H "Content-Type: application/json" \
  -H "x-api-key: YOUR_KEY" \
  -d '{
    "domain": "your-org.com",
    "data": {
      "email_send_password": {
        "from": "YourApp <no-reply@yourapp.com>",
        "subject": "Your new YourApp account",
        "text": "Welcome! Here is your password. Please sign in and change it as soon as possible."
      }
    }
  }'
```

### Authenticate a user with your organization's custom emails

```bash
curl -X POST https://api.internet.dev/api/users/authenticate \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "their-password", "source": "your-org.com"}'
```

### Reset a user's password with your organization's custom email

```bash
curl -X POST https://api.internet.dev/api/users/reset-password \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "source": "your-org.com"}'
```

### Guest checkout — create session and buy a physical item

```bash
# 1. Create a guest session
curl -X POST https://api.internet.dev/api/marketplace/guest/session \
  -H "Content-Type: application/json" \
  -d '{"domain": "your-org.com"}'

# 2. Checkout with payment method and shipping address (no email needed for physical goods)
curl -X POST https://api.internet.dev/api/marketplace/guest/checkout \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer SESSION_TOKEN" \
  -d '{
    "items": [{"sku": "TSHIRT-L-BLK", "quantity": 1}],
    "payment_method_id": "pm_1234567890",
    "shipping_address": {"street": "123 Main St", "city": "Portland", "state": "OR", "zip": "97201", "country": "US"}
  }'

# 3. Look up the order by ID (no auth needed)
curl https://api.internet.dev/api/marketplace/guest/orders/ORDER_ID
```

### Health check

```bash
curl https://api.internet.dev/api/status
```

---

## Client-Side Data Handling (JavaScript / TypeScript)

When calling this API from JavaScript, use `JSON.stringify()` on the **entire** request body. Never stringify individual nested fields inside it — that turns them into strings instead of objects and corrupts the data in the database.

### Basic query helper

```typescript
async function apiPost(route: string, body: Record<string, any>, apiKey?: string) {
  const headers: Record<string, string> = { 'Content-Type': 'application/json' };
  if (apiKey) headers['x-api-key'] = apiKey;

  const response = await fetch(`https://api.internet.dev${route}`, {
    method: 'POST',
    headers,
    body: JSON.stringify(body),  // stringify the entire body once
  });
  return await response.json();
}

async function apiGet(route: string, apiKey?: string) {
  const headers: Record<string, string> = {};
  if (apiKey) headers['x-api-key'] = apiKey;

  const response = await fetch(`https://api.internet.dev${route}`, { headers });
  return await response.json();
}
```

### Nested objects — do not double-stringify

Many endpoints accept nested objects (`fields`, `data`, `updates`, `customer_info`, `shipping_address`, `units`). Pass these as plain JavaScript objects inside the body. `JSON.stringify` is called once on the outer object — never on inner fields.

```typescript
// ✅ Correct — nested objects are plain objects inside the body
const result = await apiPost('/api/posts/create', {
  type: 'GENERAL',
  fields: {
    title: 'My Post',
    body: 'Hello world',
    metadata: { tags: ['intro', 'hello'], draft: false },
  },
}, apiKey);

// ❌ Wrong — double-stringifying turns fields into a string, not an object
const result = await apiPost('/api/posts/create', {
  type: 'GENERAL',
  fields: JSON.stringify({
    title: 'My Post',
    body: 'Hello world',
  }),
}, apiKey);
```

The same applies to all endpoints with nested data:

```typescript
// ✅ Organization update — data is a plain object
await apiPost('/api/organizations/update', {
  domain: 'your-org.com',
  data: {
    email: {
      from: 'YourApp <no-reply@yourapp.com>',
      subject: 'Verify your email',
      text: 'Click the link below to verify.',
    },
  },
}, apiKey);

// ✅ Inventory create — data and units[].data are plain objects
await apiPost('/api/inventory/create', {
  domain: 'your-org.com',
  sku: 'ITEM-001',
  slug: 'blue-widget',
  price_cents: 1999,
  data: { title: 'Blue Widget', tags: ['widget'] },
  units: [
    { data: { serial: 'SN-001', color: 'blue' } },
    { data: { serial: 'SN-002', color: 'red' } },
  ],
}, apiKey);

// ✅ Checkout — customer_info and shipping_address are plain objects
await apiPost('/api/marketplace/checkout', {
  items: [{ sku: 'ITEM-001', quantity: 1 }],
  domain: 'your-org.com',
  customer_info: { email: 'buyer@example.com', name: 'Jane' },
  shipping_address: { street: '123 Main St', city: 'SF', state: 'CA', zip: '94102', country: 'US' },
}, apiKey);
```

### Update merging — send only what changed

Most update endpoints **merge** `data` into the existing record. Send only the fields you want to change — not the full object you fetched earlier. Re-sending a full object risks overwriting concurrent changes and leaking server-managed fields back into the data.

```typescript
// ✅ Correct — only send the fields you want to update
await apiPost('/api/posts/update', {
  id: postId,
  updates: {
    data: { title: 'New Title' },  // other fields in data are preserved
  },
}, apiKey);

// ❌ Risky — sending the entire fetched data back
const post = (await apiGet(`/api/posts/${postId}`)).data;
post.data.title = 'New Title';
await apiPost('/api/posts/update', {
  id: postId,
  updates: { data: post.data },  // sends ALL fields, may overwrite concurrent changes
}, apiKey);
```

This merge behavior applies to: `/api/posts/update`, `/api/organizations/update`, `/api/inventory/update`, `/api/documents/update`, `/api/events/update`, and `/api/users/update`.

### Extra state — do not send server-managed fields

When you store API responses in client state (React state, a database, etc.), be careful not to send server-only fields back when updating. These fields are managed by the server and should never appear in your update payloads:

```typescript
// ❌ Wrong — storing the full response and sending it all back
const { user } = await apiPost('/api/users/authenticate', { email, password });
// Later, updating the user...
await apiPost('/api/users/update', {
  id: user.id,
  updates: {
    // These fields should NOT be in your updates object:
    // hash, key, wallet_signature — security fields (never returned anyway)
    // created_at, updated_at, deleted_at — server timestamps
    // level — managed by subscription tier, not direct update
    // email — changed via authenticate, not update
    // verified — changed via verify endpoint, not update
    bio: 'New bio',
  },
}, apiKey);

// ✅ Correct — only send the profile fields you intend to change
await apiPost('/api/users/update', {
  id: user.id,
  updates: {
    bio: 'New bio',
    avatar_url: 'https://example.com/photo.jpg',
  },
}, apiKey);
```

For inventory and marketplace endpoints, the same rule applies — do not re-send fields like `id`, `organization_id`, `created_at`, `available_count`, or `units` when updating an item. Only send `data`, `price_cents`, `price_tokens`, and other mutable fields.

### Form inputs are strings — coerce numeric fields

HTML `<input>` elements always produce strings. Fields like `price_cents`, `quantity`, `unit_count`, and `min_tier` must be numbers when sent to the API. Coerce them before building the request body.

```typescript
// ❌ Wrong — input values are strings, API expects numbers
await apiPost('/api/inventory/create', {
  domain: 'your-org.com',
  sku: formState.sku,
  slug: formState.slug,
  price_cents: formState.priceCents,  // "1999" (string from input)
  unit_count: formState.unitCount,    // "50" (string from input)
  data: { title: formState.title },
}, apiKey);

// ✅ Correct — coerce to numbers
await apiPost('/api/inventory/create', {
  domain: 'your-org.com',
  sku: formState.sku,
  slug: formState.slug,
  price_cents: Number(formState.priceCents),  // 1999
  unit_count: Number(formState.unitCount),    // 50
  data: { title: formState.title },
}, apiKey);
```

Fields that must be numbers: `price_cents`, `price_tokens`, `quantity`, `unit_count`, `min_tier`, `amount`, `value`, `max_uses`, `min_order_cents`, `shipping_cents`, `tax_cents`.

### `null` vs `undefined` vs empty string

`JSON.stringify` strips `undefined` keys but preserves `null`. This matters for fields with `null` semantics:

| Field | `null` means | `undefined` (omitted) means | `""` (empty string) means |
|-------|-------------|---------------------------|--------------------------|
| `price_tokens` | USD-only, no token price | No change (on update) | Bug — not a valid value |
| `min_tier` | No tier gate, anyone can claim | No change (on update) | Bug — not a valid value |
| `discount_code` | No discount | No discount | Fails validation |

When building request bodies from React form state, convert empty strings to the correct type:

```typescript
// ❌ Wrong — empty input becomes "" which is not null
const body = {
  id: itemId,
  price_tokens: formState.priceTokens,  // "" from empty input
  min_tier: formState.minTier,          // "" from empty input
};

// �� Correct — convert empty strings to null or omit
const body = {
  id: itemId,
  price_tokens: formState.priceTokens ? Number(formState.priceTokens) : null,
  min_tier: formState.minTier ? Number(formState.minTier) : null,
};
```

### Building request bodies from React state

When your component state mixes UI concerns with API data, explicitly pick only the API-relevant fields before sending. Never spread your entire state into a request body.

```typescript
// ❌ Dangerous — component state has UI fields mixed in
const [state, setState] = useState({
  // API fields
  title: '', body: '', description: '',
  // UI fields that should never be sent
  loading: false, activeTab: 'edit', previewHtml: '', validationErrors: {},
});
await apiPost('/api/posts/create', {
  type: 'GENERAL',
  fields: state,  // sends loading, activeTab, previewHtml, validationErrors into post data
}, apiKey);

// ✅ Correct — pick only the fields the API expects
await apiPost('/api/posts/create', {
  type: 'GENERAL',
  fields: {
    title: state.title,
    body: state.body,
    description: state.description,
  },
}, apiKey);
```

A reliable pattern is to separate API data from UI state entirely:

```typescript
// Separate concerns — API data and UI state are different objects
const [formData, setFormData] = useState({ title: '', body: '', description: '' });
const [uiState, setUiState] = useState({ loading: false, activeTab: 'edit' });

// formData can be sent directly — it only has API fields
await apiPost('/api/posts/create', {
  type: 'GENERAL',
  fields: formData,
}, apiKey);
```

### Events — only allowed fields are accepted

The `/api/events/update` endpoint whitelists allowed fields: `begins_at`, `ends_at`, `status`, `visibility`, and `data`. Any other fields in your `updates` object are silently dropped.

```typescript
// ✅ Correct — only allowed fields
await apiPost('/api/events/update', {
  id: eventId,
  domain: 'your-org.com',
  updates: {
    status: 'APPROVED',
    data: { notes: 'Updated event notes' },
  },
}, apiKey);
```
