# Kitchen Cupboard API Reference Kitchen Cupboard exposes a REST API for AI agents, scripts, and integrations. ## Discovery | URL | Purpose | |---|---| | `/api/` | Health check and links to all documentation | | `/api/context` | Complete machine-readable operation and authentication index | | `/api/agent-guide` | Plain-text agent quick start | | `/api/openapi.json` | Canonical OpenAPI contract | | `/api/docs` | Interactive Swagger UI | | `/api/redoc` | ReDoc interface | The live OpenAPI contract is generated from the registered application routes. Each operation includes an `x-kitchen-cupboard-auth` object stating whether it is public, JWT-only, or available to API keys and which scope it requires. ## API-key quick start Create a key while signed in at **Settings > API Keys**. The complete key is shown once; the prefix displayed later is not a usable credential. An API key is not a username or password and must not be sent to `/api/auth/login`. Send it directly to resource endpoints: ```http Authorization: Bearer kc_your_full_api_key ``` ```bash BASE="http://192.168.x.x:8111" API_KEY="kc_your_full_key" # Discover available operations curl -s "$BASE/api/context" | jq # List every list visible to the key owner curl -s -H "Authorization: Bearer $API_KEY" "$BASE/api/lists" | jq # Add an item using an ID returned by the previous request curl -s -X POST \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"Milk","quantity":2,"unit":"pints"}' \ "$BASE/api/lists/{list_id}/items" | jq ``` API keys act as the user who created them and can access only that user's owned or shared lists. List roles still apply: viewers cannot mutate a list, editors can modify it, and only owners can share or delete it. Scopes: - `read` permits resource GET requests and read-only WebSocket subscriptions. - `read,write` additionally permits resource POST, PUT, and DELETE requests. - API keys never permit profile, password, API-key management, invitation, or administrator operations. ## Complete endpoint index ### Discovery and registration policy | Method | Endpoint | Authentication | Description | |---|---|---|---| | `GET` | `/api/` | Public | Health and documentation links | | `GET` | `/api/registration-status` | Public | Registration and invitation policy | | `GET` | `/api/context` | Public | Machine-readable agent capability index | | `GET` | `/api/agent-guide` | Public | Plain-text agent guide | ### Authentication and account management | Method | Endpoint | Authentication | Description | |---|---|---|---| | `POST` | `/api/auth/register` | Public | Register a user; an invite may be required | | `POST` | `/api/auth/login` | Public, username/password body | Return an access JWT and set a refresh cookie | | `POST` | `/api/auth/refresh` | Refresh cookie | Rotate the refresh cookie and return a new JWT | | `POST` | `/api/auth/logout` | Public | Clear the refresh cookie | | `GET` | `/api/auth/me` | JWT only | Return the signed-in user | | `PUT` | `/api/auth/me` | JWT only | Update the signed-in user | | `POST` | `/api/auth/change-password` | JWT only | Change the signed-in user's password | | `GET` | `/api/auth/api-keys` | JWT only | List the user's key metadata; full keys are never returned | | `POST` | `/api/auth/api-keys` | JWT only | Create a `read` or `read,write` key | | `DELETE` | `/api/auth/api-keys/{key_id}` | JWT only | Revoke one of the user's keys | | `GET` | `/api/auth/invite-codes` | Administrator JWT | List invitation codes | | `POST` | `/api/auth/invite-codes` | Administrator JWT | Create an invitation code | | `DELETE` | `/api/auth/invite-codes/{code_id}` | Administrator JWT | Revoke an unused invitation code | | `GET` | `/api/auth/users` | Administrator JWT | List users | | `PUT` | `/api/auth/users/{user_id}/toggle-active` | Administrator JWT | Enable or disable a user | | `DELETE` | `/api/auth/users/{user_id}` | Administrator JWT | Delete a user | ### Shopping lists | Method | Endpoint | API-key scope | Additional permission | |---|---|---|---| | `GET` | `/api/lists` | `read` | Returns owned and shared lists | | `POST` | `/api/lists` | `write` | Creates a list owned by the key owner | | `GET` | `/api/lists/{list_id}` | `read` | Owner, editor, or viewer | | `PUT` | `/api/lists/{list_id}` | `write` | Owner or editor | | `DELETE` | `/api/lists/{list_id}` | `write` | Owner only | | `POST` | `/api/lists/{list_id}/share` | `write` | Owner only | | `DELETE` | `/api/lists/{list_id}/share/{user_id}` | `write` | Owner only | Create a list: ```json { "name": "Weekly Groceries", "description": "Shopping for the week", "color": "#22c55e", "icon": "shopping-cart" } ``` Share a list using `role` `editor` or `viewer`: ```json {"username":"partner","role":"editor"} ``` ### List items | Method | Endpoint | API-key scope | Description | |---|---|---|---| | `GET` | `/api/lists/{list_id}/items` | `read` | Get every item in the list | | `POST` | `/api/lists/{list_id}/items` | `write` | Add an item | | `PUT` | `/api/lists/{list_id}/items/{item_id}` | `write` | Edit or check/uncheck an item | | `DELETE` | `/api/lists/{list_id}/items/{item_id}` | `write` | Remove an item | | `POST` | `/api/lists/{list_id}/items/reorder` | `write` | Replace item sort order using `item_ids` | | `POST` | `/api/lists/{list_id}/items/clear-checked` | `write` | Delete all checked items or a captured set | | `POST` | `/api/lists/{list_id}/items/import-recipe/preview` | `write` | Parse a recipe URL without adding items | | `POST` | `/api/lists/{list_id}/items/import-recipe` | `write` | Parse a recipe URL and add its ingredients | Create an item: ```json { "id": "optional-client-generated-uuid", "name": "Milk", "quantity": 2, "unit": "litres", "category_id": "optional-category-uuid", "notes": "Semi-skimmed", "sort_order": 0 } ``` When `id` is supplied, retrying the same create against the same list returns the existing item. Reusing that UUID in a different list returns `409`. If `category_id` is omitted, Kitchen Cupboard uses remembered category history when possible. Update requests contain only changed fields. For example: ```json {"checked":true} ``` Reorder items: ```json {"item_ids":["first-item-uuid","second-item-uuid"]} ``` Clear a captured set of checked items, or omit the body to clear every currently checked item: ```json {"item_ids":["checked-item-uuid"]} ``` Preview or import a recipe: ```json {"url":"https://example.com/recipe"} ``` ### Categories, suggestions, and favourites | Method | Endpoint | API-key scope | Description | |---|---|---|---| | `GET` | `/api/categories` | `read` | List default and custom categories | | `POST` | `/api/categories` | `write` | Create a custom category | | `PUT` | `/api/categories/{category_id}` | `write` | Update a category created by this user | | `DELETE` | `/api/categories/{category_id}` | `write` | Delete a category created by this user | | `GET` | `/api/suggestions?q={query}` | `read` | Search remembered items and categories | | `GET` | `/api/favourites?limit={limit}` | `read` | Return frequently used items | Default categories cannot be modified or deleted. A custom category can be changed only by its creator. Create a category: ```json {"name":"Pet Supplies","icon":"tag","color":"#f97316","sort_order":15} ``` ### Global library Ingredients, meals, and the single Basics checklist are global: every authenticated active user sees the same active records. A `read` key can browse and preview them, while a `read,write` key can create, edit, archive when permitted, and commit selected rows to a list. A global record may be archived only by its creator or an administrator. Archived-record views and all restore operations require an administrator JWT, never an API key. All global edits use optimistic versions. Send the currently returned `expected_version`; stale writes return `409` and must be reviewed against the latest resource. Archived ingredients remain visible through existing meal rows but cannot be chosen for new rows. #### Ingredients | Method | Endpoint | Authentication | Description | |---|---|---|---| | `GET` | `/api/ingredients?q=` | `read` | Search active ingredients | | `POST` | `/api/ingredients` | `write` | Create a normalized global ingredient | | `GET` | `/api/ingredients/{ingredient_id}` | `read` | Read one ingredient | | `PUT` | `/api/ingredients/{ingredient_id}` | `write` | Edit using `expected_version` | | `DELETE` | `/api/ingredients/{ingredient_id}` | `write`, creator/admin | Archive using an `ArchiveRequest` body | | `POST` | `/api/ingredients/{ingredient_id}/restore` | Admin JWT | Restore using `expected_version` | Ingredient names are trimmed, internal whitespace is collapsed, and matching is case-insensitive. The normalized value is unique. ```json {"name":"Chickpeas","default_unit":"g","default_category_id":null} ``` ```json {"expected_version":1,"name":"Tinned chickpeas","default_unit":"tin"} ``` #### Meals | Method | Endpoint | Authentication | Description | |---|---|---|---| | `GET` | `/api/meals?q=` | `read` | Search active reusable meals | | `POST` | `/api/meals` | `write` | Create a meal and ordered ingredient rows | | `GET` | `/api/meals/{meal_id}` | `read` | Read a meal and its rows | | `PUT` | `/api/meals/{meal_id}` | `write` | Atomically replace metadata and all rows | | `DELETE` | `/api/meals/{meal_id}` | `write`, creator/admin | Archive with `expected_version` | | `POST` | `/api/meals/{meal_id}/restore` | Admin JWT | Restore an archived meal | | `POST` | `/api/meals/import-recipe/preview` | `write` | Parse a URL without saving | | `POST` | `/api/meals/import-recipe` | `write` | Parse a URL and save it as a global meal | | `POST` | `/api/meals/{meal_id}/preview` | `read` | Preview scaling and destination matches | | `POST` | `/api/meals/{meal_id}/commit` | `write`, list editor | Idempotently add selected rows | Meal names do not need to be unique. An ingredient row supplies exactly one of `ingredient_id` or `name`. A name reuses an existing catalogue record case-insensitively, or creates one in the same transaction. The same catalogue ingredient cannot occur twice in a meal. ```json { "name": "Tomato pasta", "description": "A quick supper", "base_servings": 2, "ingredients": [ {"name":"Pasta","quantity":200,"unit":"g","scales_with_servings":true}, {"name":"Salt","quantity":1,"unit":"pinch","scales_with_servings":false} ] } ``` A full meal update sends the same document plus `expected_version`. The replacement is atomic. #### Basics | Method | Endpoint | Authentication | Description | |---|---|---|---| | `GET` | `/api/basics` | `read` | Read the singleton and ordered active entries | | `POST` | `/api/basics/items` | `write` | Add an entry using the collection `expected_version` | | `PUT` | `/api/basics/items/{item_id}` | `write` | Edit an entry using its `expected_version` | | `DELETE` | `/api/basics/items/{item_id}` | `write`, creator/admin | Archive an entry | | `POST` | `/api/basics/items/{item_id}/restore` | Admin JWT | Restore an entry | | `POST` | `/api/basics/reorder` | `write` | Replace active order using the collection version | | `POST` | `/api/basics/preview` | `read` | Preview destination matches | | `POST` | `/api/basics/commit` | `write`, list editor | Idempotently add selected entries | The same ingredient cannot occur twice in Basics. Reorder requests must contain every active item ID exactly once. Add a catalogue ingredient to Basics using the version returned by `GET /api/basics`: ```json { "expected_version": 3, "ingredient_id": "catalogue-ingredient-id", "quantity": 2, "unit": "tins", "category_id": null, "notes": "", "scales_with_servings": false } ``` Supply `name` instead of `ingredient_id` to case-insensitively reuse or create a catalogue entry. Item updates and archives use the item's own version; adding and reordering use the Basics collection version. #### Preview and commit workflow Preview a meal for four servings: ```http POST /api/meals/{meal_id}/preview Authorization: Bearer kc_full_key Content-Type: application/json {"list_id":"destination-list-id","target_servings":4} ``` Every preview row has a stable `source_row_id`, scaled quantity, `matches_existing`, and `selected`. New rows default to selected; detected destination matches default to unselected. Non-scalable rows retain their quantity. Basics uses the same request at `/api/basics/preview`; its target servings acts as a multiplier from one. Commit a subset using the exact returned source version: ```json { "list_id": "destination-list-id", "target_servings": 4, "source_version": 3, "selected_source_row_ids": ["meal-row-id"], "request_id": "9ad1ab0e-31ea-4fc6-bc42-a13ba7342893" } ``` Generate one request ID on the client and retain it for all retries of that logical commit. An identical retry returns the stored original result and does not add quantities twice. Reusing the ID with different data returns `409`. A changed meal/Basics source version also returns `409` so an agent cannot silently apply a recipe it did not preview. Smart merging compares normalized ingredient name and unit without converting units. Same-unit matches have their quantities added and are restored to unchecked if necessary; their existing category and notes remain. A different unit creates a separate appended row. New category priority is row override, catalogue default, then remembered item category. ### WebSocket updates Connect to `WS /ws/{list_id}` and send authentication as the first message so the credential is not exposed in query-string logs: ```json {"type":"auth","token":"kc_your_full_api_key"} ``` A JWT or API key with `read` scope is accepted. The authenticated user must have access to the list. The server responds with `{"type":"auth_ok"}` and then publishes item/list events. Send the text `ping` to receive `pong`. ## Status codes | Code | Meaning | |---|---| | `200` | Successful read or update | | `201` | Resource created | | `204` | Resource deleted; no response body | | `400` | Request violates an application rule | | `401` | Credentials are missing, invalid, expired, revoked, or not accepted by this endpoint | | `403` | API-key scope, list role, owner, or administrator permission is missing | | `404` | Resource does not exist or is deliberately hidden from this user | | `409` | ID reuse, optimistic version, archived-edit, or bulk idempotency conflict | | `422` | Request data failed validation or referenced category does not exist | Error bodies use FastAPI's `detail` field: ```json {"detail":"API key missing required scope: write"} ``` Validation failures use an array in the same `detail` field. Refer to `/api/openapi.json` for exact request and response schemas. ## Recipes, planner and optional integrations Existing `/api/meals` resources now include ordered `steps`, `tags`, `recipe_category`, `prep_minutes`, `cook_minutes`, `allow_weekly_repeat`, `to_try`, ratings and images. Ingredient `quantity: null` means unquantified; never substitute a numeric quantity. List/search accepts `q`, `tag`, `category`, `max_minutes`, `to_try` and `sort=rating`. | Operation | Endpoint | |---|---| | Full editable URL draft | `POST /api/recipes/import/url` (`url`) | | Ordered photo draft | `POST /api/recipes/import/photos` (multipart `files`, 1–5) | | Photo availability | `GET /api/recipes/import/status` | | Upload cover | `POST /api/recipes/images` (multipart `file`) | | Authenticated image | `GET /api/recipes/images/{id}` | | Set own rating | `PUT /api/recipes/{meal_id}/rating` (`value`, 1–5) | | Shared To try | `PUT /api/recipes/{meal_id}/collection` (`to_try`) | | Recipe download | `GET /api/recipes/{meal_id}/export?format=text|pdf&servings=4` | | Active library export | `GET /api/recipes/export?format=csv|pdf` | | Shared week | `GET /api/planner?week=YYYY-MM-DD` | | Review slot changes | `POST /api/planner/preview` (`expected_version`, `slots`, `remove_ids`) | | Apply reviewed slots | `POST /api/planner/commit` (`token`, `request_id`) | | Suggestions review | `POST /api/planner/suggestions` (`week`, `expected_version`, `vegetarian`, `fish`, `avoid_weeks`) | | Grocery review | `POST /api/planner/shopping/preview` (`week`, `list_id`, `include_staples`) | | Apply grocery review | `POST /api/planner/shopping/commit` (`token`, `request_id`) | | Pantry staples | `GET /api/pantry` | | Set staple | `PUT /api/pantry/{ingredient_id}` (`expected_version`, `usually_have`) | | Admin planner defaults | `PUT /api/planner/settings` | | Admin integration status/config | `GET /api/integrations`, `PUT /api/integrations/{vision|nextcloud}` | | Admin calendar setup/recovery | `POST /api/integrations/nextcloud/{discover|calendar|retry|disconnect}` | Reviews are bound to the authenticated user. Generate a fresh UUID `request_id` for each commit and reuse it when retrying that commit. A 409 means reload and review again. Shopping reviews/commits require destination-list edit permission and never grant list access to other planner users. Ordinary reads/writes retain API-key read/write scopes; integration administration and planner defaults require an administrator's interactive JWT login. Slot changes supply `id`, ISO `day`, `meal_type`, `kind` (`recipe`, `leftover`, `skip`), `meal_id` or `cooking_slot_id`, `servings`, `notes`, `time`, `duration`, and `pinned`. Replacing positions in one preview supports moves/swaps. When removing a cooking slot, include removal or reassignment of all linked leftovers in the same review. The week response includes `linked_slots` for reviewing batches across week boundaries. Full imports return editable drafts and do not create meals. Save reviewed drafts with `POST /api/meals`; include returned `image_ids` to attach retained originals. Existing ingredient-only import operations remain available. Binary downloads use Bearer authentication and `Content-Disposition`; no public recipe or image links are introduced. Shared invalidations use authenticated `/ws/shared`; destination shopping updates stay on the existing list-specific WebSocket. See [release setup and validation](docs/recipes-planner-release.md) for limits, worker/reconciliation behaviour and migration/rollout requirements.