Skip to main content

API v1

Spoonjoy Developer Platform

Build clients on Spoonjoy's public-by-default Chef graph, then add scoped auth only when a workflow needs private shopping-list state, recipe-cover management, token management, or delegated access.

Version

v1

Base path

REST v1

Spec

Machine-readable

Errors

16 codes

Terminal Quickstart

curl + jq

First successful calls

Anonymous public reads work immediately. For the first external token, use delegated approval: the client shows an approval URL, the chef signs into Spoonjoy, and polling returns a one-time-display bearer token.

export SJ_BASE='https://spoonjoy.app'
umask 077
state_dir="$(mktemp -d "${TMPDIR:-/tmp}/spoonjoy.XXXXXX")"
trap 'rm -rf "$state_dir"' EXIT

curl -fsS "$SJ_BASE/api/v1/health" | jq
curl -fsS "$SJ_BASE/api/v1/recipes?query=pasta&limit=5" \
  | jq -r '.data.recipes[] | [.id, .title] | @tsv'

# Public catalog calls omit Authorization. Use the delegated token below
# for private shopping-list calls, not for every public recipe request.
# First external shopping-list token without browser cookies: delegated approval
curl -fsS "$SJ_BASE/api/tools/start_agent_connection" \
  -H 'Content-Type: application/json' \
  --data '{"agentName":"Kitchen CLI","scopes":"shopping_list:read shopping_list:write"}' \
  > "$state_dir/start.json"

jq -r '.data.authorizationUrl' "$state_dir/start.json"
jq -r '.data.verificationUri' "$state_dir/start.json"
jq -r '.data.userCode' "$state_dir/start.json"
jq -r '.data.deviceCode' "$state_dir/start.json"

while :; do
  curl -fsS "$SJ_BASE/api/tools/poll_agent_connection" \
    -H 'Content-Type: application/json' \
    --data "{\"deviceCode\":\"$(jq -r '.data.deviceCode' "$state_dir/start.json")\"}" \
    > "$state_dir/poll.json"
  status="$(jq -r '.data.status' "$state_dir/poll.json")"
  test "$status" = approved && break
  if test "$status" != pending; then
    jq . "$state_dir/poll.json" >&2
    echo "Connection status is $status; start a new delegated approval request if you still need access." >&2
    exit 1
  fi
  sleep "$(jq -r '.data.interval // 2' "$state_dir/start.json")"
done

export SPOONJOY_TOKEN="$(jq -r '.data.token' "$state_dir/poll.json")"

Current API Boundary

Available now

  • - public recipe and cookbook reads
  • - Authenticated recipe create, update, delete, fork, step editing, and import from URL, video URL, text, or JSON-LD
  • - public recipe spoon history plus authenticated recipe spoon create, update, and delete
  • - Owner-scoped recipe cover candidate management
  • - owner-scoped shopping-list read, sync, item writes, recipe adds, and clear actions
  • - native account profile, profile-photo, notification-preference, APNs device, token, and OAuth app connection settings
  • - session-created and bearer-created API tokens
  • - OAuth/PKCE delegated access
  • - delegated agent/device approval links
  • - remote MCP endpoint
  • - cursor-paginated public recipe and cookbook lists

Not in v1 yet

  • - Recipe export endpoints beyond the current create, edit, delete, fork, import, step, spoon, and cover surfaces
  • - Private recipe-library endpoints
  • - Inventory or pantry stock APIs
  • - Meal plan or "today's recipes" APIs
  • - Full account export APIs
  • - Canonical unit registry or density-based ingredient conversion
  • - webhooks, REST Hooks, SSE, and event subscriptions
  • - Arbitrary bulk shopping-list import or batch mutation endpoints

Corporate tenant/admin APIs, inventory, meal plans, full exports, canonical unit conversion, webhooks, REST Hooks, batch mutations, and recipe export endpoints are not in API v1 yet; they are future API surface, not hidden current endpoints.

