# SHELF API Reference

Build integrations with SHELF using our RESTful JSON API. Manage inventory, shopping lists, and storage locations programmatically.

**Base URL:** `https://myshelf.life/api/v1`

---

## Authentication

All API requests require a Bearer token in the Authorization header:

```
Authorization: Bearer YOUR_API_KEY
```

API keys can be created in the SHELF app under Settings > API Keys.

### Permission Levels

- **Read**: Can view locations, inventory, and shopping list data
- **Read & Write**: Full access including creating, updating, and deleting items

---

## Error Responses

The API returns standard HTTP status codes with JSON error bodies:

### 401 Unauthorized

```json
{"error": "Invalid or disabled API key", "code": "unauthorized"}
```

### 403 Forbidden

```json
{"error": "This API key does not have write permissions", "code": "forbidden"}
```

### 404 Not Found

```json
{"error": "Resource not found", "code": "not_found"}
```

### 422 Unprocessable Entity

```json
{"error": "Name can't be blank", "code": "unprocessable_entity", "details": {"name": ["can't be blank"]}}
```

---

## Endpoints

### Read Endpoints

These endpoints require a key with **Read** permission or higher.

#### GET /api/v1/locations

List all storage locations in your household.

**Example Request:**

```bash
curl -H "Authorization: Bearer YOUR_API_KEY" \
     https://myshelf.life/api/v1/locations
```

**Example Response:**

```json
{
  "locations": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "name": "Kühlschrank",
      "icon": "🧊",
      "location_type": "standard",
      "is_default": true,
      "items_count": 15,
      "created_at": "2024-01-15T10:30:00Z"
    }
  ]
}
```

#### GET /api/v1/inventory

Get full inventory grouped by location, including all items with expiry dates and status.

**Example Request:**

```bash
curl -H "Authorization: Bearer YOUR_API_KEY" \
     https://myshelf.life/api/v1/inventory
```

**Example Response:**

```json
{
  "locations": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "name": "Kühlschrank",
      "icon": "🧊",
      "location_type": "standard",
      "items": [
        {
          "id": "550e8400-e29b-41d4-a716-446655440003",
          "name": "Milch",
          "quantity": 2.0,
          "unit": "Liter",
          "category": "dairy",
          "icon": "🥛",
          "expires_on": "2026-03-01",
          "status": "ok",
          "notes": "Bio",
          "added_by": "Max",
          "created_at": "2026-01-15T10:30:00Z"
        }
      ]
    }
  ]
}
```

#### GET /api/v1/shopping_list

Get the active shopping list with all items.

**Query parameters:**

| Param | Default | Meaning |
|---|---|---|
| `crossed_off` | `false` | Return the crossed-off items instead of the active ones, most recently crossed off first. |
| `limit` | `50` (max `200`) | Only with `crossed_off=true`. Crossed-off items accumulate forever, so that view is paged. |

`active_count` and `crossed_off_count` are always both reported, whichever view
you asked for.

| Header | Meaning |
|---|---|
| `X-Shelflife-Viewing: list` | Send it with every poll while the list is on screen. Next to `shopping_list` the response always carries `poll_after` (seconds until the next poll: `2` while you and someone else in the household are both on the list, otherwise `5`), `active_shoppers` and `shoppers` (the other members looking at the list right now). |

**Example Request:**

```bash
curl -H "Authorization: Bearer YOUR_API_KEY" \
     https://myshelf.life/api/v1/shopping_list

# what has already been bought
curl -H "Authorization: Bearer YOUR_API_KEY" \
     "https://myshelf.life/api/v1/shopping_list?crossed_off=true&limit=20"
```

**Example Response:**

```json
{
  "shopping_list": {
    "id": "550e8400-e29b-41d4-a716-446655440001",
    "name": "Einkaufsliste",
    "active_count": 5,
    "crossed_off_count": 12,
    "items": [
      {
        "id": "550e8400-e29b-41d4-a716-446655440002",
        "name": "Milch",
        "quantity": 2.0,
        "unit": "Liter",
        "category": "dairy",
        "added_by": "Max",
        "crossed_off": false,
        "crossed_off_at": null,
        "crossed_off_by": null,
        "created_at": "2024-01-20T14:00:00Z"
      }
    ]
  },
  "poll_after": 5,
  "active_shoppers": 1,
  "shoppers": []
}
```

