# Costko Receipt Tracker -- API Specification for AI Agents

> **Version:** 2026-03-23
> **Base URL:** `https://costko-receipt-tracker.jamessathyapal.workers.dev`
> **Runtime:** Cloudflare Workers (Hono framework)

---

## 1. Overview

The Costko Receipt Tracker API powers an iOS app and WhatsApp bot that helps Costco members track receipts, detect price-protection claims, manage shopping lists, and coordinate household shopping. The API handles receipt image upload and AI-powered OCR extraction, price intelligence (trending items, price history, shelf tag scanning), shopping list management with voice/bulk input, household sharing, delivery session coordination, paid picker marketplace, and push notifications via APNs.

### Base URL

```
https://costko-receipt-tracker.jamessathyapal.workers.dev
```

### Authentication Model

1. **Device registration** -- New devices call `POST /auth/device` with a UUID v4 device ID. The server creates an anonymous user and returns a JWT.
2. **Apple Sign-In linking** -- Users can optionally link their Apple ID via `POST /auth/device/link-apple`, which upgrades the anonymous account to an Apple-linked account.
3. **Demo accounts** -- `POST /auth/demo` creates a sandboxed demo account with pre-seeded data (10-day expiry).
4. **Session tokens** -- All authenticated requests use `Authorization: Bearer <jwt>`. Tokens are HMAC-SHA256 JWTs with a 30-day expiry.

### Request/Response Conventions

- **Content-Type:** `application/json` for all endpoints except file uploads (`multipart/form-data`).
- **Field naming:** All JSON fields use `snake_case`.
- **Timestamps:** Most timestamps are Unix milliseconds (`Date.now()`). Exceptions noted per-endpoint. Fields ending in `_at` from D1 tables created with `unixepoch()` are Unix seconds.
- **Error format:** `{ "error": "string", "message"?: "string", "code"?: "string" }`
- **Pagination:** List endpoints accept `?limit=N&offset=N` query parameters.

---

## 2. Authentication

### Register a device

```
POST /auth/device
```

Send this on first app launch. If the device has been seen before, it resumes the existing session.

**Request body:**
```json
{
  "device_id": "550e8400-e29b-41d4-a716-446655440000",
  "platform": "ios",
  "device_name": "iPhone 15 Pro",
  "app_version": "1.2.0",
  "os_version": "18.3"
}
```

- `device_id` (required): Must be a valid UUID v4. Must not start with `user_` or `mock_`.
- `platform` (optional): Defaults to `"ios"`.
- `device_name`, `app_version`, `os_version` (optional): Metadata stored for analytics.

**Response (201 -- new device):**
```json
{
  "token": "eyJhbGc...",
  "user_id": "usr_a1b2c3d4e5f6",
  "is_new_user": true,
  "auth_type": "device"
}
```

**Response (200 -- returning device):**
```json
{
  "token": "eyJhbGc...",
  "user_id": "usr_a1b2c3d4e5f6",
  "is_new_user": false,
  "auth_type": "device"
}
```

### Using the token

Include on every authenticated request:

```
Authorization: Bearer eyJhbGc...
```

### Token lifetime

Tokens expire after 30 days. When a request returns **401**, the agent must re-register the device to obtain a fresh token.

### Apple Sign-In linking

```
POST /auth/device/link-apple
Authorization: Bearer <token>
```

Links an Apple ID to the current device-based account. If the Apple ID already belongs to another user, the accounts are merged (Apple account wins, device account data is re-parented).

**Request body:**
```json
{
  "identity_token": "<Apple identity JWT>",
  "full_name": "James S."
}
```

**Response (200):**
```json
{
  "token": "eyJhbGc...",
  "user_id": "usr_a1b2c3d4e5f6",
  "auth_type": "apple",
  "email": "user@icloud.com",
  "merged_from": null
}
```

If a merge occurred:
```json
{
  "token": "eyJhbGc...",
  "user_id": "existing_apple_user_id",
  "auth_type": "apple",
  "email": "user@icloud.com",
  "merged_from": "usr_old_device_id",
  "merged_receipts": 5,
  "merged_claims": 2
}
```

---

## 3. Workflows

### Workflow 1: First-time setup

```
1. POST /auth/device          -- Register device, get JWT + user_id
2. (All subsequent requests)  -- Include Authorization: Bearer <jwt>
3. GET /auth/me               -- Fetch user profile + stats
4. GET /api/feature-flags     -- Check which features are enabled
```

### Workflow 2: Scan and track a receipt

```
1. POST /api/receipts/upload              -- Upload receipt image (multipart/form-data)
   Response: { receipt_id, status: "processing" }

2. GET /api/receipts/:receipt_id          -- Poll until processing_status changes
   Poll every 2-3 seconds. States: uploading -> processing -> confirmed | failed | needs_review

3. GET /api/receipts/:receipt_id          -- Once confirmed, response includes items array

4. PATCH /api/receipts/:id/status         -- User confirms: { processing_status: "confirmed" }

5. GET /api/claims                        -- Check if any new claims were detected
```

### Workflow 3: Price intelligence

```
1. GET /api/trending                      -- Get trending items for user's region
2. GET /api/products/:id                  -- View product detail + 90-day price history
3. POST /api/products/:id/track           -- Set stock alert (requires stock_alerts flag)
4. GET /api/stock-alerts                  -- List active stock alerts
5. POST /api/products/scan-tag            -- Scan a shelf price tag (multipart/form-data)
6. POST /api/products/add-price           -- Manually record a price observation
```

### Workflow 4: Shopping list management

```
1. POST /api/my-list                      -- Add single item: { name, quantity?, notes? }
2. POST /api/my-list/parse-voice          -- Voice input: { text: "..." } -> parsed + added items
3. POST /api/my-list/parse-bulk           -- Paste text: { text: "..." } -> parsed items (not auto-added)
4. POST /api/my-list/batch                -- Batch add: { items: [{ name, quantity }] }
5. PATCH /api/my-list/:id                 -- Check off: { checked_off_at: <timestamp_ms> }
6. DELETE /api/my-list/:id                -- Remove item
7. GET /api/my-list                       -- List all items (household-wide)
8. GET /api/pantry                        -- View purchase history (de-duplicated products bought)
```

### Workflow 5: Household setup

```
1. POST /api/household                    -- Create household: { name: "The Smiths" }
2. POST /api/household/invites            -- Generate 6-char invite code (7-day expiry)
3. (Share code with family member)
4. GET /api/household/join/:code          -- Preview: member sees household name + count
5. POST /api/household/join               -- Join: { code: "ABC123" }
6. GET /api/household                     -- View household + all members
```

### Workflow 6: Claims

```
1. GET /api/claims                        -- List eligible + claimed claims
2. GET /api/claims?status=expired         -- View expired (missed) claims
3. POST /api/claims/:id/claim             -- Mark as claimed at service desk
```

### Workflow 7: Delivery sessions (Share My List)

```
1. GET /api/delivery/stores               -- List available stores
2. GET /api/delivery/users/lookup?phone=+1XXXXXXXXXX  -- Find recipient by phone
3. POST /api/delivery/sessions            -- Create session (snapshot My List)
4. GET /api/delivery/sessions/:id         -- View session + items
5. PATCH /api/delivery/sessions/:id/items/:itemId  -- Check off item
6. POST /api/delivery/sessions/:id/complete  -- Mark session complete
```

### Workflow 8: Paid Pickers

```
1. POST /api/pickers/register             -- Register as a picker
2. POST /api/pickers/requests             -- Create a pickup request (requester)
3. GET /api/pickers/requests/available     -- List nearby open requests (picker)
4. POST /api/pickers/requests/:id/accept   -- Accept a request (picker)
5. POST /api/pickers/requests/:id/status   -- Advance status: accepted -> shopping -> on_the_way -> delivered
6. POST /api/pickers/requests/:id/rate     -- Rate the picker after delivery (requester)
```

---

## 4. Endpoints Reference

---

### 4.1 Identity (8 endpoints)

---

#### POST /auth/device
Auth: public

Register a new device or resume an existing session.

Request:
```json
{
  "device_id": "uuid-v4 (required)",
  "platform": "ios (default)",
  "device_name": "string (optional)",
  "app_version": "string (optional)",
  "os_version": "string (optional)"
}
```

Response (201 new / 200 returning):
```json
{
  "token": "string",
  "user_id": "string",
  "is_new_user": true,
  "auth_type": "device"
}
```

Errors:
- 400: `device_id is required` / `device_id must be a valid UUID v4` / `Invalid device_id format`

---

#### POST /auth/device/link-apple
Auth: required

Link Apple ID to the current device account. Merges data if Apple ID already exists.

Request:
```json
{
  "identity_token": "string (required)",
  "full_name": "string (optional)"
}
```

