# meowtrace — Lookup API Reference (API.md)

> ↩ **Back to canonical entry:** [SPEC.md](./SPEC.md)
> The v1 Lookup API (Phase 2). Endpoint shapes, request/response, error codes.
> Keep linked from SPEC.md's document table; no orphaned docs.

**Last updated:** 2026-07-27
**Status:** Phases 2–5 built & verified; **live in PRODUCTION** (Phase 6)
**Source:** `src/backend/` (Node + TypeScript + Fastify)

---

## Running (local dev)

```bash
cd src/backend && npm install
MEOWTRACE_DSN='postgresql://meowtrace:<pw>@127.0.0.1:5432/meowtrace' \
  PORT=8080 npm run serve
# dev with reload: npm run dev
```

Base URL (local): `http://127.0.0.1:8080`

> **v1 scope (decision "a"):** responses carry **country / region / city** only —
> no latitude/longitude, no ASN/ISP metadata (see [DATA-IMPORT.md §5](./DATA-IMPORT.md)).
>
> **Auth + credits (Phase 3, live):** lookup endpoints require an **API key** via
> `X-API-Key: mt_live_…` (or `Authorization: Bearer mt_live_…`). Each successful lookup
> **debits 1 credit**; the balance is returned in the `x-credits-remaining` header, and a
> per-request id in `x-request-id`. When credits run out, lookups return **429
> `out_of_credits`** until the next refill/reset (SPEC §8 hard-cap).

---

## Endpoints

### `GET /health`
Liveness probe. → `200 {"status":"ok"}`

### `GET /v1`
Service metadata + endpoint index. → `200`

### `GET /v1/lookup/:ip` — single lookup (SPEC §4.1)

Resolve one IPv4 address.

**200** — found (single result wrapped in `result`):
```json
{
  "result": {
    "ip": "143.130.141.229",
    "country": { "code": "AT", "name": "Austria" },
    "region": "Wien",
    "city": "Vienna"
  }
}
```
> **Result body (canonical, decided 2026-07-20):** `ip`, `country: { code, name }`
> (nested), `region`, `city`. When location is unknown, `country` is
> `{ code: null, name: null }` (stable shape), and `region`/`city` may be `null`.
> v1 carries country/region/city only — no lat/long or ASN (decision "a",
> [DATA-IMPORT.md §5](./DATA-IMPORT.md)).
> ✅ **Shipped** — the nested `{ result: { …, country: { code, name } } }` envelope is live in
> `src/backend` (`routes.ts` `toResult()` → `{ result }`). This replaced the flat Phase-2
> shape (`country_code`/`country`/…).
**404** — `not_found` (valid IP, no range contains it):
```json
{ "error": { "code": "not_found", "message": "no geolocation range contains 8.8.8.8" } }
```
**400** — `invalid_ip` (malformed IPv4):
```json
{ "error": { "code": "invalid_ip", "message": "'999.1.1.1' is not a valid IPv4 address" } }
```

### `POST /v1/lookup` — bulk lookup (SPEC §4.2)

Resolve many IPs in one request. One bad entry does **not** fail the batch.

**Request:**
```json
{ "ips": ["1.0.148.5", "1.2.3.4", "8.8.8.8", "bad.ip"] }
```
- Max **1000** IPs per batch (`batch_too_large` → 400 if exceeded — working cap).

**Response — `200`** when all found, **`207` Multi-Status** when any entry is
`not_found`/`invalid` (partial success per SPEC §4.2). Each entry carries a `status`;
found entries wrap the canonical result body in `result` (same shape as single lookup):
```json
{
  "count": 4,
  "partial": true,
  "results": [
    { "status": "found", "result": {
        "ip": "1.0.148.5", "country": { "code": "TH", "name": "Thailand" },
        "region": "Chumphon", "city": "Pathio" } },
    { "status": "found", "result": {
        "ip": "1.2.3.4", "country": { "code": "PL", "name": "Poland" },
        "region": "Mazowieckie", "city": "Warsaw" } },
    { "ip": "8.8.8.8", "status": "not_found" },
    { "ip": "bad.ip", "status": "invalid" }
  ]
}
```
Per-IP `status`: `found` | `not_found` | `invalid`. Non-found entries carry `ip` +
`status` only (no `result`).
> ✅ **Shipped** — the per-entry `status` + nested `result` shape is live in `src/backend`
> (`routes.ts` bulk handler; `207` on partial).

