# Orange Wine — API Part 01: System and Authentication

Version 1.0 — 28 Sep 2026 · 16 endpoints · Base URL `/api/v1` · Read with Part 00 (conventions, error catalog, permissions).

## 1. Before you start

- Base URL `/api/v1` (webhooks: `/webhooks/...`). JSON, `snake_case` keys, prefixed string IDs, ISO 8601 UTC timestamps.
- Success: `{ data, request_id, correlation_id }`. Error: `{ error: { code, message, details, retryable } }`. Gated feature: `data.enabled = false`, `status = "pending_configuration"`.
- Auth via HttpOnly cookies (`access_token`, `refresh_token`); every non-GET browser request sends `X-XSRF-TOKEN`.
- Money is always `{ amount_minor, currency }`. Percentages are basis points.
- Commands marked Idempotency = Required need an `Idempotency-Key` header (UUID v4).
- Status never changes through PATCH — use the named command endpoints.
- Full conventions, error catalog, permission catalog and open items: Part 00.

### Error codes used in this part

| Code | HTTP | Meaning |
|---|---|---|
| `AUTHENTICATION_REQUIRED` | 401 | No valid session, or the access token has expired and refresh failed. |
| `FORBIDDEN` | 403 | Authenticated, but missing permission, location scope, manager approval, or a valid webhook signature. |
| `RATE_LIMITED` | 429 | Too many requests. `Retry-After` header set. Limits are configured later. |
| `RESOURCE_NOT_FOUND` | 404 | Resource doesn't exist or isn't visible to the caller (never reveals existence). |
| `VALIDATION_ERROR` | 422 | Request failed validation. `details.fields` maps field → messages. |

### PROPOSED and gated endpoints in this part

| Status | Method | Path |
|---|---|---|
| CONFIGURATION GATE | POST | `/auth/staff/mfa/challenge` |

## 2. System

Public configuration and health. No authentication.

| # | Method | Path | Title | Status |
|---|---|---|---|---|
| 2.1 | GET | `/system/config` | Storefront-safe configuration | CONFIRMED |
| 2.2 | GET | `/system/health` | Health check | CONFIRMED |

### 2.1 `GET /system/config`