Response (200):
```json
{
  "token": "string",
  "user_id": "string",
  "auth_type": "apple",
  "email": "string | null",
  "merged_from": "string | null",
  "merged_receipts": 0,
  "merged_claims": 0
}
```

Errors:
- 400: `identity_token is required`
- 401: `Invalid Apple identity token`
- 404: `User not found`
- 409: `This account is already linked to a different Apple ID`

---

#### POST /auth/apple
Auth: public

Exchange an Apple identity token for a session (direct Apple Sign-In without device registration).

Request:
```json
{
  "identity_token": "string (required)",
  "full_name": "string (optional)"
}
```

Response (200):
```json
{
  "token": "string",
  "user_id": "string",
  "email": "string | null"
}
```

Errors:
- 400: `identity_token is required`
- 401: `Invalid Apple identity token`

---

#### POST /auth/demo
Auth: public
Rate limit: 5 per hour per IP

Create a sandboxed demo account with pre-seeded receipts, claims, and shopping list items. Expires after 10 days.

Request: (empty body or `{}`)

Response (201):
```json
{
  "token": "string",
  "user_id": "usr_demo_xxxxxxxxxxxx",
  "is_demo": true,
  "demo_expires_at": 1711929600
}
```

`demo_expires_at` is Unix seconds.

Errors:
- 429: `Too many demo accounts created. Please try again later.`

---

#### GET /auth/me
Auth: required

Get current user profile, preferences, and statistics.

Response (200):
```json
{
  "user": {
    "id": "string",
    "email": "string",
    "display_name": "string | null",
    "preferred_stores": ["string"],
    "notification_prefs": { "email": true, "push": true },
    "auth_type": "device | apple | demo",
    "created_at": 1700000000000,
    "updated_at": 1700000000000
  },
  "stats": {
    "receipts_count": 12,
    "active_stock_requests": 3,
    "total_spent": 1234.56,
    "total_savings": 45.00
  },
  "session": {
    "authenticated": true,
    "email": "string"
  }
}
```

Errors:
- 404: `User not found`

---

#### PUT /auth/me
Auth: required

Update user profile fields.

Request (all fields optional):
```json
{
  "display_name": "string",
  "preferred_stores": ["string"],
  "notification_prefs": { "email": true, "push": true }
}
```

Response (200):
```json
{
  "user": { "...same as GET /auth/me user object..." },
  "message": "Profile updated successfully"
}
```

Errors:
- 400: `No valid fields to update`

---

#### DELETE /auth/me
Auth: required

Soft-delete the current user's account. For Apple users, optionally revokes Apple Sign-In tokens.

Request (optional body for Apple users):
```json
{
  "apple_authorization_code": "string (optional)"
}
```

Response (200):
```json
{
  "deleted": true,
  "user_id": "string",
  "deleted_at": 1700000000,
  "retention_days": 365
}
```

`deleted_at` is Unix seconds. Data retained for 365 days before hard purge.

---

#### GET /auth/stats
Auth: required

Lightweight user statistics. More efficient than `/auth/me` when only stats are needed.

Response (200):
```json
{
  "receipts_count": 12,
  "total_savings": 45.00,
  "claimable_total": 15.50,
  "total_spent": 1234.56,
  "household_stats": {
    "household_id": "hh_abcd1234",
    "household_name": "The Smiths",
    "member_count": 3,
    "total_receipts": 25,
    "total_savings": 89.00
  }
}
```

`household_stats` is `null` if the user is not in a household or is the only member.

---

#### GET /auth/session
Auth: required

Validate the current session token.

Response (200):
```json
{
  "valid": true,
  "user_id": "string",
  "email": "string",
  "timestamp": 1700000000000
}
```

---

### 4.2 Receipts (10 endpoints)

---

#### POST /api/receipts/upload
Auth: required
Rate limit: 10 per minute per IP
Content-Type: multipart/form-data

Upload a receipt image for AI-powered OCR extraction. Processing happens asynchronously.

Request (form fields):
- `image` (required): Image file (JPEG, PNG, HEIC). Max size controlled by `MAX_RECEIPT_SIZE_MB` env var.
- `ocr_text` (optional): On-device OCR text to supplement Claude Vision.
- `latitude` (optional): GPS latitude at upload time.
- `longitude` (optional): GPS longitude at upload time.
- `picker_request_id` (optional): Link to a picker request (picker uploads only).

Response (201):
```json
{
  "receipt_id": "uuid",
  "status": "processing",
  "image_url": "receipts/usr_xxx/uuid-timestamp.jpg"
}
```

Errors:
- 400: `Missing image file` / `File too large` / `Invalid file type`
- 403: `You are not the assigned picker for this request`
- 404: `Picker request not found`
- 409: `A receipt is already linked to this request`
- 429: `Rate limit exceeded`

---

#### GET /api/receipts
Auth: required

List receipts for the current user and household members (paginated). Includes owner attribution.

Query parameters:
- `limit` (default 20)
- `offset` (default 0)
- `status` (optional): Filter by `processing_status` (e.g., `confirmed`, `processing`, `failed`)
- `member` (optional): Filter to a specific `user_id` within the household
- `type` (optional): `user`, `picker`, or `all` (default `all`)

Response (200):
```json
{
  "receipts": [
    {
      "id": "uuid",
      "user_id": "string",
      "owner_user_id": "string",
      "owner_display_name": "string | null",
      "warehouse_number": 123,
      "city": "San Diego",
      "state": "CA",
      "purchase_date": 1700000000000,
      "total_amount": 156.78,
      "total_savings": 12.50,
      "image_url": "receipts/usr_xxx/uuid.jpg",
      "processing_status": "confirmed",
      "uploaded_at": 1700000000000,
      "latitude": 32.7157,
      "longitude": -117.1611,
      "location_source": "gps | warehouse_lookup | user_confirmed | null",
      "receipt_type": "user | picker",
      "picker_request_id": "string | null",
      "picker_display_name": "string | null",
      "is_claimable": 1
    }
  ],
  "total": 42,
  "limit": 20,
  "offset": 0
}
```

---

#### GET /api/receipts/:id
Auth: required

Get receipt detail with all items. Accessible to any household member or the requester of a linked picker request.

Response (200):
```json
{
  "id": "uuid",
  "user_id": "string",
  "owner_user_id": "string",
  "owner_display_name": "string | null",
  "warehouse_number": 123,
  "city": "San Diego",
  "state": "CA",
  "purchase_date": 1700000000000,
  "total_amount": 156.78,
  "total_savings": 12.50,
  "image_url": "receipts/usr_xxx/uuid.jpg",
  "processing_status": "confirmed",
  "uploaded_at": 1700000000000,
  "latitude": 32.7157,
  "longitude": -117.1611,
  "location_source": "string | null",
  "receipt_type": "user | picker",
  "picker_request_id": "string | null",
  "picker_display_name": "string | null",
  "is_claimable": 1,
  "items": [
    {
      "id": "string",
      "receipt_id": "uuid",
      "product_id": "string | null",
      "item_number": "string | null",
      "product_name": "KIRKLAND ORGANIC EGGS",
      "display_name": "Kirkland Organic Eggs",
      "quantity": 1,
      "regular_price": 8.99,
      "discount_amount": 2.00,
      "final_price": 6.99,
      "price": 6.99,
      "total_price": 6.99,
      "discount_type": "instant_savings | coupon | null",
      "has_asterisk": false,
      "user_confirmed": false,
      "extraction_confidence": 0.95,
      "notes": "string | null",
      "returned_at": null
    }
  ]
}
```

Errors:
- 404: `Receipt not found`

---

#### GET /api/receipts/:id/image
Auth: required

Serve the receipt image binary from R2 storage.

Response: Binary image data with `Content-Type: image/jpeg` (or original type). Cached for 24 hours (`Cache-Control: private, max-age=86400`).

Errors:
- 404: `Receipt not found` / `Image not found`

---

#### PATCH /api/receipts/:id/location
Auth: required

User confirms or corrects the receipt location.

Request:
```json
{
  "city": "San Diego",
  "state": "CA"
}
```

Response (200):
```json
{
  "success": true,
  "city": "San Diego",
  "state": "CA",
  "location_source": "user_confirmed"
}
```

Errors:
- 400: `city and state are required`
- 404: `Receipt not found`

---

#### PATCH /api/receipts/:id/status
Auth: required

User confirms or flags a receipt after reviewing extracted items.

Request:
```json
{
  "processing_status": "confirmed | needs_review"
}
```

Response (200):
```json
{
  "success": true,
  "processing_status": "confirmed"
}
```

Errors:
- 400: `Invalid processing_status`
- 404: `Receipt not found`

---