**400** — bad body (`ips` missing/not an array, empty) or `batch_too_large`.

---

## Error envelope

All errors share one shape:
```json
{ "error": { "code": "<code>", "message": "<human message>" } }
```

| Code | HTTP | Meaning |
|---|---|---|
| `invalid_ip` | 400 | malformed IPv4 |
| `bad_request` | 400 | malformed body / unknown route |
| `batch_too_large` | 400 | bulk batch over the cap |
| `not_found` | 404 | valid IP, no containing range |
| `internal_error` | 500 | unexpected server error |
| `unauthorized` | 401 | missing/invalid API key or session |
| `out_of_credits` | 429 | credit hard-cap reached |
| `email_taken` | 409 | signup email already registered |
| `invalid_credentials` | 401 | login email/password mismatch |

---

## Verification log (2026-07-20)

Proven with real HTTP calls against the live DB (306-row sample):

| Call | Result |
|---|---|
| `GET /v1/lookup/1.0.148.5` | 200 → TH/Thailand/Chumphon/Pathio |
| `GET /v1/lookup/1.2.3.4` | 200 → PL/Poland/Mazowieckie/Warsaw |
| `GET /v1/lookup/8.8.8.8` | 404 not_found |
| `GET /v1/lookup/999.1.1.1` | 400 invalid_ip |
| `POST /v1/lookup` (mixed) | 207 partial (found/found/not_found/invalid) |
| `POST /v1/lookup` (all-found) | 200 |
| bad body / unknown route | 400 / 404 |

**Phase 3 (2026-07-20), full auth+credit flow over HTTPS:**

| Call | Result |
|---|---|
| `POST /v1/auth/signup` (free) | 201 → token + balance 10 |
| `GET /v1/lookup/:ip` no key | 401 unauthorized |
| `POST /v1/keys` | 201 → raw key (shown once) |
| authed lookup ×10 | 200, `x-credits-remaining` 9→0 |
| authed lookup ×11,12 | **429 out_of_credits** |
| bulk 14 IPs @ 10 credits | **207**: 10 found (billed), 3 insufficient_credit, 1 invalid; `credits_charged:10` |
| usage_records | 14 rows logged; total billed reconciles (10 single + 10 bulk = 20) |

**Phase 4 (2026-07-20), real Stripe test-mode flow:**

| Step | Result |
|---|---|
| `stripe:setup` | created Hobby ($14.90) + Enterprise ($250) prices (idempotent) |
| `POST /v1/billing/checkout` | 200 → real `cs_test_…` at checkout.stripe.com |
| webhook bad signature | 400 bad_signature |
| real `invoice.paid` (subscription) | tier free→hobby, **balance 10 → 200000** (`stripe-monthly-reset`) |
| `customer.subscription.deleted` | tier → free, subscription → canceled |

> **Bugs caught & fixed (Phase 4):** (1) webhook raw-body parser mangled the payload →
> string-based `rawBody` preserving exact bytes; (2) `invoice.subscription` is `null` in
> newer Stripe API — the id moved to `lines.data[].parent.subscription_item_details.subscription`
> — handler now reads the new location (this had made provisioning silently no-op).

> **Bug caught & fixed:** daily-refill fired on *every* lookup (balance stuck at 9, no
> 429) — a pg `DATE` returned as a JS Date at local-midnight shifted a day under
> `toISOString()`, so "already refilled today" never matched. Fixed by comparing the
> stored date as `to_char(…,'YYYY-MM-DD')` **text** in SQL. Re-proven clean above.
>
> **Known hardening item:** `usage_records.caller_ip` currently logs the nginx proxy IP
> (`127.0.0.1`); trust `X-Forwarded-For` for the real client IP in Phase 6.

> **Bug caught & fixed during build:** the server DSN initially carried a literal
> masked password (`***`), causing `28P01` auth-fail → 500 on all DB paths. Resolved by
> supplying the real password via `MEOWTRACE_DSN` env (never hardcode credentials).

---

## Accounts, auth & API keys (Phase 3)

Portal auth uses a **bearer session token** (`Authorization: Bearer <token>` from
signup/login). API keys are separate secrets for the lookup endpoints.

### `POST /v1/auth/signup`  — `{ "email", "password", "org_name?" }`
`201` → `{ token, account_id, org_id, tier, balance }`. Always **Free** (10 credits);
creates the account's personal org (named `org_name` if given, else derived from email).
`409 email_taken` if the email exists.

