# Orange Wine — API Part 06: Shipping, Delivery and Fulfillment

Version 1.0 — 28 Sep 2026 · 19 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 |
|---|---|---|
| `ADULT_SIGNATURE_REQUIRED` | 422 | Shipment must use adult-signature service. |
| `AGE_VERIFICATION_REQUIRED` | 422 | Age verification must be recorded before this step. |
| `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. |
| `INVALID_STATE_TRANSITION` | 409 | Named command not allowed from the resource's current state. |
| `INVENTORY_ALLOCATION_CONFLICT` | 409 | Allocation or release could not be completed consistently. |
| `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). |
| `SHIPPING_DESTINATION_NOT_ELIGIBLE` | 422 | Destination state/address isn't eligible for shipping. |
| `VALIDATION_ERROR` | 422 | Request failed validation. `details.fields` maps field → messages. |

### Permissions used in this part

| Permission | Grants | Endpoints |
|---|---|---|
| `orders.fulfill` | Dashboard orders, fulfillment steps, cancellation approvals, return review/receive/inspect | 10 |
| `shipping.manage` | Labels, void labels, Rapid Ship SLA | 3 |

### Endpoints requiring Idempotency-Key

| Method | Path | Title |
|---|---|---|
| POST | `/dashboard/fulfillments/{fulfillmentId}/pick` | Pick |
| POST | `/dashboard/fulfillments/{fulfillmentId}/pack` | Pack (PROPOSED step) |
| POST | `/dashboard/fulfillments/{fulfillmentId}/ready` | Ready for pickup |
| POST | `/dashboard/fulfillments/{fulfillmentId}/ship` | Ship |
| POST | `/dashboard/fulfillments/{fulfillmentId}/handoff` | Hand over to customer |
| POST | `/dashboard/fulfillments/{fulfillmentId}/exception` | Record exception |
| POST | `/dashboard/shipments/{shipmentId}/label` | Buy label |
| POST | `/dashboard/shipments/{shipmentId}/void-label` | Void label |

### PROPOSED and gated endpoints in this part

| Status | Method | Path |
|---|---|---|
| CONFIGURATION GATE | POST | `/shipping/quote` |
| CONFIGURATION GATE (deny by default) | GET | `/shipping/eligibility` |
| CONFIGURATION GATE (D-36) | GET | `/delivery/zones` |
| CONFIGURATION GATE (D-36) | POST | `/delivery/estimate` |
| CONFIGURATION GATE | POST | `/dashboard/shipments/{shipmentId}/label` |
| CONFIGURATION GATE | POST | `/dashboard/shipments/{shipmentId}/void-label` |
| PROPOSED | GET | `/dashboard/rapid-ship/sla` |

## 2. Shipping, Fulfillment and Rapid Ship

Fulfillment commands are keyed by fulfillment (one order can split into several groups, D-39). Shipping provider, eligibility matrix and local delivery are CONFIGURATION GATES; gated endpoints return the disabled envelope, never 404.