#### PATCH /api/receipts/:receiptId/items/:itemId
Auth: required

User corrects a low-confidence receipt item. Automatically marks it as `user_confirmed`.

Request (all fields optional):
```json
{
  "name": "string",
  "final_price": 12.99,
  "item_number": "12345",
  "extraction_confidence": 1.0
}
```

Response (200): The updated receipt item object (same shape as items in GET /api/receipts/:id).

Errors:
- 400: `No fields to update`
- 404: `Receipt not found` / `Item not found`

---

#### PATCH /api/receipts/:receiptId/items/:itemId/return
Auth: required

Mark a receipt item as returned. Invalidates any related eligible claims.

Response (200): The updated receipt item object with `returned_at` set.

Errors:
- 404: `Receipt not found` / `Item not found`
- 409: `Item already returned`

---

#### PATCH /api/receipts/:receiptId/items/:itemId/unreturn
Auth: required

Undo a return (clears `returned_at`). Does NOT restore previously invalidated claims.

Response (200): The updated receipt item object with `returned_at: null`.

Errors:
- 404: `Receipt not found` / `Item not found`
- 409: `Item is not returned`

---

### 4.3 Price Intelligence (9 endpoints)

---

#### GET /api/trending
Auth: required

Get trending items grouped by metric type for the user's region (derived from their most recent receipts).

Query parameters:
- `region` (optional): Override auto-detected region (e.g., `"san diego, ca"`).

Response (200):
```json
{
  "on_sale_now": [
    {
      "id": "string",
      "product_id": "string",
      "product_name": "KIRKLAND ORGANIC EGGS",
      "display_name": "Kirkland Organic Eggs",
      "product_image_url": "string | null",
      "item_number": "12345",
      "category": "dairy",
      "product_emoji": "string | null",
      "current_avg_price": 8.99,
      "region": "san diego, ca",
      "metric_type": "on_sale_now",
      "rank": 1,
      "score": 0.85,
      "metadata": { "current_price": 6.99, "regular_price": 8.99, "sample_stores": [123, 456] },
      "generated_at": 1700000000000,
      "expires_at": 1700100000000
    }
  ],
  "popular_now": [...],
  "best_deal": [...],
  "stores": [
    { "warehouse_number": 123, "name": "San Diego", "city": "San Diego", "state": "CA" }
  ],
  "region": "san diego, ca"
}
```

---

#### GET /api/products
Auth: public

Search or list products in the catalog.

Query parameters:
- `q` (optional): Search query (matches product name via LIKE). Without `q`, returns up to 100 products.

Response (200): Array of product objects:
```json
[
  {
    "id": "string",
    "item_number": "12345",
    "name": "KIRKLAND ORGANIC EGGS",
    "category": "dairy",
    "image_url": "string | null",
    "emoji": "string | null",
    "current_avg_price": 8.99
  }
]
```

---

#### GET /api/products/:id
Auth: required

Get product details with 90-day price history, available warehouses, and price statistics.

Response (200):
```json
{
  "id": "string",
  "item_number": "12345",
  "name": "KIRKLAND ORGANIC EGGS",
  "display_name": "Kirkland Organic Eggs",
  "category": "dairy",
  "image_url": "string | null",
  "emoji": "string | null",
  "current_avg_price": 8.99,
  "created_at": 1700000000000,
  "updated_at": 1700000000000,
  "price_history": [
    {
      "id": "string",
      "product_id": "string",
      "price": 6.99,
      "warehouse_number": 123,
      "recorded_at": 1700000000000,
      "source_receipt_id": "string | null",
      "discount_amount": 2.00,
      "discount_type": "instant_savings | null"
    }
  ],
  "current_price": 8.99,
  "lowest_price": 6.99,
  "highest_price": 10.99,
  "available_warehouses": [123, 456],
  "available_stores": [
    { "warehouse_number": 123, "name": "San Diego", "city": "San Diego", "state": "CA" }
  ]
}
```

Errors:
- 404: `Product not found`

---

#### POST /api/products/lookup
Auth: required

Look up a product by item number. Creates a stub product if not found.

Request:
```json
{
  "item_number": "12345"
}
```

Response (200):
```json
{
  "product": { "...full product object..." }
}
```

Errors:
- 400: `item_number is required`

---

#### POST /api/products/add-price
Auth: required

Add a price observation for a product. Finds or creates the product by item number. Optionally creates a lightweight receipt for pantry tracking and auto-tracks the product via stock alerts.

Request:
```json
{
  "item_number": "12345",
  "price": 8.99,
  "discount_price": 6.99,
  "discount_type": "instant_savings",
  "store_warehouse": 123,
  "product_name": "Organic Eggs",
  "add_to_pantry": true
}
```

- `item_number` (required)
- `price` (required): Regular price
- `discount_price` (optional): Sale price (if on discount)
- `discount_type` (optional): Type of discount
- `store_warehouse` (optional): Warehouse number where price was observed
- `product_name` (optional): Human-readable name for the product
- `add_to_pantry` (optional, default true): Create a lightweight receipt for pantry

Response (201):
```json
{
  "product": { "...product object..." },
  "price_history_id": "ph_abcd1234",
  "recorded_price": 6.99
}
```

---

#### POST /api/products/scan-tag
Auth: required
Rate limit: 10 per minute per IP
Content-Type: multipart/form-data

Upload a Costco shelf price tag image for AI-powered OCR extraction using Claude Vision.

Request (form fields):
- `image` (required): Image file of the shelf tag
- `ocr_text` (optional): On-device OCR pre-extraction text

Response (200):
```json
{
  "extraction": {
    "item_number": "12345",
    "product_name": "Organic Eggs",
    "price": 8.99,
    "discount_price": 6.99,
    "discount_type": "instant_savings",
    "unit_price": "string | null",
    "has_asterisk": false
  },
  "image_url": "tags/usr_xxx/uuid.jpg"
}
```

Errors:
- 400: `Missing image file` / `Invalid file type` / `File too large`
- 422: `Could not read shelf tag` (AI extraction failed)
- 429: `Rate limit exceeded`

---

#### POST /api/products/:id/track
Auth: required
Feature flag: `stock_alerts`

Create a stock alert for a product. Sends a push notification confirming the alert.

Request:
```json
{
  "preferred_stores": [123, 456],
  "radius_miles": 25
}
```

- `preferred_stores` (required): Array of warehouse numbers
- `radius_miles` (optional, default 25)

Response (201):
```json
{
  "request_id": "uuid",
  "status": "active",
  "expires_at": 1702600000000
}
```

`expires_at` is Unix milliseconds (30 days from creation).

Errors:
- 400: `At least one preferred store is required`
- 404: `Product not found`

---

#### GET /api/stock-alerts
Auth: required
Feature flag: `stock_alerts`

List the user's active, unfulfilled stock alerts with product details.

Response (200):
```json
{
  "alerts": [
    {
      "id": "uuid",
      "user_id": "string",
      "product_id": "string",
      "product_name": "KIRKLAND ORGANIC EGGS",
      "item_number": "12345",
      "image_url": "string | null",
      "product_emoji": "string | null",
      "preferred_stores": [123, 456],
      "radius_miles": 25,
      "created_at": 1700000000000,
      "expires_at": 1702600000000,
      "fulfilled": 0,
      "active": 1
    }
  ],
  "total": 3
}
```

---

#### DELETE /api/stock-alerts/:id
Auth: required
Feature flag: `stock_alerts`

Cancel a stock alert (marks as inactive, does not hard-delete).

Response (200):
```json
{
  "message": "Stock alert cancelled successfully",
  "id": "uuid"
}
```

Errors:
- 404: `Stock alert not found`

---

### 4.4 Claims (2 endpoints)

---

#### GET /api/claims
Auth: required

List price-protection claims for the current user and household. Default returns eligible + claimed; use `?status=expired` for expired claims.

Query parameters:
- `status` (optional): `expired` to get expired claims only.

Response (200 -- default):
```json
{
  "claims": [
    {
      "id": "string",
      "owner_user_id": "string",
      "owner_display_name": "string | null",
      "receipt_id": "string",
      "receipt_item_id": "string",
      "item_number": "string",
      "product_name": "Kirkland Organic Eggs",
      "product_emoji": "string | null",
      "purchase_date": 1700000000000,
      "store_location": {
        "warehouse_number": 123,
        "city": "San Diego",
        "state": "CA"
      },
      "price_paid": 8.99,
      "lowest_seen_price": 6.99,
      "quantity": 1,
      "claim_amount": 2.00,
      "expires_at": 1702600000000,
      "detected_at": 1700000000000,
      "status": "eligible | claimed"
    }
  ],
  "total": 3,
  "claimable_total": 6.00
}
```

