# Orange Wine — API Part 03: Storefront Catalog and Locations

Version 1.0 — 28 Sep 2026 · 37 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 |
|---|---|---|
| `FORBIDDEN` | 403 | Authenticated, but missing permission, location scope, manager approval, or a valid webhook signature. |
| `FULFILLMENT_NOT_ELIGIBLE` | 422 | Selected fulfillment mode isn't allowed for this cart/location/time. |
| `PROVIDER_UNAVAILABLE` | 503 | External provider unreachable or not configured; safe to retry 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. |

### Permissions used in this part

| Permission | Grants | Endpoints |
|---|---|---|
| `locations.manage` | Locations, hours, holidays, closures | 12 |

### PROPOSED and gated endpoints in this part

| Status | Method | Path |
|---|---|---|
| CONFIGURATION GATE | POST | `/addresses/validate` |

## 2. Catalog and Discovery

Public read APIs. Search is an advisory projection (D-38 engine choice open); cart, checkout and POS always revalidate live data.

| # | Method | Path | Title | Status |
|---|---|---|---|---|
| 2.1 | GET | `/products/{slug}` | Product detail | CONFIRMED |
| 2.2 | GET | `/products/{slug}/related` | Related products | CONFIRMED |
| 2.3 | GET | `/departments` | Departments | CONFIRMED |
| 2.4 | GET | `/categories` | Categories | CONFIRMED |
| 2.5 | GET | `/categories/{slug}` | Category detail | CONFIRMED |
| 2.6 | GET | `/collections` | Collections | CONFIRMED |
| 2.7 | GET | `/collections/{slug}` | Collection detail | CONFIRMED |
| 2.8 | GET | `/brands` | Brands | CONFIRMED |
| 2.9 | GET | `/brands/{slug}` | Brand detail | CONFIRMED |
| 2.10 | GET | `/attributes` | Filterable attributes | CONFIRMED |
| 2.11 | GET | `/attributes/{attributeId}/values` | Attribute values | CONFIRMED |
| 2.12 | GET | `/seo-pages/{slug}` | SEO landing page | CONFIRMED |
| 2.13 | GET | `/pages/{slug}` | Editorial page | CONFIRMED |
| 2.14 | GET | `/blog/posts` | Blog posts | CONFIRMED |
| 2.15 | GET | `/blog/posts/{slug}` | Blog post | CONFIRMED |
| 2.16 | GET | `/menus/{code}` | Navigation menu | CONFIRMED |
| 2.17 | GET | `/search` | Product search | CONFIRMED |
| 2.18 | GET | `/pricing/quote` | Advisory price quote | CONFIRMED |

### 2.1 `GET /products/{slug}`