| # | Method | Path | Title | Status |
|---|---|---|---|---|
| 2.1 | GET | `/shipping/methods` | Shipping methods | CONFIRMED |
| 2.2 | POST | `/shipping/quote` | Shipping quote | CONFIGURATION GATE |
| 2.3 | GET | `/shipping/eligibility` | Destination eligibility | CONFIGURATION GATE (deny by default) |
| 2.4 | GET | `/delivery/zones` | Local delivery zones | CONFIGURATION GATE (D-36) |
| 2.5 | POST | `/delivery/estimate` | Local delivery estimate | CONFIGURATION GATE (D-36) |
| 2.6 | GET | `/shipments/{shipmentId}/tracking` | Tracking | CONFIRMED |
| 2.7 | GET | `/dashboard/fulfillments` | Fulfillment queue | CONFIRMED |
| 2.8 | GET | `/dashboard/fulfillments/{fulfillmentId}` | Fulfillment detail | CONFIRMED |
| 2.9 | POST | `/dashboard/fulfillments/{fulfillmentId}/pick` | Pick | CONFIRMED |
| 2.10 | POST | `/dashboard/fulfillments/{fulfillmentId}/pack` | Pack (PROPOSED step) | CONFIRMED |
| 2.11 | POST | `/dashboard/fulfillments/{fulfillmentId}/ready` | Ready for pickup | CONFIRMED |
| 2.12 | POST | `/dashboard/fulfillments/{fulfillmentId}/ship` | Ship | CONFIRMED |
| 2.13 | POST | `/dashboard/fulfillments/{fulfillmentId}/curbside-arrival` | Record curbside arrival | CONFIRMED |
| 2.14 | POST | `/dashboard/fulfillments/{fulfillmentId}/age-verification` | Record age check | CONFIRMED |
| 2.15 | POST | `/dashboard/fulfillments/{fulfillmentId}/handoff` | Hand over to customer | CONFIRMED |
| 2.16 | POST | `/dashboard/fulfillments/{fulfillmentId}/exception` | Record exception | CONFIRMED |
| 2.17 | POST | `/dashboard/shipments/{shipmentId}/label` | Buy label | CONFIGURATION GATE |
| 2.18 | POST | `/dashboard/shipments/{shipmentId}/void-label` | Void label | CONFIGURATION GATE |
| 2.19 | GET | `/dashboard/rapid-ship/sla` | Rapid Ship SLA records | PROPOSED |

### 2.1 `GET /shipping/methods`

**Shipping methods**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/shipping/methods` |
| 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 |  |
| postal_code | string · optional |  |

**Request example**

```http
GET /api/v1/shipping/methods HTTP/1.1
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "id": "ship_ground",
        "name": "Ground (adult signature)",
        "carrier": "ups",
        "estimated_business_days": {
          "min": 2,
          "max": 5
        },
        "adult_signature_supported": true,
        "rapid_ship_service": false,
        "enabled": true
      },
      {
        "id": "ship_rapid_2day",
        "name": "Rapid Ship 2-Day",
        "carrier": "ups",
        "estimated_business_days": {
          "min": 1,
          "max": 2
        },
        "adult_signature_supported": true,
        "rapid_ship_service": true,
        "enabled": true
      }
    ],
    "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[].carrier | string | Configured carrier code. |
| items[].rapid_ship_service | boolean | Service used for Rapid Ship groups. |
| items[].enabled | boolean |  |

---

### 2.2 `POST /shipping/quote`

**Shipping quote**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/shipping/quote` |
| Auth | Public / session |
| Status | CONFIGURATION GATE |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Request example**

```http
POST /api/v1/shipping/quote 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",
  "cart_type": "normal",
  "items": [
    {
      "product_id": "prod_202",
      "quantity": 12
    }
  ],
  "destination": {
    "line1": "100 Main Street",
    "line2": "Suite 4",
    "city": "Albany",
    "state": "NY",
    "postal_code": "12201",
    "country": "US"
  }
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| location_id | string · required |  |
| cart_type | enum · required | `normal` (pre-arrival quotes at checkout). |
| items[] | object[] · required | `{ product_id, quantity }`. |
| destination | Address · required |  |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "eligible": true,
    "adult_signature_required": true,
    "ship_free_12": {
      "qualified": true,
      "reason_code": "QUALIFIED"
    },
    "options": [
      {
        "method_id": "ship_ground",
        "amount": {
          "amount_minor": 0,
          "currency": "USD"
        },
        "estimated_delivery_date": "2026-09-22"
      }
    ]
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| options[].amount | Money object `{ amount_minor: integer, currency: "USD" }` | `0` when 12 Ship Free qualifies (standard shipping charge only). |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `SHIPPING_DESTINATION_NOT_ELIGIBLE` | 422 | Destination state/address isn't eligible for shipping. |
| `VALIDATION_ERROR` | 422 | Product missing weight/dimensions |
| `PROVIDER_UNAVAILABLE` | 503 | External provider unreachable or not configured; safe to retry later. |

---

### 2.3 `GET /shipping/eligibility`