Response (200 -- `?status=expired`):
```json
{
  "claims": [...],
  "total": 5,
  "missed_total": 12.50
}
```

---

#### POST /api/claims/:id/claim
Auth: required

Mark a claim as claimed (user visited the Costco service desk). Auto-expires any stale claims first. Rejects claims on picker receipts.

Response (200):
```json
{
  "id": "string",
  "status": "claimed",
  "claim_amount": 2.00,
  "new_total_savings": 45.00
}
```

Errors:
- 403: `Price protection claims are not available for picker receipts`
- 404: `Claim not found or already processed`

---

### 4.5 Shopping List / My List (7 endpoints)

---

#### GET /api/my-list
Auth: required

List wishlist items for the current user and all household members. Includes product enrichment (emoji, sale status, trending status, prices).

Response (200):
```json
{
  "items": [
    {
      "id": "uuid",
      "owner_user_id": "string",
      "owner_display_name": "string | null",
      "product_id": "string | null",
      "name": "Organic Eggs",
      "quantity": 2,
      "notes": "string | null",
      "created_at": 1700000000000,
      "checked_off_at": null,
      "image_url": "string | null",
      "product_emoji": "string | null",
      "display_name": "Kirkland Organic Eggs",
      "current_avg_price": 8.99,
      "is_on_sale": 0,
      "is_trending": 0,
      "current_price": null,
      "regular_price": null
    }
  ],
  "total": 8
}
```

Items are sorted: unchecked first (newest first), then checked items.

---

#### POST /api/my-list
Auth: required

Add a single item. Deduplicates: if an unchecked item with the same name (case-insensitive) exists, increments its quantity instead of creating a duplicate. Automatically matches against the product catalog.

Request:
```json
{
  "product_id": "string (optional)",
  "name": "Organic Eggs (required)",
  "quantity": 1,
  "notes": "string (optional)"
}
```

Response (201 new / 200 deduplicated): Single wishlist item object.

Errors:
- 400: `name is required`

---

#### PATCH /api/my-list/:id
Auth: required

Update a wishlist item. Only provided fields are updated. Only the item owner can update.

Request (all fields optional):
```json
{
  "name": "string",
  "quantity": 2,
  "notes": "string | null",
  "checked_off_at": 1700000000000
}
```

Set `checked_off_at` to a timestamp to check off, or `null` to uncheck.

Response (200): Updated wishlist item object with product enrichment.

Errors:
- 400: `No updatable fields provided`
- 404: `Wishlist item not found`

---

#### DELETE /api/my-list/:id
Auth: required

Remove a wishlist item. Only the item owner can delete.

Response (200):
```json
{ "ok": true }
```

Errors:
- 404: `Wishlist item not found`

---

#### POST /api/my-list/parse-voice
Auth: required
Feature flag: `voice_input`
Rate limit: 10 per minute per IP

Parse raw speech transcription into shopping list items using Claude Haiku, then batch-insert them (with deduplication) into the user's wishlist.

Request:
```json
{
  "text": "I need two dozen eggs and some milk and bread (max 500 chars)"
}
```

Response (201):
```json
{
  "parsed": [
    { "name": "Eggs", "quantity": 2 },
    { "name": "Milk", "quantity": 1 },
    { "name": "Bread", "quantity": 1 }
  ],
  "added": [
    { "id": "uuid", "product_id": "string | null", "name": "Eggs", "quantity": 2, "...": "..." }
  ]
}
```

Errors:
- 400: `text is required` / `text must be 500 characters or fewer`
- 422: `Could not parse voice input`
- 429: `Rate limit exceeded`

---

#### POST /api/my-list/parse-bulk
Auth: required
Feature flag: `voice_input`
Rate limit: 10 per minute per IP

Parse pasted/shared text into shopping list items using Claude Haiku. Does NOT auto-add items to the list (returns parsed items for confirmation).

Request:
```json
{
  "text": "string (max 2000 chars)"
}
```

Response (200):
```json
{
  "items": [
    { "name": "Eggs", "quantity": 2 },
    { "name": "Milk", "quantity": 1 }
  ]
}
```

Errors:
- 400: `text is required` / `text must be 2000 characters or fewer`
- 429: `Rate limit exceeded`

---

#### POST /api/my-list/batch
Auth: required

Batch-add items with deduplication and product catalog matching. Max 50 items per batch.

Request:
```json
{
  "items": [
    { "name": "Eggs", "quantity": 2 },
    { "name": "Milk", "quantity": 1 }
  ]
}
```

Response (201):
```json
{
  "added": 2,
  "updated": 1,
  "items": [
    { "id": "uuid", "product_id": "string | null", "name": "Eggs", "quantity": 2, "...": "..." }
  ]
}
```

Errors:
- 400: `items array is required and must not be empty` / `Maximum 50 items allowed per batch`

---

### 4.6 Pantry (1 endpoint)

---

#### GET /api/pantry
Auth: required

De-duplicated list of products the user has bought, derived from confirmed receipt items. Includes burn-rate estimation (`days_until_restock`) and sale/trending flags.

Response (200):
```json
{
  "items": [
    {
      "product_id": "string | null",
      "name": "KIRKLAND ORGANIC EGGS",
      "display_name": "Kirkland Organic Eggs",
      "category": "dairy | null",
      "image_url": "string | null",
      "emoji": "string | null",
      "times_bought": 5,
      "last_bought_date": 1700000000000,
      "last_price_paid": 8.99,
      "is_on_sale": 0,
      "is_trending": 0,
      "days_until_restock": 14
    }
  ],
  "total": 25
}
```

`days_until_restock` is `null` if fewer than 2 purchases exist for the product.

---

### 4.7 Stats (2 endpoints)

---

#### GET /api/stats
Auth: required

Aggregated statistics: total savings, claimable amount, receipt/claim/item counts.

Response (200):
```json
{
  "total_savings": 45.00,
  "total_claimable": 15.50,
  "receipt_count": 12,
  "claim_count": 3,
  "claimed_count": 2,
  "item_count": 73
}
```

---

#### GET /api/feature-flags
Auth: required

Returns evaluated feature flags for the authenticated user.

Response (200):
```json
{
  "flags": {
    "household": true,
    "stock_alerts": true,
    "voice_input": true,
    "share_my_list": false,
    "paid-pickers": false,
    "whatsapp_bot": false
  }
}
```

---

### 4.8 Household (11 endpoints)

All household routes require the `household` feature flag.

---

#### POST /api/household
Auth: required
Feature flag: `household`

Create a new household. The creator becomes the owner.

Request:
```json
{
  "name": "The Smiths (1-60 chars, required)"
}
```

Response (201):
```json
{
  "household": {
    "id": "hh_abcd1234",
    "name": "The Smiths",
    "created_by": "usr_xxx",
    "role": "owner",
    "member_ids": ["usr_xxx"],
    "members": [
      { "user_id": "usr_xxx", "display_name": "James", "role": "owner", "joined_at": 1700000000 }
    ]
  }
}
```

Errors:
- 400: `name is required` / `name must be 60 characters or fewer`
- 409: `You are already in a household. Leave it before creating a new one.`

---

#### GET /api/household
Auth: required
Feature flag: `household`

Get current household and all members.

Response (200):
```json
{
  "household": { "...same structure as POST response..." }
}
```

Errors:
- 404: `You are not in a household`

---

#### PATCH /api/household
Auth: required
Feature flag: `household`

Rename the household. Owner only.

Request:
```json
{
  "name": "New Name"
}
```

Response (200):
```json
{
  "household": { "id": "hh_abcd1234", "name": "New Name" }
}
```

Errors:
- 403: `Only the household owner can rename it`
- 404: `You are not in a household`

---

#### DELETE /api/household
Auth: required
Feature flag: `household`

Disband the household. Owner only. Cascades to members and invites.

Response (200):
```json
{ "ok": true }
```

Errors:
- 403: `Only the household owner can disband it`
- 404: `You are not in a household`

---

#### DELETE /api/household/me
Auth: required
Feature flag: `household`

Leave the household. Members only (owner must transfer ownership or disband).

Response (200):
```json
{ "ok": true }
```

Errors:
- 403: `Transfer ownership or disband the household first.`
- 404: `You are not in a household`

---

#### POST /api/household/invites
Auth: required
Feature flag: `household`

Generate a 6-character invite code. Valid for 7 days. Any household member can create invites.

Response (201):
```json
{
  "invite": {
    "id": "inv_abcd1234",
    "code": "ABC123",
    "expires_at": 1700604800
  }
}
```

`expires_at` is Unix seconds.

---

#### GET /api/household/invites
Auth: required
Feature flag: `household`

List active (unused, unexpired) invites for the current household.