**Storefront-safe configuration**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/system/config` |
| Purpose | Returns feature flags and public settings the frontend needs on load. Never returns secrets or provider keys. |
| Auth | Public |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Request example**

```http
GET /api/v1/system/config HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "currency": "USD",
    "default_country": "US",
    "features": {
      "local_delivery": false,
      "pre_arrival": true,
      "rapid_ship": true,
      "ship_free_12": true,
      "offline_pos": false,
      "marketplaces": false,
      "staff_mfa": false,
      "address_validation": false
    },
    "age_gate": {
      "enabled": true,
      "minimum_age": 21
    },
    "cancellation_window_hours": 24,
    "return_window_days": 30,
    "rapid_ship": {
      "cutoff_local_time": "12:00",
      "delivery_target_days": 2
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| currency | string | Always `USD` in V1. |
| default_country | string | Always `US` in V1. |
| features.<flag> | boolean | `true` = enabled. Flags: `local_delivery` (gate, false), `pre_arrival`, `rapid_ship`, `ship_free_12`, `offline_pos` (false, deferred), `marketplaces` (false, deferred), `staff_mfa` (gate), `address_validation` (gate). |
| age_gate.enabled | boolean | Show the 21+ acknowledgement pop-up. |
| age_gate.minimum_age | integer | `21`. |
| cancellation_window_hours | integer | Normal-order cancellation window (D-24). Currently `24`; configurable. |
| return_window_days | integer | Currently `30` (D-24). |
| rapid_ship.cutoff_local_time | string HH:MM | Default cutoff; per-location value is on the location resource. |
| rapid_ship.delivery_target_days | integer | Currently `2`. |

---

### 2.2 `GET /system/health`

**Health check**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/system/health` |
| Purpose | Coarse dependency status for monitoring. No internal details. |
| Auth | Public |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Request example**

```http
GET /api/v1/system/health HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "status": "degraded",
    "dependencies": {
      "database": "up",
      "search": "up",
      "payments": "unknown",
      "shipping": "up",
      "tax": "unknown"
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| status | enum | `ok` \| `degraded` \| `down`. |
| dependencies.<name> | enum | `up` \| `down` \| `unknown` (`unknown` = provider not configured yet). |

---

## 3. Authentication

Cookie-based auth for customers and staff (D-00, D-28). Access token = JWT, refresh token = opaque; both Secure HttpOnly cookies, never in a JSON body. One login endpoint for everyone. Wrong credentials and disabled staff return the same generic VALIDATION_ERROR. Rate-limit numbers are PENDING (D-29).

| # | Method | Path | Title | Status |
|---|---|---|---|---|
| 3.1 | GET | `/auth/csrf` | Initialize CSRF cookie | CONFIRMED |
| 3.2 | POST | `/auth/register` | Register customer | CONFIRMED |
| 3.3 | POST | `/auth/login` | Log in (customers and staff) | CONFIRMED |
| 3.4 | POST | `/auth/staff/mfa/challenge` | Complete staff MFA | CONFIGURATION GATE |
| 3.5 | POST | `/auth/refresh` | Rotate tokens | CONFIRMED |
| 3.6 | POST | `/auth/logout` | Log out | CONFIRMED |
| 3.7 | GET | `/auth/me` | Current principal | CONFIRMED |
| 3.8 | POST | `/auth/forgot-password` | Request password reset | CONFIRMED |
| 3.9 | POST | `/auth/reset-password` | Reset password with token | CONFIRMED |
| 3.10 | POST | `/auth/change-password` | Change password | CONFIRMED |
| 3.11 | POST | `/auth/verify-email` | Verify email | CONFIRMED |
| 3.12 | POST | `/auth/resend-verification` | Resend verification email | CONFIRMED |
| 3.13 | GET | `/auth/sessions` | List my sessions | CONFIRMED |
| 3.14 | POST | `/auth/sessions/{sessionId}/revoke` | Revoke one of my sessions | CONFIRMED |

### 3.1 `GET /auth/csrf`

**Initialize CSRF cookie**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/auth/csrf` |
| Purpose | Sets the readable `XSRF-TOKEN` cookie. Call once on app load before any mutation. |
| Auth | Public |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Request example**

```http
GET /api/v1/auth/csrf HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK
Set-Cookie: XSRF-TOKEN=8f3a...c91; Secure; SameSite=Lax; Path=/

{
  "data": {
    "initialized": true
  },
  "request_id": "req_a001",
  "correlation_id": "corr_a001"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| initialized | boolean | Always `true`. |
| request_id | string | Opaque, unique per request. |
| correlation_id | string | Opaque; groups related requests. Client may send `X-Correlation-ID`. |

---

### 3.2 `POST /auth/register`

**Register customer**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/auth/register` |
| Purpose | Creates a customer account. Staff are never created here (use POST /dashboard/staff). |
| Auth | Public |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | Yes |

**Request example**

```http
POST /api/v1/auth/register HTTP/1.1
Content-Type: application/json
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>

{
  "email": "customer@example.com",
  "password": "a-strong-password-here",
  "first_name": "John",
  "last_name": "Smith",
  "phone": "+15185550100",
  "accepts_marketing": false
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| email | string · required | Valid email; unique among customers (case-insensitive). |
| password | string · required | Complexity rules PENDING; enforced server-side. |
| first_name | string · required |  |
| last_name | string · required |  |
| phone | string · optional | E.164, e.g. `+15185550100`. |
| accepts_marketing | boolean · optional · default false | `true` \| `false`. |

**Response — success (201)**

```http
HTTP/1.1 201 Created

{
  "data": {
    "user": {
      "id": "usr_101",
      "first_name": "John",
      "last_name": "Smith",
      "email": "customer@example.com",
      "roles": [
        "customer"
      ],
      "email_verified": false,
      "created_at": "2026-09-17T16:00:00Z"
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

Registration does not log the user in (auto-login PENDING).

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| user.id | string | Opaque; `usr_` prefix by convention. |
| user.roles | string[] | Always `["customer"]`. |
| user.email_verified | boolean | Always `false` right after registration. |
| user.created_at | ISO 8601 UTC |  |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 422 | Invalid field or email already registered |
| `RATE_LIMITED` | 429 | Too many attempts |

**Error example — VALIDATION_ERROR**

```http
HTTP/1.1 422 Unprocessable Entity

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "The request contains invalid fields.",
    "details": {
      "fields": {
        "email": [
          "This email address is already registered."
        ]
      }
    },
    "retryable": false
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

---

### 3.3 `POST /auth/login`

**Log in (customers and staff)**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/auth/login` |
| Purpose | Single login for every account type. The backend decides customer vs staff. |
| Auth | Public |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | Yes |

**Request example**

```http
POST /api/v1/auth/login HTTP/1.1
Content-Type: application/json
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>

{
  "email": "customer@example.com",
  "password": "a-strong-password-here"
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| email | string · required |  |
| password | string · required |  |

**Response — success — customer or staff without MFA (200)**

```http
HTTP/1.1 200 OK
Set-Cookie: access_token=<HttpOnly-JWT>; Secure; HttpOnly; SameSite=Lax; Path=/
Set-Cookie: refresh_token=<HttpOnly-opaque-token>; Secure; HttpOnly; SameSite=Lax; Path=/

{
  "data": {
    "user": {
      "id": "usr_101",
      "first_name": "John",
      "last_name": "Smith",
      "roles": [
        "customer"
      ]
    },
    "mfa_required": false
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| user.roles | string[] | Customer: `["customer"]`. Staff: configured role names, e.g. `cashier`, `store_manager`, `fulfillment`, `admin`. Never mixed. |
| mfa_required | boolean | `false` = session established (cookies set). |

**Response — success — staff, MFA enforced (CONFIGURATION GATE) (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "user": {
      "id": "staff_02",
      "first_name": "Jane",
      "last_name": "Doe",
      "roles": [
        "cashier"
      ]
    },
    "mfa_required": true,
    "mfa_challenge_token": "mfa_chal_7001"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| mfa_required | boolean | `true` = not logged in yet; no cookies set. |
| mfa_challenge_token | string | Short-lived, single-use. Only present when `mfa_required` is `true`. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 422 | Wrong email/password or disabled staff (same generic message) |
| `RATE_LIMITED` | 429 | Too many attempts |

**Error example — VALIDATION_ERROR**

```http
HTTP/1.1 422 Unprocessable Entity

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "The email or password is incorrect.",
    "details": {},
    "retryable": false
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

---

### 3.4 `POST /auth/staff/mfa/challenge`

**Complete staff MFA**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/auth/staff/mfa/challenge` |
| Purpose | Completes staff login when login returned `mfa_required: true`. |
| Auth | MFA challenge token |
| Status | CONFIGURATION GATE |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | Yes |

**Request example**

```http
POST /api/v1/auth/staff/mfa/challenge HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>

{
  "mfa_challenge_token": "mfa_chal_7001",
  "code": "482913"
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| mfa_challenge_token | string · required | From login; not expired. |
| code | string · required | Factor-dependent; 6-digit TOTP typical (PENDING). |

**Response — success (200)**

```http
HTTP/1.1 200 OK
Set-Cookie: access_token=<HttpOnly-JWT>; Secure; HttpOnly; SameSite=Lax; Path=/
Set-Cookie: refresh_token=<HttpOnly-opaque-token>; Secure; HttpOnly; SameSite=Lax; Path=/

{
  "data": {
    "user": {
      "id": "staff_02",
      "first_name": "Jane",
      "last_name": "Doe",
      "roles": [
        "cashier"
      ]
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| user | object | Same shape as login success; `mfa_required` absent. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 422 | Wrong or reused code |
| `AUTHENTICATION_REQUIRED` | 401 | Challenge token expired — restart login |
| `RATE_LIMITED` | 429 | Too many attempts |

---

### 3.5 `POST /auth/refresh`

**Rotate tokens**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/auth/refresh` |
| Purpose | Rotates access and refresh cookies. Called automatically after a 401. Not idempotent: each call invalidates the previous refresh token. |
| Auth | Refresh cookie |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Request example**

```http
POST /api/v1/auth/refresh HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK
Set-Cookie: access_token=<new-HttpOnly-JWT>; Secure; HttpOnly; SameSite=Lax; Path=/
Set-Cookie: refresh_token=<new-HttpOnly-opaque-token>; Secure; HttpOnly; SameSite=Lax; Path=/

{
  "data": {
    "refreshed": true
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| refreshed | boolean | Always `true`. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `AUTHENTICATION_REQUIRED` | 401 | Refresh cookie missing/expired, or reuse detected (backend revokes the whole session chain) |

---

### 3.6 `POST /auth/logout`

**Log out**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/auth/logout` |
| Purpose | Deletes the refresh-token record server-side and clears cookies. |
| Auth | Session |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Request example**

```http
POST /api/v1/auth/logout HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK
Set-Cookie: access_token=; Max-Age=0; ...
Set-Cookie: refresh_token=; Max-Age=0; ...

{
  "data": {
    "logged_out": true
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| logged_out | boolean | Always `true`. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `AUTHENTICATION_REQUIRED` | 401 | Not signed in |

---

### 3.7 `GET /auth/me`

**Current principal**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/auth/me` |
| Purpose | Returns who is logged in. The frontend branches on `type`. |
| Auth | Session |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Request example**

```http
GET /api/v1/auth/me HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success — customer (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "id": "usr_101",
    "type": "customer",
    "first_name": "John",
    "last_name": "Smith",
    "email": "customer@example.com",
    "email_verified": true,
    "roles": [
      "customer"
    ]
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| type | enum | `customer` \| `staff`. |
| email_verified | boolean | Customers only. |

**Response — success — staff (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "id": "staff_02",
    "type": "staff",
    "first_name": "Jane",
    "last_name": "Doe",
    "email": "jane@orangewine.example",
    "roles": [
      "cashier"
    ],
    "permissions": [
      "pos.sell",
      "register.open",
      "register.close",
      "inventory.view"
    ],
    "location_assignments": [
      {
        "location_id": "loc_albany",
        "name": "Orange Wine Albany"
      }
    ]
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| roles | string[] | Admin-configurable role names. |
| permissions | string[] | Effective permission codes (see Appendix B). For UI hiding only; the API enforces. |
| location_assignments[] | object[] | `{ location_id, name }`. Store Manager: exactly one entry. May be empty for a new staff record. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `AUTHENTICATION_REQUIRED` | 401 | Not signed in |

---

### 3.8 `POST /auth/forgot-password`

**Request password reset**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/auth/forgot-password` |
| Purpose | Sends a reset link if the account exists. Identical response either way. |
| Auth | Public |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | Yes |

**Request example**

```http
POST /api/v1/auth/forgot-password HTTP/1.1
Content-Type: application/json
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>

{
  "email": "customer@example.com"
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| email | string · required |  |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "requested": true
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| requested | boolean | Always `true`, even for unknown emails. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 422 | Malformed email only (never 'not found') |
| `RATE_LIMITED` | 429 | Too many requests |

---

### 3.9 `POST /auth/reset-password`

**Reset password with token**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/auth/reset-password` |
| Auth | Reset token |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | Yes |

**Request example**

```http
POST /api/v1/auth/reset-password HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>

{
  "reset_token": "prt_9f2a1c",
  "new_password": "a-new-strong-password"
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| reset_token | string · required | Single-use, time-limited. |
| new_password | string · required | Same rules as registration (PENDING). |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "reset": true
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| reset | boolean | Always `true`. All sessions for the account are revoked. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 422 | Token invalid/expired or weak password |

---

### 3.10 `POST /auth/change-password`

**Change password**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/auth/change-password` |
| Auth | Session |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Request example**

```http
POST /api/v1/auth/change-password HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>

{
  "current_password": "the-old-password",
  "new_password": "a-new-strong-password"
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| current_password | string · required |  |
| new_password | string · required |  |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "changed": true
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| changed | boolean | Always `true`. All *other* sessions revoked; current stays. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 422 | Current password wrong or new password weak |
| `AUTHENTICATION_REQUIRED` | 401 | Not signed in |

---

### 3.11 `POST /auth/verify-email`

**Verify email**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/auth/verify-email` |
| Purpose | Email verification scope in V1 is PENDING (D-41); endpoint is built. |
| Auth | Verification token |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Request example**

```http
POST /api/v1/auth/verify-email HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>

{
  "verification_token": "evt_4b7c2e"
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| verification_token | string · required | Single-use. |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "verified": true,
    "user_id": "usr_101"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| verified | boolean | Always `true`. |
| user_id | string | Account verified. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 422 | Token invalid or expired |

---

### 3.12 `POST /auth/resend-verification`

**Resend verification email**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/auth/resend-verification` |
| Auth | Session or email |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | Yes |

**Request example**

```http
POST /api/v1/auth/resend-verification HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>

{
  "email": "customer@example.com"
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| email | string · required only when not signed in | Signed-in users send no body. |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "requested": true
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| requested | boolean | Always `true` (non-enumerating). |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `RATE_LIMITED` | 429 | Too many requests |

---

### 3.13 `GET /auth/sessions`

**List my sessions**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/auth/sessions` |
| Auth | Session |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Request example**

```http
GET /api/v1/auth/sessions HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "session_id": "sess_501",
        "created_at": "2026-09-15T09:00:00Z",
        "last_active_at": "2026-09-17T16:00:00Z",
        "is_current": true,
        "user_agent_summary": "Chrome on macOS"
      },
      {
        "session_id": "sess_498",
        "created_at": "2026-09-10T14:00:00Z",
        "last_active_at": "2026-09-14T08:00:00Z",
        "is_current": false,
        "user_agent_summary": "Safari on iOS"
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 2,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| items[].session_id | string | Refresh-token record ID. |
| items[].is_current | boolean | Exactly one item is `true`. |
| items[].user_agent_summary | string | Display text only. |
| pagination.page | integer | Current page. |
| pagination.per_page | integer | Page size used. |
| pagination.total | integer | Total matching items. |
| pagination.last_page | integer | Last page number; 0 when there are no items. |

---

### 3.14 `POST /auth/sessions/{sessionId}/revoke`

**Revoke one of my sessions**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/auth/sessions/{sessionId}/revoke` |
| Auth | Session |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| sessionId | string | From GET /auth/sessions. |

**Request example**

```http
POST /api/v1/auth/sessions/{sessionId}/revoke HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "session_id": "sess_498",
    "revoked": true,
    "revoked_at": "2026-09-17T18:00:00Z"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| revoked | boolean | Always `true`. |
| revoked_at | ISO 8601 UTC |  |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `RESOURCE_NOT_FOUND` | 404 | Session doesn't exist or already ended |
| `FORBIDDEN` | 403 | Not your session |

---