**Product detail**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/products/{slug}` |
| Auth | Public |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| slug | string | Product slug. |

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| location_id | string · optional | Selected store; defaults to session's location. |

**Request example**

```http
GET /api/v1/products/{slug} HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "id": "prod_101",
    "slug": "example-cabernet-2023-750ml",
    "name": "Example Cabernet 2023 750ml",
    "sku": "CAB-2023-750",
    "sell_unit": "bottle",
    "pack_quantity": 1,
    "price": {
      "amount_minor": 6275,
      "currency": "USD"
    },
    "compare_at_price": null,
    "availability": {
      "location_id": "loc_albany",
      "available": true,
      "state": "available_now"
    },
    "merchandising": {
      "ship_free_12_eligible": true,
      "rapid_ship_eligible": true,
      "pre_arrival": false
    },
    "pre_arrival": null,
    "product_group": {
      "id": "pg_20",
      "related_products": [
        {
          "id": "prod_102",
          "slug": "example-cabernet-2023-case-12",
          "name": "Example Cabernet 2023 Case (12)",
          "sell_unit": "case",
          "pack_quantity": 12
        }
      ]
    },
    "brand": {
      "id": "brand_7",
      "name": "Example Estate"
    },
    "category": {
      "id": "cat_red",
      "name": "Red Wine"
    },
    "attributes": {
      "country": "USA",
      "region": "Napa Valley",
      "varietal": "Cabernet Sauvignon",
      "vintage": 2023,
      "size": "750ml",
      "abv": 14.5
    },
    "description": "Full-bodied red...",
    "tasting_notes": "Blackcurrant, cedar.",
    "media": [
      {
        "id": "med_1",
        "url": "https://cdn.example/cab.jpg",
        "alt": "Bottle front",
        "position": 1
      }
    ],
    "age_restricted": true,
    "shipping": {
      "eligible": true,
      "weight": {
        "value": 1.35,
        "unit": "lb"
      },
      "dimensions": {
        "length": 4,
        "width": 4,
        "height": 13,
        "unit": "in"
      }
    },
    "seo": {
      "title": "Example Cabernet 2023 | Orange Wine",
      "description": "..."
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| id / slug / sku | string | Each size/pack is its own product (D-09). No `variant_id` exists anywhere. |
| sell_unit | enum | `bottle` \| `case` \| `pack` \| `other`. |
| pack_quantity | integer | Descriptive only — never used to convert stock. |
| price | Money object `{ amount_minor: integer, currency: "USD" }` | Display price for the selected location. Checkout quote is authoritative. |
| compare_at_price | Money \| null | Strike-through price when a promotion applies. |
| availability.state | enum | `available_now` \| `pre_arrival` \| `backordered` \| `unavailable` \| `archived`. |
| availability.available | boolean | Never an exact quantity. |
| merchandising.* | boolean | Eligibility flags for 12 Ship Free, Rapid Ship, pre-arrival. Flags don't promise the cart will qualify. |
| pre_arrival | object \| null | When the product is pre-arrival: `{ expected_lead_time_days: 75, estimated_delivery_if_ordered_today: "2026-12-08" }`. |
| product_group.related_products[] | object[] | Other sizes/packs, for display/navigation only. |
| attributes.* | mixed | Catalog attributes; keys depend on product type (wine/spirit). |
| age_restricted | boolean | `true` for alcohol. |
| shipping.eligible | boolean | Product-level shippability (destination eligibility is separate). |
| shipping.weight.unit | enum | `lb` \| `oz` \| `kg` \| `g`. |
| shipping.dimensions.unit | enum | `in` \| `cm`. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `RESOURCE_NOT_FOUND` | 404 | Unknown or unpublished product |

---

### 2.2 `GET /products/{slug}/related`

**Related products**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/products/{slug}/related` |
| Auth | Public |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| slug | string |  |

**Request example**

```http
GET /api/v1/products/{slug}/related HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "same_group": [
      {
        "id": "prod_101",
        "slug": "example-cabernet-2023-750ml",
        "name": "Example Cabernet 2023 750ml",
        "price": {
          "amount_minor": 6275,
          "currency": "USD"
        },
        "image_url": "https://cdn.example/cab.jpg",
        "availability": {
          "state": "available_now"
        },
        "badges": {
          "rapid_ship": true,
          "ship_free_12": true,
          "pre_arrival": false
        }
      }
    ],
    "recommended": []
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| same_group[] | ProductCard | Other sizes/packs via `product_group_id`. |
| recommended[] | ProductCard | Merchandising recommendations. |

---

### 2.3 `GET /departments`

**Departments**

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

**Request example**

```http
GET /api/v1/departments HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "id": "dept_wine",
        "name": "Wine",
        "slug": "wine",
        "parent_id": null,
        "product_count": 128
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| items[].id | string |  |
| items[].name | string |  |
| items[].slug | string | URL segment. |
| items[].parent_id | string \| null | For nested categories. |
| items[].product_count | integer | Published products. |

---

### 2.4 `GET /categories`

**Categories**

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

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| department_id | string · optional |  |
| parent_id | string · optional |  |

**Request example**

```http
GET /api/v1/categories HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "id": "cat_red",
        "name": "Red Wine",
        "slug": "red-wine",
        "parent_id": null,
        "product_count": 128
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| items[].id | string |  |
| items[].name | string |  |
| items[].slug | string | URL segment. |
| items[].parent_id | string \| null | For nested categories. |
| items[].product_count | integer | Published products. |

---

### 2.5 `GET /categories/{slug}`

**Category detail**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/categories/{slug}` |
| Auth | Public |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| slug | string |  |

**Request example**

```http
GET /api/v1/categories/{slug} HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "id": "cat_red",
    "name": "Red Wine",
    "slug": "red-wine",
    "parent_id": null,
    "product_count": 128,
    "description": "...",
    "seo": {
      "title": "Red Wine",
      "description": "..."
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `RESOURCE_NOT_FOUND` | 404 | Unknown category |

---

### 2.6 `GET /collections`

**Collections**

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

**Request example**

```http
GET /api/v1/collections HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "id": "col_12sf",
        "name": "12 Ship Free Picks",
        "slug": "12-ship-free-picks",
        "parent_id": null,
        "product_count": 128
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| items[].id | string |  |
| items[].name | string |  |
| items[].slug | string | URL segment. |
| items[].parent_id | string \| null | For nested categories. |
| items[].product_count | integer | Published products. |

---

### 2.7 `GET /collections/{slug}`

**Collection detail**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/collections/{slug}` |
| Auth | Public |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| slug | string |  |

**Request example**

```http
GET /api/v1/collections/{slug} HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "id": "col_12sf",
    "name": "12 Ship Free Picks",
    "slug": "12-ship-free-picks",
    "parent_id": null,
    "product_count": 128,
    "description": "..."
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `RESOURCE_NOT_FOUND` | 404 | Unknown collection |

---

### 2.8 `GET /brands`

**Brands**

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

**Request example**

```http
GET /api/v1/brands HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "id": "brand_7",
        "name": "Example Estate",
        "slug": "example-estate",
        "parent_id": null,
        "product_count": 128
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| items[].id | string |  |
| items[].name | string |  |
| items[].slug | string | URL segment. |
| items[].parent_id | string \| null | For nested categories. |
| items[].product_count | integer | Published products. |

---

### 2.9 `GET /brands/{slug}`

**Brand detail**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/brands/{slug}` |
| Auth | Public |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| slug | string |  |

**Request example**

```http
GET /api/v1/brands/{slug} HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "id": "brand_7",
    "name": "Example Estate",
    "slug": "example-estate",
    "parent_id": null,
    "product_count": 128,
    "description": "..."
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `RESOURCE_NOT_FOUND` | 404 | Unknown brand |

---

### 2.10 `GET /attributes`

**Filterable attributes**

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

**Request example**

```http
GET /api/v1/attributes HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "id": "attr_varietal",
        "code": "varietal",
        "name": "Varietal",
        "type": "select",
        "filterable": true
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| items[].code | string | e.g. `country`, `region`, `appellation`, `varietal`, `spirit_type`, `vintage`, `size`, `score`. |
| items[].type | enum | `select` \| `multi_select` \| `number` \| `text` \| `boolean`. |

---

### 2.11 `GET /attributes/{attributeId}/values`

**Attribute values**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/attributes/{attributeId}/values` |
| Auth | Public |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| attributeId | string |  |

**Request example**

```http
GET /api/v1/attributes/{attributeId}/values HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "id": "av_1",
        "value": "cabernet-sauvignon",
        "label": "Cabernet Sauvignon"
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

---

### 2.12 `GET /seo-pages/{slug}`

**SEO landing page**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/seo-pages/{slug}` |
| Auth | Public |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| slug | string |  |

**Request example**

```http
GET /api/v1/seo-pages/{slug} HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "slug": "napa-cabernet",
    "title": "Napa Cabernet",
    "body_html": "<p>...</p>",
    "search_preset": {
      "region": "napa-valley",
      "varietal": "cabernet-sauvignon"
    },
    "seo": {
      "title": "...",
      "description": "..."
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| search_preset | object | Filters applied to the product grid on this page. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `RESOURCE_NOT_FOUND` | 404 | Resource doesn't exist or isn't visible to the caller (never reveals existence). |

---

### 2.13 `GET /pages/{slug}`

**Editorial page**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/pages/{slug}` |
| Auth | Public |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| slug | string | e.g. `shipping-policy`, `returns`. |

**Request example**

```http
GET /api/v1/pages/{slug} HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "slug": "returns",
    "title": "Returns Policy",
    "body_html": "<p>...</p>",
    "updated_at": "2026-09-28T00:00:00Z"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `RESOURCE_NOT_FOUND` | 404 | Resource doesn't exist or isn't visible to the caller (never reveals existence). |

---

### 2.14 `GET /blog/posts`

**Blog posts**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/blog/posts` |
| Auth | Public |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| page | integer · optional · default 1 | 1-based page number. |
| per_page | integer · optional · default 24 · max 100 | Items per page. Above 100 → VALIDATION_ERROR (D-26, reject-vs-clamp still open). |
| sort | string · optional | Field name, prefix `-` for descending, e.g. `-created_at`. |

**Request example**

```http
GET /api/v1/blog/posts?page=1&per_page=24 HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "slug": "harvest-2026",
        "title": "Harvest 2026",
        "excerpt": "...",
        "published_at": "2026-09-20T00:00:00Z",
        "image_url": "https://cdn.example/h.jpg"
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

---

### 2.15 `GET /blog/posts/{slug}`

**Blog post**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/blog/posts/{slug}` |
| Auth | Public |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| slug | string |  |

**Request example**

```http
GET /api/v1/blog/posts/{slug} HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "slug": "harvest-2026",
    "title": "Harvest 2026",
    "body_html": "<p>...</p>",
    "published_at": "2026-09-20T00:00:00Z"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `RESOURCE_NOT_FOUND` | 404 | Resource doesn't exist or isn't visible to the caller (never reveals existence). |

---

### 2.16 `GET /menus/{code}`

**Navigation menu**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/menus/{code}` |
| Auth | Public |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| code | string | e.g. `header`, `footer`. |

**Request example**

```http
GET /api/v1/menus/{code} HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "code": "header",
    "items": [
      {
        "label": "Wine",
        "url": "/wine",
        "children": []
      }
    ]
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `RESOURCE_NOT_FOUND` | 404 | Resource doesn't exist or isn't visible to the caller (never reveals existence). |

---

### 2.17 `GET /search`

**Product search**

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

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| q | string · optional | Free text. |
| location_id | string · optional | Selected store. |
| department / category / collection / brand | string · optional | Slugs; repeatable for multi-select. |
| country / region / appellation / varietal / spirit_type / vintage / size / score | string · optional | Attribute value slugs; repeatable. |
| price_min / price_max | integer · optional | Minor units. |
| rapid_ship | boolean · optional | Products whose Rapid Ship flag is set. |
| ship_free_12 | boolean · optional | Products whose 12 Ship Free flag is set. |
| in_store_today | boolean · optional | Available now at the selected store. |
| availability | enum · optional | `available_now` \| `pre_arrival` \| `backordered`. |
| sort | enum · optional | `relevance` \| `price` \| `-price` \| `name` \| `-created_at` \| `-score`. |
| page / per_page | integer · optional | Max per_page 100. |

**Request example**

```http
GET /api/v1/search HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "id": "prod_101",
        "slug": "example-cabernet-2023-750ml",
        "name": "Example Cabernet 2023 750ml",
        "price": {
          "amount_minor": 6275,
          "currency": "USD"
        },
        "image_url": "https://cdn.example/cab.jpg",
        "availability": {
          "state": "available_now"
        },
        "badges": {
          "rapid_ship": true,
          "ship_free_12": true,
          "pre_arrival": false
        }
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    },
    "facets": {
      "varietal": [
        {
          "value": "cabernet-sauvignon",
          "label": "Cabernet Sauvignon",
          "count": 42
        }
      ],
      "price": {
        "min": {
          "amount_minor": 999,
          "currency": "USD"
        },
        "max": {
          "amount_minor": 49900,
          "currency": "USD"
        }
      }
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| items[] | ProductCard | `id, slug, name, price, image_url, availability.state, badges{rapid_ship, ship_free_12, pre_arrival}`. |
| facets.<attribute>[] | object[] | `{ value, label, count }`. |
| facets.price | object | `{ min: Money, max: Money }`. |
| 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. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 422 | Invalid filter or per_page > 100 |

---

### 2.18 `GET /pricing/quote`

**Advisory price quote**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/pricing/quote` |
| Auth | Public or customer |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| product_id | string · required |  |
| quantity | integer · optional · default 1 |  |
| location_id | string · optional |  |

**Request example**

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

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "product_id": "prod_101",
    "quantity": 1,
    "unit_price": {
      "amount_minor": 6275,
      "currency": "USD"
    },
    "discount": {
      "amount_minor": 0,
      "currency": "USD"
    },
    "line_total": {
      "amount_minor": 6275,
      "currency": "USD"
    },
    "advisory": true
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| advisory | boolean | Always `true` — the checkout quote is authoritative. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `RESOURCE_NOT_FOUND` | 404 | Unknown product |

---

## 3. Locations, Hours and Addresses

Store data for the storefront and administration. A browser-selected location is a preference, never authority.

| # | Method | Path | Title | Status |
|---|---|---|---|---|
| 3.1 | GET | `/locations` | List stores | CONFIRMED |
| 3.2 | GET | `/locations/{locationId}` | Store detail | CONFIRMED |
| 3.3 | GET | `/locations/nearby` | Nearby stores | CONFIRMED |
| 3.4 | GET | `/location/current` | Session's selected store | CONFIRMED |
| 3.5 | POST | `/location/select` | Select store | CONFIRMED |
| 3.6 | GET | `/pickup/windows` | Pickup time slots | CONFIRMED |
| 3.7 | POST | `/addresses/validate` | Validate address | CONFIGURATION GATE |
| 3.8 | GET | `/dashboard/locations` | List stores (admin) | CONFIRMED |
| 3.9 | POST | `/dashboard/locations` | Create store | CONFIRMED |
| 3.10 | GET | `/dashboard/locations/{locationId}` | Store detail (admin) | CONFIRMED |
| 3.11 | PATCH | `/dashboard/locations/{locationId}` | Update store | CONFIRMED |
| 3.12 | GET | `/dashboard/locations/{locationId}/hours` | Weekly hours | CONFIRMED |
| 3.13 | PUT | `/dashboard/locations/{locationId}/hours` | Replace weekly hours | CONFIRMED |
| 3.14 | GET | `/dashboard/locations/{locationId}/holidays` | Holidays | CONFIRMED |
| 3.15 | POST | `/dashboard/locations/{locationId}/holidays` | Add holiday | CONFIRMED |
| 3.16 | DELETE | `/dashboard/locations/{locationId}/holidays/{holidayId}` | Remove holiday | CONFIRMED |
| 3.17 | GET | `/dashboard/locations/{locationId}/closures` | Temporary closures | CONFIRMED |
| 3.18 | POST | `/dashboard/locations/{locationId}/closures` | Add closure | CONFIRMED |
| 3.19 | DELETE | `/dashboard/locations/{locationId}/closures/{closureId}` | Remove closure | CONFIRMED |

### 3.1 `GET /locations`

**List stores**

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

**Request example**

```http
GET /api/v1/locations HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "id": "loc_albany",
        "name": "Orange Wine Albany",
        "timezone": "America/New_York",
        "address": {
          "line1": "100 Main Street",
          "line2": "Suite 4",
          "city": "Albany",
          "state": "NY",
          "postal_code": "12201",
          "country": "US"
        },
        "phone": "+15185550000",
        "geo": {
          "lat": 42.6526,
          "lng": -73.7562
        },
        "hours": [
          {
            "day": "mon",
            "open": "09:00",
            "close": "21:00"
          },
          {
            "day": "sun",
            "open": "10:00",
            "close": "20:00"
          }
        ],
        "capabilities": {
          "pickup": true,
          "curbside": true,
          "shipping": true,
          "local_delivery": false,
          "pos": true
        },
        "rapid_ship": {
          "enabled": true,
          "cutoff_local_time": "12:00",
          "weekend_holiday_enabled": true
        }
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| timezone | IANA string | Used for Rapid Ship cutoff, pickup windows and report boundaries. |
| hours[].day | enum | `mon` \| `tue` \| `wed` \| `thu` \| `fri` \| `sat` \| `sun`. |
| hours[].open / close | string HH:MM | Local time. Launch: Mon–Sat 09:00–21:00, Sun 10:00–20:00. |
| capabilities.* | boolean | `pickup`, `curbside`, `shipping`, `local_delivery` (gate — false), `pos`. |
| rapid_ship.enabled | boolean | Rapid Ship available from this location right now (false when weekend/holiday fulfillment is off). |
| rapid_ship.cutoff_local_time | string HH:MM | Default `12:00`. |
| rapid_ship.weekend_holiday_enabled | boolean |  |
| 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.2 `GET /locations/{locationId}`

**Store detail**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/locations/{locationId}` |
| Auth | Public |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| locationId | string |  |

**Request example**

```http
GET /api/v1/locations/{locationId} HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "id": "loc_albany",
    "name": "Orange Wine Albany",
    "timezone": "America/New_York",
    "address": {
      "line1": "100 Main Street",
      "line2": "Suite 4",
      "city": "Albany",
      "state": "NY",
      "postal_code": "12201",
      "country": "US"
    },
    "phone": "+15185550000",
    "geo": {
      "lat": 42.6526,
      "lng": -73.7562
    },
    "hours": [
      {
        "day": "mon",
        "open": "09:00",
        "close": "21:00"
      },
      {
        "day": "sun",
        "open": "10:00",
        "close": "20:00"
      }
    ],
    "capabilities": {
      "pickup": true,
      "curbside": true,
      "shipping": true,
      "local_delivery": false,
      "pos": true
    },
    "rapid_ship": {
      "enabled": true,
      "cutoff_local_time": "12:00",
      "weekend_holiday_enabled": true
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| timezone | IANA string | Used for Rapid Ship cutoff, pickup windows and report boundaries. |
| hours[].day | enum | `mon` \| `tue` \| `wed` \| `thu` \| `fri` \| `sat` \| `sun`. |
| hours[].open / close | string HH:MM | Local time. Launch: Mon–Sat 09:00–21:00, Sun 10:00–20:00. |
| capabilities.* | boolean | `pickup`, `curbside`, `shipping`, `local_delivery` (gate — false), `pos`. |
| rapid_ship.enabled | boolean | Rapid Ship available from this location right now (false when weekend/holiday fulfillment is off). |
| rapid_ship.cutoff_local_time | string HH:MM | Default `12:00`. |
| rapid_ship.weekend_holiday_enabled | boolean |  |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `RESOURCE_NOT_FOUND` | 404 | Resource doesn't exist or isn't visible to the caller (never reveals existence). |

---

### 3.3 `GET /locations/nearby`

**Nearby stores**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/locations/nearby` |
| Auth | Public |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| lat / lng | number · optional | Coordinates. |
| postal_code | string · optional | Alternative to lat/lng. |
| radius_miles | number · optional |  |

**Request example**

```http
GET /api/v1/locations/nearby HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "id": "loc_albany",
        "name": "Orange Wine Albany",
        "timezone": "America/New_York",
        "address": {
          "line1": "100 Main Street",
          "line2": "Suite 4",
          "city": "Albany",
          "state": "NY",
          "postal_code": "12201",
          "country": "US"
        },
        "phone": "+15185550000",
        "geo": {
          "lat": 42.6526,
          "lng": -73.7562
        },
        "hours": [
          {
            "day": "mon",
            "open": "09:00",
            "close": "21:00"
          },
          {
            "day": "sun",
            "open": "10:00",
            "close": "20:00"
          }
        ],
        "capabilities": {
          "pickup": true,
          "curbside": true,
          "shipping": true,
          "local_delivery": false,
          "pos": true
        },
        "rapid_ship": {
          "enabled": true,
          "cutoff_local_time": "12:00",
          "weekend_holiday_enabled": true
        },
        "distance_miles": 3.2
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| items[].distance_miles | number |  |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 422 | Neither coordinates nor postal code supplied |

---

### 3.4 `GET /location/current`

**Session's selected store**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/location/current` |
| Auth | Public / session |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Request example**

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

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "location_id": "loc_albany",
    "source": "customer_selected"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| location_id | string \| null |  |
| source | enum | `customer_selected` \| `account_preference` \| `default`. |

---

### 3.5 `POST /location/select`

**Select store**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/location/select` |
| Auth | Public / session |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Request example**

```http
POST /api/v1/location/select 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>

{
  "location_id": "loc_albany"
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| location_id | string · required | Must be an active store. |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "location_id": "loc_albany",
    "source": "customer_selected"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 422 | Unknown or inactive location |

---

### 3.6 `GET /pickup/windows`

**Pickup time slots**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/pickup/windows` |
| Auth | Public |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| location_id | string · required |  |
| date | YYYY-MM-DD · optional | Defaults to today (store time). |

**Request example**

```http
GET /api/v1/pickup/windows HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "location_id": "loc_albany",
    "timezone": "America/New_York",
    "windows": [
      {
        "id": "pw_1001",
        "start": "2026-09-17T14:00:00Z",
        "end": "2026-09-17T15:00:00Z",
        "available": true
      }
    ]
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| windows[].id | string | Pass as `pickup_window_id` at checkout. |
| windows[].available | boolean | Derived from hours, holidays, closures and capacity. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 422 | Location missing |
| `FULFILLMENT_NOT_ELIGIBLE` | 422 | Pickup disabled at this store |

---

### 3.7 `POST /addresses/validate`

**Validate address**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/addresses/validate` |
| Purpose | Google Maps address validation. While the gate is closed returns the disabled envelope. |
| Auth | Public / session |
| Status | CONFIGURATION GATE |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Request example**

```http
POST /api/v1/addresses/validate 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>

{
  "line1": "100 Main Street",
  "line2": "Suite 4",
  "city": "Albany",
  "state": "NY",
  "postal_code": "12201",
  "country": "US"
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| line1 | string · required | Street address. |
| line2 | string · optional | Apartment, suite, unit. |
| city | string · required |  |
| state | string · required | 2-letter US state code, e.g. `NY`. |
| postal_code | string · required | 5-digit or ZIP+4. |
| country | string · required | ISO 3166-1 alpha-2. `US` only in V1. |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "validation_status": "corrected",
    "address": {
      "line1": "100 Main Street",
      "line2": "Suite 4",
      "city": "Albany",
      "state": "NY",
      "postal_code": "12207",
      "country": "US"
    },
    "place_id": "ChIJexample",
    "deliverable": true
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| validation_status | enum | `validated` \| `corrected` \| `failed` (PROPOSED value set). |
| address | Address | Normalized address to show the customer for confirmation. |
| deliverable | boolean | Carrier deliverability (not alcohol eligibility). |

**Response — gate closed (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "enabled": false,
    "status": "pending_configuration",
    "reason_code": "ADDRESS_VALIDATION_NOT_CONFIGURED",
    "message": "Address validation is not available yet."
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 422 | Missing fields |
| `PROVIDER_UNAVAILABLE` | 503 | Provider down |

---

### 3.8 `GET /dashboard/locations`

**List stores (admin)**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/dashboard/locations` |
| Auth | Staff `locations.manage` |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Request example**

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

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "id": "loc_albany",
        "name": "Orange Wine Albany",
        "timezone": "America/New_York",
        "address": {
          "line1": "100 Main Street",
          "line2": "Suite 4",
          "city": "Albany",
          "state": "NY",
          "postal_code": "12201",
          "country": "US"
        },
        "phone": "+15185550000",
        "geo": {
          "lat": 42.6526,
          "lng": -73.7562
        },
        "hours": [
          {
            "day": "mon",
            "open": "09:00",
            "close": "21:00"
          },
          {
            "day": "sun",
            "open": "10:00",
            "close": "20:00"
          }
        ],
        "capabilities": {
          "pickup": true,
          "curbside": true,
          "shipping": true,
          "local_delivery": false,
          "pos": true
        },
        "rapid_ship": {
          "enabled": true,
          "cutoff_local_time": "12:00",
          "weekend_holiday_enabled": true
        },
        "status": "active",
        "registers": 3
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| items[].status | enum | `draft` \| `active` \| `inactive`. |

---

### 3.9 `POST /dashboard/locations`

**Create store**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/locations` |
| Auth | Staff `locations.manage` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Request example**

```http
POST /api/v1/dashboard/locations 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>

{
  "name": "Orange Wine Saratoga",
  "timezone": "America/New_York",
  "address": {
    "line1": "100 Main Street",
    "line2": "Suite 4",
    "city": "Albany",
    "state": "NY",
    "postal_code": "12201",
    "country": "US"
  },
  "phone": "+15185550001",
  "capabilities": {
    "pickup": true,
    "curbside": false,
    "shipping": false,
    "local_delivery": false,
    "pos": true
  }
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| name | string · required |  |
| timezone | IANA · required |  |
| address | Address · required |  |
| phone | string · optional |  |
| capabilities | object · optional | `local_delivery` cannot be `true` while D-36 is closed. |

**Response — created (201)**

```http
HTTP/1.1 201 Created

{
  "data": {
    "id": "loc_saratoga",
    "name": "Orange Wine Albany",
    "timezone": "America/New_York",
    "address": {
      "line1": "100 Main Street",
      "line2": "Suite 4",
      "city": "Albany",
      "state": "NY",
      "postal_code": "12201",
      "country": "US"
    },
    "phone": "+15185550000",
    "geo": {
      "lat": 42.6526,
      "lng": -73.7562
    },
    "hours": [
      {
        "day": "mon",
        "open": "09:00",
        "close": "21:00"
      },
      {
        "day": "sun",
        "open": "10:00",
        "close": "20:00"
      }
    ],
    "capabilities": {
      "pickup": true,
      "curbside": true,
      "shipping": true,
      "local_delivery": false,
      "pos": true
    },
    "rapid_ship": {
      "enabled": true,
      "cutoff_local_time": "12:00",
      "weekend_holiday_enabled": true
    },
    "status": "draft"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| status | enum | New stores start `draft`. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 422 | Invalid field or gated capability |

---

### 3.10 `GET /dashboard/locations/{locationId}`

**Store detail (admin)**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/dashboard/locations/{locationId}` |
| Auth | Staff `locations.manage` |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| locationId | string |  |

**Request example**

```http
GET /api/v1/dashboard/locations/{locationId} HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "id": "loc_albany",
    "name": "Orange Wine Albany",
    "timezone": "America/New_York",
    "address": {
      "line1": "100 Main Street",
      "line2": "Suite 4",
      "city": "Albany",
      "state": "NY",
      "postal_code": "12201",
      "country": "US"
    },
    "phone": "+15185550000",
    "geo": {
      "lat": 42.6526,
      "lng": -73.7562
    },
    "hours": [
      {
        "day": "mon",
        "open": "09:00",
        "close": "21:00"
      },
      {
        "day": "sun",
        "open": "10:00",
        "close": "20:00"
      }
    ],
    "capabilities": {
      "pickup": true,
      "curbside": true,
      "shipping": true,
      "local_delivery": false,
      "pos": true
    },
    "rapid_ship": {
      "enabled": true,
      "cutoff_local_time": "12:00",
      "weekend_holiday_enabled": true
    },
    "status": "active"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `FORBIDDEN` | 403 | Location not in caller's scope |
| `RESOURCE_NOT_FOUND` | 404 | Resource doesn't exist or isn't visible to the caller (never reveals existence). |

---

### 3.11 `PATCH /dashboard/locations/{locationId}`

**Update store**

| Property | Value |
|---|---|
| Endpoint | `PATCH /api/v1/dashboard/locations/{locationId}` |
| Auth | Staff `locations.manage` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| locationId | string |  |

**Request example**

```http
PATCH /api/v1/dashboard/locations/{locationId} 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>

{
  "phone": "+15185550002",
  "rapid_ship": {
    "enabled": true,
    "cutoff_local_time": "12:00",
    "weekend_holiday_enabled": false
  }
}
```

Status changes are not done here (PROPOSED commands `/activate`, `/deactivate`).

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| name / phone / address / capabilities | optional |  |
| rapid_ship.enabled | boolean · optional |  |
| rapid_ship.cutoff_local_time | HH:MM · optional |  |
| rapid_ship.weekend_holiday_enabled | boolean · optional | When `false`, Rapid Ship is disabled on weekends/holidays (D-12). |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "id": "loc_albany",
    "name": "Orange Wine Albany",
    "timezone": "America/New_York",
    "address": {
      "line1": "100 Main Street",
      "line2": "Suite 4",
      "city": "Albany",
      "state": "NY",
      "postal_code": "12201",
      "country": "US"
    },
    "phone": "+15185550000",
    "geo": {
      "lat": 42.6526,
      "lng": -73.7562
    },
    "hours": [
      {
        "day": "mon",
        "open": "09:00",
        "close": "21:00"
      },
      {
        "day": "sun",
        "open": "10:00",
        "close": "20:00"
      }
    ],
    "capabilities": {
      "pickup": true,
      "curbside": true,
      "shipping": true,
      "local_delivery": false,
      "pos": true
    },
    "rapid_ship": {
      "enabled": true,
      "cutoff_local_time": "12:00",
      "weekend_holiday_enabled": true
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `FORBIDDEN` | 403 | Authenticated, but missing permission, location scope, manager approval, or a valid webhook signature. |
| `VALIDATION_ERROR` | 422 | Request failed validation. `details.fields` maps field → messages. |

---

### 3.12 `GET /dashboard/locations/{locationId}/hours`

**Weekly hours**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/dashboard/locations/{locationId}/hours` |
| Auth | Staff `locations.manage` |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| locationId | string |  |

**Request example**

```http
GET /api/v1/dashboard/locations/{locationId}/hours HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "location_id": "loc_albany",
    "hours": [
      {
        "day": "mon",
        "open": "09:00",
        "close": "21:00"
      },
      {
        "day": "sun",
        "open": "10:00",
        "close": "20:00"
      }
    ]
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

---

### 3.13 `PUT /dashboard/locations/{locationId}/hours`

**Replace weekly hours**

| Property | Value |
|---|---|
| Endpoint | `PUT /api/v1/dashboard/locations/{locationId}/hours` |
| Auth | Staff `locations.manage` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| locationId | string |  |

**Request example**

```http
PUT /api/v1/dashboard/locations/{locationId}/hours 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>

{
  "hours": [
    {
      "day": "mon",
      "open": "09:00",
      "close": "21:00"
    },
    {
      "day": "sun",
      "open": "10:00",
      "close": "20:00"
    }
  ]
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| hours[] | object[] · required | One entry per open day; missing day = closed. `close` must be after `open`. |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "location_id": "loc_albany",
    "hours": [
      {
        "day": "mon",
        "open": "09:00",
        "close": "21:00"
      },
      {
        "day": "sun",
        "open": "10:00",
        "close": "20:00"
      }
    ]
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 422 | Invalid times |

---

### 3.14 `GET /dashboard/locations/{locationId}/holidays`

**Holidays**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/dashboard/locations/{locationId}/holidays` |
| Auth | Staff `locations.manage` |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| locationId | string |  |

**Request example**

```http
GET /api/v1/dashboard/locations/{locationId}/holidays HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "id": "hol_1",
        "date": "2026-12-25",
        "name": "Christmas",
        "closed": true,
        "open": null,
        "close": null
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| items[].closed | boolean | `false` = special hours in `open`/`close`. |

---

### 3.15 `POST /dashboard/locations/{locationId}/holidays`

**Add holiday**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/locations/{locationId}/holidays` |
| Auth | Staff `locations.manage` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| locationId | string |  |

**Request example**

```http
POST /api/v1/dashboard/locations/{locationId}/holidays 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>

{
  "date": "2026-12-24",
  "name": "Christmas Eve",
  "closed": false,
  "open": "09:00",
  "close": "17:00"
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| date | YYYY-MM-DD · required |  |
| name | string · required |  |
| closed | boolean · required |  |
| open / close | HH:MM · required when closed=false |  |

**Response — created (201)**

```http
HTTP/1.1 201 Created

{
  "data": {
    "id": "hol_2",
    "date": "2026-12-24",
    "name": "Christmas Eve",
    "closed": false,
    "open": "09:00",
    "close": "17:00"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 422 | Request failed validation. `details.fields` maps field → messages. |

---

### 3.16 `DELETE /dashboard/locations/{locationId}/holidays/{holidayId}`

**Remove holiday**

| Property | Value |
|---|---|
| Endpoint | `DELETE /api/v1/dashboard/locations/{locationId}/holidays/{holidayId}` |
| Auth | Staff `locations.manage` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| locationId | string |  |
| holidayId | string |  |

**Request example**

```http
DELETE /api/v1/dashboard/locations/{locationId}/holidays/{holidayId} 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": {
    "id": "hol_2",
    "deleted": true
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `RESOURCE_NOT_FOUND` | 404 | Resource doesn't exist or isn't visible to the caller (never reveals existence). |

---

### 3.17 `GET /dashboard/locations/{locationId}/closures`

**Temporary closures**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/dashboard/locations/{locationId}/closures` |
| Auth | Staff `locations.manage` |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| locationId | string |  |

**Request example**

```http
GET /api/v1/dashboard/locations/{locationId}/closures HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "id": "clo_1",
        "starts_at": "2026-10-01T14:00:00Z",
        "ends_at": "2026-10-01T18:00:00Z",
        "reason": "Inventory count"
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

---

### 3.18 `POST /dashboard/locations/{locationId}/closures`

**Add closure**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/locations/{locationId}/closures` |
| Auth | Staff `locations.manage` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| locationId | string |  |

**Request example**

```http
POST /api/v1/dashboard/locations/{locationId}/closures 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>

{
  "starts_at": "2026-10-01T14:00:00Z",
  "ends_at": "2026-10-01T18:00:00Z",
  "reason": "Inventory count"
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| starts_at / ends_at | ISO 8601 UTC · required | `ends_at` after `starts_at`. |
| reason | string · required |  |

**Response — created (201)**

```http
HTTP/1.1 201 Created

{
  "data": {
    "id": "clo_2",
    "starts_at": "2026-10-01T14:00:00Z",
    "ends_at": "2026-10-01T18:00:00Z",
    "reason": "Inventory count"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 422 | Request failed validation. `details.fields` maps field → messages. |

---

### 3.19 `DELETE /dashboard/locations/{locationId}/closures/{closureId}`

**Remove closure**

| Property | Value |
|---|---|
| Endpoint | `DELETE /api/v1/dashboard/locations/{locationId}/closures/{closureId}` |
| Auth | Staff `locations.manage` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| locationId | string |  |
| closureId | string |  |

**Request example**

```http
DELETE /api/v1/dashboard/locations/{locationId}/closures/{closureId} 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": {
    "id": "clo_2",
    "deleted": true
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `RESOURCE_NOT_FOUND` | 404 | Resource doesn't exist or isn't visible to the caller (never reveals existence). |

---
