REST API Overview
OvenLovin REST at /api/v1. JSON bodies. OpenAPI 3.1 from the same Zod schemas the handlers use. All ids are UUIDs. version on /health is the API version (0.1.0), the same value as OpenAPI info.version and MCP serverInfo.version.
Base URL
Your instance: https://your-app.vercel.app/api/v1
Local Development: http://localhost:3000/api/v1
Authentication
Send Authorization: ApiKey rkapi_... or Bearer with the same key or a Clerk JWT. Clerk users must already have a kitchen (open the app once). On a Clerk JWT, optional X-Kitchen-Id selects a kitchen you belong to. An API key is bound to one kitchen; a mismatched X-Kitchen-Id is 403.
No auth: GET /health, GET /status, GET /openapi.json, GET /invites/{token}.
Keys store scopes. A request outside the key's scopes returns 403 with type <origin>/problems/forbidden. The detail names the missing scope. Clerk sessions are not scope-checked. Omit scopes when you create a key for full access. HEAD is treated as GET.
GET /api-keys, POST /api-keys, and DELETE /api-keys/{id} are session-only. An API key gets 401. POST /api-keys and DELETE /api-keys/{id} also require a signed-in admin or owner. The response field plaintext holds the full rkapi_... key. It is returned once.
Routes also authorize by kitchen membership role (viewer / member / admin / owner).
Error Response (RFC 9457)
instance is the full request URL. Non-JSON bodies are 400 problems/bad-request "Request body must be JSON".
Statuses the handlers return:
200/201/204- Success, created (Locationwhen set), no content400- Invalid input, including Zod validation (problems/validation-error)401- Missing or invalid auth403- Role not allowed, kitchen header mismatch, demo write/upload cap, or API key missing a required scope (problems/forbidden)404- Missing resource, unknown path, or a malformed or foreign id. Never500. Never another kitchen's data.409- Conflict (duplicate import, near-duplicate, in-flight import, household conflict)410- Expired invite500- Unhandled server error503- AI recipe reading is busy (import_ai_unavailable).Retry-After: 60
There is no rate limit today. Be gentle: retry 503 import_ai_unavailable after Retry-After seconds. The public demo can 403 writes after its sandbox cap.
not_a_public_url is an http(s) URL whose host is private, loopback, link-local, or not on port 80 or 443. Content that is not an http(s) URL at all (for example file: or ftp:) is 400 with detail "That doesn't look like a web address" and no code. Image fields otherwise: a public http(s) image URL, a stored blob URL, or an inline image.
Idempotency-Key
Optional header. Any string (trimmed, first 512 characters). Not required to be a UUID.
Honored when saving a recipe: POST /recipes, POST /recipes/import with save: true, POST /recipes/import/photo with save: true. A completed key returns the first saved recipe (200). There is no "key conflict" status. 409 means a duplicate URL or photo, or an in-flight lock.
Other POSTs may read the header and ignore it.
Pagination
limit defaults to 20 (GET /cookbooks: 50). There is no maximum. GET /cookbooks?view=cards returns every cookbook in one page. Response: { items, nextCursor, hasMore }. Rule and preference GETs return the array itself. search is a case-sensitive substring match on the title today.
Endpoints
Health
GET /health-{ status: "ok", version, timestamp }. No auth.versionis0.1.0.GET /status- No auth.{ mode, commitSha, ready, checks }GET /openapi.json- OpenAPI 3.1. No auth.
Recipes
GET /recipes-limit,cursor,status(draft,needs_review,published),search(title only; case-sensitive),cookbookId,uncategorized=true,forAttendees(person UUIDs, repeat or comma-separated). Blocked recipes land inhidden.POST /recipes-{ title, ingredients: [{ rawText }], steps: [{ text }], ... }.201, or200on idempotent replayGET /recipes/{id}- Details. Optional?servings=.ingredientsare rows withrawTextplus parsedname,quantityMin,quantityMax,unit.stepsare rows withtextandorder.PATCH /recipes/{id}- Title, times, images, ingredients, steps,cookbookIdsDELETE /recipes/{id}-204POST /recipes/{id}/restore- Undo a delete (200recipe)GET /recipes/{id}/image-302to the photo, or bytes when stored inline.404if nonePOST /recipes/{id}/image-{ image: <data URL> }(700KB max).200recipePOST /recipes/import- URL or paste. Preview unlesssave: truePOST /recipes/import/page- Captured page{ url, html?, jsonLd? }. Saves.201(form post without JSON Accept:303)POST /recipes/import/photo- Photo. Preview unlesssave: true
Import failures: 400 with not_a_public_url (private/loopback http(s)), import_blocked, import_page_private (the page is private or needs a sign-in), import_page_gone, import_not_a_recipe, import_unsupported_site. Photo: PHOTO_BLURRY, PHOTO_NOT_RECIPE. 409 with import_duplicate (URL or exact photo already saved), import_near_duplicate (photo save: true without allowDuplicate: true), import_in_progress (same Idempotency-Key still running). Photo import on an instance without AI Gateway is 400 with no code. Busy model: 503 import_ai_unavailable.
There is no POST /recipes/{id}/check. To check one recipe for people, call GET /recipes?forAttendees=<personId> (blocked recipes are listed in hidden) or the MCP tool search_recipes with forAttendees.
Cookbooks
GET /cookbooks- defaultlimit50, no maximum.?view=cardsreturns every cookbook in one page (recipeCount,previewImages)POST /cookbooks-{ name, description? }GET /cookbooks/{id}/PATCH /cookbooks/{id}/DELETE /cookbooks/{id}(204)POST /cookbooks/{id}/recipes-{ recipeId }DELETE /cookbooks/{id}/recipes?recipeId=
People and rules
GET /people/POST /people-{ displayName }GET /people/{id}/PATCH /people/{id}/DELETE /people/{id}(mutate: admin)POST /people/reorder-{ personIds }(every person, once)GET /people/{id}/rules- Rule arrayPOST /people/{id}/rules-{ target, severity, kind?, note? }(admin).201or200if duplicateGET /people/{id}/rules/{ruleId}/PATCH/DELETE(204)GET /people/{id}/preferences?type=love/POST/GET .../{prefId}/DELETE
Meal plans
GET /meal-plans-limit,cursor,status,weekStart,weekEndPOST /meal-plans-{ weekStart, status?, cookFor?, entries? }. Omit orcookFor: nullto follow the household. A number is a hand-set count.201with the plan andentries. Entries come back withmealSlot(themealyou sent),freeformMeal,scaleFactor, andattendees.GET /meal-plans/{id}/PATCH/DELETE(204). PATCHcookFor: nullreturns the plan to following the household. OmitcookForto leave it.GET /meal-plans/{id}/check- Run check (dry run; does not persist)POST /meal-plans/{id}/check- Run and persist (write). No body. Findings arebyEntryandbyPerson. Ifstatusisincomplete, do not treat the plan as safe.POST /meal-plans/{id}/undo/GET /meal-plans/{id}/historyGET /meal-plans/{id}/shopping-list-format,groupBy,includeStaples,timeZone. JSON isShoppingListData(mealPlanId,weekStart,groupBy,includeStaples,includePantryItems,ingredients,sections,itemCount,generatedAt,warnings,scaledFor,scaledForMixed). Section items usekey,name,quantity,unit,displayQuantity,displayUnit,aisle,category,isStaple,isPantryItem,recipes[],checked. Warnings are listed once in top-levelwarnings. A badtimeZoneis400.GET /meal-plans/{id}/shopping-list/state/PATCH-{ checkedKeys, extras }
Household
GET /households/PATCH /households(owner,{ name })GET /households/mine(API key: its one kitchen,active: true) /POST /households/select(signed-in only; API key401)GET /households/settings/PATCH-{ mode: family|fixed|recipe, servings? }GET /households/schedule/PUT-{ week }GET /households/members/PATCH|DELETE /households/members/{userId}(owner)POST /households/leave- signed-in onlyGET|POST /households/invites/DELETE /households/invites/{id}(admin;200with the revoked invite)GET /invites/{token}(no auth) /POST /invites/{token}/accept(signed-in)
There is no GET /households/{id}/context. GET /households returns the key's kitchen. GET /people lists people. GET /people/{id}/rules lists a person's rules. MCP get_household_context returns all of it in one call.
Uploads and API keys
POST /uploads/images-fileor{ url }.201{ url }. Demo403GET /api-keys/POST /api-keys/DELETE /api-keys/{id}- signed-in only. An API key gets401.POSTandDELETEalso require admin or owner.
See Kitchen invites.
This page lists common endpoints. The complete, current list is the OpenAPI spec at /api/v1/openapi.json.
OpenAPI Specification
There is no YAML spec route.
Code Examples
TypeScript
cURL
Python
API key scopes
Live table: REST_SCOPE_TABLE and MCP_SCOPE_TABLE in packages/core/src/auth/api-key-scopes.ts. HEAD maps to GET. Unknown REST paths deny. null means any authenticated key (then a later requireUserId can still return 401).
| Scope | REST |
|---|---|
| (public, no key) | GET /health, GET /status, GET /openapi.json, GET /invites/{token} |
(session-only; API key 401; POST/DELETE admin or owner) | GET /api-keys, POST /api-keys, DELETE /api-keys/{id} |
recipes:read | GET /recipes, GET /recipes/{id}, GET /recipes/{id}/image, GET /cookbooks, GET /cookbooks/{id} |
recipes:write | POST /recipes, PATCH /recipes/{id}, DELETE /recipes/{id}, POST /recipes/{id}/restore, POST /recipes/{id}/image, POST /recipes/import, POST /recipes/import/page, POST /recipes/import/photo, POST /cookbooks, PATCH /cookbooks/{id}, DELETE /cookbooks/{id}, POST /cookbooks/{id}/recipes, DELETE /cookbooks/{id}/recipes, POST /uploads/images |
plans:read | GET /meal-plans, GET /meal-plans/{id}, GET /meal-plans/{id}/check, GET /meal-plans/{id}/history |
plans:write | POST /meal-plans, PATCH /meal-plans/{id}, DELETE /meal-plans/{id}, POST /meal-plans/{id}/check, POST /meal-plans/{id}/undo |
lists:export | GET /meal-plans/{id}/shopping-list, GET /meal-plans/{id}/shopping-list/state, PATCH /meal-plans/{id}/shopping-list/state |
people:read | GET /people, GET /people/{id}, GET /people/{id}/rules, GET /people/{id}/rules/{ruleId}, GET /people/{id}/preferences, GET /people/{id}/preferences/{prefId}, GET /households, GET /households/mine, GET /households/settings, GET /households/schedule, GET /households/members, GET /households/invites |
people:write | POST /people, PATCH /people/{id}, DELETE /people/{id}, POST /people/reorder, POST /people/{id}/rules, PATCH /people/{id}/rules/{ruleId}, DELETE /people/{id}/rules/{ruleId}, POST /people/{id}/preferences, DELETE /people/{id}/preferences/{prefId}, PATCH /households, POST /households/select, PATCH /households/settings, PUT /households/schedule, PATCH /households/members/{userId}, DELETE /households/members/{userId}, POST /households/leave, POST /households/invites, DELETE /households/invites/{id}, POST /invites/{token}/accept |
POST /households/select, POST /households/leave, and POST /invites/{token}/accept still require a signed-in user after the scope gate.
| MCP tool | Scope |
|---|---|
whoami | (any authenticated key) |
search_recipes, get_recipe, list_cookbooks, get_cookbook | recipes:read |
create_recipe, import_recipe, import_recipe_from_image, delete_recipe, manage_cookbook | recipes:write |
get_household_context, get_person_rules | people:read |
manage_household_schedule, manage_household_settings, manage_kitchen_invites, manage_kitchen_members, manage_people, manage_person_rules, manage_person_loves, reorder_people | people:write |
check_meal_plan, get_meal_plan | plans:read |
save_meal_plan, edit_meal_plan, delete_meal_plan | plans:write |
export_shopping_list | lists:export |
MCP resources use people:read. A tool call outside the key's scopes returns JSON-RPC -32003 with data.code "forbidden". MCP id fields and id arrays must be UUIDs.
Multi-Tenant Security
Every query is filtered by the caller's household in application code. A malformed or foreign id on every [id] route returns 404. Never 500. Never another kitchen's data.