Delegated approval helper endpoints under /api/tools/* are part of the current public connection flow. Other legacy app-only /api/* routes are not the external contract.

Legacy /api/* routes reject OAuth access tokens that are audience-bound to /mcp. New external REST clients should use /api/v1/*.

External Client Guide

Client examples

  • Tiny-device clients: Use sync cursors, small payloads, and idempotent retries when a device is offline or battery constrained.
  • Mobile apps: Read public recipes without auth, then request shopping-list scopes or kitchen:write for native recipe create, edit, delete, fork, step editing, import, spoon, and cover management after a chef connects their account.
  • CLI/script clients: Use bearer credentials, curl, and OpenAPI JSON only when the script cannot share a Spoonjoy session.
  • Browser clients: Use same-origin Session only inside spoonjoy.app. Extensions and third-party browser apps use OAuth/PKCE.
  • Agent clients: Use MCP or delegated connection endpoints when a chef needs to approve an assistant-style runtime.
  • Enterprise clients: API v1 is individual delegated access today; there are no tenant, admin, employee, or org-export APIs yet.

No token required

Read the public Chef graph

Start with GET /api/v1/recipes and GET /api/v1/cookbooks. Public graph reads do not require Authorization: Bearer, and the OpenAPI contract is available at /api/v1/openapi.json.

curl 'https://spoonjoy.app/api/v1/recipes?query=pasta&limit=20'
curl 'https://spoonjoy.app/api/v1/cookbooks?limit=20'

Requires login

Use your Spoonjoy session

Sign into Spoonjoy, open the playground, and leave auth on Session. There is no token to mint or paste for playground calls; the browser sends your normal Spoonjoy session cookie, and private endpoints treat that as the authenticated chef.

https://spoonjoy.app/api/playground

External clients

Use bearer only outside the session

Bearer mode is for clients that cannot use the logged-in Spoonjoy browser session. The generated POST /api/v1/tokens operation is available in the playground because it is part of API v1, not because private playground calls need a separate token.

Playground auth: Session
Generated operation: POST /api/v1/tokens
Use the returned sj_... secret only in an external client or Bearer-mode test.

Requires account:read kitchen:read

Bootstrap the native offline cache

First-party Spoonjoy Apple clients use GET /api/v1/me/sync as the cursor sync feed that restores the signed-in chef's profile, owned recipes, owned cookbooks, and shopping-list item tombstones into the native account/environment/schema cache boundary. Store nextCursor only after applying the whole page.

curl -fsS 'https://spoonjoy.app/api/v1/me/sync?limit=20' \
  -H 'Authorization: Bearer sj_first_party_native_token'

Requires shopping_list:read

Sync a private shopping list

Use GET /api/v1/shopping-list/sync with an opaque cursor to fetch active rows and deletion records for removed rows. Omit cursor on the first request, then store nextCursor only after your client applies the whole response.

curl -fsS 'https://spoonjoy.app/api/v1/shopping-list/sync' \
  -H 'Authorization: Bearer sj_client_token'

Requires shopping_list:write

Perform an idempotent shopping-list mutation

Use POST /api/v1/shopping-list/items with clientMutationId so retries can replay the same write without duplicating items.

curl -fsS -X POST 'https://spoonjoy.app/api/v1/shopping-list/items' \
  -H 'Authorization: Bearer sj_client_token' \
  -H 'Content-Type: application/json' \
  -d '{"clientMutationId":"device-uuid-1","name":"Eggs","quantity":12,"unit":"Each"}'

Public read + kitchen:write mutations

Recipe spoon endpoints

GET /api/v1/recipes/{id}/spoons lists recent non-deleted cook events publicly with recipes:read checks when credentials are sent. POST /api/v1/recipes/{id}/spoons creates a spoon with note, nextTime, cookedAt, photoUrl, and useAsRecipeCover. PATCH and DELETE /api/v1/recipes/{id}/spoons/{spoonId} are owner-only, idempotent with clientMutationId, validate the path recipe id, and reuse the same Spoonjoy spoon domain logic as the web product.

GET /api/v1/recipes/{id}/spoons
POST /api/v1/recipes/{id}/spoons
PATCH /api/v1/recipes/{id}/spoons/{spoonId}
DELETE /api/v1/recipes/{id}/spoons/{spoonId}

Requires kitchen:write

Manage owner recipe covers

Recipe cover management endpoints let native clients list cover candidates and spoon photo sources, activate a cover variant, set an explicit no-cover state, archive a candidate with replacement or confirmed no-cover, regenerate editorial imagery, and create a cover from a spoon photo. These operations are owner-scoped to the authenticated chef's recipes.

GET /api/v1/recipes/{id}/covers
PATCH /api/v1/recipes/{id}/covers
PATCH /api/v1/recipes/{id}/covers/{coverId}
DELETE /api/v1/recipes/{id}/covers/{coverId}
POST /api/v1/recipes/{id}/covers/regenerate
POST /api/v1/recipes/{id}/covers/from-spoon/{spoonId}

Requires account:* and tokens:*

Manage native account settings

Native clients can read and update the signed-in chef's profile, profile photo, notification preferences, APNs device registrations, token metadata, and OAuth app connections through API v1. Profile, notification, and APNs settings use explicit account scopes; token and OAuth connection administration uses tokens scopes and returns generation-aware opaque connection ids for disconnects.

GET /api/v1/me
GET /api/v1/me/sync
PATCH /api/v1/me
POST /api/v1/me/photo
DELETE /api/v1/me/photo
GET /api/v1/me/notification-preferences
PATCH /api/v1/me/notification-preferences
POST /api/v1/me/apns-devices
DELETE /api/v1/me/apns-devices/{deviceId}
GET /api/v1/me/connections
DELETE /api/v1/me/connections/{connectionId}

Spoonjoy Apple native dogfood quickstart

Native Apple

Spoonjoy Apple dogfoods the public REST and OAuth contracts with a real HTTPS universal-link redirect, Keychain-backed token storage, and the same offline sync rules as in-app UI, Siri, and Shortcuts.

  • - For unsigned local dogfood, run pnpm native:dogfood:api with DATABASE_URL and SPOONJOY_FORCE_SQLITE_LOCAL_DB=1 so DB-backed native auth/sync use the same API dispatcher without the Cloudflare local dev Prisma hang.
  • - From the Apple repo, pass SPOONJOY_WEB_REPO or --web-repo to scripts/verify-native-password-dogfood.sh; it clones the prepared local test DB, seeds a disposable username/password account, proves bad credentials return 401, then drives /api/v1/auth/password/native and /api/v1/me/sync from the Swift client.
  • - Spoonjoy Apple sign-in is first-party native auth, not an OAuth web bounce: local dogfood uses /api/v1/auth/password/native and production-shaped Apple identity tokens use /api/v1/auth/apple/native.
  • - Enable applinks:spoonjoy.app on the Apple targets so spoonjoy.app links route into the correct native surface; spoonjoy:// is for app navigation and deep links.
  • - Native clients persist access_token and refresh_token in Keychain, never persist the password, replace the stored refresh token atomically, and run single-flight refresh when concurrent requests hit 401.
  • - decode Spoonjoy REST envelopes for /api/v1 resources: ok, requestId, data on success and ok, requestId, error on failure.
DATABASE_URL=file:./prisma/native-dogfood.db SPOONJOY_FORCE_SQLITE_LOCAL_DB=1 pnpm native:dogfood:api --port 5179

SPOONJOY_WEB_REPO=/path/to/spoonjoy-v2 ./scripts/verify-native-password-dogfood.sh --web-repo /path/to/spoonjoy-v2

SPOONJOY_API_BASE_URL=http://127.0.0.1:5179 SPOONJOY_NATIVE_DOGFOOD_PASSWORD_FILE=/tmp/native-password.txt ./scripts/dogfood-native-password-auth.sh

POST /api/v1/auth/password/native
{ "emailOrUsername": "codex-native@example.test", "password": "<ephemeral dogfood password>" }

POST /api/v1/auth/apple/native
{ "identityToken": "apple.jwt", "rawNonce": "nonce" }

Associated Domains: applinks:spoonjoy.app
URL scheme: spoonjoy://recipes/{id}, spoonjoy://cookbooks/{id}, spoonjoy://search

GET /api/v1/me/sync?limit=50
Authorization: Bearer sj_...

Token Acquisition

Same-origin

No token: signed-in browser

A same-origin browser client does not fetch or store a bearer token. The chef signs into Spoonjoy with password, passkey, or any configured Google, GitHub, or Apple provider, and private API calls use the resulting session cookie. Those provider buttons are Spoonjoy sign-in methods, not OAuth providers that your client owns.

Login surface: /login
Then call: fetch("/api/v1/shopping-list", { credentials: "same-origin" })

Direct token

Personal token: signed-in chef creates one

For a script, device, or developer-owned client, the chef signs in first and runs POST /api/v1/tokens from Session auth, such as through the generated playground. An existing bearer credential with tokens:write can also create another token, but never with broader scopes than it already has. Spoonjoy returns the raw sj_... secret once; save it outside browser bundles.

POST /api/v1/tokens
Auth: Session cookie or Bearer sj_... with tokens:write
Body: { "name": "Kitchen script", "scopes": ["recipes:read", "cookbooks:read", "shopping_list:read", "shopping_list:write"] }
Response: { "ok": true, "data": { "token": "sj_...", "credential": { "id": "cred_...", "scopes": [...] } } }

Third-party

Delegated token: OAuth/PKCE

For a third-party app, register a public client and redirect the chef to /oauth/authorize. If they are not signed in, Spoonjoy routes them through /login and the full auth surface before consent. The client never handles the chef's password. The client exchanges the authorization code at /oauth/token for an sj_... access_token plus rotating refresh_token.

POST /oauth/register -> client_id: cm_client_id_from_register
GET /oauth/authorize?client_id=cm_client_id_from_register&redirect_uri=...&response_type=code&scope=shopping_list%3Aread+shopping_list%3Awrite&state=...&code_challenge=...&code_challenge_method=S256
POST /oauth/token -> access_token: sj_...
POST /oauth/revoke -> revoke refresh_token and matching live OAuth access credentials

Native Apple

First-party native token: Spoonjoy Apple app sign-in

Spoonjoy's own native Apple app can exchange either a native Sign in with Apple credential or a Spoonjoy username/password credential for the app's rotating token session. This is a first-party app bootstrap path, not OAuth grant_type=password, and native clients must never persist the password after the request completes. The password endpoint is online-only, rate limited with Spoonjoy's dedicated auth limiter, returns no session cookie, and does not publish wildcard browser CORS headers.

POST /api/v1/auth/password/native
Content-Type: application/json
Body: { "emailOrUsername": "ari@spoonjoy.app", "password": "correct horse battery staple" }
Response: access_token sj_... + refresh_token ort_...

Agent/device

Delegated token: approval link

For clients that cannot run a browser-based OAuth callback, call POST /api/tools/start_agent_connection, show the authorizationUrl or stable verificationUri plus userCode to the chef, then poll POST /api/tools/poll_agent_connection no faster than the returned interval. Pass scopes for least privilege, such as shopping_list:read shopping_list:write; omitted scopes default to the same shopping-list read/write pair. The approval page also uses Spoonjoy's full login surface before issuing a one-time-display sj_... bearer token plus credential id. Personal and delegated bearer tokens do not expire unless expiresAt is non-null; rerun approval when a stored token returns 401 invalid_token. A device can revoke its own credential id with DELETE /api/v1/tokens/{credentialId}; revoking any other token requires tokens:write.

POST /api/tools/start_agent_connection -> verificationUri + verificationUriComplete + authorizationUrl + userCode + deviceCode + expiresIn: 600
POST /api/tools/poll_agent_connection -> status: pending | approved | denied | expired | claimed
Approved response -> token: sj_... + credential metadata, including scopes and expiresAt

Security

No third-party password-token API

Spoonjoy does not support an OAuth password grant or third-party API endpoint where a client trades a chef's password for a token. Browser email/password login creates a session cookie, not an API token. The only password-to-token exception is Spoonjoy's own native Apple app endpoint, which exists so the first-party app can sign in without shoving chefs through the website. Third-party clients should use OAuth/PKCE or delegated approval so Spoonjoy, not the client, handles password, passkey, and provider login.

Do not implement: grant_type=password
Use instead for third parties: OAuth/PKCE or delegated approval link

Auth Implementation

Browser

Same-origin browser session

After a chef signs in, your logged-in Spoonjoy session is the credential. Call relative /api/v1 URLs with credentials: "same-origin". Do not send Authorization; if an Authorization header is present, bearer auth wins over the session.

await fetch("/api/v1/shopping-list", {
  credentials: "same-origin",
  headers: { "X-Request-Id": "web-shopping-list" },
});

Bearer

External REST client

Use bearer only when a client cannot share the logged-in Spoonjoy session. In the playground, leave auth on Session and run the generated POST /api/v1/tokens operation, then store the sj_... secret outside browser bundles. Bearer-created tokens inherit the caller's scopes by default. Bearer callers cannot create a token with broader scopes than they already have.

curl 'https://spoonjoy.app/api/v1/shopping-list' \
  -H 'Authorization: Bearer sj_client_token' \
  -H 'X-Request-Id: client-shopping-list'

Delegated

OAuth/PKCE app

Register a public client with token_endpoint_auth_method: none and no client secret, redirect the chef through consent, then exchange the single-use 60-second code with a form-encoded POST /oauth/token request. Registration accepts common RFC 7591/OIDC metadata, but Spoonjoy stores only client_name and exact redirect_uris today. Optional scope metadata can be validated at registration, but the authorize request scope is the grant. Use a 43-128 character high-entropy PKCE verifier, S256 code challenge, and state. If the chef is not signed in, Spoonjoy routes them through /login first, where password, passkey, and configured Google, GitHub, or Apple sign-in all return to consent. OAuth accepts account scopes, kitchen scopes, and least-privilege public, recipe, cookbook, and shopping-list scopes. Generic OAuth access tokens last 15 minutes (expires_in: 900). MCP-bound access tokens stay active until disconnect and omit expires_in. The refresh_token rotates on every refresh grant, and POST /oauth/revoke disconnects the stored refresh token plus live OAuth access credentials for that client/resource.

POST /oauth/register
Response: { "client_id": "cm_client_id_from_register" }
GET /oauth/authorize?client_id=cm_client_id_from_register&redirect_uri=https%3A%2F%2Fexample.com%2Foauth%2Fcallback&response_type=code&scope=shopping_list%3Aread+shopping_list%3Awrite&state=...&code_challenge=...&code_challenge_method=S256
POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&client_id=...&redirect_uri=https%3A%2F%2Fexample.com%2Foauth%2Fcallback&code=...&code_verifier=...

POST /oauth/revoke
token=ort_...&client_id=cm_client_id_from_register&token_type_hint=refresh_token

Errors

Auth failures

Treat authentication_required and invalid_token as 401 responses, insufficient_scope as 403, and malformed Authorization headers as validation_error. Public endpoints can be anonymous; Omit `Authorization` on public calls unless you require authenticated behavior. If you send credentials to an optional endpoint, Spoonjoy validates them and checks scopes. Log requestId or X-Request-Id for support.

{
  "ok": false,
  "requestId": "client-shopping-list",
  "error": { "code": "insufficient_scope", "status": 403 }
}

Response Protocols

API v1 REST response shape

/api/v1 REST resources return ok, requestId, and data or error fields. Raw OpenAPI documents are the exception.

{ "ok": true, "requestId": "req_example", "data": {} }
{ "ok": false, "requestId": "req_example", "error": { "code": "invalid_token", "status": 401 } }

Protocol exceptions

OAuth endpoints use OAuth token/error JSON except 429 rate limits, delegated /api/tools/* helpers use the legacy helper envelope, and /mcp uses JSON-RPC result/error objects.

OAuth: { "access_token": "sj_...", "refresh_token": "ort_..." }
Agent helper: { "ok": true, "data": { "status": "pending" } }
MCP: { "jsonrpc": "2.0", "result": {} }

OAuth And Delegated Flows

Browser clients may call /oauth/register, /oauth/token, and /oauth/revoke cross-origin; those endpoints answer OPTIONS with CORS headers. Cookie-authenticated API mutations remain same-origin session calls and should never rely on copied cookies in external clients.

Do not request `offline_access` in OAuth authorize. OAuth clients can request account scopes, kitchen scopes, or least-privilege public, recipe, cookbook, and shopping-list scopes; refresh tokens are returned by the authorization-code flow.

Mobile, SaaS, extension

OAuth/PKCE delegated app

Use for third-party apps that can receive a redirect callback and should never handle a chef password.

/.well-known/oauth-authorization-server -> /oauth/register -> /oauth/authorize -> /oauth/token -> /oauth/revoke

  • - Dynamic client registration is public and returns token_endpoint_auth_method: none.
  • - Redirect URIs must be HTTPS; HTTP is accepted only for localhost and 127.0.0.1.
  • - PKCE is required: use a 43-128 character code_verifier and S256 code_challenge.
  • - Authorization codes are single-use and expire after 60 seconds.
  • - Access tokens are sj_... bearer credentials with a 900-second lifetime.
  • - Refresh tokens are ort_... values and rotate on every refresh grant; POST /oauth/revoke disconnects a stored refresh token and revokes live OAuth access credentials for that client/resource.
  • - Registration validates optional scope metadata but does not grant it; the authorize request scope is the grant. Blank authorize scope defaults to kitchen:read, but apps should send explicit least-privilege scopes.
  • - Account profile, profile-photo, and notification settings require explicit account:read or account:write; broad kitchen scopes do not include account identity settings.
  • - OAuth scopes never grant tokens:read or tokens:write; personal token management uses /api/v1/tokens.
  • - grant_type=password is never supported.
  • - Most OAuth errors use standard OAuth JSON. Rate-limit responses are Spoonjoy's generic rate-limit shape with Retry-After.
curl -sS 'https://spoonjoy.app/oauth/register' \
  -H 'Content-Type: application/json' \
  --data '{"client_name":"Example grocery app","redirect_uris":["https://example.com/oauth/callback"],"token_endpoint_auth_method":"none"}'

# Open this URL in the browser after generating a PKCE S256 challenge.
open 'https://spoonjoy.app/oauth/authorize?client_id=cm_client_id_from_register&redirect_uri=https%3A%2F%2Fexample.com%2Foauth%2Fcallback&response_type=code&scope=shopping_list%3Aread+shopping_list%3Awrite&state=...&code_challenge=...&code_challenge_method=S256'

curl -sS 'https://spoonjoy.app/oauth/token' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data 'grant_type=authorization_code&client_id=cm_client_id_from_register&redirect_uri=https%3A%2F%2Fexample.com%2Foauth%2Fcallback&code=oac_...&code_verifier=pkce_verifier_0123456789_abcdefghijklmnopqrstuvwxyz_ABCDEF'

curl -sS -X POST 'https://spoonjoy.app/oauth/revoke' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data 'token=ort_...&client_id=cm_client_id_from_register&token_type_hint=refresh_token'

Agent, appliance, no callback

Delegated approval link

Use for agents, CLIs, kitchen displays, and constrained devices that can show a chef an approval URL but cannot run an OAuth callback.

/api/tools/start_agent_connection -> /api/tools/poll_agent_connection -> /api/v1/tokens/{credentialId}

  • - The device code expires after 10 minutes.
  • - Poll no faster than the returned interval, currently 2 seconds.
  • - A pending poll returns pending plus authorizationUrl, verificationUri, verificationUriComplete, and userCode.
  • - Pass scopes such as shopping_list:read shopping_list:write to request a least-privilege delegated token; omitted scopes default to shopping_list:read shopping_list:write.
  • - Tiny devices can show verificationUri plus userCode instead of the long authorizationUrl.
  • - An approved poll returns the sj_... token once, plus token metadata.
  • - The token is a normal bearer credential. A device can revoke its own credential id with DELETE /api/v1/tokens/{credentialId}; revoking any other credential requires tokens:write.
SJ_BASE='https://spoonjoy.app'
umask 077
state_dir="$(mktemp -d "${TMPDIR:-/tmp}/spoonjoy.XXXXXX")"
trap 'rm -rf "$state_dir"' EXIT

curl -sS 'https://spoonjoy.app/api/tools/start_agent_connection' \
  -H 'Content-Type: application/json' \
  --data '{"agentName":"Kitchen display","scopes":"shopping_list:read shopping_list:write"}' \
  > "$state_dir/start.json"

echo "Open $(jq -r '.data.verificationUri' "$state_dir/start.json") and enter $(jq -r '.data.userCode' "$state_dir/start.json")"

while :; do
  curl -sS "$SJ_BASE/api/tools/poll_agent_connection" \
    -H 'Content-Type: application/json' \
    --data "{\"deviceCode\":\"$(jq -r '.data.deviceCode' "$state_dir/start.json")\"}" \
    > "$state_dir/poll.json"
  status=$(jq -r '.data.status' "$state_dir/poll.json")
  test "$status" = approved && break
  test "$status" = pending || exit 1
  sleep "$(jq -r '.data.interval // 2' "$state_dir/start.json")"
done
export SPOONJOY_TOKEN="$(jq -r '.data.token' "$state_dir/poll.json")"

Assistant runtime

Remote MCP client

Use for MCP-capable clients that discover OAuth metadata, get a bearer token, then call the remote Spoonjoy MCP endpoint.

/mcp -> /.well-known/oauth-protected-resource/mcp -> /.well-known/oauth-protected-resource -> /.well-known/oauth-authorization-server

  • - POST /mcp challenges unauthenticated callers with path-specific OAuth protected-resource metadata.
  • - MCP delegated scopes use kitchen:read and kitchen:write.
  • - Token-management tools require personal tokens:read or tokens:write scopes; OAuth kitchen scopes do not grant them.
  • - The approved bearer token must be sent as Authorization: Bearer sj_...
curl -sS 'https://spoonjoy.app/mcp' \
  -H 'Authorization: Bearer sj_...' \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

OAuth Scope Mapping

account:read

account:read

account:write

account:write

kitchen:read

cookbooks:read + public:read + recipes:read + shopping_list:read

kitchen:write

kitchen:write + shopping_list:write

shopping_list:read

shopping_list:read

shopping_list:write

shopping_list:write

recipes:read

recipes:read

cookbooks:read

cookbooks:read

public:read

recipes:read + cookbooks:read

OAuth clients may request the delegated scopes above directly. Token-management scopes remain personal-token or Session-only and are not granted by OAuth.

Blank OAuth authorize scope defaults to kitchen:read. Omitted delegated approval scopes default to shopping_list:read shopping_list:write. Omit resource for REST OAuth apps. Use resource=https://spoonjoy.app/mcp only for MCP OAuth; resource-bound MCP tokens are rejected by REST API v1. OAuth kitchen scopes do not grant tokens:read or tokens:write.

Scenario Quickstarts

iOS + Android

Native mobile OAuth

Register once per app install or environment, persist client_id, and do not register on every launch. Use HTTPS universal links or Android App Links for production callbacks, with localhost or 127.0.0.1 loopback only for development. Store tokens in Keychain or Android Keystore-backed storage. Refresh tokens rotate, so replace the stored refresh token atomically and use single-flight refresh when concurrent requests hit 401.

POST /oauth/register
{ "client_name": "Spoonjoy Apple", "redirect_uris": ["https://spoonjoy.app/oauth/callback"], "token_endpoint_auth_method": "none" }

GET /oauth/authorize?...&state=client_state&code_challenge=pkce_s256&scope=shopping_list:read+shopping_list:write

POST /oauth/token
grant_type=authorization_code&client_id=cm_client_id_from_register&redirect_uri=https%3A%2F%2Fspoonjoy.app%2Foauth%2Fcallback&code=oac_...&code_verifier=pkce_verifier_...

POST /oauth/token
grant_type=refresh_token&client_id=cm_client_id_from_register&refresh_token=ort_...

GET /api/v1/shopping-list/sync?limit=50

Extension

Browser extension OAuth

Use /api/v1/recipes/import with kitchen:write for full recipe clipping, or keep shopping_list:read shopping_list:write when the extension only syncs ingredient rows. Run OAuth/PKCE in the extension background, persist client_id plus state and code_verifier until callback, verify state, and make bearer API calls from the background instead of a content script. Register the HTTPS callback from chrome.identity.getRedirectURL/launchWebAuthFlow exactly; custom extension schemes are rejected.

POST /api/v1/recipes/import
Authorization: Bearer sj_...
{ "clientMutationId": "extension:recipe-import:...", "source": { "type": "url", "url": "https://example.com/recipe" } }

const item = { sourceRowId: "row-42", name: "Eggs", quantity: 12, unit: "Each" }
clientMutationId = `extension:${sha256(recipeUrl)}:${item.sourceRowId}:${bodyHash}`
POST /api/v1/shopping-list/items
Authorization: Bearer sj_...
{ "clientMutationId": "extension:...", "name": "Eggs", "quantity": 12, "unit": "Each" }

CLI/script

Cron shopping-list export/import

Omit cursor on the first sync, then store data.nextCursor after applying every item and tombstone. Use limit for small payloads and keep fetching while hasMore is true. Import with deterministic chef-wide mutation ids such as shopping-import:<source-system>:<source-row-id>:<body-hash>; this is not a durable set-desired-state API after the 24-hour idempotency window.

umask 077
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/spoonjoy-sync.XXXXXX")"
trap 'rm -rf "$tmp_dir"' EXIT
curl -fsS 'https://spoonjoy.app/api/v1/shopping-list/sync?limit=20' \
  -H 'Authorization: Bearer sj_...' > "$tmp_dir/sync.json"
jq -n --arg cursor "$(jq -r '.data.nextCursor' "$tmp_dir/sync.json")" '{cursor:$cursor}' > state.json

Serverless

Cloudflare Worker sync bridge

Store access_token, rotating refresh_token, and cursor state behind a Durable Object, queue-level serializer, or D1 row lock. KV is fine for read-through snapshots after the lock, but not as the refresh-token compare-and-set mechanism. Respect Retry-After and never store authenticated bearer responses in caches.default.

Queue message: { chefId, mutation, clientMutationId }
Durable Object per chef: refresh once, atomically store the rotated refresh_token, then retry queued mutations with the same clientMutationId.
GET /api/v1/shopping-list/sync?limit=50
Authorization: Bearer sj_...

Zapier + Make

No-code connector profile

Import /api/v1/openapi.connector.json for an OpenAPI 3.0 REST-only profile. Configure OAuth as public client, no client secret, PKCE S256, authorization URL /oauth/authorize, token and refresh URL /oauth/token, revoke URL /oauth/revoke. API v1 has no webhooks yet; expose shopping-list sync as a polling trigger.

Trigger: New, updated, or removed shopping-list item
1. GET /api/v1/shopping-list/sync?limit=50
2. Sort returned rows by updatedAt descending for the no-code platform.
3. Dedupe events by id:updatedAt so changed items are not suppressed.
4. Treat deletedAt rows as removals or filtered tombstones.
5. Persist nextCursor only after the run succeeds.

Reporting

Public BI snapshot export

Public recipe/cookbook lists use createdAt/id cursor walks. They are not repeatable snapshot guarantees or updatedAt incremental exports, and they do not include deletion tombstones; restart a full crawl when you need to catch public edits or removals. Preserve attribution, respect Retry-After, and do not treat public JSON as a photo-copying or commercial republication license.

snapshot_resource recipes recipes recipes.ndjson
snapshot_resource cookbooks cookbooks cookbooks.ndjson

GET /api/v1/recipes?limit=50
GET /api/v1/cookbooks?limit=50
GET /api/v1/recipes?cursor=v1.cursor_from_nextCursor&limit=50

Anonymous public responses may include Cache-Control: public, max-age=60, stale-while-revalidate=300 but not ETag or Last-Modified.

REST-powered embeds only

Recipe blog embeds

Fetch public JSON and render your own HTML with textContent, not innerHTML. Spoonjoy pages are not iframe embeds. Recipe steps are returned in ascending stepNum order, step duration is minutes when present, ingredients are step-attached in API order, and usingSteps lists prior step outputs used by a step. Validate sourceUrl as http/https before linking, write your own image alt text, and avoid copying photos where removals cannot be honored.

GET /api/v1/recipes/{id}
If 404 not_found, hide or replace the embed before rendering stale content.
Render servings, step ingredients, ordered steps, step output dependencies, and attribution.creditText as a link to attribution.canonicalUrl.
If attribution.sourceRecipe.deleted is true, credit it as unavailable instead of linking it.

Offline Product Contract

Native clients treat offline as product behavior. They restore cached reads only inside the account/environment/schema boundary, preserve freshness metadata, apply tombstones before advancing cursors, and queue only safe product mutations.

Metadata

Every cached record carries accountId, environment, schemaVersion, fetchedAt, lastValidatedAt, sourceEndpoint, and a server revision marker such as cursor, etag, updatedAt, or a tombstone token.

Freshness

Account/settings/shopping bootstrap data is fresh for 15 minutes; detail/profile/spoon/cook-mode backing data is fresh for 6 hours; catalog/search result pages are fresh for 24 hours.

Queueable account writes

Profile display-field updates, profile photo upload/remove after local media staging, notification preference updates, and APNs device registration/revocation after a system device token exists are queueable.

Online-only account actions

API token create/revoke, OAuth connection disconnect, logout/session revoke, passkey/password/provider-link actions are online-only.

Visible severe states

queued work, sync failure, conflict, blocker, and destructive confirmation states remain visible until resolved.

Reference

root

/api/v1

GETAnonymous

Anonymous callers allowed; authenticated callers are scope checked.

health

/api/v1/health

GETAnonymous

Anonymous callers allowed; authenticated callers are scope checked.

openapi

/api/v1/openapi.json

GETAnonymous

Anonymous callers allowed; authenticated callers are scope checked.

openapi-sdk

/api/v1/openapi.sdk.json

GETAnonymous

Anonymous callers allowed; authenticated callers are scope checked.

openapi-connector

/api/v1/openapi.connector.json

GETAnonymous

Anonymous callers allowed; authenticated callers are scope checked.

auth-apple-native

/api/v1/auth/apple/native

POSTAnonymous

Anonymous callers allowed; authenticated callers are scope checked.

auth-password-native

/api/v1/auth/password/native

POSTAnonymous

Anonymous callers allowed; authenticated callers are scope checked.

native-telemetry

/api/v1/native/telemetry

POSTAnonymous

Anonymous callers allowed; authenticated callers are scope checked.

search

/api/v1/search

GETAnonymous

Anonymous callers allowed; authenticated callers are scope checked.

recipes

/api/v1/recipes

GETrecipes:read

Anonymous callers allowed; authenticated callers are scope checked.

recipe-create

/api/v1/recipes

POSTkitchen:write

Authenticated chef surface.

recipe

/api/v1/recipes/{id}

GETrecipes:read

Anonymous callers allowed; authenticated callers are scope checked.

recipe-write

/api/v1/recipes/{id}

PATCHkitchen:writeDELETEkitchen:write

Authenticated chef surface.

recipe-fork

/api/v1/recipes/{id}/fork

POSTkitchen:write

Authenticated chef surface.

recipe-import

/api/v1/recipes/import

POSTkitchen:write

Authenticated chef surface.

recipe-steps

/api/v1/recipes/{id}/steps

POSTkitchen:write

Authenticated chef surface.

recipe-step

/api/v1/recipes/{id}/steps/{stepId}

PATCHkitchen:writeDELETEkitchen:write

Authenticated chef surface.

recipe-step-reorder

/api/v1/recipes/{id}/steps/reorder

POSTkitchen:write

Authenticated chef surface.

recipe-step-ingredients

/api/v1/recipes/{id}/steps/{stepId}/ingredients

POSTkitchen:write

Authenticated chef surface.

recipe-step-ingredient

/api/v1/recipes/{id}/steps/{stepId}/ingredients/{ingredientId}

DELETEkitchen:write

Authenticated chef surface.

recipe-step-output-uses

/api/v1/recipes/{id}/step-output-uses

PUTkitchen:write

Authenticated chef surface.

recipe-spoons

/api/v1/recipes/{id}/spoons

GETrecipes:read

Anonymous callers allowed; authenticated callers are scope checked.

recipe-spoons-create

/api/v1/recipes/{id}/spoons

POSTkitchen:write

Authenticated chef surface.

recipe-spoon

/api/v1/recipes/{id}/spoons/{spoonId}

PATCHkitchen:writeDELETEkitchen:write

Authenticated chef surface.

recipe-image

/api/v1/recipes/{id}/image

POSTkitchen:write

Authenticated chef surface.

recipe-covers

/api/v1/recipes/{id}/covers

GETkitchen:writePOSTkitchen:writePATCHkitchen:write

Authenticated chef surface.

recipe-cover

/api/v1/recipes/{id}/covers/{coverId}

PATCHkitchen:writeDELETEkitchen:write

Authenticated chef surface.

recipe-cover-generate

/api/v1/recipes/{id}/covers/generate

POSTkitchen:write

Authenticated chef surface.

recipe-cover-regenerate

/api/v1/recipes/{id}/covers/regenerate

POSTkitchen:write

Authenticated chef surface.

recipe-cover-from-spoon

/api/v1/recipes/{id}/covers/from-spoon/{spoonId}

POSTkitchen:write

Authenticated chef surface.

cookbooks

/api/v1/cookbooks

GETcookbooks:read

Anonymous callers allowed; authenticated callers are scope checked.

cookbooks-create

/api/v1/cookbooks

POSTkitchen:write

Authenticated chef surface.

cookbook

/api/v1/cookbooks/{id}

GETcookbooks:read

Anonymous callers allowed; authenticated callers are scope checked.

cookbook-mutate

/api/v1/cookbooks/{id}

PATCHkitchen:writeDELETEkitchen:write

Authenticated chef surface.

cookbook-recipes

/api/v1/cookbooks/{id}/recipes/{recipeId}

POSTkitchen:writeDELETEkitchen:write

Authenticated chef surface.

me

/api/v1/me

GETaccount:readPATCHaccount:write

Authenticated chef surface.

me-sync

/api/v1/me/sync

GETaccount:readkitchen:read

Authenticated chef surface.

me-photo

/api/v1/me/photo

POSTaccount:writeDELETEaccount:write

Authenticated chef surface.

me-notification-preferences

/api/v1/me/notification-preferences

GETaccount:readPATCHaccount:write

Authenticated chef surface.

me-apns-devices

/api/v1/me/apns-devices

POSTaccount:write

Authenticated chef surface.

me-apns-device

/api/v1/me/apns-devices/{deviceId}

DELETEaccount:write

Authenticated chef surface.

me-connections

/api/v1/me/connections

GETtokens:read

Authenticated chef surface.

me-connection

/api/v1/me/connections/{connectionId}

DELETEtokens:write

Authenticated chef surface.

shopping-list

/api/v1/shopping-list

GETshopping_list:read

Authenticated chef surface.

shopping-list-sync

/api/v1/shopping-list/sync

GETshopping_list:read

Authenticated chef surface.

shopping-list-items

/api/v1/shopping-list/items

POSTshopping_list:write

Authenticated chef surface.

shopping-list-item

/api/v1/shopping-list/items/{itemId}

PATCHshopping_list:writeDELETEshopping_list:write

Authenticated chef surface.

shopping-list-add-from-recipe

/api/v1/shopping-list/add-from-recipe

POSTshopping_list:write

Authenticated chef surface.

shopping-list-clear-completed

/api/v1/shopping-list/clear-completed

POSTshopping_list:write

Authenticated chef surface.

shopping-list-clear-all

/api/v1/shopping-list/clear-all

POSTshopping_list:write

Authenticated chef surface.

tokens

/api/v1/tokens

GETtokens:readPOSTtokens:write

Authenticated chef surface.

token

/api/v1/tokens/{credentialId}

DELETEtokens:write

Authenticated chef surface.

Scopes

public:read

Public read

account:read

Account settings read

account:write

Account settings write

kitchen:read

Delegated kitchen read

kitchen:write

Delegated kitchen write

recipes:read

Recipe graph read

cookbooks:read

Cookbook graph read

shopping_list:read

Shopping list read

shopping_list:write

Shopping list write

tokens:read

Token metadata read

tokens:write

Bearer credential create and revoke

Auth

Spoonjoy session

Best for the playground and same-origin browser clients. Sign in once; your login is the credential for private API calls.

Bearer credentials

Best only when a client runs outside the Spoonjoy browser session. The token API is exposed as part of the generated surface.

OAuth/PKCE apps

Best for third-party apps. Dynamic registration, authorize, and token routes are exposed for delegated consent.

MCP clients

Best for assistant-style clients that need a tool connection instead of raw REST calls.

Delegated and device-style authorization

Best for clients that need a chef to approve access from a constrained device or external runtime.

Sync And Safety

Recipe and cookbook lists are public catalog search endpoints today. The query parameter wins when both query and q are supplied; lists include cursor, nextCursor, hasMore, cover images, canonicalUrl, and attribution fields. Native owner sync lives at /api/v1/me/sync and includes owned recipe/cookbook delete tombstones.

Recipe import is an authenticated native app and automation surface at /api/v1/recipes/import. Store nextCursor after applying each native sync or shopping-list sync batch and retry mutations with stable clientMutationId values.

Recipe ingredient quantities, units, servings, temperatures, and timers are original author data in API v1. Units are free-form display strings, not a canonical conversion model; there is no /api/v1/units registry or density table yet.

API v1 is rate limited by IP and credential before authentication work. Rate-limited responses return 429 and Retry-After; configured limits are edge abuse protection, not a globally precise quota meter.

Cursor

Use the returned nextCursor as the next request cursor after applying every item in the page durably. Treat it as opaque; ISO timestamps are accepted only as a bootstrap convenience.

Tombstones

Sync includes deleted rows with deletedAt so offline clients can remove local items.

Pagination

Use limit from 1 to 50 for small payloads. hasMore: true means continue with the returned nextCursor; webhooks, REST Hooks, SSE, and event subscriptions are not available yet.

Idempotent owner mutations

clientMutationId is scoped to the chef, retained for 24 hours, and bound to method, path, and body hash for account profile, profile photo, notification preferences, APNs device registration/revocation, recipe create, update, delete, fork, step editing, import, cookbook writes, shopping-list writes, recipe spoon writes, and recipe-cover writes. Persist and retry the exact request values for that mutation id.

Replay

Retry the same request with the same clientMutationId after a timeout; Spoonjoy returns the recorded response with mutation.replayed: true.

Conflict

Reusing the same clientMutationId for a different method, path, or body returns 409 idempotency_conflict.

Retries

Retry network timeouts, 429, and 5xx responses with the same mutation id. Refresh or reconnect on 401. Do not retry validation, scope, or idempotency conflicts unchanged.

Sample flow

  1. Read public recipes and cookbooks anonymously before adding auth.
  2. Use Session for logged-in playground calls; use bearer or OAuth only when a client runs outside that session.
  3. Use a stable mutation id for recipe create, update, delete, fork, step editing, import, and shopping-list writes, then retry with the same value when a network call is interrupted.
  4. Use the sync cursor to fetch shopping-list changes, including removed items.

Client Starting Points

Public catalog

GET /api/v1/recipes and GET /api/v1/cookbooks need no token.

Recipe import

POST /api/v1/recipes/import uses kitchen:write plus a stable clientMutationId.

Private list

Use shopping-list read and write scopes for pantry-style clients.

Machine errors

Every v1 error returns ok false, requestId, code, message, and status.