### `POST /v1/auth/login`  — `{ "email", "password" }`
`200` → `{ token, account_id, org_id, tier }`. `401 invalid_credentials` otherwise.

### `POST /v1/auth/forgot`  — `{ "email" }`
Self-service password recovery. **Enumeration-safe:** always `200`
`{ ok, message }` whether or not the email exists. If it exists, a single-use reset
token (sha256-hashed at rest, ~1h expiry) is created and a reset link emailed via
SendGrid → `${app_base_url}/reset?token=…`.

### `POST /v1/auth/reset`  — `{ "token", "new_password" }`
Consumes a valid reset token and sets the new password (min 8 chars); invalidates any
other outstanding tokens for that account. `200` `{ ok, message }`.
`400 invalid_token` if the token is unknown, used, or expired.

### `GET /v1/me`  *(Bearer session)*
`200` → `{ account_id, email, tier, role, balance, org_id, org_role, current_org, orgs: […] }`
— the caller's account plus their **current org** (id/role) and the full list of orgs they
belong to (for the org switcher).

### `POST /v1/keys`  *(Bearer session)*  — `{ "label?" }`
`201` → `{ id, key, prefix, label, created_at, warning }`. **The raw `key` is shown
ONCE** — only its SHA-256 is stored. Use it as `X-API-Key`.

### `GET /v1/keys`  *(Bearer session)*
`200` → `{ keys: [{ id, key_prefix, label, created_at, revoked_at }] }` (no secrets).

### `DELETE /v1/keys/:id`  *(Bearer session)*
`200` → `{ revoked: true, id }`. `404` if not found / already revoked.

### Credit model (SPEC §8)
- **Free:** 10 credits/day, reset **00:00 GMT+8** (no rollover).
- **Hobby:** 200k/mo (no rollover). **Enterprise:** unlimited (fair-usage).
- Balance is the **sum of the append-only `credit_ledger`** (source of truth); each
  lookup writes a `-1` debit inside the same transaction as the lookup (atomic).
- **Bulk + hard-cap:** a batch is processed until credits are exhausted; remaining
  valid IPs get per-IP status `insufficient_credit` and the response is **207** (SPEC §4.2).

## Billing & subscriptions (Phase 4, Stripe)

Stripe (test mode) drives subscriptions, invoicing, and credit provisioning (SPEC §10).
Plans: **Hobby $14.90/mo (200k credits)**, **Enterprise $250/mo (unlimited)**.

### `GET /v1/billing/config`
Public. → `{ publishable_key, ready, tiers }` for the frontend.

### `POST /v1/billing/checkout`  *(Bearer session)*  — `{ "tier": "hobby"|"enterprise" }`
`200` → `{ checkout_url }` (hosted Stripe Checkout). `503 billing_unavailable` if keys unset.
The hosted page shows Stripe's **Add promotion code** field (`allow_promotion_codes: true`);
coupons/promo codes are managed in the Stripe dashboard.

### `POST /v1/billing/portal`  *(Bearer session)*
`200` → `{ portal_url }` (Stripe Customer Portal for self-serve payment/invoices).

### `POST /v1/webhooks/stripe`  *(Stripe only)*
Signature-verified (`STRIPE_WEBHOOK_SECRET`), idempotent (`processed_webhooks`). Handles:
- **`checkout.session.completed`** → set tier, grant credits, upsert subscription.
- **`invoice.paid`** → monthly credit reset (no rollover); update subscription state.
- **`customer.subscription.deleted`** → downgrade to free; mark subscription canceled.
`400 bad_signature` on verification failure.

> **Credit provisioning:** grants/resets write to the same `credit_ledger` (source of
> truth). Reset = top-up to the tier allowance (no rollover). Enterprise = no ledger
> grant (unlimited, fair-usage).

## Organizations (Phase 5b)

Orgs are the tenant (SPEC §5.3 / §6). All Bearer-session; the current org comes from the
session token (`/v1/orgs/switch` re-tokens). From `org_routes.ts` + `accounts_routes.ts`.

### `GET /v1/orgs`  *(Bearer)*
`200` → the caller's orgs (`OrgDetail`: id, name, org_role, tier, balance, created_at).

### `PATCH /v1/orgs`  *(Bearer, owner)*  — `{ "name" }`
`200` → the full updated `OrgDetail` (balance + created_at). Renames the current org.