Response (200):
```json
{
  "invites": [
    { "id": "inv_xxx", "code": "ABC123", "expires_at": 1700604800, "created_at": 1700000000 }
  ]
}
```

---

#### DELETE /api/household/invites/:code
Auth: required
Feature flag: `household`

Revoke an invite. Owner only.

Response (200):
```json
{ "ok": true }
```

Errors:
- 403: `Only the household owner can revoke invites`

---

#### GET /api/household/join/:code
Auth: required
Feature flag: `household`

Preview a household before joining. Code is case-insensitive.

Response (200):
```json
{
  "household_name": "The Smiths",
  "member_count": 3,
  "expires_at": 1700604800
}
```

Errors:
- 404: `Invalid invite code`
- 409: `Invite code has already been used`
- 410: `Invite code has expired`

---

#### POST /api/household/join
Auth: required
Feature flag: `household`

Join a household using an invite code.

Request:
```json
{
  "code": "ABC123"
}
```

Response (200):
```json
{
  "household": { "...full household object with members..." }
}
```

Errors:
- 400: `code is required`
- 404: `Invalid invite code`
- 409: `You are already in a household` / `Invite code has already been used`
- 410: `Invite code has expired`

---

#### DELETE /api/household/members/:targetUserId
Auth: required
Feature flag: `household`

Remove a member from the household. Owner only. Cannot remove yourself.

Response (200):
```json
{ "ok": true }
```

Errors:
- 400: `Cannot remove yourself`
- 403: `Only the household owner can remove members`
- 404: `Target user is not in your household`

---

#### PATCH /api/household/members/:targetUserId/role
Auth: required
Feature flag: `household`

Transfer household ownership. Current owner becomes a member.

Request:
```json
{
  "role": "owner"
}
```

Response (200):
```json
{ "ok": true }
```

Errors:
- 400: `Only role transfer to "owner" is supported` / `Cannot transfer ownership to yourself`
- 403: `Only the owner can transfer ownership`
- 404: `Target user is not in your household`

---

### 4.9 Delivery / Share My List (7 endpoints)

All delivery routes require the `share_my_list` feature flag.

---

#### GET /api/delivery/users/lookup
Auth: required
Feature flag: `share_my_list`

Resolve a phone number to a Costko user.

Query: `?phone=+16195550101`

Response (200):
```json
{
  "user": { "id": "usr_xxx", "display_name": "Sarah" }
}
```

Errors:
- 400: `phone query parameter is required` / `Invalid phone number format`
- 404: `No Costko account found for this number` (code: `USER_NOT_FOUND`)

---

#### GET /api/delivery/stores
Auth: required
Feature flag: `share_my_list`

List available stores.

Response (200):
```json
{
  "stores": [
    { "id": "string", "name": "Costco San Diego", "...": "..." }
  ]
}
```

---

#### POST /api/delivery/sessions
Auth: required
Feature flag: `share_my_list`

Create a delivery session. Snapshots the requester's current My List and assigns a recipient + store. Session expires in 8 hours.

Request:
```json
{
  "recipient_user_id": "usr_xxx",
  "store_id": "string"
}
```

Response (201):
```json
{
  "session": {
    "id": "ds_xxxxxxxxxxxx",
    "requester_user_id": "string",
    "recipient_user_id": "string",
    "store_id": "string",
    "store_name": "Costco San Diego",
    "status": "active",
    "expires_at": 1700028800,
    "created_at": 1700000000,
    "items": [
      { "id": "dsi_xxx", "item_id": "string", "name": "Eggs", "quantity": 2, "status": "pending" }
    ]
  }
}
```

Errors:
- 400: `recipient_user_id is required` / `store_id is required`
- 404: `Recipient not found` / `Store not found`
- 409: `You already have an active session` (code: `ACTIVE_SESSION_EXISTS`)
- 422: `Your list is empty` (code: `EMPTY_LIST`)

---

#### GET /api/delivery/sessions
Auth: required
Feature flag: `share_my_list`

List delivery sessions for the calling user.

Query parameters:
- `role` (optional): `requester` or `recipient`. Omit for both.
- `status` (optional): Filter by status (e.g., `active`, `complete`, `expired`, `cancelled`).

Response (200):
```json
{
  "sessions": [
    {
      "...session fields...",
      "store_name": "string",
      "requester_display_name": "string",
      "recipient_display_name": "string",
      "items_total": 5,
      "items_picked_up": 3
    }
  ]
}
```

---

#### GET /api/delivery/sessions/:id
Auth: required
Feature flag: `share_my_list`

Get session detail with all item states. Only accessible to participants.

Response (200):
```json
{
  "session": {
    "...session fields...",
    "items_total": 5,
    "items_picked_up": 3,
    "items_not_found": 1,
    "items": [
      { "id": "dsi_xxx", "item_id": "string", "name": "Eggs", "quantity": 2, "status": "picked_up", "resolved_at": 1700001000, "resolved_by": "usr_xxx" }
    ]
  }
}
```

Errors:
- 403: `Access denied` (code: `NOT_A_PARTICIPANT`)
- 404: `Session not found`
- 410: `Session has expired` (code: `SESSION_EXPIRED`)

---

#### PATCH /api/delivery/sessions/:id/items/:itemId
Auth: required
Feature flag: `share_my_list`

Check off or update an item's status. Recipient (shopper) only. Auto-closes session when all items are resolved.

Request:
```json
{
  "status": "picked_up | not_found | pending"
}
```

Response (200):
```json
{
  "item": {
    "id": "dsi_xxx",
    "status": "picked_up",
    "resolved_at": 1700001000,
    "resolved_by": "usr_xxx"
  },
  "session_complete": false,
  "completed_at": null
}
```

Errors:
- 400: `status must be picked_up, not_found, or pending`
- 403: `Only the recipient can update items`
- 404: `Session not found` / `Item not found`
- 409: `Session is ${status}`
- 410: `Session has expired`

---

#### POST /api/delivery/sessions/:id/complete
Auth: required
Feature flag: `share_my_list`

Manually mark a session as complete. Recipient only.

Response (200):
```json
{
  "ok": true,
  "session_id": "ds_xxx",
  "status": "complete",
  "completed_at": 1700001000
}
```

Errors:
- 403: `Only the recipient can complete a session`
- 404: `Session not found`
- 409: `Session is already ${status}`

---

#### DELETE /api/delivery/sessions/:id
Auth: required
Feature flag: `share_my_list`

Cancel an active session. Requester only.

Response (200):
```json
{
  "ok": true,
  "session_id": "ds_xxx",
  "status": "cancelled"
}
```

Errors:
- 403: `Only the requester can cancel a session`
- 404: `Session not found`
- 409: `Session is already ${status}`

---

### 4.10 Paid Pickers (18 endpoints)

All picker routes require the `paid-pickers` feature flag.

---

#### POST /api/pickers/register
Auth: required
Feature flag: `paid-pickers`

Register as a picker. Requires a phone number on the user account.

Request:
```json
{
  "display_name": "string (1-50 chars, required)",
  "rate_cents": 1500,
  "bio": "string (optional)"
}
```

- `rate_cents` (required): Picker's rate in cents (0-50000)

Response (201):
```json
{
  "picker": { "...full picker object..." }
}
```

Errors:
- 400: `display_name must be 1-50 characters` / `rate_cents must be 0-50000`
- 409: `Already registered as a picker`
- 422: `Phone number required to become a picker` (code: `PHONE_REQUIRED`)

---

#### GET /api/pickers/me
Auth: required
Feature flag: `paid-pickers`

Get the current user's picker profile.

Response (200):
```json
{
  "picker": { "...full picker object..." }
}
```

Errors:
- 404: `Not registered as a picker`

---

#### PATCH /api/pickers/me
Auth: required
Feature flag: `paid-pickers`

Update picker profile.

Request (all optional):
```json
{
  "display_name": "string",
  "rate_cents": 2000,
  "bio": "string",
  "is_active": 1
}
```

`is_active: 1` requires phone to be verified first.

Errors:
- 404: `Not registered as a picker`
- 422: `Phone must be verified before going active`

---

#### POST /api/pickers/me/location
Auth: required
Feature flag: `paid-pickers`

Update picker's GPS coordinates. Returns nearest warehouse within 10 miles.

Request:
```json
{
  "lat": 32.7157,
  "lng": -117.1611
}
```

Response (200):
```json
{
  "ok": true,
  "nearest_warehouse": {
    "warehouse_number": 123,
    "name": "San Diego",
    "distance_miles": 2.3
  }
}
```

---

#### POST /api/pickers/me/stripe-onboarding
Auth: required
Feature flag: `paid-pickers`

Create a Stripe Express connected account and return the onboarding URL.

Response (200):
```json
{
  "onboarding_url": "https://connect.stripe.com/...",
  "stripe_account_id": "acct_xxx"
}
```

