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. read covers GET; everything else needs write, 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 a tz; every read uses the saved timezone.
  • Macros are kcal, protein_g, carb_g, fat_g, and fiber_g when the source reports it. A macros object 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 a detail field (error shapes).

Diary & history

  • GET/api/diary?date=YYYY-MM-DDread

    One day's entries grouped under breakfast, lunch, dinner and snack (always all four), plus totals against your targets. No date means today.

  • GET/api/history?range=3d|w|m|yread

    Totals over a window ending today: the last 3, 7 or 30 days, or 52 weeks (default w, the last 7 days). For 3d, w and m, days has one row per day (an unlogged day is zeros with logged: false); for y, weeks has weekly averages over the logged days. Also targets and your logging streak. There is no custom start date; use the diary for single days.

200 OK · GET /api/diaryjson
{
  "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/logswrite

    Stage 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). Optional history carries 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, and agent with the agent's reply, any options it offers, and a weight if you mentioned one.

  • GET/api/logs/{log_id}write

    The log's rows as they stand now. A row can carry stale: true when its lookup stalled; retry it with /api/resolve/tier3. A row that stalls again after its retries is set to not_found by this read, so it needs write.

  • POST/api/logs/{log_id}/commitwrite

    Body: items, the rows to keep, in the staged shape (edit food, serving, qty or meal first if you like), and optional save_intent: {name} to also save them as a saved meal. Only resolved rows 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-row errors, saved_meal and totals_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-itemwrite

    Body: 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}write

    Discard a staged log. Discarding twice is harmless; a committed log is 409.

  • DELETE/api/logs/{log_id}/items/{item_index}write

    Remove one staged row before commit. A row already committed is 409.

  • POST/api/logs/from-foodwrite

    Stage 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 optional serving_id, qty, meal_hint. Counts as the day's log on a demo account.

Fix a staged row

  • POST/api/resolvewrite

    Body: log_id, item_index, phrase, optional qty and unit. Re-resolves a row from new words, or adds one at a new index. Returns the row.

  • POST/api/resolve/answerwrite

    Body: log_id, item_index, answer. Answers a row's question with one of its options, an alternate's food_id, or free text.

  • POST/api/resolve/tier3write

    Body: log_id, item_index. Runs the lookup again for a row still searching.

/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).

POST /api/logs · requestjson
{
  "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"
}
200 OK · POST /api/logs (trimmed)json
{
  "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
  }
}
200 OK · POST /api/logs/{log_id}/commitjson
{
  "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}write

    Body: any of qty, serving_id, food_id, meal. Returns {entry, totals}. A meal alone moves the entry and leaves its amount as it was. An entry's time cannot be changed. 422 when nothing changes or qty is not positive.

  • DELETE/api/entries/{entry_id}write

    Deletes the entry for good and returns the day's new totals.

Weights

  • GET/api/weights?days=90read

    latest weigh-in plus entries from the last days days (1 to 3660, default 90), newest first.

  • PUT/api/weightswrite

    Body: weight, unit ("kg" or "lb"), optional day (default today). One weigh-in per day: a second one replaces the first. Stored in kg; 30 to 300 kg is accepted.

PUT /api/weights · requestjson
{ "weight": 180.4, "unit": "lb" }
200 OK · PUT /api/weightsjson
{
  "day": "2026-09-23",
  "weight_kg": 81.83,
  "unit": "lb",
  "source": "manual",
  "log_id": null
}

Foods

  • GET/api/foods/search?q=&source=all|local|fdcread

    Returns local hits from Tally's food cache (shared by all accounts, up to 10) and, unless source=local, generic and branded hits from USDA FoodData Central. If USDA fails, fdc_error says so and local hits still come back.

  • GET/api/foods/recentread

    Foods you have logged, newest first, up to 200.

  • GET/api/foods/{food_id}read

    One food: its servings (with macros per serving), every nutrient the source reported, a source badge, and the raw provider payload.

  • GET/api/foods/barcode?code=write

    Look 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 needs write.

  • POST/api/foods/selectwrite

    Body: source ("fdc"), source_id, and optionally log_id + item_index. Adds a USDA search hit to the cache and returns its food_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=falseread

    Your saved meals; deleted ones too with include_inactive=true.

  • POST/api/mealswrite

    Body: name, trigger_phrases (default: the name), items (food_id, serving_id, qty). Answers 201; a second active meal with the same name is 409.

  • PATCH/api/meals/{meal_id}write

    Any of name, trigger_phrases, items, is_active. Lists you send replace the stored ones.

  • DELETE/api/meals/{meal_id}write

    Deactivates the meal. The row is kept, so past diary entries keep their context.

  • POST/api/meals/from-logwrite

    Body: log_id, name, optional trigger_phrases. Saves a committed log's entries as a meal, or updates the meal of that name.

Settings

  • GET/api/settingsread

    body (height, weight, age and the rest of the profile), targets (null until goals are set), meal_windows, profile_notes, timezone.

  • PUT/api/settingswrite

    Partial update: only the fields you send are written. timezone must be an IANA name; targets.kcal must be 800 to 10000.

  • POST/api/settings/goals/computeread

    Proposes targets from height, weight, age, sex and activity level. Saves nothing; PUT the targets you want. Because it saves nothing, a read key may call it.

Me

  • GET/api/meread

    email, is_owner, and demo: {limit, used_today, resets_at} for a demo account, null for 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.