**Destination eligibility**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/shipping/eligibility` |
| Auth | Public |
| Status | CONFIGURATION GATE (deny by default) |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| location_id | string · required |  |
| state | string · required |  |
| postal_code | string · required |  |
| product_id | string · optional |  |

**Request example**

```http
GET /api/v1/shipping/eligibility HTTP/1.1
```

**Response — allowed (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "eligible": true,
    "adult_signature_required": true,
    "ruleset_version": "ny-2026-09-01"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| ruleset_version | string | Matrix version applied. |

**Response — blocked (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "eligible": false,
    "reason_code": "DESTINATION_NOT_APPROVED",
    "status": "blocked"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| reason_code | enum | `DESTINATION_NOT_APPROVED` \| `PRODUCT_RESTRICTED` \| `QUANTITY_LIMIT` \| `CARRIER_UNAVAILABLE` (PROPOSED set). |

---

### 2.4 `GET /delivery/zones`

**Local delivery zones**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/delivery/zones` |
| Auth | Public |
| Status | CONFIGURATION GATE (D-36) |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Query parameters**

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

**Request example**

```http
GET /api/v1/delivery/zones HTTP/1.1
```

**Response — gate closed (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "enabled": false,
    "status": "pending_configuration",
    "reason_code": "LOCAL_DELIVERY_NOT_CONFIGURED",
    "message": "Local delivery is not available for this location."
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

---

### 2.5 `POST /delivery/estimate`

**Local delivery estimate**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/delivery/estimate` |
| Auth | Public / session |
| Status | CONFIGURATION GATE (D-36) |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Request example**

```http
POST /api/v1/delivery/estimate 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",
  "destination": {
    "line1": "100 Main Street",
    "line2": "Suite 4",
    "city": "Albany",
    "state": "NY",
    "postal_code": "12201",
    "country": "US"
  }
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| location_id | string · required |  |
| destination | Address · required |  |

**Response — gate closed (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "enabled": false,
    "status": "pending_configuration",
    "reason_code": "LOCAL_DELIVERY_NOT_CONFIGURED",
    "message": "Local delivery is not available for this location."
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

---

### 2.6 `GET /shipments/{shipmentId}/tracking`

**Tracking**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/shipments/{shipmentId}/tracking` |
| Auth | Owner or staff |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

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

**Request example**

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

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "shipment_id": "shp_1001",
    "carrier": "ups",
    "tracking_number": "1Z999",
    "status": "in_transit",
    "events": [
      {
        "status": "in_transit",
        "description": "Departed facility",
        "occurred_at": "2026-09-18T08:00:00Z"
      }
    ]
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| status | enum | `label_created` \| `in_transit` \| `out_for_delivery` \| `delivered` \| `exception` \| `returned_to_sender` \| `voided`. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `FORBIDDEN` | 403 | Authenticated, but missing permission, location scope, manager approval, or a valid webhook signature. |
| `RESOURCE_NOT_FOUND` | 404 | Resource doesn't exist or isn't visible to the caller (never reveals existence). |

---

### 2.7 `GET /dashboard/fulfillments`

**Fulfillment queue**

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

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| location_id | string · optional |  |
| status | enum · optional |  |
| kind | enum · optional | `rapid_ship` first in default sort. |
| mode | enum · optional |  |
| 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/dashboard/fulfillments?page=1&per_page=24 HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "fulfillment_id": "ful_9001",
        "order_id": "ord_5001",
        "confirmation_number": "OW-5001",
        "kind": "rapid_ship",
        "mode": "shipping",
        "location_id": "loc_albany",
        "status": "unassigned",
        "lines": [
          {
            "order_line_id": "ol_1001",
            "product_id": "prod_101",
            "sku": "CAB-2023-750",
            "quantity": 2,
            "picked_quantity": 0
          }
        ],
        "dispatch_deadline_at": "2026-09-17T23:59:00Z",
        "pickup_window": null,
        "age_check_result": null,
        "shipments": [],
        "curbside": 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 |
|---|---|---|
| kind | enum | `rapid_ship` \| `standard` \| `pre_arrival`. |
| mode | enum | `shipping` \| `pickup` \| `curbside` \| `local_delivery` (gate). |
| status | enum | `unassigned` \| `picking` \| `packed` \| `ready_for_pickup` \| `handed_off` \| `shipped` \| `out_for_delivery` \| `delivered` \| `failed` \| `returned` \| `expired` \| `cancelled`. |
| dispatch_deadline_at | ISO 8601 \| null | Rapid Ship groups only. |
| age_check_result | enum \| null | `passed` \| `failed` \| `refused` \| `unavailable`. |
| curbside | object \| null | `{ arrived_at, vehicle, parking_spot }`. |
| 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. |

---

### 2.8 `GET /dashboard/fulfillments/{fulfillmentId}`

**Fulfillment detail**

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

**Path parameters**

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

**Request example**

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

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "fulfillment_id": "ful_9001",
    "order_id": "ord_5001",
    "confirmation_number": "OW-5001",
    "kind": "rapid_ship",
    "mode": "shipping",
    "location_id": "loc_albany",
    "status": "unassigned",
    "lines": [
      {
        "order_line_id": "ol_1001",
        "product_id": "prod_101",
        "sku": "CAB-2023-750",
        "quantity": 2,
        "picked_quantity": 0
      }
    ],
    "dispatch_deadline_at": "2026-09-17T23:59:00Z",
    "pickup_window": null,
    "age_check_result": null,
    "shipments": [],
    "curbside": null
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| kind | enum | `rapid_ship` \| `standard` \| `pre_arrival`. |
| mode | enum | `shipping` \| `pickup` \| `curbside` \| `local_delivery` (gate). |
| status | enum | `unassigned` \| `picking` \| `packed` \| `ready_for_pickup` \| `handed_off` \| `shipped` \| `out_for_delivery` \| `delivered` \| `failed` \| `returned` \| `expired` \| `cancelled`. |
| dispatch_deadline_at | ISO 8601 \| null | Rapid Ship groups only. |
| age_check_result | enum \| null | `passed` \| `failed` \| `refused` \| `unavailable`. |
| curbside | object \| null | `{ arrived_at, vehicle, parking_spot }`. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `FORBIDDEN` | 403 | Authenticated, but missing permission, location scope, manager approval, or a valid webhook signature. |
| `RESOURCE_NOT_FOUND` | 404 | Resource doesn't exist or isn't visible to the caller (never reveals existence). |

---

### 2.9 `POST /dashboard/fulfillments/{fulfillmentId}/pick`

**Pick**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/fulfillments/{fulfillmentId}/pick` |
| Purpose | Picking consumes the allocation: `allocated` and `on_hand` both decrease. A shortfall must use /exception. |
| Auth | Staff `orders.fulfill` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

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

**Request example**

```http
POST /api/v1/dashboard/fulfillments/{fulfillmentId}/pick 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>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13

{
  "lines": [
    {
      "order_line_id": "ol_1001",
      "picked_quantity": 2
    }
  ]
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| lines[].order_line_id | string · required |  |
| lines[].picked_quantity | integer · required |  |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "fulfillment_id": "ful_9001",
    "order_id": "ord_5001",
    "confirmation_number": "OW-5001",
    "kind": "rapid_ship",
    "mode": "shipping",
    "location_id": "loc_albany",
    "status": "picking",
    "lines": [
      {
        "order_line_id": "ol_1001",
        "product_id": "prod_101",
        "sku": "CAB-2023-750",
        "quantity": 2,
        "picked_quantity": 0
      }
    ],
    "dispatch_deadline_at": "2026-09-17T23:59:00Z",
    "pickup_window": null,
    "age_check_result": null,
    "shipments": [],
    "curbside": null
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| status | enum | Now `picking`. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `INVALID_STATE_TRANSITION` | 409 | Not in `unassigned` |
| `FORBIDDEN` | 403 | Location scope |
| `INVENTORY_ALLOCATION_CONFLICT` | 409 | Allocation missing or changed |

---

### 2.10 `POST /dashboard/fulfillments/{fulfillmentId}/pack`

**Pack (PROPOSED step)**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/fulfillments/{fulfillmentId}/pack` |
| Purpose | Moves the fulfillment from `picking` to `packed`. |
| Auth | Staff `orders.fulfill` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

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

**Request example**

```http
POST /api/v1/dashboard/fulfillments/{fulfillmentId}/pack 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>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13

{
  "packages": [
    {
      "weight": {
        "value": 18.5,
        "unit": "lb"
      },
      "dimensions": {
        "length": 14,
        "width": 10,
        "height": 13,
        "unit": "in"
      }
    }
  ]
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| packages[] | object[] · required | Weight and dimensions per package. |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "fulfillment_id": "ful_9001",
    "order_id": "ord_5001",
    "confirmation_number": "OW-5001",
    "kind": "rapid_ship",
    "mode": "shipping",
    "location_id": "loc_albany",
    "status": "packed",
    "lines": [
      {
        "order_line_id": "ol_1001",
        "product_id": "prod_101",
        "sku": "CAB-2023-750",
        "quantity": 2,
        "picked_quantity": 0
      }
    ],
    "dispatch_deadline_at": "2026-09-17T23:59:00Z",
    "pickup_window": null,
    "age_check_result": null,
    "shipments": [],
    "curbside": null
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| status | enum | Now `packed`. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `INVALID_STATE_TRANSITION` | 409 | Not in `picking` |
| `FORBIDDEN` | 403 | Location scope |

---

### 2.11 `POST /dashboard/fulfillments/{fulfillmentId}/ready`

**Ready for pickup**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/fulfillments/{fulfillmentId}/ready` |
| Purpose | Pickup/curbside only; notifies the customer. |
| Auth | Staff `orders.fulfill` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

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

**Request example**

```http
POST /api/v1/dashboard/fulfillments/{fulfillmentId}/ready HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "fulfillment_id": "ful_9001",
    "order_id": "ord_5001",
    "confirmation_number": "OW-5001",
    "kind": "rapid_ship",
    "mode": "shipping",
    "location_id": "loc_albany",
    "status": "ready_for_pickup",
    "lines": [
      {
        "order_line_id": "ol_1001",
        "product_id": "prod_101",
        "sku": "CAB-2023-750",
        "quantity": 2,
        "picked_quantity": 0
      }
    ],
    "dispatch_deadline_at": "2026-09-17T23:59:00Z",
    "pickup_window": null,
    "age_check_result": null,
    "shipments": [],
    "curbside": null
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| status | enum | Now `ready_for_pickup`. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `INVALID_STATE_TRANSITION` | 409 | Not in `picking` |
| `FORBIDDEN` | 403 | Location scope |

---

### 2.12 `POST /dashboard/fulfillments/{fulfillmentId}/ship`

**Ship**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/fulfillments/{fulfillmentId}/ship` |
| Auth | Staff `orders.fulfill` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

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

**Request example**

```http
POST /api/v1/dashboard/fulfillments/{fulfillmentId}/ship 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>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13

{
  "shipment_id": "shp_1001"
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| shipment_id | string · required | Shipment with a purchased label. |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "fulfillment_id": "ful_9001",
    "order_id": "ord_5001",
    "confirmation_number": "OW-5001",
    "kind": "rapid_ship",
    "mode": "shipping",
    "location_id": "loc_albany",
    "status": "shipped",
    "lines": [
      {
        "order_line_id": "ol_1001",
        "product_id": "prod_101",
        "sku": "CAB-2023-750",
        "quantity": 2,
        "picked_quantity": 0
      }
    ],
    "dispatch_deadline_at": "2026-09-17T23:59:00Z",
    "pickup_window": null,
    "age_check_result": null,
    "shipments": [
      {
        "shipment_id": "shp_1001",
        "tracking_number": "1Z999"
      }
    ],
    "curbside": null
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| status | enum | `shipped`. Rapid Ship SLA `dispatched_at` recorded. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `INVALID_STATE_TRANSITION` | 409 | Not `packed` |
| `VALIDATION_ERROR` | 422 | No label |

---

### 2.13 `POST /dashboard/fulfillments/{fulfillmentId}/curbside-arrival`

**Record curbside arrival**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/fulfillments/{fulfillmentId}/curbside-arrival` |
| Auth | Staff `orders.fulfill` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

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

**Request example**

```http
POST /api/v1/dashboard/fulfillments/{fulfillmentId}/curbside-arrival 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>

{
  "vehicle": {
    "description": "Blue Honda Civic",
    "plate": "ABC1234"
  },
  "parking_spot": "3"
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| vehicle | object · optional |  |
| parking_spot | string · optional |  |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "fulfillment_id": "ful_9001",
    "order_id": "ord_5001",
    "confirmation_number": "OW-5001",
    "kind": "rapid_ship",
    "mode": "curbside",
    "location_id": "loc_albany",
    "status": "ready_for_pickup",
    "lines": [
      {
        "order_line_id": "ol_1001",
        "product_id": "prod_101",
        "sku": "CAB-2023-750",
        "quantity": 2,
        "picked_quantity": 0
      }
    ],
    "dispatch_deadline_at": "2026-09-17T23:59:00Z",
    "pickup_window": null,
    "age_check_result": null,
    "shipments": [],
    "curbside": {
      "arrived_at": "2026-09-17T19:00:00Z",
      "vehicle": {
        "description": "Blue Honda Civic"
      },
      "parking_spot": "3"
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `FULFILLMENT_NOT_ELIGIBLE` | 422 | Not curbside |

---

### 2.14 `POST /dashboard/fulfillments/{fulfillmentId}/age-verification`

**Record age check**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/fulfillments/{fulfillmentId}/age-verification` |
| Auth | Staff `orders.fulfill` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

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

**Request example**

```http
POST /api/v1/dashboard/fulfillments/{fulfillmentId}/age-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>

{
  "result": "passed",
  "method": "physical_id_check",
  "notes": "Government-issued ID checked."
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| result | enum · required | `passed` \| `failed` \| `refused` \| `unavailable`. |
| method | enum · required | `physical_id_check` (only method in V1). |
| notes | string · optional | Never store ID numbers or images. |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "fulfillment_id": "ful_9001",
    "order_id": "ord_5001",
    "confirmation_number": "OW-5001",
    "kind": "rapid_ship",
    "mode": "shipping",
    "location_id": "loc_albany",
    "status": "unassigned",
    "lines": [
      {
        "order_line_id": "ol_1001",
        "product_id": "prod_101",
        "sku": "CAB-2023-750",
        "quantity": 2,
        "picked_quantity": 0
      }
    ],
    "dispatch_deadline_at": "2026-09-17T23:59:00Z",
    "pickup_window": null,
    "age_check_result": "passed",
    "shipments": [],
    "curbside": null
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

---

### 2.15 `POST /dashboard/fulfillments/{fulfillmentId}/handoff`

**Hand over to customer**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/fulfillments/{fulfillmentId}/handoff` |
| Auth | Staff `orders.fulfill` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

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

**Request example**

```http
POST /api/v1/dashboard/fulfillments/{fulfillmentId}/handoff 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>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13

{
  "notes": "Handed at curbside."
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| notes | string · optional |  |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "fulfillment_id": "ful_9001",
    "order_id": "ord_5001",
    "confirmation_number": "OW-5001",
    "kind": "rapid_ship",
    "mode": "shipping",
    "location_id": "loc_albany",
    "status": "handed_off",
    "lines": [
      {
        "order_line_id": "ol_1001",
        "product_id": "prod_101",
        "sku": "CAB-2023-750",
        "quantity": 2,
        "picked_quantity": 0
      }
    ],
    "dispatch_deadline_at": "2026-09-17T23:59:00Z",
    "pickup_window": null,
    "age_check_result": null,
    "shipments": [],
    "curbside": null,
    "handed_off_at": "2026-09-17T19:05:00Z"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `AGE_VERIFICATION_REQUIRED` | 422 | No passed age check recorded |
| `INVALID_STATE_TRANSITION` | 409 | Not `ready_for_pickup` |

**Error example — AGE_VERIFICATION_REQUIRED**

```http
HTTP/1.1 422 Unprocessable Entity

{
  "error": {
    "code": "AGE_VERIFICATION_REQUIRED",
    "message": "A passed physical age check is required before handoff.",
    "details": {
      "age_check_result": "failed"
    },
    "retryable": false
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

---

### 2.16 `POST /dashboard/fulfillments/{fulfillmentId}/exception`

**Record exception**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/fulfillments/{fulfillmentId}/exception` |
| Auth | Staff `orders.fulfill` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

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

**Request example**

```http
POST /api/v1/dashboard/fulfillments/{fulfillmentId}/exception 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>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13

{
  "type": "inventory_discrepancy",
  "notes": "System shows 2 allocated, only 1 physically found."
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| type | enum · required | `inventory_discrepancy` \| `weather_hold` \| `carrier_delay` \| `failed_delivery` \| `returned_package` \| `customer_no_show` \| `other`. |
| notes | string · required |  |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "fulfillment_id": "ful_9001",
    "exception_type": "inventory_discrepancy",
    "status": "failed",
    "allocation_released": 2,
    "inventory_exception_id": "iex_55",
    "audit_event_id": "aud_5501"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| allocation_released | integer | Units released for inventory_discrepancy. |
| inventory_exception_id | string \| null | Open exception for staff resolution — never a silent correction. |

---

### 2.17 `POST /dashboard/shipments/{shipmentId}/label`

**Buy label**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/shipments/{shipmentId}/label` |
| Auth | Staff `shipping.manage` (PROPOSED permission) |
| Status | CONFIGURATION GATE |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

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

**Request example**

```http
POST /api/v1/dashboard/shipments/{shipmentId}/label 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>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13

{
  "rate_id": "shippo_rate_1001",
  "adult_signature": true
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| rate_id | string · required | Provider rate. |
| adult_signature | boolean · required | `true` for alcohol. |

**Response — success (201)**

```http
HTTP/1.1 201 Created

{
  "data": {
    "shipment_id": "shp_1001",
    "provider": "shippo",
    "provider_transaction_id": "txn_abc",
    "tracking_number": "1Z999",
    "label_url": "https://.../label.pdf",
    "status": "label_created",
    "adult_signature": true
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| label_url | string | Staff only; never sent to customers. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `PROVIDER_UNAVAILABLE` | 503 | Fulfillment state unchanged; retry |
| `ADULT_SIGNATURE_REQUIRED` | 422 | Shipment must use adult-signature service. |
| `VALIDATION_ERROR` | 422 | Request failed validation. `details.fields` maps field → messages. |

---

### 2.18 `POST /dashboard/shipments/{shipmentId}/void-label`

**Void label**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/shipments/{shipmentId}/void-label` |
| Auth | Staff `shipping.manage` |
| Status | CONFIGURATION GATE |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

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

**Request example**

```http
POST /api/v1/dashboard/shipments/{shipmentId}/void-label 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>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13

{
  "reason": "Wrong package size"
}
```

**Request keys**

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

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "shipment_id": "shp_1001",
    "status": "voided"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `INVALID_STATE_TRANSITION` | 409 | Already in transit |
| `PROVIDER_UNAVAILABLE` | 503 | External provider unreachable or not configured; safe to retry later. |

---

### 2.19 `GET /dashboard/rapid-ship/sla`

**Rapid Ship SLA records**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/dashboard/rapid-ship/sla` |
| Auth | Staff `shipping.manage` |
| Status | PROPOSED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| sla_met | boolean · optional | `false` = missed. |
| refund_status | enum · optional |  |
| location_id | string · optional |  |
| 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/dashboard/rapid-ship/sla?page=1&per_page=24 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": "rss_301",
        "order_id": "ord_5001",
        "fulfillment_group_id": "ful_9001",
        "eligible_at_checkout": true,
        "cutoff_local_time": "12:00",
        "location_timezone": "America/New_York",
        "order_placed_at": "2026-09-17T15:00:00Z",
        "dispatch_deadline_at": "2026-09-17T23:59:00Z",
        "dispatched_at": "2026-09-17T21:00:00Z",
        "delivery_target_at": "2026-09-19T23:59:00Z",
        "delivered_at": "2026-09-20T15:00:00Z",
        "carrier": "ups",
        "service_level": "2day",
        "sla_met": false,
        "refund_eligible": true,
        "refund_amount": {
          "amount_minor": 1800,
          "currency": "USD"
        },
        "refund_status": "not_issued",
        "refund_id": 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 |
|---|---|---|
| sla_met | boolean \| null | `null` while in progress. |
| refund_status | enum | `not_issued` \| `pending` \| `completed` \| `declined` (declined PROPOSED). |
| refund_amount | Money object `{ amount_minor: integer, currency: "USD" }` | From the configured SLA refund rule (value PENDING). |

---