---

#### GET /api/pickers/me/stripe-status
Auth: required
Feature flag: `paid-pickers`

Check Stripe onboarding status.

Response (200):
```json
{
  "stripe_account_id": "acct_xxx",
  "charges_enabled": true,
  "payouts_enabled": true,
  "onboarding_complete": true,
  "details_submitted": true
}
```

---

#### POST /api/pickers/requests
Auth: required
Feature flag: `paid-pickers`

Create a pickup request. Snapshots the requester's unchecked wishlist items. Request expires in 4 hours.

Request:
```json
{
  "warehouse_number": 123,
  "delivery_address": "123 Main St",
  "delivery_lat": 32.7157,
  "delivery_lng": -117.1611
}
```

Response (201):
```json
{
  "request": {
    "id": "preq_xxxxxxxxxxxx",
    "...all request fields...",
    "items": [
      { "id": "pri_xxx", "item_id": "string", "name": "Eggs", "quantity": 2, "status": "pending" }
    ]
  }
}
```

Errors:
- 400: `warehouse_number, delivery_address, delivery_lat, delivery_lng are required`
- 404: `Warehouse not found`
- 409: `You already have an active request` (code: `ACTIVE_REQUEST_EXISTS`)
- 422: `Your list is empty` / `Delivery address is too far from warehouse` (>50 miles)

---

#### GET /api/pickers/requests/active
Auth: required
Feature flag: `paid-pickers`

Get the requester's current active request (if any).

Response (200):
```json
{
  "request": {
    "...request fields...",
    "picker": { "...picker object or null..." },
    "items": [...]
  }
}
```

Returns `{ "request": null }` if no active request.

---

#### GET /api/pickers/requests/available
Auth: required
Feature flag: `paid-pickers`

List open requests near the picker's current location (within 10 miles of nearby warehouses).

Query: `?warehouse_number=123` (optional filter)

Response (200):
```json
{
  "requests": [
    {
      "id": "preq_xxx",
      "warehouse_number": 123,
      "warehouse_name": "San Diego",
      "items_count": 5,
      "delivery_distance_miles": 3.2,
      "expires_at": 1700014400,
      "created_at": 1700000000
    }
  ]
}
```

Errors:
- 403: `Must be an active picker`

---

#### GET /api/pickers/requests/:id
Auth: required
Feature flag: `paid-pickers`

Get request detail with items and picker info. Only accessible to requester or assigned picker.

Response (200):
```json
{
  "request": {
    "...all fields...",
    "picker": { "...picker object or null..." },
    "items": [...],
    "items_total": 5,
    "items_found": 3,
    "items_not_found": 1,
    "items_pending": 1
  }
}
```

Errors:
- 403: `Not authorized`
- 404: `Request not found`

---

#### GET /api/pickers/requests/:id/available-pickers
Auth: required
Feature flag: `paid-pickers`

List nearby active pickers for an open request. Requester only.

Response (200):
```json
{
  "pickers": [
    {
      "id": "pkr_xxx",
      "display_name": "string",
      "rate_cents": 1500,
      "avg_rating": 4.8,
      "total_ratings": 25,
      "total_deliveries": 50,
      "distance_miles": 2.1,
      "...other picker fields..."
    }
  ]
}
```

Errors:
- 403: `Not authorized`
- 409: `Request is not open`

---

#### POST /api/pickers/requests/:id/select-picker
Auth: required
Feature flag: `paid-pickers`

Requester selects a picker. If the picker has Stripe set up, creates a PaymentIntent with manual capture.

Request:
```json
{
  "picker_id": "pkr_xxx",
  "tip_cents": 500,
  "payment_method_id": "ppm_xxx (optional, defaults to default card)"
}
```

Response (200):
```json
{
  "request": { "...updated request..." },
  "payment": {
    "id": "ppay_xxx",
    "status": "authorized",
    "rate_cents": 1500,
    "tip_cents": 500,
    "platform_fee_cents": 150,
    "total_charge_cents": 2000
  }
}
```

Errors:
- 400: `picker_id is required`
- 402: `Payment failed` (code: `PAYMENT_FAILED`)
- 404: `Request not found` / `Picker not found or not active`
- 409: `Request is not open`
- 422: `Picker has not completed payment setup` / `A payment method is required`

---

#### DELETE /api/pickers/requests/:id
Auth: required
Feature flag: `paid-pickers`

Cancel a request. Requester only. Voids any authorized payment.

Response (200):
```json
{ "ok": true, "status": "cancelled" }
```

Errors:
- 403: `Not authorized`
- 404: `Request not found`
- 409: `Cannot cancel request in current status`

---

#### POST /api/pickers/requests/:id/accept
Auth: required
Feature flag: `paid-pickers`

Picker accepts an open request (alternative to requester selecting a picker).

Response (200):
```json
{
  "request": { "...updated request with picker_id set..." }
}
```

Errors:
- 403: `Must be an active, phone-verified picker`
- 404: `Request not found`
- 409: `Request is not open`

---

#### POST /api/pickers/requests/:id/status
Auth: required
Feature flag: `paid-pickers`

Picker advances request status through the lifecycle.

Valid transitions: `accepted` -> `shopping` -> `on_the_way` -> `delivered`

Request:
```json
{
  "status": "shopping | on_the_way | delivered"
}
```

When status becomes `delivered`, the authorized payment is captured automatically.

Response (200):
```json
{
  "request": { "...updated request..." }
}
```

Errors:
- 400: `Invalid transition from ${current} to ${requested}`
- 403: `Not a picker`
- 404: `Request not found or not assigned to you`

---

#### PATCH /api/pickers/requests/:id/items/:itemId
Auth: required
Feature flag: `paid-pickers`

Picker updates an item's status (found or not found, with optional substitution).

Request:
```json
{
  "status": "found | not_found",
  "substitute_name": "string (optional, only with not_found)"
}
```

Response (200):
```json
{
  "item": { "...updated item..." }
}
```

---

#### POST /api/pickers/requests/:id/substitution-response
Auth: required
Feature flag: `paid-pickers`

Requester approves or declines a substitution proposed by the picker.

Request:
```json
{
  "item_id": "pri_xxx",
  "approved": true
}
```

Response (200):
```json
{
  "item": { "...updated item with status substituted or skipped..." }
}
```

---

#### POST /api/pickers/requests/:id/rate
Auth: required
Feature flag: `paid-pickers`

Requester rates the picker after delivery. Updates the picker's average rating.

Request:
```json
{
  "rating": 5,
  "comment": "string (optional)"
}
```

Response (201):
```json
{
  "rating": { "id": "prt_xxx", "rating": 5, "comment": "...", "...": "..." }
}
```

Errors:
- 400: `rating must be 1-5`
- 403: `Not authorized`
- 409: `Request must be delivered before rating` / `Already rated`

---

#### POST /api/pickers/payment-setup
Auth: required
Feature flag: `paid-pickers`

Create a Stripe Customer and SetupIntent for saving a payment method.

Response (200):
```json
{
  "client_secret": "seti_xxx_secret_xxx",
  "customer_id": "cus_xxx",
  "publishable_key": "pk_xxx"
}
```

---

#### POST /api/pickers/payment-methods/confirm
Auth: required
Feature flag: `paid-pickers`

Save a payment method after SetupIntent confirmation.

Request:
```json
{
  "setup_intent_id": "seti_xxx"
}
```

Response (201):
```json
{
  "payment_method": {
    "id": "ppm_xxx",
    "card_brand": "visa",
    "card_last4": "4242",
    "is_default": 1
  }
}
```

---

#### GET /api/pickers/payment-methods
Auth: required
Feature flag: `paid-pickers`

List saved payment methods.

Response (200):
```json
{
  "payment_methods": [
    { "id": "ppm_xxx", "card_brand": "visa", "card_last4": "4242", "is_default": 1 }
  ]
}
```

---

#### DELETE /api/pickers/payment-methods/:id
Auth: required
Feature flag: `paid-pickers`

Remove a saved payment method. If it was the default, the next card is promoted.

Response (200):
```json
{ "ok": true }
```

Errors:
- 404: `Payment method not found`

---

### 4.11 Notifications (5 endpoints)

---

#### POST /api/push/register
Auth: required

Register an APNs device token for push notifications. Upserts on (user_id, token).

Request:
```json
{
  "token": "hex-encoded APNs device token (required)",
  "environment": "sandbox | production (default production)"
}
```

Response (200):
```json
{ "ok": true }
```

---

#### DELETE /api/push/unregister
Auth: required

Remove an APNs device token.

Request:
```json
{
  "token": "hex-encoded APNs device token (required)"
}
```

Response (200):
```json
{ "ok": true }
```