---

### Write Endpoints

These endpoints require a key with **Read & Write** permission.

#### POST /api/v1/shopping_list/items

Add a new item to the shopping list.

**Request Body:**

| Field    | Type   | Description                      |
|----------|--------|----------------------------------|
| name     | string | Item name (required)             |
| quantity | number | Amount (optional, default: 1)    |
| unit     | string | Unit of measurement (optional)   |

**Example Request:**

```bash
curl -X POST "https://myshelf.life/api/v1/shopping_list/items" \
     -H "Authorization: Bearer YOUR_API_KEY" \
     -H "Content-Type: application/json" \
     -d '{"item": {"name": "Milch", "quantity": 2, "unit": "Liter"}}'
```

**Example Response (201 Created):**

```json
{
  "item": {
    "id": "550e8400-e29b-41d4-a716-446655440010",
    "name": "Milch",
    "quantity": "2.0",
    "unit": "Liter",
    "category": "dairy",
    "added_by": "Max",
    "created_at": "2026-02-15T10:30:00Z"
  }
}
```

#### PATCH /api/v1/shopping_list/items/:id

Update a shopping list item.

**Example Request:**

```bash
curl -X PATCH "https://myshelf.life/api/v1/shopping_list/items/ITEM_ID" \
     -H "Authorization: Bearer YOUR_API_KEY" \
     -H "Content-Type: application/json" \
     -d '{"item": {"quantity": 500, "unit": "ml"}}'
```

**Example Response (200 OK):**

```json
{
  "item": {
    "id": "550e8400-e29b-41d4-a716-446655440002",
    "name": "Milch",
    "quantity": "500.0",
    "unit": "ml",
    "category": "dairy",
    "added_by": "Max",
    "created_at": "2026-01-20T14:00:00Z"
  }
}
```

#### POST /api/v1/shopping_list/items/:id/cross_off

Cross an item off the list, the same thing as tapping it in the app. It leaves
the active list and moves to the crossed-off section, and the cross-off counts
towards the crosser's weekly score. Idempotent: crossing off an already
crossed-off item returns 200 without counting twice.

**Example Request:**

```bash
curl -X POST "https://myshelf.life/api/v1/shopping_list/items/ITEM_ID/cross_off" \
     -H "Authorization: Bearer YOUR_API_KEY"
```

**Example Response (200 OK):**

```json
{
  "item": {
    "id": "550e8400-e29b-41d4-a716-446655440002",
    "name": "Milch",
    "quantity": "2.0",
    "unit": "Liter",
    "category": "dairy",
    "added_by": "Max",
    "crossed_off": true,
    "crossed_off_at": "2026-01-20T15:12:00Z",
    "crossed_off_by": "Max",
    "created_at": "2026-01-20T14:00:00Z"
  }
}
```

#### POST /api/v1/shopping_list/items/:id/uncross

Put a crossed-off item back on the active list. Idempotent.

```bash
curl -X POST "https://myshelf.life/api/v1/shopping_list/items/ITEM_ID/uncross" \
     -H "Authorization: Bearer YOUR_API_KEY"
```

#### DELETE /api/v1/shopping_list/items/:id

Delete a shopping list item outright. Unlike pantry items there is no archive
here, the record is gone and cannot be restored. Use `cross_off` if you mean
"bought it"; use `DELETE` if the item should never have been on the list.
Returns the deleted item so the caller can report what disappeared.

```bash
curl -X DELETE "https://myshelf.life/api/v1/shopping_list/items/ITEM_ID" \
     -H "Authorization: Bearer YOUR_API_KEY"
```

#### PATCH /api/v1/inventory/items/:id

Update an inventory item (quantity, unit, notes, expiry date, etc.).

**Example Request:**

