Reference
REST API
The endpoints the Tally app itself uses, grouped by what they are for. Each row shows the method, the path and the scope a key needs.
This page covers the routes you are likely to call. The machine-readable contract for every route, generated from the backend's models, is /api/openapi.json (OpenAPI 3.1). It also lists a few routes the app uses internally that are not covered here: the streaming chat, photo upload, aliases and telemetry.
Conventions
- Base URL
https://tally.art3m1s.me; every path starts with/api. JSON in, JSON out. - Auth:
Authorization: Bearer tally_live_…on every request.readcovers GET; everything else needswrite, except three routes marked below (scopes). - Days are your local days, in the timezone from your settings (UTC if unset). Dates are
YYYY-MM-DD, timestamps ISO 8601, ids UUIDs. Only log creation takes atz; every read uses the saved timezone. - Macros are
kcal,protein_g,carb_g,fat_g, andfiber_gwhen the source reports it. Amacrosobject may carry other nutrient keys, always numbers. - Another account's ids read as
404, exactly like ids that do not exist. Errors are JSON with adetailfield (error shapes).
Diary & history
- GET
/api/diary?date=YYYY-MM-DDreadOne day's entries grouped under
breakfast,lunch,dinnerandsnack(always all four), plustotalsagainst yourtargets. Nodatemeans today. - GET
/api/history?range=3d|w|m|yreadTotals over a window ending today: the last 3, 7 or 30 days, or 52 weeks (default
w, the last 7 days). For3d,wandm,dayshas one row per day (an unlogged day is zeros withlogged: false); fory,weekshas weekly averages over the logged days. Alsotargetsand your loggingstreak. There is no custom start date; use the diary for single days.
{
"date": "2026-09-23",
"meals": {
"breakfast": [
{
"id": "0f6b2c8e-4d1a-4c3b-9e7f-2a5d8c1b6e40",
"meal": "breakfast",
"food": {
"id": "2d4f8a61-9c3e-4b7a-8f10-6e2b5c9d1a37",
"name": "Egg, whole, cooked, hard-boiled",
"brand": null
},
"serving": { "id": "93437", "description": "1 large" },
"qty": 2.0,
"macros": { "kcal": 155.0, "protein_g": 12.58, "carb_g": 1.12, "fat_g": 10.61, "fiber_g": 0.0 },
"logged_at": "2026-09-23T15:15:00Z",
"grams": 100.0,
"source": "fdc",
"web_source": null,
"estimated": "none"
}
],
"lunch": [],
"dinner": [],
"snack": []
},
"totals": {
"kcal": 155.0,
"protein_g": 12.58,
"carb_g": 1.12,
"fat_g": 10.61,
"targets": { "kcal": 2200.0, "protein_g": 160.0, "carb_g": 220.0, "fat_g": 70.0 }
}
}Logging
Logging is two steps. Stage: send what you ate in words; Tally names the foods, finds portions and the meal, looks the foods up and stores the rows as a staged log. Commit: send back the rows to keep and they go into the diary. Nothing reaches the diary before the commit, and a staged log you do not want can be discarded.
- POST
/api/logswriteStage a log from text. Body:
id(a UUID you generate: resending the same id returns the stored result instead of logging twice),text,captured_at(ISO timestamp),tz(IANA name). Optionalhistorycarries earlier chat turns so a follow-up can refer back. The call waits for the lookups, up to about two minutes. On a demo account it spends the day's log.Returns
log_id,meal,items(the staged rows),logged_at(set when words like “yesterday” moved the day),saved_meal,note, andagentwith the agent'sreply, anyoptionsit offers, and aweightif you mentioned one. - GET
/api/logs/{log_id}writeThe log's rows as they stand now. A row can carry
stale: truewhen its lookup stalled; retry it with/api/resolve/tier3. A row that stalls again after its retries is set tonot_foundby this read, so it needswrite. - POST
/api/logs/{log_id}/commitwriteBody:
items, the rows to keep, in the staged shape (editfood,serving,qtyormealfirst if you like), and optionalsave_intent: {name}to also save them as a saved meal. Onlyresolvedrows are written; others you send stay on the log; rows you leave out are removed. Macros are recomputed from the food database, never taken from your payload. Returns the day's totals, per-rowerrors,saved_mealandtotals_day(null when the rows landed on more than one day). Committing a log that is already committed writes nothing and answers 200 again, so a retry is safe. - POST
/api/logs/{log_id}/commit-itemwriteBody:
item_index,item. Adds one row that resolved after the log was committed. The log must already be committed; returns the day's totals. - DELETE
/api/logs/{log_id}writeDiscard a staged log. Discarding twice is harmless; a committed log is 409.
- DELETE
/api/logs/{log_id}/items/{item_index}writeRemove one staged row before commit. A row already committed is 409.
- POST
/api/logs/from-foodwriteStage exactly one row from a food you already have an id for, with no model involved. Body:
id,food_id,input_mode("search"or"barcode"),captured_at, and optionalserving_id,qty,meal_hint. Counts as the day's log on a demo account.
Fix a staged row
- POST
/api/resolvewriteBody:
log_id,item_index,phrase, optionalqtyandunit. Re-resolves a row from new words, or adds one at a new index. Returns the row. - POST
/api/resolve/answerwriteBody:
log_id,item_index,answer. Answers a row'squestionwith one of its options, an alternate'sfood_id, or freetext. - POST
/api/resolve/tier3writeBody:
log_id,item_index. Runs the lookup again for a row stillsearching.
/api/resolve, /api/resolve/tier3 and a free-text answer can each run a fresh lookup, so give them a long timeout (request time). On a demo account they share a budget of 10 a day (limits).
{
"id": "7c0e5a0e-2f4b-4f7e-9d0a-5b8f1f3c2a91",
"text": "two eggs and a fairlife",
"captured_at": "2026-09-23T08:15:00-07:00",
"tz": "America/Los_Angeles"
}{
"log_id": "7c0e5a0e-2f4b-4f7e-9d0a-5b8f1f3c2a91",
"meal": "breakfast",
"items": [
{
"item_index": 0,
"phrase": "eggs",
"status": "resolved",
"food": {
"id": "2d4f8a61-9c3e-4b7a-8f10-6e2b5c9d1a37",
"name": "Egg, whole, cooked, hard-boiled",
"brand": null
},
"serving": { "id": "93437", "description": "1 large" },
"qty": 2.0,
"macros": { "kcal": 155.0, "protein_g": 12.58, "carb_g": 1.12, "fat_g": 10.61, "fiber_g": 0.0 },
"meal": "breakfast",
"confidence": "high",
"estimated": "none"
},
{
"item_index": 1,
"phrase": "fairlife",
"status": "resolved",
"food": {
"id": "9a3e7b52-1f6c-4d8e-b0a4-73c5e2d91f68",
"name": "Ultra-filtered 2% milk",
"brand": "fairlife"
},
"serving": { "id": "label", "description": "1 cup (240 ml)" },
"qty": 1.0,
"macros": { "kcal": 120.0, "protein_g": 13.0, "carb_g": 6.0, "fat_g": 4.5, "fiber_g": null },
"meal": "breakfast",
"confidence": "high",
"estimated": "none"
}
],
"logged_at": null,
"saved_meal": null,
"note": null,
"agent": {
"reply": "Two eggs and a fairlife, staged for breakfast.",
"weight": null,
"options": null,
"discarded": null,
"updated": null
}
}{
"kcal": 275.0,
"protein_g": 25.58,
"carb_g": 7.12,
"fat_g": 15.11,
"targets": { "kcal": 2200.0, "protein_g": 160.0, "carb_g": 220.0, "fat_g": 70.0 },
"errors": [],
"saved_meal": null,
"totals_day": "2026-09-23"
}Each staged row has a status: resolved (ready to commit), searching (lookup not finished), needs_input (see its question) or not_found. Tally never makes up numbers for a food it cannot find: it says not_found.
Entries
Committed diary rows. Their ids are the id fields in the diary response.
- PATCH
/api/entries/{entry_id}writeBody: any of
qty,serving_id,food_id,meal. Returns{entry, totals}. Amealalone moves the entry and leaves its amount as it was. An entry's time cannot be changed.422when nothing changes orqtyis not positive. - DELETE
/api/entries/{entry_id}writeDeletes the entry for good and returns the day's new totals.
Weights
- GET
/api/weights?days=90readlatestweigh-in plusentriesfrom the lastdaysdays (1 to 3660, default 90), newest first. - PUT
/api/weightswriteBody:
weight,unit("kg"or"lb"), optionalday(default today). One weigh-in per day: a second one replaces the first. Stored in kg; 30 to 300 kg is accepted.
{ "weight": 180.4, "unit": "lb" }{
"day": "2026-09-23",
"weight_kg": 81.83,
"unit": "lb",
"source": "manual",
"log_id": null
}Foods
- GET
/api/foods/search?q=&source=all|local|fdcreadReturns
localhits from Tally's food cache (shared by all accounts, up to 10) and, unlesssource=local,genericandbrandedhits from USDA FoodData Central. If USDA fails,fdc_errorsays so and local hits still come back. - GET
/api/foods/recentreadFoods you have logged, newest first, up to 200.
- GET
/api/foods/{food_id}readOne food: its
servings(with macros per serving), every nutrient the source reported, a source badge, and the raw providerpayload. - GET
/api/foods/barcode?code=writeLook a barcode up: the cache first, then USDA, then Open Food Facts. A miss is
{"found": false}, not a 404. A new hit is added to the shared food cache, so it needswrite. - POST
/api/foods/selectwriteBody:
source("fdc"),source_id, and optionallylog_id+item_index. Adds a USDA search hit to the cache and returns itsfood_id; with a log row given, also sets that row to the food.
Saved meals
A saved meal is a name, trigger phrases and a fixed list of foods. Saying a trigger phrase in a log stages the whole list.
- GET
/api/meals?include_inactive=falsereadYour saved meals; deleted ones too with
include_inactive=true. - POST
/api/mealswriteBody:
name,trigger_phrases(default: the name),items(food_id,serving_id,qty). Answers201; a second active meal with the same name is409. - PATCH
/api/meals/{meal_id}writeAny of
name,trigger_phrases,items,is_active. Lists you send replace the stored ones. - DELETE
/api/meals/{meal_id}writeDeactivates the meal. The row is kept, so past diary entries keep their context.
- POST
/api/meals/from-logwriteBody:
log_id,name, optionaltrigger_phrases. Saves a committed log's entries as a meal, or updates the meal of that name.
Settings
- GET
/api/settingsreadbody(height, weight, age and the rest of the profile),targets(nulluntil goals are set),meal_windows,profile_notes,timezone. - PUT
/api/settingswritePartial update: only the fields you send are written.
timezonemust be an IANA name;targets.kcalmust be 800 to 10000. - POST
/api/settings/goals/computereadProposes targets from height, weight, age, sex and activity level. Saves nothing; PUT the
targetsyou want. Because it saves nothing, a read key may call it.
Me
- GET
/api/mereademail,is_owner, anddemo:{limit, used_today, resets_at}for a demo account,nullfor the owner. - GET
/api/healthno auth{"ok": true, "service": "tally"}. Needs no key, like/api/openapi.json.
Managing API keys (/api/keys) needs a signed-in app session: API keys.