---

#### GET /api/notifications
Auth: required

List the current user's notification history, newest first.

Query: `?limit=50` (max 100)

Response (200):
```json
{
  "notifications": [
    {
      "id": "string",
      "type": "claim_detected | stock_alert | test | ...",
      "title": "string",
      "body": "string",
      "data": "JSON string",
      "sent_at": 1700000000,
      "read_at": null
    }
  ]
}
```

`sent_at` and `read_at` are Unix seconds.

---

#### POST /api/notifications/read-all
Auth: required

Mark all unread notifications as read.

Response (200):
```json
{ "ok": true }
```

---

#### POST /api/notifications/test-push
Auth: required

Send a test push notification to all registered devices for the current user. Dev/debug use.

Response (200):
```json
{ "ok": true, "sent": 2 }
```

Errors:
- 404: `No device tokens registered for this user`

---

### 4.12 User Profile (4 endpoints)

---

#### GET /api/users/me
Auth: required

Returns the current user's profile (id, display_name, phone).

Response (200):
```json
{
  "user": {
    "id": "string",
    "display_name": "string | null",
    "phone": "string | null"
  }
}
```

Errors:
- 404: `User not found`

---

#### PATCH /api/users/me/phone
Auth: required

Save or update the current user's phone number.

Request:
```json
{
  "phone": "+16195550101"
}
```

Validation: Must be E.164 format (`/^\+[1-9]\d{6,14}$/`).

Response (200):
```json
{
  "user": { "id": "string", "phone": "+16195550101" }
}
```

Errors:
- 409: `This phone number is already associated with another account` (code: `PHONE_ALREADY_REGISTERED`)
- 422: `phone must be present and in E.164 format`

---

#### POST /api/users/link-whatsapp
Auth: required

Start WhatsApp linking. Sends a 6-digit verification code via WhatsApp.

Request:
```json
{
  "phone_number": "+16195550101"
}
```

Response (200):
```json
{
  "success": true,
  "message": "Verification code sent to WhatsApp",
  "phone_number": "+16195550101",
  "expires_in_seconds": 600
}
```

Errors:
- 400: `Phone number is required` / `Invalid phone number format`
- 409: `This WhatsApp number is already linked to another account`

---

#### POST /api/users/verify-whatsapp
Auth: required

Verify the WhatsApp linking code.

Request:
```json
{
  "code": "123456"
}
```

Response (200):
```json
{
  "success": true,
  "message": "WhatsApp successfully linked",
  "phone_number": "+16195550101"
}
```

Errors:
- 400: `Valid 6-digit code is required` / `No verification pending` / `Verification code expired` / `Invalid verification code`

---

#### DELETE /api/users/unlink-whatsapp
Auth: required

Unlink WhatsApp number from the account.

Response (200):
```json
{
  "success": true,
  "message": "WhatsApp unlinked successfully"
}
```

---

#### GET /api/users/whatsapp-status
Auth: required

Check WhatsApp linking status.

Response (200):
```json
{
  "linked": true,
  "phone_number": "+16195550101",
  "last_message_at": 1700000000000
}
```

---

### 4.13 Analytics (1 endpoint)

---

#### POST /api/analytics/event
Auth: required

Log a client-side analytics event.

Request:
```json
{
  "event_name": "demo_tour_started | demo_tour_completed"
}
```

Only the allowed event names above are accepted.

Response (201):
```json
{ "ok": true }
```

Errors:
- 400: `Invalid event_name`

---

### 4.14 Webhooks (3 endpoints)

---

#### GET /api/webhooks/whatsapp
Auth: public
Feature flag: `whatsapp_bot`

Meta WhatsApp webhook verification endpoint. Responds with the challenge parameter.

---

#### POST /api/webhooks/whatsapp
Auth: public
Feature flag: `whatsapp_bot`

Meta WhatsApp webhook for incoming messages. Must respond with 200 OK.

---

#### POST /api/webhooks/stripe-connect
Auth: public (Stripe signature verification)

Stripe Connect webhook for payment events (`payment_intent.succeeded`, `payment_intent.payment_failed`, `charge.dispute.created`, `account.updated`).

Response (200):
```json
{ "received": true }
```

---

### 4.15 Admin (12 endpoints)

All admin endpoints require either `X-Admin-Secret` header or `?secret=<value>` query parameter. In production, protected by Cloudflare Access.

---

#### POST /api/admin/compute-trending
Auth: X-Admin-Secret

Manually recompute trending items.

Response (200):
```json
{ "ok": true, "message": "Trending recomputed" }
```

---

#### POST /api/admin/reprocess-receipts
Auth: X-Admin-Secret

Re-queue receipts for OCR processing.

Request:
```json
{ "receipt_ids": ["uuid1", "uuid2"] }
```

Response (200):
```json
{
  "queued": ["uuid1"],
  "failed": [{ "id": "uuid2", "error": "not found" }]
}
```

---

#### POST /api/admin/backfill-product-names
Auth: X-Admin-Secret

Generate clean display names for products where `display_name` is NULL using Claude Haiku.

Response (200):
```json
{
  "ok": true,
  "updated": 15,
  "results": [
    { "id": "prod_xxx", "name": "KS ORG EGGS", "display_name": "Kirkland Organic Eggs" }
  ]
}
```

---

#### POST /api/admin/purge-deleted-users
Auth: X-Admin-Secret

Hard-delete users whose `deleted_at` is older than 1 year. Cleans up all associated data and R2 objects.

Response (200):
```json
{
  "ok": true,
  "purged": 3,
  "user_ids": ["usr_xxx", "usr_yyy", "usr_zzz"]
}
```

---

#### POST /api/admin/run-cron
Auth: X-Admin-Secret

Manually trigger cron tasks (trending recompute, delivery session expiry, stale demo cleanup).

Response (200):
```json
{
  "status": "success",
  "error_message": null,
  "tasks_run": "{\"trending\":\"ok\",\"delivery_expiry\":\"ok\",\"demo_cleanup\":\"ok (deleted 0)\"}",
  "duration_ms": 1234
}
```

---

#### GET /api/admin/cron-logs
Auth: X-Admin-Secret

HTML dashboard showing cron run history with a trigger button.

---

#### GET /api/admin/feature-flags
Auth: X-Admin-Secret

List all feature flags with segments and overrides.

Response (200):
```json
{
  "flags": [
    {
      "id": "household",
      "description": "Enable household features",
      "enabled": 1,
      "rollout_percentage": 100,
      "is_stale": false,
      "segments": [
        { "id": "uuid", "flag_id": "household", "segment_type": "user_list", "user_list": "[\"usr_xxx\"]" }
      ],
      "overrides": [
        { "flag_id": "household", "user_id": "usr_xxx", "enabled": 1 }
      ],
      "created_at": 1700000000,
      "updated_at": 1700000000
    }
  ]
}
```

---

#### POST /api/admin/feature-flags
Auth: X-Admin-Secret

Create a new feature flag.

Request:
```json
{
  "id": "new_feature",
  "description": "What this flag controls",
  "enabled": 0,
  "rollout_percentage": 0
}
```

Response (201):
```json
{ "ok": true, "id": "new_feature" }
```

Errors:
- 400: `id is required`
- 409: `Flag already exists`

---

#### PUT /api/admin/feature-flags/:id
Auth: X-Admin-Secret

Update a feature flag.

Request (all optional):
```json
{
  "description": "string",
  "enabled": 1,
  "rollout_percentage": 50,
  "rollout_start_percentage": 0,
  "rollout_end_percentage": 100,
  "rollout_start_date": 1700000000,
  "rollout_end_date": 1702600000
}
```

Response (200):
```json
{ "ok": true }
```

Errors:
- 404: `Flag not found`

---

#### DELETE /api/admin/feature-flags/:id
Auth: X-Admin-Secret

Hard-delete a feature flag.

Response (200):
```json
{ "ok": true }
```

---

#### POST /api/admin/feature-flags/:id/segments
Auth: X-Admin-Secret

Add a segment to a flag.

Request:
```json
{
  "segment_type": "user_list",
  "user_list": ["usr_xxx", "usr_yyy"]
}
```

Response (201):
```json
{ "ok": true, "segment_id": "uuid" }
```

---

#### DELETE /api/admin/feature-flags/:id/segments/:segmentId
Auth: X-Admin-Secret

Remove a segment from a flag.

Response (200):
```json
{ "ok": true }
```

---

#### PUT /api/admin/feature-flags/:id/overrides/:userId
Auth: X-Admin-Secret

Set a per-user override for a flag.

Request:
```json
{ "enabled": 1 }
```

Response (200):
```json
{ "ok": true }
```

---

#### DELETE /api/admin/feature-flags/:id/overrides/:userId
Auth: X-Admin-Secret