```bash
curl -X PATCH "https://myshelf.life/api/v1/inventory/items/ITEM_ID" \
     -H "Authorization: Bearer YOUR_API_KEY" \
     -H "Content-Type: application/json" \
     -d '{"item": {"quantity": 500, "unit": "ml"}}'
```

**Example Response (200 OK):**

```json
{
  "item": {
    "id": "550e8400-e29b-41d4-a716-446655440003",
    "name": "Milch",
    "quantity": "500.0",
    "unit": "ml",
    "category": "dairy",
    "icon": "🥛",
    "expires_on": "2026-03-01",
    "status": "ok",
    "notes": "Bio",
    "added_by": "Max",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
```

#### DELETE /api/v1/inventory/items/:id

Archive an inventory item (moves it to the archive, does not permanently delete).

**Example Request:**

```bash
curl -X DELETE "https://myshelf.life/api/v1/inventory/items/ITEM_ID" \
     -H "Authorization: Bearer YOUR_API_KEY"
```

**Example Response (200 OK):**

```json
{
  "item": {
    "id": "550e8400-e29b-41d4-a716-446655440003",
    "name": "Milch",
    "quantity": "2.0",
    "unit": "Liter",
    "category": "dairy",
    "icon": "🥛",
    "expires_on": "2026-03-01",
    "status": "ok",
    "notes": "Bio",
    "added_by": "Max",
    "created_at": "2026-01-15T10:30:00Z"
  }
}
```

---

## Quick Start Examples

```bash
# Read shopping list
curl -H "Authorization: Bearer YOUR_API_KEY" \
     https://myshelf.life/api/v1/shopping_list

# Add item to shopping list
curl -X POST "https://myshelf.life/api/v1/shopping_list/items" \
     -H "Authorization: Bearer YOUR_API_KEY" \
     -H "Content-Type: application/json" \
     -d '{"item": {"name": "Milch", "quantity": 2, "unit": "Liter"}}'
```

---

## Meal Plans

Plan meals per day. Each meal plan is a recipe (title, description, ingredients)
scheduled for a date — freely movable between days. Each ingredient may reference
an inventory item it consumes (via `item_id`); if omitted it is matched by name
against your active inventory, so responses show **what a meal would consume and
whether it is in stock**. Marking a plan as *cooked* deducts the consumed
quantities (and archives an item once it reaches zero).

### GET /api/v1/meal_plans

Requires **Read**. Meal plans in a date range (defaults to the current week).
Query params: `from`, `to` (YYYY-MM-DD). Ingredients are resolved against current inventory.

```bash
curl -H "Authorization: Bearer YOUR_API_KEY" \
     "https://myshelf.life/api/v1/meal_plans?from=2026-06-02&to=2026-06-08"
```

```json
{
  "from": "2026-06-02", "to": "2026-06-08",
  "meal_plans": [{
    "id": "…", "date": "2026-06-04", "title": "Pasta Pomodoro",
    "in_stock_count": 1, "total_ingredients": 2,
    "ingredients": [
      {"name": "Pasta", "quantity": 500, "unit": "g", "item_id": "…", "in_stock": true, "available_quantity": "2.0", "location": "Vorratskammer"},
      {"name": "Basilikum", "quantity": 1, "unit": null, "item_id": null, "in_stock": false}
    ]
  }]
}
```

### POST /api/v1/meal_plans

Requires **Read & Write**. Body: `meal_plan` with `date` (required), `title`
(required), `description`, and `ingredients` (`[{ name, quantity, unit, item_id }]`).

```bash
curl -X POST "https://myshelf.life/api/v1/meal_plans" \
     -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \
     -d '{"meal_plan": {"date": "2026-06-04", "title": "Pasta Pomodoro", "ingredients": [{"name": "Pasta", "quantity": 500, "unit": "g"}, {"name": "Tomaten", "quantity": 1, "unit": "Dose"}]}}'
```

### PATCH /api/v1/meal_plans/:id

Requires **Read & Write**. Edit the recipe or move it to another `date` (same body shape as POST).

### DELETE /api/v1/meal_plans/:id

Requires **Read & Write**. Soft-deletes (archives) the meal plan.

