See Library & Recipes and
Import from a URL for what these operations do in
the UI. A recipe’s id is a slug derived from its title (deduplicated
with a numeric suffix on collision), not a UUID.
GET /api/recipes
GETAny session
Lists every recipe in the library — the shared record only, with no
per-profile rating, favorite, or cooked status attached (that state lives
separately; see PATCH /api/recipes/:id below).
Response: { "recipes": RecipeRecord[] }
POST /api/recipes
POSTAny session
Creates a recipe by hand. multipart/form-data:
| Field | Type | Required | Notes |
|---|---|---|---|
title |
string | Yes | |
source |
string | No | Author or site name |
sourceUrl |
string | No | |
videoUrl |
string | No | Must be http/https; a YouTube link embeds in the recipe view, anything else shows as a “Watch the video” link |
description |
string | No | |
servings |
number | No | |
prepMinutes |
number | No | |
cookMinutes |
number | No | |
difficulty |
"easy" | "medium" | "hard" |
No | |
cuisine |
string | No | |
tags |
JSON string | No | JSON array of strings |
ingredients |
JSON string | No | JSON array of { quantity?, unit?, name, note?, isGroup? } |
steps |
JSON string | No | JSON array of { id?, text } |
image |
file | No | Hero photo; rejected with 413 past the server’s upload size limit — see Settings |
step-image-<stepId> |
file | No | One field per step that has a photo, keyed by that step’s id in the steps JSON |
Array order is display order for both ingredients and steps — there’s
no separate ordering field, so reordering means resending the array in
the new order. An ingredients entry with isGroup: true is a section
header, not an ingredient: name holds the header text and
quantity/unit/note are ignored (the server clears them even if
sent).
Response: 201 with { "recipe": RecipeRecord }, or 400 if
title is missing, 413 if a photo is too large.
POST /api/recipes/import
POSTAny session
Imports a recipe from a URL — see Import from a URL
for how the scraper works. A YouTube URL is handled separately: it
always sets videoUrl, title, source (channel name), and the cover
photo from YouTube’s oEmbed endpoint, and best-effort parses ingredients
and steps out of the video description when it finds any.
Request body: { "url": string }
Response: 201 with { "recipe": RecipeRecord }, 400 if url is
missing or not http/https, or 422 if the page has no scrapeable recipe
(YouTube URLs only 422 when the video itself can’t be found).
POST /api/recipes/:id/edit
POSTAny session
Updates an existing recipe. Same fields as POST /api/recipes above.
Steps whose photo isn’t being replaced should echo back their current
hasImage/imageExt in the steps JSON — the server diffs against the
existing record and deletes any now-orphaned step photos.
Response: { "recipe": RecipeRecord }, or 404 if the recipe
doesn’t exist, 400 if title is missing, 413 if a photo is too
large.
POST /api/recipes/:id/duplicate
POSTAny session
Duplicates a recipe — copies its metadata and photos (hero and step
images) under a new id, derived the same way as a title’s (see above,
deduplicated with a numeric suffix if needed). The copy’s title gets a
“ (Copy)“ suffix and addedAt is reset to now; everything else,
including sourceUrl, carries over unchanged. The copy has no
per-profile state (rating, favorite, cooked) — PATCH /api/recipes/:id
below starts it fresh, same as any new recipe.
Response: 201 with { "recipe": RecipeRecord }, or 404 if the
recipe doesn’t exist.
PATCH /api/recipes/:id
PATCHAny session
Updates the active profile’s state for one recipe — rating, favorite, and cooked status all live per-profile, not on the shared recipe record.
Request body — all fields optional:
| Field | Type | Notes |
|---|---|---|
rating |
integer 0–5 | 0 clears the rating |
favorite |
boolean | |
cooked |
boolean | Setting it true also stamps lastCookedAt |
Response: { "recipe": Recipe } — the recipe merged with the active
profile’s state.
DELETE /api/recipes/:id
DELETEAdmin only
Deletes the recipe’s files from storage, removes it from the index, and clears every profile’s state for it.
Response: { "ok": true }
GET /api/recipes/:id/activity
GETAny session
Lists other profiles (excluding the active one) that have cooked, rated, or favorited this recipe — powers the “Cooking Activity” panel on a shared install.
Response: { "activity": [{ "profileId": string, "name": string, "color": string, "cooked": boolean, "rating"?: number }] }