Remove a per-user override.

Response (200):
```json
{ "ok": true }
```

---

#### GET /api/admin/feature-flags/:id/audit-log
Auth: X-Admin-Secret

List audit log entries for a flag.

Response (200):
```json
{
  "entries": [
    {
      "id": "uuid",
      "flag_id": "household",
      "action": "update",
      "old_value": "{...}",
      "new_value": "{...}",
      "changed_by": "admin",
      "created_at": 1700000000
    }
  ]
}
```

---

#### GET /api/admin/feature-flags/:id/stats
Auth: X-Admin-Secret

Evaluation statistics for a flag (last 30 days).

Response (200):
```json
{
  "stats": [
    { "date": "2026-03-20", "true_count": 150, "false_count": 25 }
  ]
}
```

---

### 4.16 Health / Public (4 endpoints)

---

#### GET /
Auth: public

API index. Returns service name and endpoint summary.

---

#### GET /health
Auth: public

Global health check.

Response (200):
```json
{
  "status": "ok",
  "timestamp": 1700000000000,
  "environment": "production"
}
```

---

#### GET /auth/health
Auth: public

Auth service health check.

Response (200):
```json
{
  "status": "ok",
  "service": "auth",
  "timestamp": 1700000000000,
  "environment": "production",
  "auth": "device-identity"
}
```

---

#### GET /api/notifications/health
Auth: public

APNs push notification health check. Confirms credentials are configured.

Response (200):
```json
{
  "status": "healthy",
  "push_provider": "apns",
  "apns_team_id": "ABCD...",
  "apns_key_id": "1234...",
  "apns_private_key_present": true,
  "timestamp": "2026-03-23T00:00:00.000Z"
}
```

---

#### GET /api/whatsapp/health
Auth: public
Feature flag: `whatsapp_bot`

WhatsApp bot health check.

Response (200):
```json
{
  "status": "healthy",
  "timestamp": 1700000000000,
  "checks": { "configuration": true, "database": true },
  "version": "1.0.0-phase1"
}
```

---

#### GET /join/:code
Auth: public

Universal Link redirect for household invites. On iOS devices, redirects to `costko://join/<code>`. On other devices, shows an HTML page with a link.

---

## 5. Error Reference

### Global error format

```json
{
  "error": "Short error description",
  "message": "Detailed explanation (dev environments only)",
  "code": "MACHINE_READABLE_CODE (when available)"
}
```

### Status code table

| Status | Meaning | Agent action |
|--------|---------|-------------|
| 200 | Success | Process response normally |
| 201 | Created | Resource created; process response normally |
| 400 | Bad request | Fix the request body/params and retry |
| 401 | Unauthorized | Token expired or invalid. Re-register device via `POST /auth/device` |
| 402 | Payment failed | Stripe charge declined. Prompt user for different payment method |
| 403 | Forbidden | Feature not enabled, or user lacks permission. Check feature flags or user role |
| 404 | Not found | Resource does not exist. Verify the ID is correct |
| 409 | Conflict | Duplicate operation (already exists, already linked, etc.). Do not retry |
| 410 | Gone | Resource expired (invite code, delivery session). Obtain a new one |
| 422 | Unprocessable | Validation failed (invalid phone, voice parse failed, etc.). Fix input |
| 429 | Rate limited | Back off. Wait 60 seconds before retrying |
| 500 | Server error | Retry once after 2 seconds. If still failing, report the error |
| 503 | Service degraded | Health check indicates partial outage. Retry with backoff |

### Common error codes

| Code | Endpoint(s) | Meaning |
|------|-------------|---------|
| `USER_NOT_FOUND` | delivery/users/lookup | Phone number not registered |
| `RECIPIENT_NOT_FOUND` | delivery/sessions | Recipient user does not exist |
| `ACTIVE_SESSION_EXISTS` | delivery/sessions | Already has an active delivery session |
| `ACTIVE_REQUEST_EXISTS` | pickers/requests | Already has an active picker request |
| `SESSION_EXPIRED` | delivery/sessions | Session past its 8-hour window |
| `EMPTY_LIST` | delivery/sessions, pickers/requests | No unchecked items in My List |
| `NOT_A_PARTICIPANT` | delivery/sessions | User is not requester or recipient |
| `PHONE_REQUIRED` | pickers/register | User must set phone before becoming picker |
| `PHONE_NOT_VERIFIED` | pickers/me | Phone verification needed before going active |
| `PHONE_ALREADY_REGISTERED` | users/me/phone | Phone already linked to another account |
| `PAYMENT_METHOD_REQUIRED` | pickers/select-picker | Must add a card before selecting a paid picker |
| `PAYMENT_FAILED` | pickers/select-picker | Stripe charge authorization failed |
| `PICKER_NOT_PAYMENT_READY` | pickers/select-picker | Picker hasn't completed Stripe onboarding |
| `PICKER_RECEIPT_NOT_CLAIMABLE` | claims/:id/claim | Price protection not available for picker receipts |
| `RECEIPT_ALREADY_LINKED` | receipts/upload | A receipt is already attached to the picker request |
| `DELIVERY_TOO_FAR` | pickers/requests | Delivery address >50 miles from warehouse |
| `INVALID_PHONE` | users/me/phone | Phone not in E.164 format |
| `FLAG_EXISTS` | admin/feature-flags | Feature flag ID already exists |

---

## 6. Rate Limits

| Endpoint | Limit | Window | Scope |
|----------|-------|--------|-------|
| `POST /auth/demo` | 5 requests | 1 hour | Per IP |
| `POST /api/receipts/upload` | 10 requests | 60 seconds | Per IP |
| `POST /api/my-list/parse-voice` | 10 requests | 60 seconds | Per IP |
| `POST /api/my-list/parse-bulk` | 10 requests | 60 seconds | Per IP |
| `POST /api/products/scan-tag` | 10 requests | 60 seconds | Per IP |
| All other endpoints | No hard limit | -- | -- |

Rate limit responses return status 429 with:
```json
{
  "error": "Too many requests",
  "message": "Rate limit exceeded. Try again in 60 seconds."
}
```

---

## 7. Feature Flags

Feature flags gate access to specific features. An agent should call `GET /api/feature-flags` after authentication to check which features are available.

| Flag | Gates | Description |
|------|-------|-------------|
| `household` | All `/api/household/*` routes | Household creation, invites, member management |
| `stock_alerts` | `POST /api/products/:id/track`, `GET /api/stock-alerts`, `DELETE /api/stock-alerts/:id` | Product stock alert system |
| `voice_input` | `POST /api/my-list/parse-voice`, `POST /api/my-list/parse-bulk` | Voice and bulk text parsing for shopping list |
| `share_my_list` | All `/api/delivery/*` routes | Share My List delivery sessions |
| `paid-pickers` | All `/api/pickers/*` routes | Paid picker marketplace |
| `whatsapp_bot` | WhatsApp webhook endpoints, `/api/whatsapp/health` | WhatsApp bot integration |

When a feature flag is disabled, gated endpoints return:
```json
{ "error": "Feature not enabled" }
```
with status 403.

---

## 8. Timestamp Reference

Most fields use Unix milliseconds (`Date.now()` in JavaScript). The following fields use Unix seconds:

- `household_invites.expires_at`, `household_invites.created_at`
- `household_members.joined_at`
- `households.created_at`, `households.updated_at`
- `delivery_sessions.expires_at`, `delivery_sessions.created_at`, `delivery_sessions.completed_at`
- `delivery_session_items.resolved_at`
- `notifications.sent_at`, `notifications.read_at`
- `device_tokens.created_at`, `device_tokens.updated_at`
- `devices.created_at`, `devices.last_seen_at`
- `picker_requests.expires_at`, `picker_requests.created_at`, and all `*_at` fields
- `pickers.created_at`, `pickers.updated_at`, `pickers.last_location_at`
- `feature_flags.created_at`, `feature_flags.updated_at`
- `cron_logs.started_at`, `cron_logs.finished_at`
- `demo_expires_at` (on user object, returned from `POST /auth/demo`)

Fields using Unix milliseconds:
- `receipts.purchase_date`, `receipts.uploaded_at`
- `receipt_items.returned_at`
- `claims.purchase_date`, `claims.expires_at`, `claims.detected_at`, `claims.created_at`, `claims.updated_at`
- `wishlist_items.created_at`, `wishlist_items.checked_off_at`
- `price_history.recorded_at`
- `users.created_at`, `users.updated_at`
- `users.whatsapp_last_message_at`, `users.whatsapp_verification_expires`
- `stock_requests.created_at`, `stock_requests.expires_at`
- `products.created_at`, `products.updated_at`
- `trending_items.generated_at`, `trending_items.expires_at`