### POST /api/v1/meal_plans/:id/cook

Requires **Read & Write**. Marks the plan cooked and deducts consumed inventory
(decrement by the recipe amount; archive at zero). Returns a `consumed` log.

```bash
curl -X POST "https://myshelf.life/api/v1/meal_plans/MEAL_PLAN_ID/cook" \
     -H "Authorization: Bearer YOUR_API_KEY"
```

---

## Household Actions

Chores that earn points ("Take out trash", +10), grouped into the household's
own categories. Every household member may manage them. Removing an action
archives it: archived actions keep their history (name and points are copied
into every logged entry) and can be restored. There is one order everywhere:
categories by `position`, then actions by `position`. An action's `position`
counts through the whole household, active actions first, archived ones last.

Errors carry a German `message` for people, next to `error` and `code`.

### GET /api/v1/household_actions

Requires **Read**. All actions, archived ones included (`active: false`), in that
order. `category` and `category_emoji` are the category's name and emoji, as older
clients read them; `category_id` and `household_action_category` describe it fully.

```json
{
  "household_actions": [{
    "id": "…", "name": "Staubsaugen", "emoji": "🧹", "points": 30, "active": true,
    "category": "Putzen", "category_emoji": "🧹", "category_id": "…",
    "household_action_category": {"id": "…", "name": "Putzen", "emoji": "🧹", "position": 0},
    "position": 0, "claimed_today": false, "created_at": "2026-10-03T09:30:00Z"
  }]
}
```

### POST /api/v1/household_actions

Requires **Read & Write**. Body: `household_action` with `name` and `category_id`
(both required), `points` (1 to 100, default 10), `emoji` (default ✅) and optionally
`id`, a UUID chosen by the client. A retry with the same `id` returns the existing
action (200) instead of creating a second one. New actions go to the end of their
category. A name only has to be unique among active actions. An unknown category
or one of another household answers 422 with `code: "unknown_category"`.

```bash
curl -X POST "https://myshelf.life/api/v1/household_actions" \
     -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \
     -d '{"household_action": {"name": "Kinderzimmer aufräumen", "emoji": "🧸", "points": 30, "category_id": "CATEGORY_ID"}}'
```

Returns `{"household_action": {...}}` (201).

### PATCH /api/v1/household_actions/:id

Requires **Read & Write**. Any of `name`, `emoji`, `points`, `category_id` and
`active` (`false` archives, `true` restores; restoring fails with 422 while an
active action has the same name). Moving to another category puts the action at
its end. New points only count for future entries, and the other members get a
push notification.

### DELETE /api/v1/household_actions/:id

Requires **Read & Write**. Archives the action and returns it. Idempotent.

### PATCH /api/v1/household_actions/reorder

Requires **Read & Write**. Body: `{"ids": [...]}` in the desired order. The order
applies within each category; the order of the categories is their own. Actions
not listed follow the listed ones of their category in their previous order. Returns
the full list, as GET does.

### Household action categories

`GET /api/v1/household_action_categories` (**Read**) lists them by `position`:

```json
{"household_action_categories": [{"id": "…", "name": "Putzen", "emoji": "🧹", "position": 0, "action_count": 6}]}
```

`action_count` counts active actions. With **Read & Write**:

- `POST /api/v1/household_action_categories` with `household_action_category`:
  `name` (required), `emoji`, optionally a client `id`. Appended at the end. An
  existing `id` or name returns the existing category (200).
- `PATCH /api/v1/household_action_categories/:id` with `name` and/or `emoji`.
- `DELETE /api/v1/household_action_categories/:id`, optionally with `move_to_id`:
  its actions, archived ones too, move to the end of that category, otherwise to
  "Sonstiges" ("Other"), which is created if needed. The answer names the target in
  `moved_to` and lists `moved_action_ids`. Removing "Sonstiges" itself while it
  holds actions needs a `move_to_id` (422, `code: "move_target_required"`).
- `PATCH /api/v1/household_action_categories/reorder` with `{"ids": [...]}`: the
  full order; categories not listed follow. Action positions follow along.