### `GET /v1/orgs/members`  *(Bearer)*
`200` → `{ members: [{ account_id, email, org_role, created_at }] }`.

### `POST /v1/orgs/invites`  *(Bearer, owner)*  — `{ "email", "org_role?" }`
`201` → invite created; invitee auto-joins on next signup/login.

### `DELETE /v1/orgs/invites/:id`  *(Bearer, owner)*
`200` → revoke a pending invite.

### `DELETE /v1/orgs/members/:accountId`  *(Bearer, owner)*
`200` → remove a member from the current org.

### `POST /v1/orgs/switch`  *(Bearer)*  — `{ "org_id" }`
`200` → `{ token }` re-issued for the selected org (must be a member).

## Admin API (role `admin`)

Role-gated (`accounts.role='admin'`); non-admins get `403`. From `admin_routes.ts`
(full behaviour spec: [ADMIN-PLAN.md §2](./ADMIN-PLAN.md)).

- `GET /v1/admin/accounts` — list accounts (search/paginate).
- `GET /v1/admin/accounts/:id` — account detail (orgs, keys, usage, balance).
- `PATCH /v1/admin/accounts/:id` — edit account (role/tier).
- `POST /v1/admin/accounts/:id/credits` — manual credit adjust (ledger `admin-adjust`).
- `POST /v1/admin/accounts/:id/suspend` — toggle `accounts.suspended` (blocks lookups).
- `GET /v1/admin/logs` — request log (drill-down).
- `GET /v1/admin/stats` — platform stats.

## Usage & analytics (Phase 5 portal)

Org-scoped to the caller's current org (Bearer session). Power the dashboard chart
and the request-history page.

### `GET /v1/usage/timeseries?window=&bucket=`  *(Bearer session)*
Requests-over-time for the dashboard chart. Served from the pre-aggregated
**`usage_rollup_minute`** table (write-time dedup'd minute-key queue + periodic flush,
`src/backend/src/usage_rollup.ts`) — never scans raw `usage_records`.
- `window`: `1h` | `6h` | `24h` (default) | `7d` | `30d`
- `bucket`: `minute` | `hour` (default)
- `200` → `{ window, bucket, series: [{ t, requests, credits, errors }] }` (errors = status ≥ 400).

### `GET /v1/usage/requests?limit=&offset=&status=`  *(Bearer session)*
The org's own request history (paginated, most recent first) from `usage_records`.
- `limit` (≤1–200, default 50), `offset` (default 0), optional `status` filter.
- `200` → `{ total, limit, offset, requests: [{ request_id, endpoint, method, status,
  latency_ms, caller_ip, queried, credits_charged, created_at }] }`.

## Not yet (next phases)
- **Phase 5:** React/Vite portals (User + Admin) + marketing site.
- **Phase 6:** deploy hardening (X-Forwarded-For client IP, ALB rate limiting, scaling).
- Response versioning headers, remaining-credit header, OpenAPI spec, SDK snippets *(TBD)*.

---

## Changelog
- **2026-07-27** — **Phase 5 portal endpoints documented.** Auth: signup now takes
  `org_name?` (not `tier`) and returns `org_id`; added `POST /v1/auth/forgot`
  (enumeration-safe) + `POST /v1/auth/reset` (single-use token). New **Usage & analytics**
  section: `GET /v1/usage/timeseries` (dashboard chart, from `usage_rollup_minute`) and
  `GET /v1/usage/requests` (org request history). `PATCH /v1/orgs` now returns the full
  `OrgDetail` (balance + created_at) to match `GET /v1/orgs` — fixed an org-rename crash.
- **2026-07-20** — **Phase 3 added:** accounts (signup/login, bearer session), API keys
  (create/list/revoke, hashed, shown once), credit ledger (balance = SUM(ledger)), daily
  refill (Free 10/day @ GMT+8), atomic debit-per-lookup, **429 hard-cap**, bulk
  partial-on-exhaust (207), and per-request usage logging. All proven over HTTPS.
  *(Fixed a timezone date-compare bug in daily refill.)*
- **2026-07-20** — Created. Phase 2 Lookup API built (Fastify): single `GET /v1/lookup/:ip`,
  bulk `POST /v1/lookup` (207 partial), health/meta, unified error envelope; all
  endpoints verified with real HTTP against the sample DB.
