> This is part 1 of 2 of the full documentation (pages 1–100 of 151).
> The content is paginated: fetch every part to see all of it.
> Next part: https://docs.january.ai/llms-full.txt/1
> Page index: https://docs.january.ai/llms.txt

# January Developer Platform

Build food search, food analysis, meal, water, and weight logging, and glucose prediction into your product with January.

## Get started

* [**Documentation**](/docs/overview): the platform, how authentication works, and what you can build.
* [**Quickstart**](/docs/quickstart): your first REST API request in a few minutes.
* [**Try it live**](https://try.january.ai/): analyze a meal in your browser and see the request and the response, with no account or API key.
* [**REST API reference**](/rest-api/api-overview): every endpoint, request field, and response.
* [**iOS SDK**](/client-sdks/ios-sdk/overview): Swift package and native food scanner.
* [**Android SDK**](/client-sdks/android-sdk/overview): Kotlin SDK and Compose food scanner.
* [**React Native SDK**](/client-sdks/react-native-sdk/overview): TypeScript API backed by the native iOS and Android SDKs.
* [**Web SDK**](/client-sdks/web-sdk/overview): TypeScript browser SDK and React example.

## Authentication boundary

Your backend authenticates with an `sk-…` API key. Apps never ship that key: your backend uses it to mint a short-lived `ct-…` client token for the signed-in end user, and the app calls January with that token. See [How authentication works](/docs/authentication).

Create and manage keys in the [January Developer Dashboard](https://dashboard.january.ai).


# Overview

January's developer platform provides one API for food search, food analysis, food, water, and weight logging, restaurant discovery, and sensor-free glucose prediction.

## Choose where to begin

* Follow the [Quickstart](/docs/quickstart) to make your first request.
* Connect a coding agent with the [MCP server](/docs/mcp-server).
* Read [How authentication works](/docs/authentication) before deciding where your app's credentials live.
* Explore the [REST API reference](/rest-api/api-overview) for every endpoint and schema.
* Choose the [iOS](/client-sdks/ios-sdk/overview), [Android](/client-sdks/android-sdk/overview), [React Native](/client-sdks/react-native-sdk/overview), or [Web](/client-sdks/web-sdk/overview) SDK for client applications.

## API base URL

All REST requests use:

```
https://partners.january.ai
```

Paths start with the API version, `/v1.2`; for example, `https://partners.january.ai/v1.2/foods`. Every request uses HTTPS and carries a bearer credential. [API v1.1](/rest-api/v1-1-legacy) is the legacy version.

## Typical integration

1. Create an API key in the [January Developer Dashboard](https://dashboard.january.ai) and keep it on your backend.
2. Call January from your backend with the key, or add a [token endpoint](/docs/authentication#your-token-endpoint) so your apps can call January with short-lived client tokens.
3. Identify each person with a stable [end-user ID](/docs/authentication) for operations on that person's data, such as food, water, and weight logs.

Continue with the [Quickstart](/docs/quickstart) to try a food search.


# Quickstart

Make your first January REST API request with an API key and `curl`.

{% hint style="info" %}
**No API key yet?** [Try it live](https://try.january.ai/): analyze a meal photo, a description, or a barcode in your browser. Each result shows the JSON response and the request that returned it.
{% endhint %}

## 1. Create an API key

Open the [January Developer Dashboard](https://dashboard.january.ai), select **API keys**, and create a key. Copy the secret when it is shown; the full value is displayed only at creation time.

API keys begin with `sk-`. Keep the key on a backend or another machine you control, never in browser or mobile app code.

## 2. Store it for this terminal session

```bash
export JANUARY_API_KEY="sk-your-key"
```

Do not commit the key to source control.

## 3. Search for a food

```bash
curl --get 'https://partners.january.ai/v1.2/foods' \
  --data-urlencode 'query=greek yogurt' \
  --data-urlencode 'limit=5' \
  --header "Authorization: Bearer ${JANUARY_API_KEY}" \
  --header 'Accept: application/json'
```

A successful response lists matching foods, best match first, trimmed here:

```json
{
  "items": [
    { "id": "70376084", "name": "greek yogurt", "type": "generic", "nutrients": { … }, "servings": [ … ], … }
  ]
}
```

A `401` means the key is missing, malformed, or not recognized. A `403 forbidden` means the key was issued for API v1.1.

## Next steps

* [Let a mobile or web app call January with client tokens](/docs/authentication)
* [Analyze a meal photo or description](/rest-api/food-analysis)
* [Predict a meal's glucose response](/rest-api/glucose)
* [Explore all REST endpoints](/rest-api/api-overview)
* [Connect a coding agent to the MCP server](/docs/mcp-server)


# How authentication works

January accepts two credentials. Both travel in the same header, `Authorization: Bearer …`:

| Credential            | Held by                          | What it can do                                                                                            |
| --------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------- |
| API key (`sk-…`)      | Your backend                     | Call every endpoint for your account.                                                                     |
| Client token (`ct-…`) | Your app, for one signed-in user | Act as that one user, within the scopes it was granted, for 5 minutes to 2 hours (30 minutes by default). |

These terms are used throughout the docs:

* **End user**: a person using your app.
* **End-user ID**: your own stable, opaque ID for that person, up to 64 characters (for example `acme-user-8271`). Never use an email address, a name, or other personal data. There is no registration call; any stable ID you choose works.
* **App session**: the login token or cookie between your app and your backend. January never sees it.

## API keys

Create a key in the [January Developer Dashboard](https://dashboard.january.ai) under **API keys → Create key**. The full value is shown once. Keep it in a secret manager or a server environment variable. Never put it in a browser bundle, a mobile app, a repository, a log, or a screenshot.

## Client tokens

An app on an end user's device never holds the API key. It asks your backend for a client token, then calls January directly with it:

```mermaid
sequenceDiagram
    autonumber
    participant App as Your app
    participant Backend as Your backend
    participant January as January API<br/>partners.january.ai

    Note over Backend: Holds your API key (sk-…).<br/>It never leaves your server.

    App->>Backend: POST /your-token-endpoint<br/>with the user's app session
    Note over Backend: Verify the session and<br/>look up that user's end-user ID
    Backend->>January: POST /v1.2/auth/client-tokens<br/>Authorization: Bearer sk-…<br/>{ end_user_id, scopes, ttl_seconds }
    January-->>Backend: 201 { token: "ct-…", expires_in: 1800, … }
    Backend-->>App: January's JSON, unchanged
    Note over App: The SDK keeps the token in memory<br/>and refreshes it before it expires

    loop Until the token expires
        App->>January: GET /v1.2/foods?query=…<br/>Authorization: Bearer ct-…
        January-->>App: Data for that user only
    end

    App->>January: A call after expiry
    January-->>App: 401 { code: "token_expired" }
    Note over App,Backend: Repeat steps 1 to 4, then retry the call once
```

1. **The app asks your backend for a token.** The request goes to an endpoint you add, authenticated like the rest of your API.
2. **Your backend mints the token** with its API key, for the user the session proves.
3. **January returns a `ct-…` token** bound to that user, with `expires_in` in seconds.
4. **Your backend returns January's JSON to the app unchanged.**
5. **The app calls January directly** with `Authorization: Bearer ct-…`. On `401 token_expired` it gets a new token and retries once. The SDKs do this for you.

Mobile apps can always call January directly. Browser apps can do so only from web origins January has enabled for your account; until then, call January from your backend. See [Web SDK runtime boundaries](/client-sdks/web-sdk/concepts/runtime-boundaries).

## Your token endpoint

The token endpoint is the only server code a client-token integration needs, and it is the same for every SDK.

1. **Authenticate the request** with your existing app session. Return `401` if there is none.
2. **Look up the end-user ID** for that session in your own records. Never take it from the request: not from the body, the query, or a `January-End-User-ID` header.
3. **Mint a token** with your API key:

   ```bash
   curl https://partners.january.ai/v1.2/auth/client-tokens \
     --header "Authorization: Bearer ${JANUARY_API_KEY}" \
     --header 'Content-Type: application/json' \
     --data '{"end_user_id":"acme-user-8271","scopes":["foods:read","food_logs:read","food_logs:write"],"ttl_seconds":1800}'
   ```

   `scopes` is required. The app's January client uses one token for every call, so list the scopes of every feature your app uses; see the [scope table](/rest-api/authentication#client-token-scopes). A call outside the token's scopes fails with `403 scope_insufficient`. `ttl_seconds` is optional: 300–7200, default 1800.
4. **Return January's `201` body unchanged**, with `Cache-Control: no-store`:

   ```json
   {
     "token": "ct-…",
     "expires_in": 1800,
     "expires_at": "2026-09-24T18:35:11.000Z",
     "end_user_id": "acme-user-8271",
     "scopes": ["foods:read", "food_logs:read", "food_logs:write"]
   }
   ```

   The iOS, Android, and Web SDKs read `token` and `expires_in` from this body, and also accept `expiresIn`. The React Native SDK reads `expiresIn`, so its token provider maps the field; see [React Native authentication](/client-sdks/react-native-sdk/getting-started/authentication).

{% hint style="warning" %}
Minting fails with `403 forbidden` until **Enable client tokens** is switched on in the [Developer Dashboard → Client tokens](https://dashboard.january.ai/dashboard/client-tokens).
{% endhint %}

Keep the endpoint safe:

* Mint a new token each time the app asks, and don't store tokens on your server. The SDKs cache the token in memory and ask again about 60 seconds before it expires.
* Never log API keys or client tokens, and keep token responses out of analytics and crash reports.
* Serve the endpoint over HTTPS and rate-limit it like the rest of your API.
* To cut a user off, for example after account deletion, revoke their tokens with [`POST /v1.2/auth/client-token-revocations`](/rest-api/authentication). Revocation takes effect within 60 seconds.

## Develop with the token relay

Until your endpoint exists, the [January Token Relay](https://github.com/January-ai/january-token-relay) can stand in for it on your computer. Clone it, run `./start.sh`, and paste your API key when asked. Then point your app's token provider at:

* `http://localhost:8787/api/january/client-token` from the iOS Simulator, a browser, or a desktop app.
* `http://10.0.2.2:8787/api/january/client-token` from the Android emulator.

Send a `POST`. A `GET` returns a status page, not a token.

|                           | Token relay (development)                                                   | Your endpoint (production)                                |
| ------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------- |
| Whose token it mints      | The user named in the `January-End-User-ID` header the app sends            | The user your session proves; request headers are ignored |
| How the app authenticates | Nothing on your own computer; a relay token when other devices can reach it | Your normal app session                                   |
| Scopes                    | All ten (set `TOKEN_SCOPES` to narrow them)                                 | Only the ones your app uses                               |
| Response                  | January's body, unchanged                                                   | January's body, unchanged                                 |

The token-provider samples in each SDK guide send both the app session and `January-End-User-ID`, so the same code works against the relay and against your endpoint. Switching over changes only the URL and the session credential. Your production endpoint must ignore the header.

See [Client tokens](/rest-api/authentication) for the scope each operation needs and the minting and revocation endpoints.


# MCP server

Use January from a coding agent. The hosted MCP server exposes the v1.2 REST API as tools that Claude Code, Codex, and other MCP clients can call while they build your integration.

Every tool call is an ordinary API request made with your API key, so [credits](/rest-api/credits-and-pricing), request limits, and the usage shown in the [January Developer Dashboard](https://dashboard.january.ai) apply exactly as they do to your own code. The server keeps no state and stores nothing.

```
https://mcp.january.ai/mcp
```

## Connect

Create an `sk-…` API key in the Developer Dashboard, then register the server with your client. The key is sent as the bearer credential on every request. The server accepts only API keys; client tokens are rejected.

{% tabs %}
{% tab title="Claude Code" %}
`--scope user` makes the server available in every project on this machine. The last command asks a question without opening the agent; `--allowedTools mcp__january` lets that run call January's tools without a permission prompt.

```bash
claude mcp add --scope user --transport http january https://mcp.january.ai/mcp \
  --header 'Authorization: Bearer sk-your-key'
claude -p "Search the January food database for greek yogurt and list the top three matches." --allowedTools mcp__january
```

{% endtab %}

{% tab title="Codex" %}
Codex reads the bearer token from an environment variable each time it starts, so keep the export in your shell profile. The last command asks a question without opening the agent; `--skip-git-repo-check` lets it run outside a Git repository.

```bash
codex mcp add january --url https://mcp.january.ai/mcp --bearer-token-env-var JANUARY_API_KEY
export JANUARY_API_KEY="sk-your-key"
codex exec --skip-git-repo-check "Search the January food database for greek yogurt and list the top three matches."
```

Codex stops waiting for a tool after 60 seconds by default, and an image analysis can take slightly longer. Raise the limit in the `[mcp_servers.january]` section that the command wrote to `~/.codex/config.toml`:

```toml
[mcp_servers.january]
tool_timeout_sec = 90
```

{% endtab %}

{% tab title="VS Code" %}
VS Code lists servers under `servers` in `.vscode/mcp.json` and needs the transport spelled out.

```json
{
  "servers": {
    "january": {
      "type": "http",
      "url": "https://mcp.january.ai/mcp",
      "headers": { "Authorization": "Bearer sk-your-key" }
    }
  }
}
```

{% endtab %}

{% tab title="Other clients" %}
Cursor and most other clients accept this configuration block. Windsurf takes the same block with `serverUrl` in place of `url`.

```json
{
  "mcpServers": {
    "january": {
      "url": "https://mcp.january.ai/mcp",
      "headers": { "Authorization": "Bearer sk-your-key" }
    }
  }
}
```

{% endtab %}
{% endtabs %}

A successful answer lists three foods from the catalog. Each time you create a key, the dashboard shows the Claude Code, Codex, and JSON setups with the new key already filled in.

{% hint style="warning" %}
The client stores the key in its own configuration on that machine. Use a key created for this purpose and delete it from the dashboard when you no longer need it.
{% endhint %}

## Tools

Tool names follow the REST resources. IDs are strings, amounts are `{ value, unit }`, and errors carry the API's `code` values plus one of the server's own, described under [Errors and cost](#errors-and-cost). Water and weight logs aren't available as MCP tools yet.

| Group     | Tool                                | What it does                                                                                 |
| --------- | ----------------------------------- | -------------------------------------------------------------------------------------------- |
| Look up   | `january_search_foods`              | Find generic, branded, and recipe foods by name.                                             |
|           | `january_get_food`                  | One food's complete record and serving list, by ID or by barcode (US barcodes only).         |
|           | `january_suggest_food_alternatives` | Healthier alternatives that honor allergens to avoid and dietary patterns to match.          |
| Interpret | `january_analyze_food`              | Detect foods and nutrition in a photo (URL or data URI) or a plain-English meal description. |
|           | `january_correct_food_analysis`     | Revise an analysis conversationally and recalculate its totals.                              |
| Record    | `january_list_food_logs`            | One end user's diary over a range of local calendar days.                                    |
|           | `january_create_food_log`           | Record a meal from food and serving selections.                                              |
|           | `january_update_food_log`           | Change a saved log's foods, time, or name.                                                   |
|           | `january_delete_food_log`           | Delete one saved log.                                                                        |
| Predict   | `january_predict_glucose`           | Predict the glucose curve a meal produces for a described person.                            |
| Nearby    | `january_search_restaurants`        | Restaurants near a coordinate.                                                               |
|           | `january_search_menu_items`         | Dishes with nutrition near a coordinate.                                                     |
|           | `january_get_restaurant_menu`       | One restaurant's menu, paged, optionally filtered by dish name.                              |
| Account   | `january_get_credits`               | Your credit allowance and usage for the current billing period. Free.                        |

Two resources are published alongside the tools: `january://openapi.json`, the live OpenAPI document, and `january://error-codes`, every error code with its retry rule and what to do next.

## Before an agent writes data

The food-log tools act on a real end user's diary under your account. Give the agent a designated test `end_user_id`: the server's instructions tell it never to invent or reuse one and to state which end user each write went to.

Updating or deleting a log requires the `etag` returned by a listing of that log within the last fifteen minutes. An agent working from a stale plan cannot delete what it has not just read, and there is no bulk delete.

Food analysis never writes a log. Glucose predictions are estimates from a model, not measurements or medical advice.

## Errors and cost

Every tool error carries the API's stable `code` together with `retryable`, `retry_after_seconds`, `next_step`, and `request_id`, so an agent can decide whether to change the request, wait, or stop. One code comes from the MCP server itself: `precondition_failed`, for a missing or stale `etag`.

`january_get_credits` costs nothing and answers even when the balance is spent. Every other tool is priced like the endpoint behind it; see [Credits and pricing](/rest-api/credits-and-pricing). When the credit allowance for the billing period is exhausted, the error includes the current balance and the reset date, and the agent is told not to retry.

## Next steps

* [Quickstart](/docs/quickstart) for the same first call with `curl`.
* [REST API reference](/rest-api/api-overview) for every endpoint the tools call.


# iOS SDK

Explore the January iOS SDK documentation for installation, authentication, food discovery, food analysis, voice capture, logs for food, water, and weight, restaurants, and glucose prediction.

Start with the [SDK overview](/client-sdks/ios-sdk/overview).


# Overview

### <mark style="background-color:orange;">**Beta**</mark>

Add food search, food analysis, food, water, and weight logs, restaurant search, and glucose prediction to an iOS app, with native Swift models and `async`/`await` APIs.

## What you can build

* Food autocomplete, search, barcode lookup, and full food details with every serving
* Restaurant and menu-item search
* Food analysis of meal photos and descriptions, with corrections, plus a ready-made camera food scanner
* Food logs with daily or weekly summaries
* Water and weight logs with daily values
* Personalized glucose predictions

## Requirements

| Component            | Requirement                                                         |
| -------------------- | ------------------------------------------------------------------- |
| Platform             | iOS 15 or later                                                     |
| Build tools          | Xcode 15 or later and Swift 5.9 or later                            |
| Runtime dependencies | None                                                                |
| Distribution         | Swift Package Manager or CocoaPods                                  |
| Production           | A token endpoint on your backend that returns January client tokens |

## Start here

1. Create an API key and switch on **Enable client tokens** in the [January Developer Dashboard](https://dashboard.january.ai) ([API keys](/docs/authentication#api-keys)).
2. [Install the SDK](/client-sdks/ios-sdk/getting-started/installation).
3. [Add a token endpoint to your backend](/client-sdks/ios-sdk/getting-started/backend-token-endpoint), or run the [token relay](/docs/authentication#develop-with-the-token-relay) while you develop.
4. [Write a token provider and create the client](/client-sdks/ios-sdk/getting-started/authentication).
5. [Make your first request](/client-sdks/ios-sdk/getting-started/quick-start).
6. Learn how [search, full food details, and portions](/client-sdks/ios-sdk/concepts/food-hydration-and-portions) fit together.

```swift
import Foundation
import January

let january = try JanuaryClient(
    endUserID: endUserID,
    timezone: TimeZone.current,
    clientTokenProvider: tokenProvider
)
let results = try await january.foods.search(.init(query: "greek yogurt"))
```

## SDK entry points

| API                      | Purpose                                                                             |
| ------------------------ | ----------------------------------------------------------------------------------- |
| `JanuaryClient`          | Holds authentication, the end-user ID, and the timezone, and exposes every resource |
| `foods`                  | Autocomplete, search, barcode lookup, full food details, and alternatives           |
| `restaurants`            | Nearby restaurants, menu-item search, and a restaurant's menu by ID                 |
| `foodAnalysis`           | Food analysis of a photo or description, and corrections                            |
| `foodLogs`               | Create, list, summarize, get, update, and delete food logs                          |
| `waterLogs`              | Log water and list daily totals                                                     |
| `weightLogs`             | Log weight and list each day's latest weight                                        |
| `glucose`                | Personalized glucose prediction                                                     |
| `JanuaryFoodScannerView` | Ready-made camera food scanner for meal photos and barcodes                         |
| `VoiceCaptureSession`    | Records speech and transcribes it on the device; makes no January call              |
| `JanuaryError`           | Error category, code, message, and HTTP status                                      |

## Security boundary

The app never holds your API key. It gets short-lived client tokens from your backend, and each token acts for one end user. See [Authentication](/client-sdks/ios-sdk/getting-started/authentication).


# Getting started

Install the SDK, connect it to your backend, and make your first request.

* [Installation](/client-sdks/ios-sdk/getting-started/installation): add the package with Swift Package Manager or CocoaPods.
* [Backend token endpoint](/client-sdks/ios-sdk/getting-started/backend-token-endpoint): what your backend returns to the SDK.
* [Authentication](/client-sdks/ios-sdk/getting-started/authentication): write the token provider and create the client.
* [First request](/client-sdks/ios-sdk/getting-started/quick-start): a complete SwiftUI app that searches for a food.
* [Example app](/client-sdks/ios-sdk/getting-started/example-app): run the demo app from the SDK repository.


# Installation

Check the [requirements](/client-sdks/ios-sdk/overview#requirements) first. Releases before 1.0 can break the API in a minor version, so allow patch updates only.

## Swift Package Manager

1. In Xcode, choose **File → Add Package Dependencies**.
2. Enter `https://github.com/January-ai/january-sdk-ios.git`.
3. Set **Dependency Rule** to **Up to Next Minor Version** from `0.3.2`.
4. Add the `January` product to your app target.

In a `Package.swift` manifest:

```swift
dependencies: [
    .package(url: "https://github.com/January-ai/january-sdk-ios.git", .upToNextMinor(from: "0.3.2")),
],
targets: [
    .target(name: "YourApp", dependencies: [
        .product(name: "January", package: "january-sdk-ios"),
    ]),
]
```

## CocoaPods

Add the `January` pod to your `Podfile`:

```ruby
platform :ios, "15.0"

target "YourApp" do
  pod "January", "~> 0.3.2"
end
```

Then run `pod install --repo-update` and open the generated `.xcworkspace`.

## Import the SDK

```swift
import January
```

Next: [Backend token endpoint](/client-sdks/ios-sdk/getting-started/backend-token-endpoint).


# Backend token endpoint

The app gets client tokens from an endpoint on your backend. The endpoint is the same for every January SDK: build it from [Your token endpoint](/docs/authentication#your-token-endpoint).

{% hint style="info" %}
No backend yet? Run the [token relay](/docs/authentication#develop-with-the-token-relay) and continue to [Authentication](/client-sdks/ios-sdk/getting-started/authentication). Come back before launch.
{% endhint %}

## What the iOS SDK reads

Return January's `201` body unchanged. The SDK reads `token` and `expires_in` (it also accepts `expiresIn`) and ignores the other fields.

## Scopes

A `JanuaryClient` uses one token for every call it makes, so mint the token with the scopes of every feature your app uses ([scope table](/rest-api/authentication#client-token-scopes)). A call outside them fails with `403 scope_insufficient`.

## Enable client tokens

Minting fails with `403 forbidden` until **Enable client tokens** is on in the [Developer Dashboard → Client tokens](https://dashboard.january.ai/dashboard/client-tokens).

Next: [Authentication](/client-sdks/ios-sdk/getting-started/authentication).


# Authentication

A `JanuaryClient` gets client tokens from a token provider, which calls your [backend token endpoint](/client-sdks/ios-sdk/getting-started/backend-token-endpoint). The SDK keeps each token in memory and asks the provider for a new one shortly before it expires ([token lifecycle](/client-sdks/ios-sdk/reference/retries-and-concurrency)).

## Write the token provider

```swift
import Foundation
import January

struct BackendTokenProvider: JanuaryTokenProvider {
    let tokenEndpoint: URL
    /// Returns the current app session token. The SDK calls the provider for
    /// the life of the client, so read the session fresh on every call.
    let appSessionToken: @Sendable () async throws -> String

    func fetchClientToken(for endUserID: String) async throws -> JanuaryClientToken {
        var request = URLRequest(url: tokenEndpoint)
        request.httpMethod = "POST"
        do {
            request.setValue("Bearer \(try await appSessionToken())", forHTTPHeaderField: "Authorization")
        } catch {
            throw JanuaryTokenProviderError("The app session is unavailable.")
        }
        // Only the token relay reads this header. Your production endpoint
        // takes the user from the session and ignores it.
        request.setValue(endUserID, forHTTPHeaderField: "January-End-User-ID")

        let data: Data
        let response: URLResponse
        do {
            (data, response) = try await URLSession.shared.data(for: request)
        } catch {
            // Network errors and timeouts are worth retrying.
            throw JanuaryTokenProviderError("Your token endpoint is unreachable.", retryable: true)
        }

        let status = (response as? HTTPURLResponse)?.statusCode ?? 0
        guard (200..<300).contains(status) else {
            throw JanuaryTokenProviderError(
                "Your token endpoint returned HTTP \(status).",
                retryable: status == 408 || status == 429 || status >= 500
            )
        }

        do {
            return try JSONDecoder().decode(JanuaryClientToken.self, from: data)
        } catch {
            throw JanuaryTokenProviderError("Your token endpoint returned an unreadable token response.")
        }
    }
}
```

Throw `JanuaryTokenProviderError` for every failure, as this provider does. The SDK retries the ones marked `retryable: true` with backoff. When the provider fails for good, the call throws an `.authentication` error with the code `client_token_provider_failed`. Any other error type reaches your code as a generic `.transport` or `.decoding` error without your message ([Errors](/client-sdks/ios-sdk/reference/error-handling#token-provider-failures)).

## Create the client

```swift
func makeJanuaryClient(
    tokenEndpoint: URL,
    endUserID: String,
    appSessionToken: @escaping @Sendable () async throws -> String
) throws -> JanuaryClient {
    try JanuaryClient(
        endUserID: endUserID,
        timezone: TimeZone.current,
        clientTokenProvider: BackendTokenProvider(
            tokenEndpoint: tokenEndpoint,
            appSessionToken: appSessionToken
        )
    )
}
```

Read the token endpoint URL from your app's configuration, and fail at startup when it's missing rather than falling back to a default. Create one client per signed-in account and reuse it ([Client lifecycle](/client-sdks/ios-sdk/concepts/client-lifecycle)). The [end-user ID and timezone](/client-sdks/ios-sdk/concepts/user-identity-and-timezone) are fixed for the client's life.

To manage tokens yourself instead, create the client from one token with `JanuaryClient(clientToken:endUserID:timezone:)`. The SDK can't refresh that token, so create a new client before it expires.

## Local development

Three options let you run the app before your token endpoint exists:

| Option                                                           | What changes in the app                                                                      | Use it to                                                    |
| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| [Token relay](/docs/authentication#develop-with-the-token-relay) | Only the provider's URL: `http://localhost:8787/api/january/client-token` from the Simulator | Run the production token flow. Prefer this option.           |
| `JanuaryDevelopmentTokenProvider`                                | The provider, which mints tokens with your API key                                           | Exercise token minting and refresh without running the relay |
| `developmentAPIKey`                                              | The initializer, which sends your API key on every call                                      | Make calls with no client tokens at all                      |

{% hint style="danger" %}
`JanuaryDevelopmentTokenProvider` and `developmentAPIKey` put your `sk-…` API key in the app process. Do not use it in production: keep both to a local Debug build, load the key from the Xcode scheme, never commit it, and never ship a build that contains it. Release builds don't compile either one, and the SDK logs a warning to the Xcode console (without the key) whenever you use one.
{% endhint %}

Set `JANUARY_API_KEY` under **Product → Scheme → Edit Scheme → Run → Arguments → Environment Variables**. An empty key fails with an `.authentication` error.

### Development token provider

`JanuaryDevelopmentTokenProvider` mints 5-minute tokens with your API key, so the SDK refreshes them about every 4 minutes. Keep the production provider in the `#else` branch:

```swift
#if DEBUG
let client = try JanuaryClient(
    endUserID: endUserID,
    timezone: TimeZone.current,
    clientTokenProvider: JanuaryDevelopmentTokenProvider(
        apiKey: ProcessInfo.processInfo.environment["JANUARY_API_KEY"] ?? ""
    )
)
#else
let client = try makeJanuaryClient(
    tokenEndpoint: tokenEndpoint,
    endUserID: endUserID,
    appSessionToken: { try await session.accessToken() }
)
#endif
```

{% hint style="warning" %}
In 0.3.2 this provider's tokens don't include the `water_logs:*` or `weight_logs:*` scopes, so water and weight calls fail with `403 scope_insufficient`. Use the token relay to test them.
{% endhint %}

### Development API key

`developmentAPIKey` skips client tokens and sends your API key on every call:

```swift
#if DEBUG
let client = try JanuaryClient(
    developmentAPIKey: ProcessInfo.processInfo.environment["JANUARY_API_KEY"] ?? "",
    endUserID: endUserID,
    timezone: TimeZone.current
)
#else
let client = try makeJanuaryClient(
    tokenEndpoint: tokenEndpoint,
    endUserID: endUserID,
    appSessionToken: { try await session.accessToken() }
)
#endif
```

Next: [First request](/client-sdks/ios-sdk/getting-started/quick-start).


# First request

Build a small SwiftUI app that gets a client token from your token endpoint, searches for a food, fetches its full details, computes a portion, and shows the result.

## 1. Create the Xcode project

1. In Xcode, choose **File → New → Project → iOS → App**.
2. Name it `JanuaryQuickstart`, with **SwiftUI** for Interface, **Swift** for Language, and iOS 15 or later as the deployment target.
3. [Add the SDK](/client-sdks/ios-sdk/getting-started/installation) to the `JanuaryQuickstart` target.

## 2. Set the scheme's environment variables

Choose **Product → Scheme → Edit Scheme → Run → Arguments** and add these environment variables:

```
JANUARY_PARTNER_TOKEN_URL=https://your-backend.example.com/january/client-token
JANUARY_PARTNER_SESSION_TOKEN=your-app-session-token
JANUARY_END_USER_ID=acme-user-8271
```

Use your token endpoint's URL, a valid session token for it, and the end-user ID your backend mints tokens for.

{% hint style="info" %}
No backend yet? Start the [token relay](/docs/authentication#develop-with-the-token-relay), set `JANUARY_PARTNER_TOKEN_URL=http://localhost:8787/api/january/client-token`, and leave out `JANUARY_PARTNER_SESSION_TOKEN`.
{% endhint %}

## 3. Add the source

Add a file named `BackendTokenProvider.swift` containing the `BackendTokenProvider` from [Authentication](/client-sdks/ios-sdk/getting-started/authentication#write-the-token-provider). Leave `JanuaryQuickstartApp.swift` as Xcode created it, and replace `ContentView.swift` with:

```swift
import Foundation
import January
import SwiftUI

struct QuickstartError: LocalizedError {
    let errorDescription: String?
    init(_ message: String) { errorDescription = message }
}

@MainActor
final class QuickstartModel: ObservableObject {
    enum State {
        case loading
        case loaded(name: String, servings: Int, calories: Double)
        case failed(String)
    }

    @Published private(set) var state: State = .loading
    private var client: JanuaryClient?

    func load() async {
        state = .loading
        do {
            let client = try self.client ?? makeClient()
            self.client = client

            let results = try await client.foods.search(
                .init(query: "greek yogurt", category: .branded, limit: 10)
            )
            guard let match = results.items.first else {
                state = .failed("January returned no matching foods.")
                return
            }

            let food = try await client.foods.get(id: match.id)
            let portion = try food.portion() // One primary serving
            state = .loaded(
                name: food.name ?? "Unnamed food",
                servings: food.servings.count,
                calories: portion.nutrition.calories?.value ?? 0
            )
        } catch is CancellationError {
            return
        } catch {
            state = .failed(error.localizedDescription)
        }
    }

    private func makeClient() throws -> JanuaryClient {
        let environment = ProcessInfo.processInfo.environment
        guard let rawURL = environment["JANUARY_PARTNER_TOKEN_URL"],
              let tokenEndpoint = URL(string: rawURL) else {
            throw QuickstartError("Set JANUARY_PARTNER_TOKEN_URL in the Xcode scheme.")
        }
        guard let endUserID = environment["JANUARY_END_USER_ID"] else {
            throw QuickstartError("Set JANUARY_END_USER_ID in the Xcode scheme.")
        }
        let sessionToken = environment["JANUARY_PARTNER_SESSION_TOKEN"] ?? ""

        return try JanuaryClient(
            endUserID: endUserID,
            timezone: TimeZone.current,
            clientTokenProvider: BackendTokenProvider(
                tokenEndpoint: tokenEndpoint,
                appSessionToken: { sessionToken }
            )
        )
    }
}

struct ContentView: View {
    @StateObject private var model = QuickstartModel()

    var body: some View {
        VStack(spacing: 16) {
            Text("January SDK")
                .font(.title.bold())

            switch model.state {
            case .loading:
                ProgressView("Connecting…")
            case .loaded(let name, let servings, let calories):
                Text(name).font(.headline)
                Text("\(servings) serving options")
                Text("\(calories, specifier: "%.1f") calories")
            case .failed(let message):
                Text(message).foregroundStyle(.red)
                Button("Try Again") {
                    Task { await model.load() }
                }
            }
        }
        .padding()
        .task {
            await model.load()
        }
    }
}
```

The model creates the client once and reuses it on **Try Again**, as a real app should ([Client lifecycle](/client-sdks/ios-sdk/concepts/client-lifecycle)).

## 4. Run it

Choose an iOS Simulator and press **Run**. The screen shows **Connecting…**, then:

```
January SDK
<food name>
<number> serving options
<number> calories
```

On failure, it shows the error message and a **Try Again** button. An error from your token endpoint shows the provider's message, such as "Your token endpoint returned HTTP 401." If the endpoint can't be reached, the SDK retries for about 47 seconds before showing the error. See [Errors](/client-sdks/ios-sdk/reference/error-handling) and [Troubleshooting](/client-sdks/ios-sdk/reference/troubleshooting).

Next: [Example app](/client-sdks/ios-sdk/getting-started/example-app), or skip to [Core concepts](/client-sdks/ios-sdk/concepts).


# Example app

The [SDK repository](https://github.com/January-ai/january-sdk-ios) includes a SwiftUI demo app, `Examples/JanuaryPartnerDemo/JanuaryPartnerDemo.xcodeproj`, that exercises the SDK's features. The demo needs Xcode 26 and an iOS 26 simulator or device; your own app needs only the SDK's [requirements](/client-sdks/ios-sdk/overview#requirements).

## Run it with the token relay

1. Start the [token relay](/docs/authentication#develop-with-the-token-relay).
2. Clone `https://github.com/January-ai/january-sdk-ios.git` and open the demo project in Xcode.
3. Choose **Product → Scheme → Edit Scheme → Run → Arguments** and add these environment variables:

   ```
   JANUARY_PARTNER_TOKEN_URL=http://localhost:8787/api/january/client-token
   JANUARY_END_USER_ID=january-sdk-demo-user
   ```
4. Choose an iOS Simulator and press **Run**.

If the relay is reachable from other devices, also set `JANUARY_PARTNER_SESSION_TOKEN` to its relay token. Never commit either value.

To run the demo against your own token endpoint, set `JANUARY_PARTNER_TOKEN_URL` to it and `JANUARY_PARTNER_SESSION_TOKEN` to a valid app session token.

## Run it with an API key

In a Debug build, you can instead leave out the token variables and set `JANUARY_API_KEY` to your API key. The demo then uses `developmentAPIKey` ([local development](/client-sdks/ios-sdk/getting-started/authentication#local-development)).

On the Simulator, the Scan tab can analyze a bundled sample meal; camera capture needs a physical device.

Next: [Core concepts](/client-sdks/ios-sdk/concepts).


# Core concepts

How the SDK models clients, end users, and food data.

* [Client lifecycle](/client-sdks/ios-sdk/concepts/client-lifecycle): when to create, keep, and replace a `JanuaryClient`.
* [User identity and timezone](/client-sdks/ios-sdk/concepts/user-identity-and-timezone): what the end-user ID and timezone control, and how days are counted.
* [Food details and portions](/client-sdks/ios-sdk/concepts/food-hydration-and-portions): from a search result to a portion you can log.


# Client lifecycle

Create one `JanuaryClient` per signed-in account and reuse it for every call. Its resources (`foods`, `restaurants`, `foodAnalysis`, `foodLogs`, `waterLogs`, `weightLogs`, and `glucose`) share one token cache, and calls can run concurrently:

```swift
async let foods = client.foods.search(.init(query: "oatmeal"))
async let restaurants = client.restaurants.search(
    .init(query: "cafe", latitude: 40.7128, longitude: -74.0060)
)

let (foodResults, restaurantResults) = try await (foods, restaurants)
```

## When to replace it

A client's end-user ID, timezone, and token cache are fixed when you create it. Create a new client when:

* the user signs out (discard the client) or a different account signs in;
* the device timezone changes (`NSSystemTimeZoneDidChange`); or
* you created it with a fixed `clientToken` and that token is about to expire.

Don't create a new client to refresh a token. The SDK refreshes provider tokens itself.

## Keep it in your app

In a SwiftUI app, a main-actor model can own the client and follow the session:

```swift
import Foundation
import January

@MainActor
final class JanuaryConnection: ObservableObject {
    @Published private(set) var client: JanuaryClient?
    private let tokenProvider: BackendTokenProvider
    private var endUserID: String?

    init(tokenProvider: BackendTokenProvider) {
        self.tokenProvider = tokenProvider
    }

    /// Call after sign-in.
    func connect(endUserID: String) throws {
        self.endUserID = endUserID
        client = try JanuaryClient(
            endUserID: endUserID,
            timezone: TimeZone.current,
            clientTokenProvider: tokenProvider
        )
    }

    /// Call on sign-out.
    func disconnect() {
        endUserID = nil
        client = nil
    }

    /// Call when the device timezone changes.
    func reconnect() throws {
        guard let endUserID else { return }
        try connect(endUserID: endUserID)
    }
}
```

`BackendTokenProvider` is the provider from [Authentication](/client-sdks/ios-sdk/getting-started/authentication#write-the-token-provider). Call `reconnect()` from your root view:

```swift
.onReceive(NotificationCenter.default.publisher(for: .NSSystemTimeZoneDidChange)) { _ in
    try? connection.reconnect()
}
```

## Cancellation

SDK calls support Swift task cancellation. Cancel the task and the call throws `CancellationError`, not a `JanuaryError`. Don't retry or display it.

Next: [User identity and timezone](/client-sdks/ios-sdk/concepts/user-identity-and-timezone).


# User identity and timezone

Every `JanuaryClient` has an end-user ID and a timezone, set when you create it.

## End-user ID

The end-user ID (`endUserID`) is your own stable, opaque ID for the signed-in user, such as `acme-user-8271`. Never use an email address, a name, or other personal data ([terms](/docs/authentication)).

The SDK passes it to your token provider's `fetchClientToken(for:)`, and your backend mints the token for the user its session proves. With a client token, the token decides which user every call acts for: the SDK removes the `January-End-User-ID` header from its calls to January, so a client can't act for anyone but its token's user. With `developmentAPIKey`, the SDK sends the end-user ID in that header on food, water, and weight log calls instead.

Request types such as `SearchFoodsRequest` still take an optional `endUserID`; leave it out. A `JanuaryClient` always uses its own end-user ID and timezone. To act for another user, create another client.

## Timezone

The timezone sets the calendar days for food-log lists and summaries and for water and weight lists. Glucose predictions are sent with it too. Pass the device timezone:

```swift
let client = try JanuaryClient(
    endUserID: endUserID,
    timezone: TimeZone.current,
    clientTokenProvider: tokenProvider
)
```

If you leave `timezone` out, the SDK uses the device timezone at the moment you create the client. Either way, the timezone doesn't follow later changes, so create a new client when the device timezone changes ([Client lifecycle](/client-sdks/ios-sdk/concepts/client-lifecycle#when-to-replace-it)).

## Days and dates

Creates take the time an entry happened: `timestampUTC`, `consumedAtUTC`, or `measuredAtUTC`, as an ISO 8601 date-time with any offset. Responses return it in UTC. Lists and summaries take inclusive `yyyy-MM-dd` dates and group entries into calendar days in the client's timezone ([days and timezones](/rest-api/api-overview#days-and-timezones)).

Build those dates in the same timezone:

```swift
let dayFormatter = DateFormatter()
dayFormatter.locale = Locale(identifier: "en_US_POSIX")
dayFormatter.calendar = Calendar(identifier: .gregorian)
dayFormatter.timeZone = TimeZone.current
dayFormatter.dateFormat = "yyyy-MM-dd"

let today = dayFormatter.string(from: Date())
let todaysLogs = try await client.foodLogs.list(start: today, end: today)
```

The 24 L daily water cap is the exception: it counts UTC days ([Log water](/client-sdks/ios-sdk/guides/water-and-weight-logs#log-water)).

Next: [Food details and portions](/client-sdks/ios-sdk/concepts/food-hydration-and-portions).


# Food details and portions

Finding a food and choosing how much was eaten are separate steps. These examples use the `client` from [Authentication](/client-sdks/ios-sdk/getting-started/authentication).

```
Autocomplete ─▶ Search ─▶ foods.get(id:) ─▶ Portion
search text     results    every serving    computed locally
```

## 1. Autocomplete suggests search text

Call autocomplete while the user types. A suggestion is text, not a food with servings: when the user picks one, search for its name.

```swift
let suggestions = try await client.foods.autocomplete(.init(query: "greek yog", limit: 8))
guard let name = suggestions.items.first?.name else { return }

let results = try await client.foods.search(.init(query: name, category: .branded))
```

## 2. Fetch the full food before showing servings

Search results are for choosing a food. When the user picks one, fetch the full food with `foods.get(id:)`; it includes every `ServingOption`:

```swift
guard let selected = results.items.first else { return }
let food = try await client.foods.get(id: selected.id)
```

## 3. Compute the portion locally

`portion(servingID:quantity:)` checks the serving and quantity, scales nutrition, weight, and glycemic load, and returns the `FoodSelection` that food logs and glucose predictions take:

```swift
// One primary serving.
let portion = try food.portion()
print(portion.serving.unit ?? "", portion.nutrition.calories?.value ?? 0)

let selection: FoodSelection = portion.selection

// With a "6 oz" primary serving: 9 oz, one and a half servings.
let larger = try food.portion(quantity: 9)
```

Without a `servingID`, it uses the primary serving (or the first one). Changing the serving or quantity needs no network call. `portion` throws `FoodPortionError` when the food has no servings, the serving isn't found, or the quantity is invalid.

### Quantity and servings

`quantity` is an amount in the serving's unit, not a number of servings. For a "6 oz" serving (`quantity` 6, `unit` "oz"), `quantity: 9` means 9 oz, and leaving `quantity` out means one serving (6 oz). `portion.selection` sends the number of servings, `quantity` divided by the serving's own quantity, so 9 oz of a "6 oz" serving is logged as 1.5 servings. Versions before 0.3.2 sent `quantity` itself as the number of servings, so food logs and glucose predictions got the wrong amount (a default "6 oz" portion was logged as 6 servings); upgrade to 0.3.2.

Next: [Foods](/client-sdks/ios-sdk/guides/foods).


# Guides

Task-by-task examples for each feature. They use the `client` from [Authentication](/client-sdks/ios-sdk/getting-started/authentication).

* [Foods](/client-sdks/ios-sdk/guides/foods): autocomplete, search, barcode lookup, full food details, and alternatives.
* [Restaurants](/client-sdks/ios-sdk/guides/restaurants): nearby restaurants, menu-item search, and a restaurant's menu.
* [Food analysis](/client-sdks/ios-sdk/guides/photo-scanning): analyze a meal photo or description and correct the result.
* [Native food scanner](/client-sdks/ios-sdk/guides/native-meal-scanner): a ready-made camera screen for meal photos and barcodes.
* [Food logs](/client-sdks/ios-sdk/guides/food-logs): create, list, summarize, update, and delete meals.
* [Water and weight logs](/client-sdks/ios-sdk/guides/water-and-weight-logs): log water and weight and read daily values.
* [Glucose prediction](/client-sdks/ios-sdk/guides/glucose-prediction): predict the glucose curve for a meal.
* [Voice capture](/client-sdks/ios-sdk/guides/voice-capture): record speech and turn it into search text on the device.


# Foods

Use `client.foods` to autocomplete and search the food database, look up a barcode, fetch a full food, and suggest alternatives. These examples use the `client` from [Authentication](/client-sdks/ios-sdk/getting-started/authentication). [Food details and portions](/client-sdks/ios-sdk/concepts/food-hydration-and-portions) explains how the steps fit together.

## Autocomplete

```swift
let suggestions = try await client.foods.autocomplete(.init(query: "ban", limit: 8))
```

A suggestion is search text, not a food with servings. When the user picks one, search for its name:

```swift
guard let name = suggestions.items.first?.name else { return }
let results = try await client.foods.search(.init(query: name, limit: 10))
```

Autocomplete's `category` filter takes `.generic` or `.branded`.

## Search by name

```swift
let results = try await client.foods.search(.init(query: "banana", category: .generic, limit: 10))
```

`category` is `.generic`, `.branded`, or `.recipe`; leave it out to search every category. Page with `offset`.

## Fetch the full food and compute a portion

```swift
guard let match = results.items.first else { return }

let food = try await client.foods.get(id: match.id)
let portion = try food.portion() // One primary serving
print(portion.nutrition.calories?.value ?? 0)
```

To use another amount, pass `quantity` in the serving's unit, not a number of servings ([Quantity and servings](/client-sdks/ios-sdk/concepts/food-hydration-and-portions#quantity-and-servings)).

## Look up a barcode

```swift
do {
    let results = try await client.foods.lookupBarcode(.init(upc: "049000006346"))
    showFoods(results.items)
} catch let error as JanuaryError where error.category == .notFound {
    offerTextSearch() // Not in the database.
}
```

A barcode is 6–14 digits. Coverage is US-only: a code issued elsewhere (for example GS1 prefixes 73, 64, 54, or 93) comes back as `.notFound`, so fall back to text search.

## Suggest alternatives

```swift
let response = try await client.foods.suggestAlternatives(
    .init(
        foodID: food.id,
        dietRestrictions: [.gluten],
        dietPreferences: [.highProtein]
    )
)
```

Leave out `dietRestrictions` and `dietPreferences`, or pass empty arrays, when none apply.

To analyze a meal description instead, see [Food analysis](/client-sdks/ios-sdk/guides/photo-scanning#analyze-a-description). Input limits are in [Validation limits](/client-sdks/ios-sdk/reference/validation).

Next: [Restaurants](/client-sdks/ios-sdk/guides/restaurants).


# Restaurants

Use `client.restaurants` to find nearby restaurants, search their menu items, and load one restaurant's menu. These examples use the `client` from [Authentication](/client-sdks/ios-sdk/getting-started/authentication).

The SDK doesn't read the device location. Get coordinates from Core Location, which needs `NSLocationWhenInUseUsageDescription` in your `Info.plist`.

## Search restaurants

```swift
let restaurants = try await client.restaurants.search(
    .init(
        query: "mediterranean",
        latitude: 37.7749,
        longitude: -122.4194,
        radius: 8_000,
        limit: 10
    )
)

for restaurant in restaurants.items {
    print(restaurant.name ?? "", restaurant.distance as Any)
}
```

## Search menu items

```swift
let menuItems = try await client.restaurants.searchMenuItems(
    .init(
        query: "grilled chicken",
        latitude: 37.7749,
        longitude: -122.4194
    )
)
```

Menu-item results can include nutrition, serving choices, photos, restaurant names, and distance.

## Load a restaurant menu

Pass the ID from a restaurant search to `getMenuItems` to load that restaurant's menu without repeating the search text or location:

```swift
var offset = 0
let limit = 100

repeat {
    let page = try await client.restaurants.getMenuItems(
        .init(
            restaurantID: restaurant.id,
            limit: limit,
            offset: offset
        )
    )

    consume(page.items)
    offset += page.items.count

    if page.items.count < limit {
        break
    }
} while true
```

The response has only `items`, with no `totalCount`, so keep paging while full pages come back. An empty page ends the menu, including for a restaurant with no menu on record. An unknown restaurant fails with `.notFound`; in a discovery UI, fall back to `searchMenuItems` with the user's original query and location when the first page is empty or the restaurant is not found.

## Input limits

* Restaurant ID: not blank
* Query: 1–256 characters
* Latitude: −90 through 90; longitude: −180 through 180, in decimal degrees
* Radius: 1–50,000 meters; default 8,000 (about 5 miles)
* Limit: 1–100
* Menu offset: 0 or more

Next: [Food analysis](/client-sdks/ios-sdk/guides/photo-scanning).


# Food analysis

Use `client.foodAnalysis` to analyze a meal photo or a description, and to correct a result. These examples use the `client` from [Authentication](/client-sdks/ios-sdk/getting-started/authentication).

{% hint style="warning" %}
Meal photos and their nutrition can be sensitive. Get the user's consent, keep as little as you can, and never write image contents or results to logs.
{% endhint %}

An analysis can take tens of seconds. Show progress, and don't retry a `.timeout` automatically.

## Analyze a photo

Pass the image bytes. The request orients the image, scales its longest edge down to 1,000 pixels, and compresses it to JPEG:

```swift
let request = try ScanFoodPhotoRequest(imageData: imageData)
let scan = try await client.foodAnalysis.analyzePhoto(request)
```

To get `imageData` from the photo library, call `loadTransferable(type: Data.self)` on a `PhotosPicker` item (iOS 16 and later), or `loadDataRepresentation(forTypeIdentifier: UTType.image.identifier)` on a `PHPickerViewController` result's item provider (iOS 15).

For an image at a public URL, pass the URL string instead:

```swift
let scan = try await client.foodAnalysis.analyzePhoto(.init(image: imageURL.absoluteString))
```

For a ready-made camera screen, use the [native food scanner](/client-sdks/ios-sdk/guides/native-meal-scanner).

## Analyze a description

```swift
let meal = try await client.foodAnalysis.analyzeDescription(
    .init(query: "one banana and a bowl of oatmeal")
)
```

## Read the result

Both calls return a `FoodScan`: `detections` (always an array, possibly empty), `totalNutrients`, and an optional `mealName`. Each detection's `food` has the food `id`, the selected `serving`, and the `quantity` eaten, so you can [log it](/client-sdks/ios-sdk/guides/food-logs#log-an-analyzed-meal) without another lookup. Food analysis doesn't return a glucose impact; use [glucose prediction](/client-sdks/ios-sdk/guides/glucose-prediction) for that.

## Correct a result

```swift
let corrected = try await client.foodAnalysis.correct(
    .init(analysis: scan, instruction: "Remove the fries and rename this grilled chicken sandwich.")
)
```

Pass the analysis back exactly as it was returned. The SDK rejects, before sending, a detection that's missing its food ID, serving ID, serving quantity, or quantity. A detection's serving `weightGrams` is sent when the API reported it.

Next: [Native food scanner](/client-sdks/ios-sdk/guides/native-meal-scanner).


# Native food scanner

The food scanner is a ready-made full-screen camera. In photo mode it runs [food analysis](/client-sdks/ios-sdk/guides/photo-scanning) on the meal; in barcode mode it looks up the barcode and fetches the full food (`foods.get`) for the first match.

## Add camera permission

Add a camera purpose string to your app's `Info.plist`:

```xml
<key>NSCameraUsageDescription</key>
<string>Scan meals and food barcodes.</string>
```

Call `try JanuaryFoodScanner.validateHostConfiguration()` before presenting the scanner; it throws `JanuaryFoodScannerConfigurationError.missingCameraUsageDescription` when the string is missing. If you skip the check, the scanner shows an alert instead of the camera.

## SwiftUI

```swift
import January
import SwiftUI

struct ScannerHost: View {
    let client: JanuaryClient
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        JanuaryFoodScannerView(
            client: client,
            configuration: .init(
                enabledModes: [.photo, .barcode],
                initialMode: .photo
            ),
            onResult: { result in
                switch result {
                case .photo(let image, let analysis):
                    print(image.pixelWidth, analysis.mealName ?? "")
                case .barcode(let value, let food):
                    print(value, food.name ?? "", food.servings.count)
                }
            },
            onCancel: { dismiss() }
        )
    }
}
```

A photo result includes the oriented, resized JPEG that was sent to January and the `FoodScan`. A barcode result includes the barcode and the full food.

## UIKit

```swift
import January
import UIKit

extension UIViewController {
    func presentJanuaryScanner(client: JanuaryClient) {
        let scanner = JanuaryFoodScanner.makeViewController(
            client: client,
            onResult: { result in
                print(result)
            },
            onCancel: { [weak self] in
                self?.dismiss(animated: true)
            }
        )
        present(scanner, animated: true)
    }
}
```

The view controller presents full screen. The configuration can enable either mode alone and set the maximum image dimension and JPEG compression quality. Camera capture needs a physical device.

Next: [Food logs](/client-sdks/ios-sdk/guides/food-logs).


# Food logs

Use `client.foodLogs` to create, list, summarize, get, update, and delete a user's meals. These examples use the `client` from [Authentication](/client-sdks/ios-sdk/getting-started/authentication).

Lists and summaries take inclusive `yyyy-MM-dd` dates in the client's timezone ([days and dates](/client-sdks/ios-sdk/concepts/user-identity-and-timezone#days-and-dates)). A list covers at most 60 days and a summary at most 366; a longer range fails with the code `date_range_too_large`.

## Choose a food and serving

Fetch the full food, then build a portion from one of its servings:

```swift
let food = try await client.foods.get(id: selectedFoodID)
let portion = try food.portion(servingID: selectedServingID) // One serving
let selectedFood = portion.selection
```

`portion.selection` is the `FoodSelection` that food logs and glucose predictions take. Leave `quantity` out for one serving; any other amount is in the serving's unit ([Quantity and servings](/client-sdks/ios-sdk/concepts/food-hydration-and-portions#quantity-and-servings)).

### Log an analyzed meal

A [food analysis](/client-sdks/ios-sdk/guides/photo-scanning) detection has its food `id`, selected `serving`, and the `quantity` eaten, so it logs without another lookup. They're optionals in Swift; unwrap them:

```swift
let selections = scan.detections.compactMap { detection -> FoodSelection? in
    guard let id = detection.food.id,
          let servingID = detection.food.serving.id,
          let quantity = detection.food.quantity else { return nil }
    return FoodSelection(id: id, serving: ServingSelection(id: servingID, quantity: quantity))
}
```

## Create

```swift
let log = try await client.foodLogs.create(
    foods: [selectedFood],
    timestampUTC: ISO8601DateFormatter().string(from: eatenAt),
    name: "Breakfast"
)
```

`timestampUTC` is when the meal was eaten, as ISO 8601 with any offset; leave it out to mean now. Creates aren't idempotent: after a timed-out create, list the day before you retry ([retries](/client-sdks/ios-sdk/reference/retries-and-concurrency#retrying-creates)).

## List

```swift
do {
    let logs = try await client.foodLogs.list(start: "2026-08-01", end: "2026-08-31")
    showLogs(logs.items)
} catch let error as JanuaryError where error.code == "date_range_too_large" {
    showInputError("Choose 60 days or fewer.")
}
```

## Summarize a range

Ask for a summary instead of adding up logs yourself when a screen shows daily or weekly totals:

```swift
let summary = try await client.foodLogs.getSummary(
    start: "2026-09-01", end: "2026-09-30", groupBy: .week
)
for week in summary.buckets {
    print(week.startDate, week.logsCount, week.nutrients.calories?.value ?? 0)
}
let dailyAverage = summary.averagePerLoggedDay.nutrients
```

## Get one log

`FoodLog.id` is optional in Swift, because a listed log can rarely have none. Unwrap it before you get, update, or delete the log:

```swift
guard let logID = log.id else { return }
let savedLog = try await client.foodLogs.get(id: logID)
```

## Update

Only the fields you pass change. An update that passes none fails with `.validation` before it's sent.

```swift
let updated = try await client.foodLogs.update(
    id: logID,
    name: "Post-workout breakfast"
)
```

## Delete

```swift
try await client.foodLogs.delete(id: logID)
```

A successful delete returns nothing.

Next: [Water and weight logs](/client-sdks/ios-sdk/guides/water-and-weight-logs).


# Water and weight logs

Use `client.waterLogs` and `client.weightLogs` to record water intake and body weight and read them back as one value per day. These examples use the `client` from [Authentication](/client-sdks/ios-sdk/getting-started/authentication). Its token needs the `water_logs:read`, `water_logs:write`, `weight_logs:read`, and `weight_logs:write` [scopes](/rest-api/authentication#client-token-scopes); without them, calls fail with an `.authorization` error whose code is `scope_insufficient`.

Lists take inclusive `yyyy-MM-dd` dates in the client's timezone ([days and dates](/client-sdks/ios-sdk/concepts/user-identity-and-timezone#days-and-dates)). A list returns at most 100 days, the most recent ones when more match, and `start` can be at most five years before today; an earlier `start` fails with the code `date_range_too_large`. To chart a longer range, such as a year, request windows of 100 days or fewer and merge them.

Creates aren't idempotent: after a timed-out create, list the day before you retry, or the entry may be recorded twice ([retries](/client-sdks/ios-sdk/reference/retries-and-concurrency#retrying-creates)).

## Log water

```swift
do {
    let log = try await client.waterLogs.create(
        amount: WaterAmount(value: 8, unit: .fluidOunces),
        consumedAtUTC: ISO8601DateFormatter().string(from: drankAt)
    )
    savedWaterLogIDs.append(log.id) // You need the ID to delete the entry.
} catch let error as JanuaryError where error.code == "daily_water_limit_exceeded" {
    showInputError("That would take today's water past 24 L.")
}
```

An amount is 1–811.5 fluid ounces (`.fluidOunces`), 0.1–101.4 US cups (`.cups`, 8 fluid ounces each), or 30–24,000 milliliters (`.milliliters`). Send the unit the user entered: the minimums don't convert evenly (1 fl oz ≈ 29.6 ml, below the 30 ml minimum). `consumedAtUTC` takes ISO 8601 with any offset and defaults to now.

Each user can log up to 24 L a day. The cap counts the UTC calendar day of `consumedAtUTC`, whatever offset you send, while lists group days in the client's timezone, so near midnight a listed day's total can differ from what the cap counted. A create over the cap fails with the code `daily_water_limit_exceeded` (a `.validation` error).

## Daily water totals

```swift
let totals = try await client.waterLogs.list(start: "2026-09-01", end: "2026-09-30", unit: .milliliters)
for day in totals.items {
    print(day.date, day.total.value, day.total.unit)
}
```

Totals are in the `unit` you ask for (fluid ounces by default), oldest day first. Days with no water are left out, so `items` can be empty.

## Delete a water log

```swift
try await client.waterLogs.delete(id: log.id)
```

Deleting an unknown or already deleted log also succeeds, so retrying a delete is safe.

## Log a weight

```swift
let weightLog = try await client.weightLogs.create(
    weight: Weight(value: 68.5, unit: .kilograms),
    measuredAtUTC: ISO8601DateFormatter().string(from: measuredAt)
)
```

A weight is 10–1,000 pounds or 4.5–453.6 kilograms. Weight logs have no ID and can't be updated or deleted. Every measurement is kept, and a day shows the one with the latest `measuredAtUTC`, so to change what a day shows, log a newer measurement for that day.

## Daily weights

```swift
let weights = try await client.weightLogs.list(start: "2026-09-01", end: "2026-09-30")
for day in weights.items {
    print(day.date, day.weight.value, day.weight.unit)
}
```

Each weight is in the unit it was logged in, oldest day first. Only days with a weight appear.

Next: [Glucose prediction](/client-sdks/ios-sdk/guides/glucose-prediction).


# Glucose prediction

Use `client.glucose` to predict a user's glucose curve after a meal. These examples use the `client` from [Authentication](/client-sdks/ios-sdk/getting-started/authentication); each prediction uses the client's timezone.

{% hint style="warning" %}
Glucose predictions and profile inputs are health data. Keep request and response values out of analytics, crash reports, and general-purpose logs.
{% endhint %}

## Predict

```swift
let request = PredictGlucoseRequest(
    userProfile: GlucosePredictionProfile(
        age: 35,
        sex: .male,
        height: Height(value: 70, unit: .inches),
        weight: Weight(value: 175, unit: .pounds),
        activityLevel: .moderatelyActive,
        healthConditions: []
    ),
    foods: [selectedFood],
    startTime: Date()
)

let prediction = try await client.glucose.predict(request)
```

`foods` takes the `FoodSelection` from a [portion](/client-sdks/ios-sdk/concepts/food-hydration-and-portions) or an [analyzed meal](/client-sdks/ios-sdk/guides/food-logs#log-an-analyzed-meal). You can also pass recent `cgmData` (`CgmReading`) and `consumedFoods` (`ConsumedHistoricalFood`).

## Read the result

* `prediction`: one `GlucosePredictionPoint` every 15 minutes from `startTime`, with `minutes` after `startTime` and the predicted glucose `value` in mg/dL.
* `impact`: a `GlucoseImpact?`, such as `.lowImpact`, `.mediumImpact`, or `.highImpact`. It can be `nil`, or a value added in a later release, so don't assume those three are the only ones.
* `chart.min` and `chart.max`: suggested y-axis bounds in mg/dL, not the lowest or highest point of the curve. They mark a fixed target range; `chart.max` is 180 when `healthConditions` includes `.type2Diabetes`, and 140 otherwise.

```swift
for point in prediction.prediction {
    print(point.minutes, point.value) // minutes after startTime, mg/dL
}
if prediction.impact == .highImpact {
    showHighImpactNote()
}
```

`curve`, `scoring`, `minimum`, and `maximum` are older aliases for `prediction`, `impact`, `chart.min`, and `chart.max`.

## Height and weight

`Height` takes inches or centimeters, and `Weight` takes pounds or kilograms. For imperial height, show separate feet and inches fields and send the total in inches (`feet * 12 + inches`), not a single inches field. Let users switch between feet and inches and centimeters, and between pounds and kilograms.

{% hint style="info" %}
Predictions are informational. Don't present them as a diagnosis or as treatment guidance.
{% endhint %}

Next: [Voice capture](/client-sdks/ios-sdk/guides/voice-capture).


# Voice capture

`VoiceCaptureSession` records AAC audio from the microphone and transcribes it with Apple Speech. It does not call January's servers and does not require a `JanuaryClient` or authentication.

## Add privacy descriptions

Add both purpose strings to the host app's `Info.plist`:

```xml
<key>NSMicrophoneUsageDescription</key>
<string>Use your voice to enter food and meal searches.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Turn your recorded voice into search text.</string>
```

The SDK checks for both entries before requesting access. Missing configuration throws `VoiceCaptureError.missingUsageDescription`.

## Capture and transcribe

Keep one session alive for each voice-enabled input. Observe its published state to show the microphone, recording, and transcription UI that fits your app:

```swift
import January
import SwiftUI
import UIKit

struct VoiceSearchField: View {
    @Binding var query: String
    @Environment(\.openURL) private var openURL
    @StateObject private var voiceCapture = VoiceCaptureSession()
    @State private var errorMessage: String?
    @State private var showPermissionAlert = false

    var body: some View {
        HStack {
            TextField("Search foods", text: $query)

            switch voiceCapture.state {
            case .idle:
                Button("Use voice input", systemImage: "mic.fill") {
                    Task {
                        do {
                            try await voiceCapture.startRecording()
                        } catch VoiceCaptureError.microphonePermissionDenied,
                                VoiceCaptureError.speechRecognitionPermissionDenied {
                            showPermissionAlert = true
                        } catch {
                            errorMessage = error.localizedDescription
                        }
                    }
                }
            case .recording:
                Text(voiceCapture.recordingDuration, format: .number.precision(.fractionLength(0)))
                Button("Stop", systemImage: "stop.fill") {
                    Task {
                        do {
                            let result = try await voiceCapture.stopAndTranscribe()
                            query += (query.isEmpty ? "" : " ") + result.transcript
                        } catch {
                            errorMessage = error.localizedDescription
                        }
                    }
                }
            case .requestingPermissions, .transcribing:
                ProgressView()
                Button("Cancel") { voiceCapture.cancel() }
            }
        }
        .alert("Voice Input Is Off", isPresented: $showPermissionAlert) {
            Button("Settings") {
                openURL(URL(string: UIApplication.openSettingsURLString)!)
            }
            Button("Cancel", role: .cancel) {}
        } message: {
            Text("Allow microphone and speech recognition access in Settings.")
        }
    }
}
```

`audioLevel` is normalized from `0...1` for waveform or level-meter UI. `recordingDuration` reports the current length in seconds.

## Permission behavior

| System status               | SDK behavior                               | Host-app behavior                              |
| --------------------------- | ------------------------------------------ | ---------------------------------------------- |
| Not determined              | Requests access after `startRecording()`   | Let the system prompt complete                 |
| Authorized                  | Starts recording                           | Show the active recording UI                   |
| Microphone denied           | Throws `microphonePermissionDenied`        | Show an alert with Cancel and Settings actions |
| Speech denied or restricted | Throws `speechRecognitionPermissionDenied` | Show an alert with Cancel and Settings actions |
| Unknown future status       | Treats access as denied                    | Use the same Settings alert                    |

iOS displays each system permission prompt only once. After denial or restriction, send the user to the app's Settings page; do not repeatedly request permission. Keep recording and transcription failures on a normal retryable error path rather than sending the user to Settings.

## Captured audio lifecycle

`stopAndTranscribe()` returns a `VoiceCaptureResult` containing only the transcript and recording duration. The SDK deletes its temporary AAC file immediately after Apple Speech finishes transcription, before `stopAndTranscribe()` returns. It also deletes the file after failed or canceled transcription.

Call `cancel()` while recording or transcribing to stop work and delete the in-progress file. Permission denial, unavailable speech recognition, an empty transcript, and recording or transcription failures are represented by stable `VoiceCaptureError` cases.

Next: [Reference](/client-sdks/ios-sdk/reference).


# Reference

The public API, models, limits, and error behavior.

* [Client and resources](/client-sdks/ios-sdk/reference/client-and-resources): `JanuaryClient` initializers and every resource method.
* [Authentication API](/client-sdks/ios-sdk/reference/authentication-api): token provider types, the development provider, and the retry policy.
* [Foods API](/client-sdks/ios-sdk/reference/foods-api): food requests, responses, and categories.
* [Restaurants API](/client-sdks/ios-sdk/reference/restaurants-api): restaurant and menu requests and responses.
* [Food analysis API](/client-sdks/ios-sdk/reference/photo-scanning-api): photo, description, and correction requests and the `FoodScan` result.
* [Food logs API](/client-sdks/ios-sdk/reference/food-logs-api): food-log requests, responses, and summaries.
* [Water and weight logs API](/client-sdks/ios-sdk/reference/water-and-weight-logs-api): water and weight requests and daily results.
* [Glucose API](/client-sdks/ios-sdk/reference/glucose-api): the prediction request, profile, and result.
* [Models and enums](/client-sdks/ios-sdk/reference/models-and-enums): identifiers, servings, nutrition, and enum values.
* [Validation limits](/client-sdks/ios-sdk/reference/validation): inputs the SDK rejects before sending.
* [Errors](/client-sdks/ios-sdk/reference/error-handling): `JanuaryError`, how to handle each category, and token provider failures.
* [Retries and concurrency](/client-sdks/ios-sdk/reference/retries-and-concurrency): the token lifecycle, retries, and cancellation.
* [Platform and security](/client-sdks/ios-sdk/reference/platform-and-security): supported platforms, credentials, and privacy.
* [Testing your integration](/client-sdks/ios-sdk/reference/testing): what to test in your token provider and app.
* [Troubleshooting](/client-sdks/ios-sdk/reference/troubleshooting): common failures and their fixes.
* [Versioning and support](/client-sdks/ios-sdk/reference/versioning-and-support): release pinning, updates, and support requests.
* [Changelog](/client-sdks/ios-sdk/reference/changelog): changes in each release.


# Client and resources

`JanuaryClient` is the SDK's entry point.

## Create a client

```swift
let client = try JanuaryClient(
    endUserID: endUserID,
    timezone: TimeZone.current,
    clientTokenProvider: tokenProvider
)
```

## Initializers

| Parameters                                                                                 | Use                                                                                                                  |
| ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
| `endUserID, timezone?, clientTokenProvider: any JanuaryTokenProvider, tokenRetryPolicy?`   | Production: a named provider type that calls your token endpoint                                                     |
| `endUserID, timezone?, clientTokenProvider: JanuaryClientTokenProvider, tokenRetryPolicy?` | Production: the same, as an `async` closure                                                                          |
| `clientToken, endUserID, timezone?`                                                        | One token your app manages; create a new client to replace it                                                        |
| `developmentAPIKey, endUserID, timezone?`                                                  | Local Debug builds only ([local development](/client-sdks/ios-sdk/getting-started/authentication#local-development)) |

Every initializer requires a non-empty `endUserID`; a blank one throws an `.authentication` error with the code `invalid_end_user_id`. [User identity and timezone](/client-sdks/ios-sdk/concepts/user-identity-and-timezone) covers `endUserID` and `timezone`.

Every initializer targets `https://partners.january.ai`. There's no public base-URL or token-endpoint override.

## Resource methods

These are the methods on a `JanuaryClient`'s resources. Each resource also has request-value forms, such as `create(_ request: CreateFoodLogRequest)`; the resource pages list them.

| Resource       | Method                                     | Purpose                                                     |
| -------------- | ------------------------------------------ | ----------------------------------------------------------- |
| `foods`        | `autocomplete(_:)`                         | Type-ahead suggestions                                      |
| `foods`        | `search(_:)`                               | Search foods by name                                        |
| `foods`        | `lookupBarcode(_:)`                        | Look up a UPC, EAN, or GTIN barcode                         |
| `foods`        | `get(id:)`                                 | Fetch the full food with every serving                      |
| `foods`        | `suggestAlternatives(_:)`                  | Find alternatives that fit dietary needs                    |
| `restaurants`  | `search(_:)`                               | Find nearby restaurants                                     |
| `restaurants`  | `searchMenuItems(_:)`                      | Find nearby menu items                                      |
| `restaurants`  | `getMenuItems(_:)`                         | Load a restaurant's menu by the ID from a restaurant search |
| `foodAnalysis` | `analyzePhoto(_:)`                         | Analyze a meal photo                                        |
| `foodAnalysis` | `analyzeDescription(_:)`                   | Analyze a meal description                                  |
| `foodAnalysis` | `correct(_:)`                              | Correct an analysis with a text instruction                 |
| `foodLogs`     | `create(foods:timestampUTC:name:)`         | Create a food log                                           |
| `foodLogs`     | `list(start:end:)`                         | List food logs for a date range                             |
| `foodLogs`     | `getSummary(start:end:groupBy:weekStart:)` | Summarize food logs per day or week                         |
| `foodLogs`     | `get(id:)`                                 | Get one food log                                            |
| `foodLogs`     | `update(id:foods:timestampUTC:name:)`      | Update a food log                                           |
| `foodLogs`     | `delete(id:)`                              | Delete a food log                                           |
| `waterLogs`    | `create(amount:consumedAtUTC:)`            | Log an amount of water                                      |
| `waterLogs`    | `list(start:end:unit:)`                    | Daily water totals for a date range                         |
| `waterLogs`    | `delete(id:)`                              | Delete a water log                                          |
| `weightLogs`   | `create(weight:measuredAtUTC:)`            | Log a weight                                                |
| `weightLogs`   | `list(start:end:)`                         | Each day's latest weight for a date range                   |
| `glucose`      | `predict(_:)`                              | Predict a glucose curve                                     |

Every method is `async throws`. It throws `JanuaryError`, or `CancellationError` when its task is canceled ([Errors](/client-sdks/ios-sdk/reference/error-handling)).

## Identifiers

The SDK wraps identifiers in types so they can't be mixed up: `PartnerUserID`, `FoodID`, and `ServingID`.

## Response types

| Operation                                        | Response                            |
| ------------------------------------------------ | ----------------------------------- |
| Food autocomplete                                | `AutocompleteFoodsResponse`         |
| Food search or barcode lookup                    | `FoodSearchResults`                 |
| Full food (`foods.get`)                          | `FoodSearchItem`                    |
| Food analysis: photo, description, or correction | `FoodScan`                          |
| Food alternatives                                | `SuggestFoodAlternativesResponse`   |
| Restaurant search                                | `SearchRestaurantsResponse`         |
| Menu-item search                                 | `SearchRestaurantMenuItemsResponse` |
| Restaurant menu                                  | `GetRestaurantMenuItemsResponse`    |
| Food-log create, get, or update                  | `FoodLog`                           |
| Food-log list                                    | `ListFoodLogsResponse`              |
| Food-log summary                                 | `FoodLogSummary`                    |
| Water-log create                                 | `WaterLog`                          |
| Water-log list                                   | `ListWaterLogsResponse`             |
| Weight-log create                                | `WeightLog`                         |
| Weight-log list                                  | `ListWeightLogsResponse`            |
| Glucose prediction                               | `GlucosePrediction`                 |


# Authentication API

## JanuaryClient

```swift
public struct JanuaryClient: Sendable {
    public let foods: FoodsResource
    public let restaurants: RestaurantsResource
    public let foodAnalysis: FoodAnalysisResource
    public let foodLogs: FoodLogsResource
    public let glucose: GlucoseResource
    public let waterLogs: WaterLogsResource
    public let weightLogs: WeightLogsResource

    public init(
        endUserID: String,
        timezone: TimeZone? = nil,
        clientTokenProvider: @escaping JanuaryClientTokenProvider,
        tokenRetryPolicy: JanuaryTokenRetryPolicy = .default
    ) throws
    public init(
        endUserID: String,
        timezone: TimeZone? = nil,
        clientTokenProvider: any JanuaryTokenProvider,
        tokenRetryPolicy: JanuaryTokenRetryPolicy = .default
    ) throws
    public init(
        clientToken: String,
        endUserID: String,
        timezone: TimeZone? = nil
    ) throws

    // Debug builds only.
    public init(
        developmentAPIKey: String,
        endUserID: String,
        timezone: TimeZone? = nil
    ) throws
}
```

`clientToken` rejects an empty value, and the SDK can't refresh it. `developmentAPIKey` is for local development only ([local development](/client-sdks/ios-sdk/getting-started/authentication#local-development)).

## Token provider

```swift
public struct JanuaryClientToken: Codable, Hashable, Sendable {
    public let token: String
    public let expiresIn: TimeInterval
    public init(token: String, expiresIn: TimeInterval)
}

public typealias JanuaryClientTokenProvider =
    @Sendable (_ endUserID: String) async throws -> JanuaryClientToken

public protocol JanuaryTokenProvider: Sendable {
    func fetchClientToken(for endUserID: String) async throws -> JanuaryClientToken
}

public struct JanuaryTokenProviderError: Error, LocalizedError, Sendable {
    public let message: String
    public let retryable: Bool

    public init(_ message: String, retryable: Bool = false)
    public var errorDescription: String? { get }
}
```

`JanuaryClientToken` decodes January's token response unchanged: it reads `token` and either `expires_in` or `expiresIn`. It encodes `expiresIn`.

Throw `JanuaryTokenProviderError` from your provider for every failure, with `retryable: true` only for transient ones: network errors, timeouts, and HTTP 408, 429, and 5xx. [Errors](/client-sdks/ios-sdk/reference/error-handling#token-provider-failures) shows what each kind of failure becomes, and [Retries and concurrency](/client-sdks/ios-sdk/reference/retries-and-concurrency) covers caching, refresh, and retries.

## Development token provider

```swift
// Debug builds only.
public struct JanuaryDevelopmentTokenProvider: JanuaryTokenProvider {
    public init(apiKey: String) throws

    public func fetchClientToken(for endUserID: String) async throws -> JanuaryClientToken
}
```

It mints 300-second tokens with your API key, for the scopes `foods:read`, `food_analysis:write`, `food_logs:read`, `food_logs:write`, `glucose:read`, and `restaurants:read`. See [local development](/client-sdks/ios-sdk/getting-started/authentication#development-token-provider).

## Retry policy

```swift
public struct JanuaryTokenRetryPolicy: Hashable, Sendable {
    public static let `default`: JanuaryTokenRetryPolicy
    public static let none: JanuaryTokenRetryPolicy

    public let maximumAttempts: Int
    public let initialDelay: TimeInterval
    public let multiplier: Double
    public let maximumDelay: TimeInterval
    public let jitterRatio: Double

    public init(
        maximumAttempts: Int = 9,
        initialDelay: TimeInterval = 1,
        multiplier: Double = 2,
        maximumDelay: TimeInterval = 8,
        jitterRatio: Double = 0.2
    )
}
```

Invalid values fail a precondition. See [provider retries](/client-sdks/ios-sdk/reference/retries-and-concurrency#provider-retries).

## User context

```swift
public struct PartnerUserContext: Hashable, Sendable {
    public var endUserID: PartnerUserID?
    public var timezone: TimeZone
    public init(endUserID: PartnerUserID? = nil, timezone: TimeZone? = nil)
}
```

Request-value forms of the log operations take a `PartnerUserContext` as `user`. On a `JanuaryClient`, the client's required end-user ID and timezone replace it. The client's `foods`, `restaurants`, `foodAnalysis`, `foodLogs`, `glucose`, `waterLogs`, and `weightLogs` resources all use them ([User identity and timezone](/client-sdks/ios-sdk/concepts/user-identity-and-timezone)). An omitted `timezone` resolves to `TimeZone.current`.


# Foods API

All methods are `async throws`. They throw `JanuaryError`, or `CancellationError` when the task is canceled.

## Operations

```swift
public func autocomplete(
    _ request: AutocompleteFoodsRequest
) async throws -> AutocompleteFoodsResponse

public func get(
    id: FoodID,
    endUserID: PartnerUserID? = nil
) async throws -> FoodSearchItem

public func search(
    _ request: SearchFoodsRequest
) async throws -> FoodSearchResults

public func lookupBarcode(
    _ request: LookupFoodByBarcodeRequest
) async throws -> FoodSearchResults

public func suggestAlternatives(
    _ request: SuggestFoodAlternativesRequest
) async throws -> SuggestFoodAlternativesResponse
```

## Requests and defaults

| Request                          | Required | Defaults                                                           |
| -------------------------------- | -------- | ------------------------------------------------------------------ |
| `AutocompleteFoodsRequest`       | `query`  | `category: nil`, `limit: 8` (1–20), `endUserID: nil`               |
| `SearchFoodsRequest`             | `query`  | `category: nil`, `limit: 10` (1–50), `offset: 0`, `endUserID: nil` |
| `LookupFoodByBarcodeRequest`     | `upc`    | `endUserID: nil`                                                   |
| `SuggestFoodAlternativesRequest` | `foodID` | empty restriction/preference arrays, `endUserID: nil`              |

## Responses

`AutocompleteFoodsResponse.items` contains `FoodSuggestion` values with an `id` and an optional `name`, brand, image, and nutrition. `FoodSearchResults` contains `totalCount` and `[FoodSearchItem]`. `get` returns the full `FoodSearchItem`, with every serving. `name` is optional on both types.

Alternatives return `SuggestFoodAlternativesResponse.alternatives: [AlternativeFood]`. The `FoodScan` result of food analysis is on the [Food analysis API](/client-sdks/ios-sdk/reference/photo-scanning-api#response-models) page.

## Categories

```swift
public enum AutocompleteFoodCategory: String, Codable, Sendable {
    case generic, branded
}

public enum FoodCategory: String, Codable, CaseIterable, Sendable {
    case generic, branded, recipe
}
```

`general` remains as a deprecated alias of `generic` on both enums.

## Errors

The SDK checks the query, limit, offset, and barcode before sending ([Validation limits](/client-sdks/ios-sdk/reference/validation)). API errors map to categories by status: 400 to `.validation`, 401 to `.authentication`, 403 to `.authorization`, 404 to `.notFound` (`get` and barcode lookup), and 429 to `.rateLimited`.

Fetch the full food with `get` after the user picks a search result and before you show servings.


# Restaurants API

## Operations

```swift
public func search(
    _ request: SearchRestaurantsRequest
) async throws -> SearchRestaurantsResponse

public func searchMenuItems(
    _ request: SearchRestaurantMenuItemsRequest
) async throws -> SearchRestaurantMenuItemsResponse

public func getMenuItems(
    _ request: GetRestaurantMenuItemsRequest
) async throws -> GetRestaurantMenuItemsResponse
```

## Requests and defaults

Both request types require `query`, `latitude`, and `longitude`. Both default to `radius: 8000`, `limit: 10`, and `endUserID: nil`.

```swift
public struct SearchRestaurantsRequest: Hashable, Sendable {
    public init(
        query: String,
        latitude: Double,
        longitude: Double,
        radius: Double = 8000,
        limit: Int = 10,
        endUserID: PartnerUserID? = nil
    )
}
```

`SearchRestaurantMenuItemsRequest` has the same initializer shape.

`GetRestaurantMenuItemsRequest` loads a menu from a restaurant ID returned by `search`. It defaults to a 100-item page, the SDK's maximum, and the first offset:

```swift
public struct GetRestaurantMenuItemsRequest: Hashable, Sendable {
    public init(
        restaurantID: String,
        limit: Int = 100,
        offset: Int = 0,
        endUserID: PartnerUserID? = nil
    )
}
```

## Responses

`SearchRestaurantsResponse` has `totalCount: Int` and `items: [Restaurant]`. A `Restaurant` exposes type, ID, name, optional chain flag, distance, city, and address fields.

`SearchRestaurantMenuItemsResponse` has `totalCount: Int` and `items: [RestaurantMenuItem]`. Menu items include ID/name, restaurant name, optional nutrition, image, glycemic data, distance, and `[ServingOption]`.

`GetRestaurantMenuItemsResponse` contains only `items: [RestaurantMenuEntry]`, with no `totalCount`; see [paging a menu](/client-sdks/ios-sdk/guides/restaurants#load-a-restaurant-menu).

## Errors

The SDK checks the query length, coordinates, radius, limit, and offset before sending ([Validation limits](/client-sdks/ios-sdk/reference/validation)). An unknown restaurant ID fails with `.notFound`. Other API errors map to `JanuaryError` by status.


# Food analysis API

## Operations

```swift
public func analyzePhoto(
    _ request: ScanFoodPhotoRequest
) async throws -> FoodScan

public func analyzeDescription(
    _ request: SearchFoodsByNaturalLanguageRequest
) async throws -> FoodScan

public func correct(
    _ request: CorrectPhotoScanRequest
) async throws -> FoodScan
```

## Requests and defaults

```swift
public struct ScanFoodPhotoRequest: Hashable, Sendable {
    public init(image: String, endUserID: PartnerUserID? = nil, reasoningEffort: AnalysisEffort? = nil)
    public init(
        imageData: Data,
        endUserID: PartnerUserID? = nil,
        maxDimension: Int = 1_000,
        compressionQuality: Double = 0.7,
        reasoningEffort: AnalysisEffort? = nil
    ) throws
}

public struct CorrectPhotoScanRequest: Hashable, Sendable {
    public init(analysis: FoodScan, instruction: String, endUserID: PartnerUserID? = nil)
}
```

The `image` string is a public URL or a data URI. The `imageData` initializer orients the image, scales its longest edge down to `maxDimension`, compresses it to JPEG, and builds the data URI; `PhotoScanImage.jpegData(from:)` and `PhotoScanImage.dataURI(from:)` do the same preparation on their own. When `reasoningEffort` is `nil`, the request leaves the choice to the API, which uses the reasoning-based analyzer. `.none` selects the standard analyzer, and `.xhigh` asks for the reasoning-based one explicitly. Both return the same result shape at the same cost.

`CorrectPhotoScanRequest` takes the complete prior `FoodScan`, unchanged, and a plain-language `instruction`. The `init(mealName:detections:userInput:endUserID:)` initializer is deprecated.

## Response models

`FoodScan.detections` is a nonoptional array. `mealName` is optional; `totalNutrients` is not. `glucoseImpact` is deprecated and always `nil`; use `glucose.predict` for a glucose impact. Each `FoodDetection` contains a `DetectedFood` and optional `ConfidenceScore` (`high`, `medium`, or `low`).

`DetectedFood` has `id`, `name`, `brandName`, `nutrients`, `serving`, and `quantity`. `serving` is the selected catalog serving (`ServingSummary` with `id`, `quantity`, `unit`, where `quantity` is the size of one serving) and `quantity` is how many of that serving were eaten. `id`, `serving.id`, and `quantity` are optionals in Swift; unwrap them and pass `FoodSelection(id: id, serving: ServingSelection(id: servingID, quantity: quantity))` to log the detection as is (see [Food logs](/client-sdks/ios-sdk/guides/food-logs#log-an-analyzed-meal)). `nutrients` are already scaled to `quantity`.

Food alternatives (`foods.suggestAlternatives`) return `AlternativeFood` values with `servings: [ServingSummary]` to read the nutrition against.

## Errors

Photo analysis maps 413 to `.validation`, and every operation maps 504 to `.timeout`. Other API errors map to `JanuaryError` by status. Image preparation throws `PhotoScanImageError.invalidImage` or `.encodingFailed`.

The camera scanner types are on the [Native food scanner](/client-sdks/ios-sdk/guides/native-meal-scanner) page and in [Models and enums](/client-sdks/ios-sdk/reference/models-and-enums#scanner-types).


# Food logs API

## Operations

```swift
public func create(_ request: CreateFoodLogRequest) async throws -> FoodLog
public func list(_ request: ListFoodLogsRequest) async throws -> ListFoodLogsResponse
public func getSummary(_ request: GetFoodLogSummaryRequest) async throws -> FoodLogSummary
public func get(_ request: GetFoodLogRequest) async throws -> FoodLog
public func update(_ request: UpdateFoodLogRequest) async throws -> FoodLog
public func delete(_ request: DeleteFoodLogRequest) async throws -> DeleteFoodLogResponse
```

## Requests and defaults

```swift
public struct CreateFoodLogRequest: Hashable, Sendable {
    public init(
        foods: [FoodSelection],
        timestampUTC: String? = nil,
        name: String? = nil,
        user: PartnerUserContext
    )
}

public struct UpdateFoodLogRequest: Hashable, Sendable {
    public init(
        id: String,
        foods: [FoodSelection]? = nil,
        timestampUTC: String? = nil,
        name: String? = nil,
        user: PartnerUserContext
    )
}

public struct ListFoodLogsRequest: Hashable, Sendable {
    public init(start: String, end: String, user: PartnerUserContext)
}

public struct GetFoodLogRequest: Hashable, Sendable {
    public init(id: String, user: PartnerUserContext)
}

public struct DeleteFoodLogRequest: Hashable, Sendable {
    public init(id: String, user: PartnerUserContext)
}

public struct GetFoodLogSummaryRequest: Hashable, Sendable {
    public init(
        start: String,
        end: String,
        groupBy: FoodLogSummaryGrouping = .day,
        weekStart: WeekStart = .monday,
        user: PartnerUserContext
    )
}
```

`FoodLogUserContext` is a public type alias for `PartnerUserContext`.

## Configured-client operations

```swift
public func create(
    foods: [FoodSelection],
    timestampUTC: String? = nil,
    name: String? = nil
) async throws -> FoodLog

public func list(start: String, end: String) async throws -> ListFoodLogsResponse

public func getSummary(
    start: String,
    end: String,
    groupBy: FoodLogSummaryGrouping = .day,
    weekStart: WeekStart = .monday
) async throws -> FoodLogSummary

public func get(id: String) async throws -> FoodLog

public func update(
    id: String,
    foods: [FoodSelection]? = nil,
    timestampUTC: String? = nil,
    name: String? = nil
) async throws -> FoodLog

public func delete(id: String) async throws -> DeleteFoodLogResponse
```

These methods are on `JanuaryClient.foodLogs` and use the client's end-user ID and timezone. The request-value forms above do too: the client's values replace the request's `user`.

## Responses

`FoodLog` contains an optional `id`, `[LoggedFood]`, `timestampUTC`, and optional `name`. Logged foods include nutrition, consumed serving, and serving details. `ListFoodLogsResponse` contains `totalCount` and `items`; `get` returns one `FoodLog`; a successful delete returns no response body. `DeleteFoodLogResponse` is a type alias for `Void`.

`list` covers at most 60 days. `getSummary` adds up the logs in the inclusive range (at most 366 days) into `buckets`, one per local calendar day or per week, each with `logsCount`, `daysWithLogs`, and summed `nutrients`. Empty periods are still returned with zero counts. `totals` covers the whole range and `averagePerLoggedDay` divides the totals by the number of days that have a log. `nutrients` is sparse: read `logsCount` to tell an empty bucket from one whose logs had no nutrition data. `weekStart` is `nil` when grouping by day.

## Errors

Create and update check an optional `timestampUTC` as ISO 8601, and update rejects an empty update, before sending. Get and update fail with `.notFound` for an unknown ID. A range that's too long fails with `.validation` and the code `date_range_too_large`. Other API errors map to `JanuaryError` by status.


# Water and weight logs API

## Operations

```swift
// JanuaryClient.waterLogs
public func create(_ request: CreateWaterLogRequest) async throws -> WaterLog
public func list(_ request: ListWaterLogsRequest) async throws -> ListWaterLogsResponse
public func delete(_ request: DeleteWaterLogRequest) async throws -> DeleteWaterLogResponse

// JanuaryClient.weightLogs
public func create(_ request: CreateWeightLogRequest) async throws -> WeightLog
public func list(_ request: ListWeightLogsRequest) async throws -> ListWeightLogsResponse
```

## Requests and defaults

```swift
public struct CreateWaterLogRequest: Hashable, Sendable {
    public init(amount: WaterAmount, consumedAtUTC: String? = nil, user: PartnerUserContext)
}

public struct ListWaterLogsRequest: Hashable, Sendable {
    public init(start: String, end: String, unit: VolumeUnit = .fluidOunces, user: PartnerUserContext)
}

public struct DeleteWaterLogRequest: Hashable, Sendable {
    public init(id: String, user: PartnerUserContext)
}

public struct CreateWeightLogRequest: Hashable, Sendable {
    public init(weight: Weight, measuredAtUTC: String? = nil, user: PartnerUserContext)
}

public struct ListWeightLogsRequest: Hashable, Sendable {
    public init(start: String, end: String, user: PartnerUserContext)
}
```

Amount, weight, and date limits are in [Validation limits](/client-sdks/ios-sdk/reference/validation) and the [Water and weight logs](/client-sdks/ios-sdk/guides/water-and-weight-logs) guide.

## Configured-client operations

```swift
// JanuaryClient.waterLogs
public func create(amount: WaterAmount, consumedAtUTC: String? = nil) async throws -> WaterLog
public func list(start: String, end: String, unit: VolumeUnit = .fluidOunces) async throws -> ListWaterLogsResponse
public func delete(id: String) async throws -> DeleteWaterLogResponse

// JanuaryClient.weightLogs
public func create(weight: Weight, measuredAtUTC: String? = nil) async throws -> WeightLog
public func list(start: String, end: String) async throws -> ListWeightLogsResponse
```

These methods use the client's end-user ID and timezone. The request-value forms above do too: the client's values replace the request's `user`.

## Responses

`WaterLog` contains `id`, the `amount` (`WaterAmount`: `value` and `VolumeUnit`) as logged, and `consumedAtUTC`. `ListWaterLogsResponse.items` is `[DailyWaterTotal]`: a local `date` and a `total` (`Volume`, rounded to one decimal place) in the requested unit, oldest first. Delete returns nothing.

`WeightLog` contains the `weight` (`Weight`: `value` and `WeightUnit`) as logged and `measuredAtUTC`. `ListWeightLogsResponse.items` is `[DailyWeight]`: a local `date` and that day's latest `weight`, in the unit it was logged in, oldest first.

Timestamps in responses are ISO 8601 in UTC with milliseconds. Dates are `yyyy-MM-dd` in the request's timezone.

## Errors

Create checks the amount or weight range and an optional ISO 8601 timestamp before sending. Operations map declared 400, 401, 403, and 429 responses plus other HTTP, transport, and decoding failures to `JanuaryError`. A response that carries an unknown unit is reported as a `.decoding` error. The API's `daily_water_limit_exceeded` and `date_range_too_large` codes arrive as `.validation` errors with `code` set.


# Glucose API

## Operation

```swift
public func predict(
    _ request: PredictGlucoseRequest
) async throws -> GlucosePrediction
```

## Request and defaults

```swift
public struct PredictGlucoseRequest: Hashable, Sendable {
    public init(
        userProfile: GlucosePredictionProfile,
        foods: [FoodSelection],
        startTime: Date,
        cgmData: [CgmReading]? = nil,
        consumedFoods: [ConsumedHistoricalFood]? = nil,
        endUserID: PartnerUserID? = nil,
        timezone: TimeZone? = nil
    )
}
```

`JanuaryClient.glucose` sends the client's timezone and ignores the request's `endUserID` and `timezone`.

## Profile

```swift
public struct GlucosePredictionProfile: Codable, Hashable, Sendable {
    public init(
        age: Double,
        sex: Sex,
        height: Height,
        weight: Weight,
        activityLevel: ActivityLevel? = nil,
        healthConditions: [MedicalCondition]? = nil
    )
}
```

The legacy `gender:height:weight:` initializer interprets raw height as inches and raw weight as pounds. Prefer the typed initializer.

## Optional history

`CgmReading` requires an ISO 8601 timestamp string and a numeric value. `ConsumedHistoricalFood` requires timestamp, `FoodID`, and `ConsumedHistoricalServing`. Invalid timestamp strings fail locally with `.validation`.

## Response

`GlucosePrediction` exposes:

* `prediction: [GlucosePredictionPoint]`;
* `impact: GlucoseImpact?`;
* `chart: GlucoseChart`;
* the older aliases `curve`, `scoring`, `minimum`, and `maximum`.

`GlucosePredictionPoint` has `minutes` after `startTime` and the predicted `value` in mg/dL. `GlucoseChart` has optional `min` and `max`: suggested y-axis bounds in mg/dL, not the curve's lowest and highest points. `GlucoseImpact` is a `RawRepresentable` struct that can carry values added later; compare against `.lowImpact`, `.mediumImpact`, and `.highImpact` without assuming they're the only ones. See [Read the result](/client-sdks/ios-sdk/guides/glucose-prediction#read-the-result).

## Errors

Predict checks that `age` is a whole number and that history timestamps are ISO 8601 before sending. It maps 400 to `.validation`, 401 to `.authentication`, 403 to `.authorization`, 429 to `.rateLimited`, and 504 to `.timeout`; other API errors map to `JanuaryError` by status.


# Models and enums

## Typed identifiers

```swift
public struct FoodID: RawRepresentable, Codable, Hashable, Sendable {
    public let rawValue: String
    public init(rawValue: String)
    public init(rawValue: Int64)
}

public struct ServingID: RawRepresentable, Codable, Hashable, Sendable {
    public let rawValue: String
    public init(rawValue: String)
    public init(rawValue: Int64)
}

public struct PartnerUserID: RawRepresentable, Codable, Hashable, Sendable {
    public let rawValue: String
    public init(rawValue: String)
}
```

## Serving and selection

| Type               | Public fields                                                                                                  |
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
| `ServingOption`    | `id`, `quantity`, `unit`, `scalingFactor`, `weightGrams`, `isPrimary`                                          |
| `ServingSelection` | `id`, `quantity`                                                                                               |
| `FoodSelection`    | `id`, `serving`                                                                                                |
| `FoodPortion`      | `foodID`, `serving`, `quantity`, `nutrition`, `totalWeightGrams`, `glycemicIndex`, `glycemicLoad`, `selection` |

```swift
public extension FoodSearchItem {
    func portion(
        servingID: ServingID? = nil,
        quantity: Double? = nil
    ) throws -> FoodPortion
}
```

Without a serving ID, portion selection uses the primary serving or the first serving. `quantity` is an amount in that serving's unit and defaults to one serving; `selection` sends the number of servings ([Quantity and servings](/client-sdks/ios-sdk/concepts/food-hydration-and-portions#quantity-and-servings)).

## Nutrition

`NutrientAmount` contains `value: Double` and `unit: String`. `NutritionFacts` exposes optional calories, protein, carbohydrates, net carbohydrates, total fat, trans fat, saturated fat, fiber, total/added sugars, cholesterol, calcium, iron, potassium, sodium, and vitamin D.

`CompleteScanNutritionFacts` is the subset food analysis returns: calories, protein, carbohydrates, net carbohydrates, total fat, saturated fat, fiber, total/added sugars, and sodium.

## Food models

`FoodSearchItem` includes a typed ID, an optional name and brand, structured and compatibility nutrition fields, glycemic values, an optional photo URL, and `[ServingOption]`, each with a nonoptional `ServingID`. `AlternativeFood.id` and a logged food's `LoggedFood.id` are nonoptional `FoodID` values, and `RestaurantMenuItem.id` is always present. `FoodSuggestion` is smaller: an ID, an optional name, and optional brand, image, and nutrition. `DetectedFood` is one food from food analysis. It carries the food `id`, the selected `serving` (`ServingSummary`, with the serving's `weightGrams` when known), and the `quantity` eaten. All three are optionals in Swift, and `quantity` is `nil` when no usable portion was found, so unwrap them before logging. `FoodLogSummaryGrouping` (`day`, `week`), `WeekStart` (`monday`, `sunday`), and `AnalysisEffort` (`none`, `xhigh`) are the remaining request enums.

## Dietary enums

`DietRestriction` values: `gluten`, `lactose`, `yeast`, `treeNuts`, `peanuts`, `dairy`, `eggs`, `sulfites`, `soy`, `wheat`, `shellfish`, `fish`, `mushrooms`, `sesame`, `monosodiumGlutamate`, `caffeine`, `fodmaps`.

`DietPreference` values: `vegetarian`, `vegan`, `keto`, `paleo`, `pescatarian`, `lowCarbohydrate`, `highProtein`, `kosher`, `halal`.

## Glucose enums and units

| Type               | Values                                                         |
| ------------------ | -------------------------------------------------------------- |
| `Sex`              | `male`, `female`                                               |
| `HeightUnit`       | `inches`, `centimeters`                                        |
| `WeightUnit`       | `pounds`, `kilograms`                                          |
| `ActivityLevel`    | `sedentary`, `lightlyActive`, `moderatelyActive`, `veryActive` |
| `MedicalCondition` | `type2Diabetes`, `prediabetes`                                 |

Use `Sex` for the profile's biological-sex field. Omit `healthConditions` or pass an empty array when no medical conditions apply. `age` is a whole number of years.

## Water and weight logs

| Type              | Fields                                                        |
| ----------------- | ------------------------------------------------------------- |
| `VolumeUnit`      | `fluidOunces` (`fl_oz`), `milliliters` (`ml`), `cups` (`cup`) |
| `WaterAmount`     | `value`, `unit` (`VolumeUnit`)                                |
| `Volume`          | `value` (one decimal place), `unit` (`VolumeUnit`)            |
| `WaterLog`        | `id`, `amount`, `consumedAtUTC`                               |
| `DailyWaterTotal` | `date`, `total` (`Volume`)                                    |
| `WeightLog`       | `weight` (`Weight`), `measuredAtUTC`                          |
| `DailyWeight`     | `date`, `weight` (`Weight`)                                   |

`Weight` and `WeightUnit` are shared with glucose prediction.

## Error categories

`ErrorCategory` values are `authentication`, `authorization`, `validation`, `notFound`, `rateLimited`, `server`, `transport`, `timeout`, and `decoding`.

`JanuaryError` exposes `category`, an optional `code`, `message`, and an optional `httpStatus` ([Errors](/client-sdks/ios-sdk/reference/error-handling#properties)). Its `requestID` and `retryAfterSeconds` are always `nil` in 0.3.2.

## Scanner types

`JanuaryFoodScannerMode` has `photo` and `barcode`. `JanuaryFoodScannerConfiguration` defaults to both modes, photo first, maximum dimension 1,000, and compression quality 0.7. `JanuaryFoodScannerResult` is either `.photo(image:analysis:)` or `.barcode(value:food:)`.


# Validation limits

The SDK checks these inputs before it sends a request. Most failures throw a `JanuaryError` with the `.validation` category; portion inputs throw `FoodPortionError`, and the scanner settings are clamped instead. The January API can apply more rules of its own.

| Input                                              | Accepted value                                                                            |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Autocomplete query                                 | At most 64 characters                                                                     |
| Autocomplete limit                                 | Integer from 1 through 20                                                                 |
| Food search query                                  | 1 through 256 characters                                                                  |
| Food search limit                                  | 1 through 50                                                                              |
| Food search offset                                 | 0 or more                                                                                 |
| Barcode                                            | 6 through 14 ASCII digits                                                                 |
| Natural-language meal                              | 1 through 512 characters                                                                  |
| Restaurant/menu query                              | 1 through 256 characters                                                                  |
| Latitude                                           | −90 through 90                                                                            |
| Longitude                                          | −180 through 180                                                                          |
| Restaurant radius                                  | 1 through 50,000 meters                                                                   |
| Restaurant limit                                   | Integer from 1 through 100                                                                |
| Food-log `timestampUTC`                            | ISO 8601 date-time                                                                        |
| Food-log update                                    | At least one of `foods`, `timestampUTC`, `name`                                           |
| Water amount                                       | 1 through 811.5 fl oz, 0.1 through 101.4 cup, or 30 through 24,000 ml                     |
| Weight-log weight                                  | 10 through 1,000 lb, or 4.5 through 453.6 kg                                              |
| Water and weight `consumedAtUTC` / `measuredAtUTC` | ISO 8601 date-time                                                                        |
| Glucose profile `age`                              | Whole number of years                                                                     |
| Food analysis correction                           | Every detection carries its food ID, serving ID, serving quantity, and quantity           |
| CGM and historical-food timestamp                  | ISO 8601 date-time                                                                        |
| Provider token                                     | Not empty, with a finite `expiresIn` greater than 60 seconds (an `.authentication` error) |
| Portion quantity, in the serving's unit            | Finite, greater than 0, and no greater than 10,000                                        |
| Scanner maximum dimension                          | Clamped to at least 1 pixel                                                               |
| Scanner JPEG quality                               | Clamped to 0 through 1                                                                    |

## Typed units

Glucose profiles use explicit units:

```swift
let height = Height(value: 178, unit: .centimeters)
let weight = Weight(value: 80, unit: .kilograms)
```

Height takes inches or centimeters, and weight takes pounds or kilograms.

## Local portion errors

`FoodPortionError` distinguishes:

* `noServings`
* `servingNotFound`
* `invalidServing`
* `invalidQuantity`

Fetch the full food (`foods.get`) before computing a portion, so every serving is available.


# Errors

SDK calls throw `JanuaryError` for local validation, authentication, API error responses, networking, timeouts, and unreadable responses. A canceled task throws `CancellationError` instead.

## Handle an error

```swift
do {
    let results = try await client.foods.search(.init(query: "banana"))
    display(results)
} catch is CancellationError {
    return
} catch let error as JanuaryError {
    switch (error.category, error.code) {
    case (.authorization, "scope_insufficient"?):
        reportIntegrationFailure(error) // Your backend minted the token without this scope.
    case (.authentication, _), (.authorization, _):
        showSignInOrConnectionError()
    case (.validation, _):
        showInputError(error.message)
    case (.rateLimited, "rate_limited"?):
        showRetryState() // A short limit: retry later, with backoff.
    case (.rateLimited, _):
        showQuotaExhausted() // The billing period's allowance is spent: don't retry.
    case (.timeout, _), (.transport, _), (.server, _):
        showRetryState()
    case (.notFound, _):
        showNotFoundState()
    case (.decoding, _):
        reportIntegrationFailure(error)
    }
} catch {
    showRetryState()
}
```

Branch on `category`, and on `code` where it matters. Codes and when to retry them are in the [API error table](/rest-api/api-overview#errors). Retry only `rate_limited`, with backoff. `request_limit_exceeded` and `credit_limit_exceeded` last until your billing period resets; don't retry them.

## Properties

| Property            | Description                                                                     |
| ------------------- | ------------------------------------------------------------------------------- |
| `category`          | `ErrorCategory` for your control flow                                           |
| `code`              | The API's error code, such as `scope_insufficient`; `nil` for most local errors |
| `message`           | A developer-facing description                                                  |
| `httpStatus`        | The HTTP status, when the API responded                                         |
| `requestID`         | Always `nil` in 0.3.2                                                           |
| `retryAfterSeconds` | Always `nil` in 0.3.2; the SDK doesn't read `Retry-After`                       |

Think before showing `message` to users. Never log credentials, photo contents, nutrition details, or health-profile data while you diagnose an error.

A `409` response has the code `conflict`. It's a `.validation` error, because sending the same request again fails the same way: change the request instead of retrying it. For a status an operation doesn't document, `code` and `message` are still the ones the API returned.

The SDK handles `401 token_expired` itself: it gets a new token and replays the call once. Don't wrap SDK calls in your own authentication retry loop.

## Token provider failures

What your token provider throws decides the error the call gets:

| Your provider                                                        | The call throws                                                                                                             |
| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Throws `JanuaryTokenProviderError(retryable: false)`                 | `.authentication`, code `client_token_provider_failed`, with your message. No January request is made.                      |
| Throws `JanuaryTokenProviderError(retryable: true)` on every attempt | `.authentication`, code `client_token_provider_failed`, "The app could not obtain a January client token after 9 attempts." |
| Throws a `DecodingError`                                             | `.decoding`, "The January API returned an unreadable response."                                                             |
| Throws any other error                                               | `.transport`, "The request to the January API failed."                                                                      |
| Returns an empty token                                               | `.authentication`, code `invalid_client_token`                                                                              |
| Returns an `expiresIn` of 60 seconds or less                         | `.authentication`, code `invalid_client_token_expiration`                                                                   |

Throw `JanuaryTokenProviderError` for every failure, as the [sample provider](/client-sdks/ios-sdk/getting-started/authentication#write-the-token-provider) does, so your own message reaches the call.


# Retries and concurrency

Getting a token and calling January follow different retry rules.

## Token lifecycle

With a token provider, the SDK:

* keeps the token in memory only, never on disk;
* asks the provider for a new token 60 seconds before the current one expires;
* shares one provider call among concurrent requests that need a token, so five requests on a cold start make one call to your backend;
* retries provider failures marked `retryable: true` (see below); and
* after January answers `401 token_expired`, gets a new token and replays the call once.

A token must report more than 60 seconds of lifetime ([validation limits](/client-sdks/ios-sdk/reference/validation)). January issues tokens for 300–7200 seconds, so a token from your endpoint passes.

## Provider retries

By default the SDK calls the provider up to nine times: once, then eight retries. With no jitter, the waits between attempts are:

```
1s → 2s → 4s → 8s → 8s → 8s → 8s → 8s
```

Each wait gets ±20% jitter and is capped at 8 seconds, so an unreachable token endpoint fails the call after about 47 seconds. Only `JanuaryTokenProviderError` with `retryable: true` is retried. Other errors, `CancellationError`, and a returned token that's empty or nearly expired stop at once.

To change the policy, pass `tokenRetryPolicy`:

```swift
let client = try JanuaryClient(
    endUserID: endUserID,
    timezone: TimeZone.current,
    clientTokenProvider: tokenProvider,
    tokenRetryPolicy: JanuaryTokenRetryPolicy(
        maximumAttempts: 4,
        initialDelay: 1,
        multiplier: 2,
        maximumDelay: 4,
        jitterRatio: 0.2
    )
)
```

`maximumAttempts` counts the first call. Pass `.none` for a single attempt.

## January API replay

Only a January response with HTTP status `401` and the code `token_expired` makes the SDK drop its token. It then gets a new token and replays the original call once. `token_invalid`, `token_revoked`, scope errors, and every other failure reach your code at once.

The SDK retries nothing else. Don't wrap SDK calls in an unbounded retry loop.

## Retrying creates

Creating a food, water, or weight log isn't idempotent: sending the same create twice records two entries, and for water, counts twice toward the daily cap. If a create times out, list the day (or get the food log) to see whether it was recorded before you retry. The `token_expired` replay is safe, because January rejects that request before recording anything.

## Cancellation

Cancellation is reported as `CancellationError`. A canceled caller stops waiting, but a token refresh it shared with other callers can still finish for them. Treat cancellation as control flow, not as an error to retry or display.


# Platform and security

## Platforms

Every SDK feature, including the food scanner and voice capture, runs on iOS 15 and later ([requirements](/client-sdks/ios-sdk/overview#requirements)). The SDK doesn't support macOS, Mac Catalyst, tvOS, watchOS, or visionOS.

## Credentials

* Keep your API key on your backend. The only exception is [local development](/client-sdks/ios-sdk/getting-started/authentication#local-development) in a Debug build.
* The SDK keeps client tokens in memory. Don't write them to disk or logs.
* Configure your token endpoint's URL explicitly; don't ship a fallback URL.
* Authenticate the token request with the app's existing session. The [token endpoint rules](/docs/authentication#your-token-endpoint) cover the backend side.
* Create a new client when a different account signs in ([Client lifecycle](/client-sdks/ios-sdk/concepts/client-lifecycle)).

## User and health data

Meal photos, food logs, glucose profiles, CGM readings, and predictions can be sensitive. Collect and keep as little as you can, get the permissions your product needs, and keep these values out of analytics and crash reports. Use opaque end-user IDs, never email addresses or names.

## Camera privacy

The food scanner needs `NSCameraUsageDescription`. Write a purpose string that matches the feature, and don't ask for camera access before the user starts scanning.

## Microphone and speech privacy

Voice capture needs both `NSMicrophoneUsageDescription` and `NSSpeechRecognitionUsageDescription`. `VoiceCaptureSession` asks for access only when you call `startRecording()`. Recorded audio stays in a temporary local file only while Apple Speech transcribes it; then the SDK deletes the file. The SDK doesn't send audio or transcripts to January.


# Testing your integration

Test your token provider on its own, then the token lifecycle against a real endpoint.

## Token provider

Check that your provider:

1. posts to the configured URL with the app session in `Authorization`;
2. sends `January-End-User-ID`, which the token relay needs;
3. decodes January's token response unchanged;
4. throws `JanuaryTokenProviderError` for every failure, with `retryable: true` only for network errors, timeouts, and HTTP 408, 429, and 5xx;
5. never logs token responses; and
6. has no fallback URL.

Use a `URLProtocol` subclass or an injected `URLSession` configuration to test it without a live server.

## Token lifecycle

To see refresh happen quickly, have your endpoint mint tokens with `ttl_seconds: 300` (on the [token relay](/docs/authentication#develop-with-the-token-relay), set `TOKEN_TTL_SECONDS=300`). The SDK then asks for a new token after about 240 seconds. Check that:

* the provider is called again before the token expires;
* concurrent calls on a cold start make one provider call;
* a provider marked retryable is retried with backoff and then fails; and
* other authentication errors don't cause a refresh loop.

The expected behavior is in [Retries and concurrency](/client-sdks/ios-sdk/reference/retries-and-concurrency).

## Consumer build

Keep a small app or package that depends on the same SDK release as production. It should import `January`, create a provider-backed client, and compile representative calls. That catches product-name, access-control, deployment-target, and concurrency problems when you update the SDK.


# Troubleshooting

## Xcode can't resolve the package

Check that:

1. the package URL is exactly `https://github.com/January-ai/january-sdk-ios.git`;
2. the dependency rule allows the release you want ([Installation](/client-sdks/ios-sdk/getting-started/installation)); and
3. the `January` product is linked to your app target.

If Xcode resolves the package but says **Unable to load the Read Me**, choose **File → Packages → Reset Package Caches**, then **File → Packages → Resolve Package Versions**.

## Authentication or authorization errors

Check `JanuaryError.category`, `code`, `httpStatus`, and `message`, without logging credentials.

| What you see                                                                               | Cause                                                                                                                                                                           |
| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `.authentication`, code `client_token_provider_failed`                                     | Your token provider failed; `message` is the provider's. Check your token endpoint's URL and response.                                                                          |
| `.transport`, "The request to the January API failed.", before any request reaches January | Your token provider threw an error that isn't a `JanuaryTokenProviderError` ([token provider failures](/client-sdks/ios-sdk/reference/error-handling#token-provider-failures)). |
| `.authentication`, code `invalid_client_token_expiration`                                  | Your endpoint returned a token with 60 seconds or less to live. Return January's response unchanged.                                                                            |
| `.authorization`, code `scope_insufficient`                                                | The token lacks the scope for this call. Mint it with the scopes of every feature your app uses ([scopes](/client-sdks/ios-sdk/getting-started/backend-token-endpoint#scopes)). |
| `403 forbidden` when your backend mints a token                                            | **Enable client tokens** is off ([Backend token endpoint](/client-sdks/ios-sdk/getting-started/backend-token-endpoint#enable-client-tokens)).                                   |
| `.authentication`, code `token_invalid` or `token_revoked`                                 | The token isn't valid. The SDK refreshes only on `token_expired`.                                                                                                               |

## Water or weight calls fail with `scope_insufficient` in development

`JanuaryDevelopmentTokenProvider` doesn't request the water and weight scopes in 0.3.2. Use the [token relay](/docs/authentication#develop-with-the-token-relay) instead.

## The token provider is called often

Frequent calls usually mean your endpoint returns short-lived tokens. The SDK asks for a new token only on the schedule in [Token lifecycle](/client-sdks/ios-sdk/reference/retries-and-concurrency#token-lifecycle) and after `401 token_expired`.

## Validation errors

Check the input against [Validation limits](/client-sdks/ios-sdk/reference/validation).

## Food analysis fails with content too large

Reduce the image's dimensions or JPEG quality before you send it. Don't retry the same image.

## The food scanner reports a missing camera usage description

Add a non-empty `NSCameraUsageDescription` string to your app's `Info.plist`. Camera capture needs a physical device.

## The example app won't start

Set `JANUARY_PARTNER_TOKEN_URL` in the Xcode Run scheme, plus `JANUARY_PARTNER_SESSION_TOKEN` when the endpoint requires a session (a local relay doesn't). `JANUARY_END_USER_ID` defaults to `january-sdk-demo-user`. See [Example app](/client-sdks/ios-sdk/getting-started/example-app).

## Requests are rate limited

Retry only `code == "rate_limited"`, with backoff. `request_limit_exceeded` and `credit_limit_exceeded` last until your billing period resets; don't retry them. The SDK doesn't expose the `Retry-After` header in 0.3.2.

## Support diagnostics

Collect the failing operation, the SDK version, the iOS version, the time of the failure, and the error's `category`, `code`, `httpStatus`, and `message`. Don't include client tokens, API keys, or user health data. Send them to your January contact ([Versioning and support](/client-sdks/ios-sdk/reference/versioning-and-support)).


# Versioning and support

## Distribution

The SDK is released as versioned Swift Package Manager tags and as the `January` CocoaPods pod. Before 1.0, a minor release can include breaking changes, so allow patch updates only: **Up to Next Minor Version** in Xcode, or `~> 0.3.2` in CocoaPods ([Installation](/client-sdks/ios-sdk/getting-started/installation)). Don't track `main`.

## Updating the SDK

When January publishes a release:

1. read the [changelog](/client-sdks/ios-sdk/reference/changelog);
2. update the dependency on a branch;
3. run your consumer build and token provider tests ([Testing your integration](/client-sdks/ios-sdk/reference/testing));
4. try the user flows you ship; and
5. release through your normal process.

## Support requests

Include:

* the SDK release you use;
* your Xcode, Swift, and iOS versions;
* the failing SDK call and the time it failed;
* the error's `category`, `code`, `httpStatus`, and `message`; and
* the smallest steps that reproduce it.

Never include API keys, client tokens, meal photos, health profiles, or complete food logs. Send the request to your January contact.


# Changelog

Read [Versioning and support](/client-sdks/ios-sdk/reference/versioning-and-support) before you update.

## 0.3.2 - 2026-09-24

* Fixed: `FoodPortion.selection` sent the portion's amount in the serving's unit as the number of servings, so a portion of a serving whose quantity isn't 1 (such as 6 oz or 100 g) was logged that many times over. It now sends the number of servings

## 0.3.1 - 2026-09-23

* Food, water, and weight logs send and read the API's `created_at`; the Swift names `timestampUTC`, `consumedAtUTC`, and `measuredAtUTC` are unchanged. Food, water, and weight logs in 0.3.0 don't work with the current API, so upgrade to 0.3.1
* Water logs accept 0.1–101.4 cups (was 0.125–101.4), matching the API

## 0.3.0 - 2026-09-23

* Water and weight logs: `waterLogs.create`/`list`/`delete` and `weightLogs.create`/`list`, with daily values over a date range
* `ServingSummary.weightGrams` on detected and alternative foods
* `foodAnalysis.correct` sends the API's correction shape and validates hand-built detections; `foodLogs.update` rejects an empty update; `glucose.predict` requires a finite, whole-number `age`
* A non-retryable `JanuaryTokenProviderError` fails the request as an authentication error (`client_token_provider_failed`) with the provider's message, instead of a transport failure
* Breaking: `ServingOption.id`, `AlternativeFood.id`, and `LoggedFood.id` are no longer optional; the API always returns them
* A photo analysis without `reasoningEffort` uses the API's default, now the reasoning-based analyzer; pass `.none` for the standard analyzer
* A `409` `conflict` response is a `validation` error with the code `conflict`, and every API error keeps the API's `code` and `message`

## 0.2.0 - 2026-09-16

* Breaking: `DetectedFood` exposes `serving` and `quantity` instead of `servings`, matching the January API; 0.1.0 fails to decode photo analyses
* `SearchFoodsRequest` gains `offset` for paging and accepts `limit` up to 50
* Food-log summaries per day or week with `foodLogs.getSummary`
* Optional reasoning-based photo analysis with `ScanFoodPhotoRequest.reasoningEffort`

## 0.1.0 - 2026-09-03

* Native Swift 5.9 package for iOS 15 and later, distributed through Swift Package Manager and CocoaPods, with no third-party runtime dependencies
* One configured client with typed `async`/`await` APIs for foods, restaurants, food analysis, food logs, and glucose prediction
* Short-lived client-token providers with in-memory caching and single-flight refresh
* Nine-attempt bounded exponential backoff with jitter for retryable provider failures
* Stable `JanuaryError` categories
* Food autocomplete, full food details, and local portion calculations
* Restaurant menu-item search, and paginated restaurant-menu lookup by restaurant ID (`getMenuItems`)
* Single food-log retrieval, and deletion with no response body
* Typed imperial and metric height and weight values
* Native photo and barcode food scanner, and image-preparation helpers
* Microphone capture, live audio metering, and Apple Speech transcription
* SwiftUI example app


# Android SDK

Explore the January Android SDK documentation for installation, authentication, food discovery, food analysis, restaurants, voice capture, logging for food, water, and weight, and glucose prediction.

Start with the [SDK overview](/client-sdks/android-sdk/overview).


# Overview

<mark style="background-color:orange;">**Beta**</mark>

Add food search, food analysis, restaurant search, voice capture, food, water, and weight logging, and glucose prediction to a native Android app, with Kotlin coroutines and typed models.

## Start here

1. [Install the SDK](/client-sdks/android-sdk/getting-started/installation).
2. Add a [token endpoint](/client-sdks/android-sdk/getting-started/backend-token-endpoint) to your backend, or run the token relay while you build.
3. Implement `JanuaryTokenProvider` in the app ([Authentication](/client-sdks/android-sdk/getting-started/authentication)).
4. Run the [first food search](/client-sdks/android-sdk/getting-started/quick-start).
5. Follow the [food discovery and serving flow](/client-sdks/android-sdk/concepts/food-lifecycle).

## Security model

The app gets a short-lived client token (`ct-…`) from your backend and sends it directly to January. Your API key (`sk-…`) stays on your backend. Never put it in an APK, whether in Gradle properties, `BuildConfig`, resources, or remote configuration.

```
Android app ── POST with the app session ──▶ Your backend
                                                  │
                                                  │ POST /v1.2/auth/client-tokens
                                                  │ with your API key
                                                  ▼
                                             January API
                                                  │
Android app ◀── { token, expires_in } ────────────┘
     │
     └── Authorization: Bearer ct-… ──▶ January API
```

The SDK always calls the production January API. It has no base-URL setting, and it never sees your token endpoint's URL; only your token provider does. [How authentication works](/docs/authentication) walks through the flow.

Never log tokens, API keys, meal images, nutrition data, or health profiles, and keep them out of analytics.

## Requirements

Android API 24 or later, compile SDK 36, Java 17, and core library desugaring in every app that uses the SDK. [Compatibility and permissions](/client-sdks/android-sdk/reference/compatibility) lists the verified toolchain and the permissions the SDK adds.

The [example app](/client-sdks/android-sdk/getting-started/example-app) uses every resource and the native food scanner.


# Getting started

Install the SDK, connect a token endpoint, and make your first request.

* [Installation](/client-sdks/android-sdk/getting-started/installation): add the Maven Central dependency and enable desugaring.
* [Backend token endpoint](/client-sdks/android-sdk/getting-started/backend-token-endpoint): what your backend returns to the app, or the relay to use until it exists.
* [Authentication](/client-sdks/android-sdk/getting-started/authentication): the token provider and the client it configures.
* [First request](/client-sdks/android-sdk/getting-started/quick-start): a minimal app that searches for a food.
* [Example app](/client-sdks/android-sdk/getting-started/example-app): the full demo app from the SDK repository.


# Installation

The SDK is published to Maven Central as `ai.january:january-sdk-android`. Requirements and the verified toolchain are in [Compatibility and permissions](/client-sdks/android-sdk/reference/compatibility).

## 1. Enable Maven Central

In your application's `settings.gradle.kts`, include Maven Central in dependency resolution:

```kotlin
pluginManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
    }
}
```

## 2. Add the dependency

```kotlin
// app/build.gradle.kts
dependencies {
    implementation("ai.january:january-sdk-android:0.3.2")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
}
```

Pin an exact SDK version. The SDK's API uses coroutines (`suspend` functions and `StateFlow`), so your app needs them too. The SDK also brings in Jetpack Compose, CameraX, ML Kit barcode scanning, Retrofit, and Moshi, and Gradle raises your app's versions of these to at least the [SDK's](/client-sdks/android-sdk/reference/compatibility#libraries).

## 3. Enable core library desugaring

The SDK supports API 24 and uses `java.time`. Its AAR metadata requires every app that depends on it to enable core library desugaring with `desugar_jdk_libs` 2.1.5 or later, whatever the app's `minSdk`. Without it, the build fails with `Dependency 'ai.january:january-sdk-android:0.3.2' requires core library desugaring to be enabled`.

```kotlin
// app/build.gradle.kts
android {
    compileOptions {
        isCoreLibraryDesugaringEnabled = true
    }
}

dependencies {
    coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
}
```

## 4. Compile against SDK 36

The SDK's AAR metadata also requires compile SDK 36 or later. `minSdk` can stay as low as 24.

```kotlin
// app/build.gradle.kts
android {
    compileSdk = 36
}
```

## 5. Verify resolution

```bash
./gradlew :app:dependencyInsight \
  --dependency january-sdk-android \
  --configuration debugRuntimeClasspath
./gradlew :app:assembleDebug
```

The dependency report should show `ai.january:january-sdk-android:0.3.2` resolved from Maven Central.

Next: [Backend token endpoint](/client-sdks/android-sdk/getting-started/backend-token-endpoint)


# Backend token endpoint

The app gets client tokens from an endpoint on your backend, which mints each one with your API key through `POST https://partners.january.ai/v1.2/auth/client-tokens`. The endpoint's contract is the same for every SDK: [Your token endpoint](/docs/authentication#your-token-endpoint).

{% hint style="info" %}
No backend yet? Run the [token relay](/docs/authentication#develop-with-the-token-relay) and continue to [Authentication](/client-sdks/android-sdk/getting-started/authentication). Come back before launch.
{% endhint %}

## Response format

The Android SDK reads January's response unchanged, so return it as is:

```json
{ "token": "ct-…", "expires_in": 1800 }
```

`JanuaryClientToken.fromJson` reads `token` and `expires_in` (`expiresIn` is also accepted) and ignores other fields. The SDK refreshes a token 60 seconds before it expires and rejects one with 60 seconds or less left, so return a freshly minted token rather than one cached on your server.

## Scopes

The app uses one token for every resource, so mint it with the scopes for every feature the app uses ([scope table](/rest-api/authentication#client-token-scopes)). A call outside those scopes fails with `403 scope_insufficient`, which the SDK reports as `ErrorCategory.AUTHORIZATION`.

Minting returns `403 forbidden` until **Enable client tokens** is switched on in the [Developer Dashboard](https://dashboard.january.ai/dashboard/client-tokens).

Next: [Authentication](/client-sdks/android-sdk/getting-started/authentication)


# Authentication

The SDK authenticates with client tokens that your app fetches from your [backend token endpoint](/client-sdks/android-sdk/getting-started/backend-token-endpoint). You supply the fetching code as a `JanuaryTokenProvider`; the SDK calls it when it needs a token.

## Recommended: token provider

This provider uses platform networking, so the endpoint and its authentication stay in your app's code:

```kotlin
import ai.january.partner.JanuaryClientToken
import ai.january.partner.JanuaryTokenProvider
import ai.january.partner.JanuaryTokenProviderException
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class BackendTokenProvider(
    endpoint: String,
    private val endUserId: String,
    private val sessionToken: suspend () -> String,
) : JanuaryTokenProvider {
    private val endpointUrl = URL(endpoint)

    override suspend fun fetchClientToken(): JanuaryClientToken {
        val session = sessionToken() // Read the current session on every call.
        return withContext(Dispatchers.IO) {
            val connection = (endpointUrl.openConnection() as HttpURLConnection).apply {
                requestMethod = "POST"
                doOutput = true
                setFixedLengthStreamingMode(0)
                connectTimeout = 10_000
                readTimeout = 10_000
                setRequestProperty("Accept", "application/json")
                setRequestProperty("Authorization", "Bearer $session")
                // Only the token relay reads this header. Your production endpoint
                // takes the user from the session and ignores it.
                setRequestProperty("January-End-User-ID", endUserId)
            }
            try {
                val status = connection.responseCode
                if (status !in 200..299) {
                    throw JanuaryTokenProviderException(
                        "Token endpoint returned HTTP $status",
                        retryable = status == 408 || status == 429 || status >= 500,
                    )
                }
                val body = connection.inputStream.bufferedReader().use { it.readText() }
                try {
                    JanuaryClientToken.fromJson(body)
                } catch (error: Exception) {
                    throw JanuaryTokenProviderException("Token endpoint returned an unreadable body", cause = error)
                }
            } catch (error: IOException) {
                // Offline, refused, or timed out.
                throw JanuaryTokenProviderException("Token endpoint unreachable", retryable = true, cause = error)
            } finally {
                connection.disconnect()
            }
        }
    }
}
```

The SDK retries only a `JanuaryTokenProviderException` with `retryable = true`, with backoff ([Retries and token lifecycle](/client-sdks/android-sdk/reference/retries-and-lifecycle)). Any other exception ends the request with `ErrorCategory.AUTHENTICATION`.

Create one client per signed-in account (for example in a session-scoped DI component) and reuse it on every screen. When the account changes, discard it and create a new one: the client caches the account's token, so a new `forUser` scope alone keeps using the previous account's token ([Client lifecycle](/client-sdks/android-sdk/concepts/client-lifecycle)).

```kotlin
import ai.january.partner.JanuaryPartnerClient

val january = JanuaryPartnerClient.withClientTokenProvider(
    provider = BackendTokenProvider(
        endpoint = requireNotNull(appConfig.januaryTokenUrl),
        endUserId = account.id,
        sessionToken = { sessionRepository.requireAccessToken() },
    ),
)
```

The SDK has no default token endpoint, so fail at startup when yours isn't configured rather than falling back to a local URL. Next, scope the client to the user ([User identity and timezone](/client-sdks/android-sdk/concepts/user-context)).

## Fixed token

`withClientToken` takes one client token. The SDK doesn't refresh it: your app fetches a new token and creates a new client before the old one expires.

```kotlin
val january = JanuaryPartnerClient.withClientToken(clientToken)
```

## Development API key

For a quick local test with no token endpoint, `JanuaryPartnerClient(developmentApiKey)` sends your API key directly. The constructor is deprecated and prints a warning. Load the key from your untracked `local.properties` into debug builds only, as the [example app](/client-sdks/android-sdk/getting-started/example-app) does, and never ship it.

Next: [First request](/client-sdks/android-sdk/getting-started/quick-start)


# First request

This smoke-test app creates a token provider and a January client, searches for a food, and shows the result on screen. Do the [installation](/client-sdks/android-sdk/getting-started/installation) first, then add these files to a minimal Android app module.

## 1. Configure the app module

Put this in `app/build.gradle.kts`. If you did the installation in this module, add only the three `providers.gradleProperty` lines, the three `buildConfigField` lines, and `buildFeatures`.

```kotlin
plugins {
    // AGP 9 compiles Kotlin itself. With AGP 8, also apply org.jetbrains.kotlin.android.
    id("com.android.application")
}

val januaryTokenUrl = providers.gradleProperty("januaryTokenUrl").orNull ?: ""
val partnerSessionToken = providers.gradleProperty("partnerSessionToken").orNull ?: ""
val januaryEndUserId = providers.gradleProperty("januaryEndUserId").orNull ?: ""

android {
    namespace = "com.example.januaryquickstart"
    compileSdk = 36

    defaultConfig {
        applicationId = "com.example.januaryquickstart"
        minSdk = 24
        targetSdk = 36
        versionCode = 1
        versionName = "1.0"
        buildConfigField("String", "JANUARY_TOKEN_URL", "\"$januaryTokenUrl\"")
        buildConfigField("String", "PARTNER_SESSION_TOKEN", "\"$partnerSessionToken\"")
        buildConfigField("String", "JANUARY_END_USER_ID", "\"$januaryEndUserId\"")
    }

    buildFeatures { buildConfig = true }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
        // Required by the SDK (it uses java.time), at any minSdk.
        isCoreLibraryDesugaringEnabled = true
    }
}

dependencies {
    coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
    implementation("ai.january:january-sdk-android:0.3.2")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
}
```

## 2. Add the manifests

Create `app/src/main/AndroidManifest.xml`:

```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application android:theme="@android:style/Theme.Material.Light.NoActionBar">
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>
```

The SDK's manifest adds the internet, camera, and microphone permissions. This app doesn't open the camera or the microphone.

Android blocks plain `http://` requests, and the token relay on your computer serves `http://`. To test against it, allow cleartext traffic in debug builds only with `app/src/debug/AndroidManifest.xml`:

```xml
<!-- app/src/debug/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application android:usesCleartextTraffic="true" />
</manifest>
```

## 3. Add the activity and provider

Create `app/src/main/java/com/example/januaryquickstart/MainActivity.kt`. The provider is the one from [Authentication](/client-sdks/android-sdk/getting-started/authentication), except that it leaves out `Authorization` when the session is blank, as it is for the local relay.

```kotlin
package com.example.januaryquickstart

import ai.january.partner.JanuaryClientToken
import ai.january.partner.JanuaryException
import ai.january.partner.JanuaryPartnerClient
import ai.january.partner.JanuaryTokenProvider
import ai.january.partner.JanuaryTokenProviderException
import ai.january.partner.PartnerUserId
import ai.january.partner.foods.SearchFoodsRequest
import android.app.Activity
import android.os.Bundle
import android.widget.TextView
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.time.ZoneId
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

private class BackendTokenProvider(
    endpoint: String,
    private val endUserId: String,
    private val sessionToken: suspend () -> String,
) : JanuaryTokenProvider {
    private val endpointUrl = URL(endpoint)

    override suspend fun fetchClientToken(): JanuaryClientToken {
        val session = sessionToken() // Read the current session on every call.
        return withContext(Dispatchers.IO) {
            val connection = (endpointUrl.openConnection() as HttpURLConnection).apply {
                requestMethod = "POST"
                doOutput = true
                setFixedLengthStreamingMode(0)
                connectTimeout = 10_000
                readTimeout = 10_000
                setRequestProperty("Accept", "application/json")
                if (session.isNotBlank()) setRequestProperty("Authorization", "Bearer $session")
                // Only the token relay reads this header. Your production endpoint
                // takes the user from the session and ignores it.
                setRequestProperty("January-End-User-ID", endUserId)
            }
            try {
                val status = connection.responseCode
                if (status !in 200..299) {
                    throw JanuaryTokenProviderException(
                        "Token endpoint returned HTTP $status",
                        retryable = status == 408 || status == 429 || status >= 500,
                    )
                }
                val body = connection.inputStream.bufferedReader().use { it.readText() }
                try {
                    JanuaryClientToken.fromJson(body)
                } catch (error: Exception) {
                    throw JanuaryTokenProviderException("Token endpoint returned an unreadable body", cause = error)
                }
            } catch (error: IOException) {
                // Offline, refused, or timed out.
                throw JanuaryTokenProviderException("Token endpoint unreachable", retryable = true, cause = error)
            } finally {
                connection.disconnect()
            }
        }
    }
}

class MainActivity : Activity() {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val output = TextView(this).apply {
            text = "Connecting…"
            textSize = 18f
            setPadding(40, 80, 40, 40)
        }
        setContentView(output)

        if (BuildConfig.JANUARY_TOKEN_URL.isBlank() || BuildConfig.JANUARY_END_USER_ID.isBlank()) {
            output.text = "Pass -PjanuaryTokenUrl and -PjanuaryEndUserId (step 4)."
            return
        }

        val january = JanuaryPartnerClient.withClientTokenProvider(
            BackendTokenProvider(
                endpoint = BuildConfig.JANUARY_TOKEN_URL,
                endUserId = BuildConfig.JANUARY_END_USER_ID,
                sessionToken = { BuildConfig.PARTNER_SESSION_TOKEN },
            ),
        )
        val user = january.forUser(
            endUserId = PartnerUserId(BuildConfig.JANUARY_END_USER_ID),
            timezone = ZoneId.systemDefault().id,
        )

        scope.launch {
            try {
                val response = user.foods.search(
                    SearchFoodsRequest(query = "greek yogurt", limit = 5),
                )
                output.text = buildString {
                    appendLine("Connected")
                    response.items.forEach { appendLine("• ${it.name}") }
                }
            } catch (error: CancellationException) {
                throw error
            } catch (error: JanuaryException) {
                output.text = "January ${error.category}: ${error.message}"
            } catch (error: Exception) {
                output.text = "Integration error: ${error.message}"
            }
        }
    }

    override fun onDestroy() {
        scope.cancel()
        super.onDestroy()
    }
}
```

## 4. Build and run

Pick the token endpoint and pass its values as Gradle properties:

| Token endpoint               | `-PjanuaryTokenUrl`                             | `-PpartnerSessionToken`   | Who picks the user                                                |
| ---------------------------- | ----------------------------------------------- | ------------------------- | ----------------------------------------------------------------- |
| Your backend                 | `https://your-backend.example/january-token`    | An app session token      | Your backend, from the session. It ignores `January-End-User-ID`. |
| Token relay on this computer | `http://10.0.2.2:8787/api/january/client-token` | Leave it out              | The relay, from `January-End-User-ID`                             |
| Hosted or LAN token relay    | The relay's URL                                 | The relay's `RELAY_TOKEN` | The relay, from `January-End-User-ID`                             |

Always pass `-PjanuaryEndUserId`, the end-user ID the token is minted for. The app scopes requests with it; the SDK removes it from January requests because the token identifies the user. `10.0.2.2` is how the Android emulator reaches your computer. Start the relay first ([Develop with the token relay](/docs/authentication#develop-with-the-token-relay)).

```bash
./gradlew :app:installDebug \
  -PjanuaryTokenUrl=https://your-backend.example/january-token \
  -PpartnerSessionToken=YOUR_APP_SESSION_TOKEN \
  -PjanuaryEndUserId=YOUR_END_USER_ID

adb shell am start -n \
  com.example.januaryquickstart/.MainActivity
```

The screen shows `Connected`, followed by up to five food names. An error shows its January error category, or an integration message. Remove session tokens from your shell history afterward; a real app reads its session from secure storage.

## In a Compose app

Make the same call from a `LaunchedEffect`, with `user` created as above, once per signed-in account. Leaving the composition cancels the request.

```kotlin
import ai.january.partner.JanuaryException
import ai.january.partner.JanuaryPartnerUserClient
import ai.january.partner.foods.SearchFoodsRequest
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue

@Composable
fun FoodSearchCheck(user: JanuaryPartnerUserClient) {
    var status by remember { mutableStateOf("Connecting…") }
    LaunchedEffect(user) {
        status = try {
            val response = user.foods.search(SearchFoodsRequest(query = "greek yogurt", limit = 5))
            "Connected\n" + response.items.joinToString("\n") { "• ${it.name}" }
        } catch (error: JanuaryException) {
            "January ${error.category}: ${error.message}"
        }
    }
    Text(status)
}
```

Next: [Example app](/client-sdks/android-sdk/getting-started/example-app)


# Example app

The `demo` module of the [January Android SDK repository](https://github.com/January-ai/january-sdk-android) is a Jetpack Compose app that uses every resource: food search with full foods and servings, food analysis and the native scanner, restaurants, food logs, a Tracking tab with each day's food, water, and weight and week, month, and year charts, glucose prediction, and user and timezone settings. It creates a new client whenever you switch users, as [Client lifecycle](/client-sdks/android-sdk/concepts/client-lifecycle) describes.

Open the repository in Android Studio, or set `ANDROID_HOME` for command-line builds.

## 1. Start the token relay

The demo gets its tokens from the [token relay](/docs/authentication#develop-with-the-token-relay). Starting it walks you through creating an API key and switching on **Enable client tokens** in the Developer Dashboard. The key stays in the relay's `.env` file and never enters the app.

## 2. Point the demo at the relay

Add the relay's values to the repository's `local.properties`, which is untracked. Never commit it.

| Where the relay runs                                                                                 | `january.partnerTokenUrl`                       | `january.partnerSessionToken` |
| ---------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ----------------------------- |
| This computer, used from the emulator                                                                | `http://10.0.2.2:8787/api/january/client-token` | Leave it out                  |
| Your Wi-Fi, used from a device (`HOST=0.0.0.0 ./start.sh`)                                           | The Wi-Fi URL the relay prints                  | The token the relay generates |
| Hosted ([Vercel guide](https://github.com/January-ai/january-token-relay#optional-deploy-to-vercel)) | The relay's HTTPS token URL                     | Its `RELAY_TOKEN`             |

```properties
january.partnerTokenUrl=http://10.0.2.2:8787/api/january/client-token
```

The demo sends a `POST` with the selected end-user ID in `January-End-User-ID`, plus `Authorization: Bearer <january.partnerSessionToken>` when that is set. Its debug build allows cleartext traffic, so the local `http://` URL works.

## 3. Build and run

```bash
./gradlew :demo:installDebug
```

Search for a food, open it, change its serving, and try the scanner. If the token URL is missing, the demo says so instead of choosing a default.

## Without the relay

For the quickest local test, leave out the token values and set `january.apiKey=sk-…` in `local.properties`. Debug builds use the key directly and show a warning. Never commit the key, share the APK, or distribute the build.

Next: [Client lifecycle](/client-sdks/android-sdk/concepts/client-lifecycle)


# Core concepts

How the SDK models clients, end users, and food data.

* [Client lifecycle](/client-sdks/android-sdk/concepts/client-lifecycle): one client per signed-in account, coroutines, and cancellation.
* [User identity and timezone](/client-sdks/android-sdk/concepts/user-context): the scoped client, the end-user ID, and how days are counted.
* [Food discovery and servings](/client-sdks/android-sdk/concepts/food-lifecycle): from search to a full food and a portion you can log.


# Client lifecycle

## One client per signed-in account

Create one `JanuaryPartnerClient` per signed-in account, for example in a session-scoped DI component, and reuse it on every screen. The client caches that account's token in memory, and every `forUser` scope of it shares that cache. Because the SDK removes `January-End-User-ID` from January requests, a new `forUser` scope alone keeps sending the previous account's token until it expires. When the account signs out or switches, discard the client and create a new one.

```kotlin
import ai.january.partner.JanuaryPartnerClient
import ai.january.partner.PartnerUserId
import java.time.ZoneId

class JanuarySession(tokenUrl: String, account: Account, sessions: SessionRepository) {
    val january = JanuaryPartnerClient.withClientTokenProvider(
        BackendTokenProvider(
            endpoint = tokenUrl,
            endUserId = account.id,
            sessionToken = { sessions.requireAccessToken() },
        ),
    )
    val user = january.forUser(PartnerUserId(account.id), timezone = ZoneId.systemDefault().id)
}

// Sign-in: create a JanuarySession for the account.
// Sign-out or account switch: drop it, and create a new one for the next account.
```

`BackendTokenProvider` is the provider from [Authentication](/client-sdks/android-sdk/getting-started/authentication). The client has no `close()`; dropping the reference is enough. Don't create a client per request, and don't recreate one to refresh a token: the client caches, refreshes, and deduplicates token requests itself ([Retries and token lifecycle](/client-sdks/android-sdk/reference/retries-and-lifecycle)).

## Coroutines

Network calls are main-safe `suspend` functions. Call them from `viewModelScope`, `lifecycleScope`, or `LaunchedEffect`. Leaving the scope cancels the HTTP request ([Cancellation](/client-sdks/android-sdk/reference/error-handling#cancellation)).

`PhotoScanImage.dataUri` is the exception: it decodes and re-encodes the image on the calling thread, so run it on `Dispatchers.Default` ([Food analysis](/client-sdks/android-sdk/guides/photo-scanning#analyze-a-photo)).


# User identity and timezone

A client token is bound to one end user. Your app owns that identity: use the stable, opaque ID your own system has for the account (at most 64 characters), never an email address or name. `PartnerUserId` rejects a blank ID but doesn't check the length. The SDK doesn't persist the ID.

After sign-in, create one scoped client and use it for every resource: `foods`, `restaurants`, `foodAnalysis`, `foodLogs`, `waterLogs`, `weightLogs`, and `glucose`.

```kotlin
import ai.january.partner.PartnerUserId
import ai.january.partner.foods.SearchFoodsRequest
import java.time.ZoneId

val user = january.forUser(
    endUserId = PartnerUserId(account.id),
    timezone = ZoneId.systemDefault().id,
)

val foods = user.foods.search(SearchFoodsRequest(query = "banana"))
```

When the account signs out or switches, create a new `JanuaryPartnerClient`, not just a new scope ([Client lifecycle](/client-sdks/android-sdk/concepts/client-lifecycle)).

With a client token, the token decides the user: the SDK removes `January-End-User-ID` from January requests. With a development API key, the food, water, and weight log operations send the scoped ID in that header.

## Timezone

`timezone` is an IANA ID. It sets the calendar days for food-log lists and summaries and for water and weight lists, and it is sent with glucose predictions. Without one, the Android SDK uses UTC (the iOS SDK uses the device timezone), so an 8 p.m. dinner in Los Angeles lands on the next day. Pass the device timezone, as above.

## Days

Each log stores an instant: `timestampUtc`, `consumedAt`, or `measuredAt` (`created_at` in the REST API). Lists and summaries sort those instants into calendar days in the scoped timezone when you read them ([Days and timezones](/rest-api/api-overview#days-and-timezones)). So:

* Build `YYYY-MM-DD` dates in that same timezone.
* To backdate an entry to a local day, send a time on that day with its offset.
* When the device timezone changes (`Intent.ACTION_TIMEZONE_CHANGED`), create a new scoped client. The same entries can then fall on a different day.

```kotlin
import ai.january.partner.waterlogs.VolumeUnit
import ai.january.partner.waterlogs.WaterAmount
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId

val zone = ZoneId.of(user.context.timezone ?: "UTC")
val today = LocalDate.now(zone)
val lastWeek = user.foodLogs.list(today.minusDays(6).toString(), today.toString())

val yesterday = today.minusDays(1)
    .atTime(LocalTime.now(zone)).atZone(zone).toOffsetDateTime().toString()
user.waterLogs.create(WaterAmount(250.0, VolumeUnit.ML), consumedAt = yesterday)
```

The 24 L daily water cap is the exception: it counts the UTC day ([Water](/client-sdks/android-sdk/guides/water-and-weight-logs#water)).


# Food discovery and servings

Food integration is a sequence, not one interchangeable response type:

```
autocomplete ── selection ──▶ search ── selected result ──▶ get
                                                           │
                                                           ▼
                                                   serving + quantity
                                                           │
                                                           ▼
                                                      FoodPortion
```

* `autocomplete` returns text-entry suggestions.
* Selecting a suggestion should fill the search box and run `search`.
* `search` returns discovery rows, which may not list every serving.
* Before showing servings, fetch the full food with `get`.
* Build a `FoodPortion` from the full food to validate the serving and scale nutrition locally.

```kotlin
import ai.january.partner.foods.GetFoodRequest
import ai.january.partner.foods.SearchFoodsRequest
import ai.january.partner.foods.portion

val results = user.foods.search(SearchFoodsRequest(query = "banana"))
val food = user.foods.get(GetFoodRequest(foodId = results.items.first().id))

// Without a serving ID, portion uses the primary serving (or the first one);
// without a quantity, it's one serving.
val portion = food.portion()

println(portion.nutrition.calories?.value)
val selection = portion.selection

// With a "6 oz" primary serving: 9 oz, one and a half servings.
val larger = food.portion(quantity = 9.0)
```

`portion.selection` is what `foodLogs.create` and `glucose.predict` take. Treat an empty serving list or a `FoodPortionException` as a data error to show the user ([Portion helper](/client-sdks/android-sdk/reference/foods-api#portion-helper)).

## Quantity and servings

`quantity` is an amount in the serving's unit, not a number of servings. For a "6 oz" serving (`quantity` 6.0, `unit` "oz"), `quantity = 9.0` means 9 oz, and leaving `quantity` out means one serving (6 oz). `portion.selection` sends the number of servings, `quantity` divided by the serving's own quantity, so 9 oz of a "6 oz" serving is logged as 1.5 servings. Versions before 0.3.2 sent `quantity` itself as the number of servings, so food logs and glucose predictions got the wrong amount (a default "6 oz" portion was logged as 6 servings); upgrade to 0.3.2.


# Guides

Task-oriented walkthroughs for each capability.

* [Foods](/client-sdks/android-sdk/guides/foods): autocomplete, search, full foods, barcodes, and alternatives.
* [Restaurants](/client-sdks/android-sdk/guides/restaurants): nearby restaurants, their menu items, and one restaurant's full menu.
* [Food analysis](/client-sdks/android-sdk/guides/photo-scanning): foods from a photo or a description, and the native food scanner.
* [Voice capture](/client-sdks/android-sdk/guides/voice-capture): speech to text for search fields and meal descriptions.
* [Food logs](/client-sdks/android-sdk/guides/food-logs): create, list, update, and summarize meals.
* [Water and weight logs](/client-sdks/android-sdk/guides/water-and-weight-logs): daily water totals and weights.
* [Glucose prediction](/client-sdks/android-sdk/guides/glucose-prediction): the predicted glucose curve for a meal.


# Foods

Use `user.foods` to autocomplete, search, fetch a full food, look up barcodes, and suggest alternatives. To turn a typed or spoken meal description into foods, use `user.foodAnalysis.analyzeDescription` ([Food analysis](/client-sdks/android-sdk/guides/photo-scanning#analyze-a-description)).

```kotlin
import ai.january.partner.foods.AutocompleteFoodsRequest

val suggestions = user.foods.autocomplete(AutocompleteFoodsRequest("ban"))
```

## Search and fetch the full food

Selecting a suggestion should fill the search field and run `search`. Search rows may not list every serving, so fetch the full food with `get` before opening a serving picker:

```kotlin
import ai.january.partner.foods.GetFoodRequest
import ai.january.partner.foods.SearchFoodsRequest
import ai.january.partner.foods.portion

val results = user.foods.search(SearchFoodsRequest("banana"))
val food = user.foods.get(GetFoodRequest(results.items.first().id))
val portion = food.portion() // One primary serving
```

`portion` validates the serving and quantity and scales nutrients locally ([Food discovery and servings](/client-sdks/android-sdk/concepts/food-lifecycle)). To use another amount, pass `quantity` in the serving's unit, not a number of servings ([Quantity and servings](/client-sdks/android-sdk/concepts/food-lifecycle#quantity-and-servings)).

Search returns up to `limit` results (1–50, default 10). To page, pass `offset`; a page shorter than `limit` is the last one. `totalCount` is the number of results in this page, not the total number of matches.

```kotlin
val secondPage = user.foods.search(SearchFoodsRequest("banana", limit = 10, offset = 10))
```

## Barcodes and alternatives

```kotlin
import ai.january.partner.foods.DietPreference
import ai.january.partner.foods.GetFoodRequest
import ai.january.partner.foods.LookupFoodByBarcodeRequest
import ai.january.partner.foods.SuggestFoodAlternativesRequest

val scanned = user.foods.lookupBarcode(LookupFoodByBarcodeRequest("012345678905")).items.first()
val food = user.foods.get(GetFoodRequest(scanned.id))
val alternatives = user.foods.suggestAlternatives(
    SuggestFoodAlternativesRequest(food.id.value, dietPreferences = listOf(DietPreference.VEGAN)), // foodId is a String
)
```

`lookupBarcode` returns one food. Fetch the full food with `get` before showing servings, as the native scanner does. A barcode with no match fails with `ErrorCategory.NOT_FOUND`. Barcode coverage is US-only: a code issued outside the United States (for example GS1 prefixes 73, 64, 54, or 93) isn't in the database. For those, analyze a photo of the label ([Food analysis](/client-sdks/android-sdk/guides/photo-scanning#analyze-a-photo)) or use food search.

Each alternative lists the `servings` its nutrition can be read against.


# Restaurants

Search nearby restaurants or their menu items with the same location request. These examples use `user`, the scoped client from [User identity and timezone](/client-sdks/android-sdk/concepts/user-context).

The SDK doesn't read the device location or declare a location permission. Your app supplies the coordinates, for example from the fused location provider after the user grants `ACCESS_COARSE_LOCATION`, or from a place the user picks.

```kotlin
import ai.january.partner.restaurants.SearchRestaurantsRequest

val request = SearchRestaurantsRequest(
    query = "mediterranean",
    latitude = 37.7749,
    longitude = -122.4194,
    radius = 8_000.0,
    limit = 10,
)

val restaurants = user.restaurants.search(request)
val menuItems = user.restaurants.searchMenuItems(request.copy(query = "chicken"))
```

Queries contain 1–256 characters, radius is 1–50,000 meters (8,000 by default), limit is 1–100, and coordinates must be valid latitude and longitude values.

## Load one restaurant's menu

Use an ID from restaurant search to load that restaurant's menu without repeating the query or coordinates:

```kotlin
import ai.january.partner.restaurants.GetRestaurantMenuItemsRequest

var offset = 0
val limit = 100

do {
    val page = user.restaurants.getMenuItems(
        GetRestaurantMenuItemsRequest(
            restaurantId = restaurant.id,
            limit = limit,
            offset = offset,
        ),
    )

    consume(page.items)
    offset += page.items.size
} while (page.items.size == limit)
```

The response contains only `items`, with no `totalCount`, so keep paging while a full page comes back. An empty page ends the menu, including for a restaurant with no menu on record. An unknown restaurant fails with `ErrorCategory.NOT_FOUND`. In a discovery UI, fall back to `searchMenuItems` with the user's original query and location when the first page is empty or the restaurant is not found.


# Food analysis

`user.foodAnalysis` turns a meal photo or a typed or spoken description into foods with portions and nutrition. The native food scanner, `JanuaryFoodScanner`, wraps photo analysis and barcode lookup in a full-screen Compose camera UI. These examples use `user`, the scoped client from [User identity and timezone](/client-sdks/android-sdk/concepts/user-context).

An analysis can take tens of seconds, so the SDK waits at least 120 seconds for the answer. Show progress while it runs, and don't wrap the call in a shorter `withTimeout`.

## Analyze a photo

`PhotoScanImage.dataUri` corrects EXIF orientation, scales the longest edge down to 1,000 pixels, and compresses to JPEG quality 70. It works on the calling thread, so run it on `Dispatchers.Default`:

```kotlin
import ai.january.partner.photos.PhotoScanImage
import ai.january.partner.photos.ScanFoodPhotoRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

val image = withContext(Dispatchers.Default) { PhotoScanImage.dataUri(photoBytes) }
val scan = user.foodAnalysis.analyzePhoto(ScanFoodPhotoRequest(image))
```

The photo can show a meal or a packaged product: the front of the pack, the ingredient list, or the Nutrition Facts panel. Nutrition read from a label can be incomplete, so check it before relying on it. A photo of only a barcode is rejected; use [`foods.lookupBarcode`](/client-sdks/android-sdk/guides/foods#barcodes-and-alternatives) instead.

## Analyze a description

```kotlin
import ai.january.partner.foods.SearchFoodsByNaturalLanguageRequest

val scan = user.foodAnalysis.analyzeDescription(
    SearchFoodsByNaturalLanguageRequest("two scrambled eggs and a slice of toast"),
)
```

A description can be typed or come from [voice capture](/client-sdks/android-sdk/guides/voice-capture). Its result has no `mealName`.

## Correct a result

Send the complete prior result back, unchanged, with a plain-language instruction:

```kotlin
import ai.january.partner.photos.CorrectPhotoScanRequest

val corrected = user.foodAnalysis.correct(
    CorrectPhotoScanRequest(analysis = scan, instruction = "Remove the fries"),
)
```

## Log the result

Each detection's `food` has its catalog `id`, the selected `serving`, and the `quantity` eaten, ready for a [food log](/client-sdks/android-sdk/guides/food-logs). `detections` is empty when nothing was recognized.

```kotlin
import ai.january.partner.models.FoodSelection
import ai.january.partner.models.ServingSelection

val foods = scan.detections.mapNotNull { detection ->
    val food = detection.food
    FoodSelection(
        food.id ?: return@mapNotNull null,
        ServingSelection(
            food.serving.id ?: return@mapNotNull null,
            food.quantity ?: return@mapNotNull null,
        ),
    )
}
if (foods.isNotEmpty()) user.foodLogs.create(foods, name = scan.mealName)
```

## Native food scanner

The scanner has photo and barcode modes. Pass it the scoped client:

```kotlin
import ai.january.partner.scanner.JanuaryFoodScanner
import ai.january.partner.scanner.JanuaryFoodScannerResult

JanuaryFoodScanner(
    userClient = user,
    onResult = { result ->
        when (result) {
            is JanuaryFoodScannerResult.Photo -> openReview(result.analysis)
            is JanuaryFoodScannerResult.Barcode -> openServingPicker(result.food) // already a full food
        }
    },
    onCancel = { navController.popBackStack() },
)
```

The scanner asks for the camera permission and shows its own permission, progress, and error states; an error offers **Try Again** and **Close**, and **Close** calls `onCancel`. It doesn't close itself after a result: navigate away in `onResult`.

Test permission denial (including "Don't ask again"), a camera that fails to start, a failed scan, and cancellation.


# Voice capture

`VoiceCaptureSession` uses Android's speech recognizer to turn microphone input into text. It doesn't call January and needs no client or token. To get foods from the text, send the transcript to `user.foodAnalysis.analyzeDescription(SearchFoodsByNaturalLanguageRequest(transcript))` ([Food analysis](/client-sdks/android-sdk/guides/photo-scanning#analyze-a-description)).

## Add voice input to a field

The SDK's manifest declares `RECORD_AUDIO`, but your app must request the runtime permission. Create one session per voice-enabled input, close it with the composition, and call every session method on the main thread. `startListening()` throws a `VoiceCaptureException` right away when the permission is missing (`PERMISSION_DENIED`), the device has no speech recognizer (`RECOGNIZER_UNAVAILABLE`, common on emulators), or a capture is already running (`INVALID_STATE`). Failures after listening starts arrive on the `error` flow instead.

```kotlin
import ai.january.partner.voice.VoiceCaptureErrorCode
import ai.january.partner.voice.VoiceCaptureException
import ai.january.partner.voice.VoiceCaptureSession
import ai.january.partner.voice.VoiceCaptureState
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat

@Composable
fun VoiceSearchField() {
    val context = LocalContext.current
    var query by rememberSaveable { mutableStateOf("") }
    var voiceError by remember { mutableStateOf<VoiceCaptureErrorCode?>(null) }

    val voiceCapture = remember { VoiceCaptureSession(context) }
    DisposableEffect(voiceCapture) { onDispose { voiceCapture.close() } }
    val state by voiceCapture.state.collectAsState()
    val result by voiceCapture.latestResult.collectAsState()
    val failure by voiceCapture.error.collectAsState()

    fun start() {
        voiceError = null
        try {
            voiceCapture.startListening()
        } catch (error: VoiceCaptureException) {
            voiceError = error.code
        }
    }
    val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
        if (granted) start() else voiceError = VoiceCaptureErrorCode.PERMISSION_DENIED
    }

    LaunchedEffect(result) {
        result?.let {
            query = "$query ${it.transcript}".trim()
            voiceCapture.clearResult()
        }
    }
    LaunchedEffect(failure) {
        failure?.let {
            voiceError = it.code
            voiceCapture.clearError()
        }
    }

    OutlinedTextField(
        value = query,
        onValueChange = { query = it },
        supportingText = { voiceError?.let { Text("Voice input failed: $it") } },
        trailingIcon = {
            IconButton(
                enabled = state != VoiceCaptureState.PROCESSING,
                onClick = {
                    when {
                        state == VoiceCaptureState.LISTENING -> voiceCapture.stopListening()
                        ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
                            PackageManager.PERMISSION_GRANTED -> start()
                        else -> permission.launch(Manifest.permission.RECORD_AUDIO)
                    }
                },
            ) { Text(if (state == VoiceCaptureState.LISTENING) "Stop" else "Mic") }
        },
    )
}
```

Ask for the permission only after the user taps the microphone. After a denial, explain why voice input needs it. If Android no longer shows the prompt, offer to open the app's settings page (`Settings.ACTION_APPLICATION_DETAILS_SETTINGS`).

## Session state

The lifecycle is `IDLE` → `LISTENING` → `PROCESSING` → `IDLE`. Call `stopListening()` to end listening and get the final transcript, or `cancel()` to discard the capture.

* `audioLevel` is a `StateFlow` from 0 to 1 for a level meter.
* `partialTranscript` is a `StateFlow` with live text while listening.
* `elapsedDurationMillis` is a plain property, not a flow; poll it (for example every 100 ms while `LISTENING`) to drive a timer.
* `latestResult` holds a `VoiceCaptureResult` with the final `transcript` and `durationMillis`. Call `clearResult()` once you've used it.
* `error` holds the latest `VoiceCaptureException`. Its `code` is one of `PERMISSION_DENIED`, `RECOGNIZER_UNAVAILABLE`, `RECOGNIZER_BUSY`, `AUDIO`, `NETWORK`, `NO_MATCH`, `INVALID_STATE`, or `UNKNOWN`. Call `clearError()` once you've shown it.

The constructor also takes `locale` (default `Locale.getDefault()`) and `endOfSpeechSilence`, how long a pause ends the capture (default two seconds; a hint that some recognizers ignore):

```kotlin
import java.time.Duration
import java.util.Locale

val voiceCapture = VoiceCaptureSession(
    context,
    locale = Locale.US,
    endOfSpeechSilence = Duration.ofSeconds(3),
)
```

Android's speech recognizer owns the microphone stream. The SDK doesn't keep or expose recorded audio.


# Food logs

These examples use `user`, the scoped client from [User identity and timezone](/client-sdks/android-sdk/concepts/user-context), and a `portion` from [Food discovery and servings](/client-sdks/android-sdk/concepts/food-lifecycle).

```kotlin
import java.time.OffsetDateTime

val log = user.foodLogs.create(
    foods = listOf(portion.selection),
    name = "Breakfast",
)

// Backdated three hours.
val snack = user.foodLogs.create(
    foods = listOf(portion.selection),
    timestampUtc = OffsetDateTime.now().minusHours(3).toString(),
    name = "Snack",
)

val logs = user.foodLogs.list("2026-08-01", "2026-08-31")
val logId = requireNotNull(log.id) { "January did not return a food log ID." }
user.foodLogs.update(logId, name = "Post-workout breakfast")
user.foodLogs.delete(logId)
```

`timestampUtc` defaults to now and accepts an ISO 8601 date-time with any offset; January returns it in UTC. `list` takes inclusive `YYYY-MM-DD` dates in the client's timezone, built in that same timezone ([Days](/client-sdks/android-sdk/concepts/user-context#days)), and spans at most 60 days.

Creating a log isn't idempotent. After a create times out, list the day before retrying, or the meal may be logged twice.

`update` sends only the fields you pass and needs at least one. `delete` returns `Unit`. Each log's `foods` are `LoggedFood` values whose nutrients are already scaled to the amount eaten ([Models and enums](/client-sdks/android-sdk/reference/models-and-enums#food-logs)).

A malformed date or date-time throws `DateTimeParseException`, and a log ID that isn't a UUID throws `IllegalArgumentException`, before any request is sent. Neither is a `JanuaryException` ([Error handling](/client-sdks/android-sdk/reference/error-handling#errors-thrown-before-a-request)).

## Summaries

For a daily or weekly overview, ask for a summary instead of paging through logs:

```kotlin
import ai.january.partner.foodlogs.FoodLogSummaryGrouping

val summary = user.foodLogs.getSummary(
    "2026-09-01", "2026-09-30",
    groupBy = FoodLogSummaryGrouping.WEEK,
)
summary.buckets.forEach { week ->
    val calories = week.nutrients.calories
    println("${week.startDate}: ${week.logsCount} logs, ${calories?.value} ${calories?.unit}")
}
val dailyAverage = summary.averagePerLoggedDay.nutrients
```

A summary spans at most 366 days, with a bucket for every day or week in the range, including empty ones. `nutrients` is sparse, so read `logsCount` to tell an empty bucket from one whose logs had no nutrition data.


# Water and weight logs

Water intake and body weight are logged per end user, like food logs. These examples use `user`, the scoped client from [User identity and timezone](/client-sdks/android-sdk/concepts/user-context). The token needs the `water_logs:read`, `water_logs:write`, `weight_logs:read`, and `weight_logs:write` scopes ([scope table](/rest-api/authentication#client-token-scopes)).

## Water

Log one amount at a time, in fluid ounces (1–811.5), milliliters (30–24,000), or US cups (0.1–101.4; 1 cup = 8 fl oz). Keep the returned `id` if the user may undo the entry:

```kotlin
import ai.january.partner.waterlogs.VolumeUnit
import ai.january.partner.waterlogs.WaterAmount
import java.time.OffsetDateTime

val glass = user.waterLogs.create(WaterAmount(8.0, VolumeUnit.FL_OZ))
user.waterLogs.delete(glass.id)

// Backdated two hours.
user.waterLogs.create(
    WaterAmount(500.0, VolumeUnit.ML),
    consumedAt = OffsetDateTime.now().minusHours(2).toString(),
)
```

`consumedAt` defaults to now and accepts an ISO 8601 date-time with any offset.

An end user's water is capped at 24 L (about 811 fl oz) per day. The cap counts the UTC calendar day of `consumedAt`, whatever offset you send, while lists group days in the client's timezone, so near midnight a listed day's total can differ from what the cap counted. A log that would go over the cap fails with `ErrorCategory.VALIDATION` and the code `daily_water_limit_exceeded`. Don't retry it.

Creating a water log isn't idempotent. After a create times out, list the day before retrying, or the water is recorded twice and counts twice toward the cap. Deleting is idempotent: deleting an unknown or already-deleted log also succeeds.

The list returns one total per local calendar day that has water logged, oldest first, in the unit you ask for:

```kotlin
val days = user.waterLogs.list("2026-09-01", "2026-09-30", VolumeUnit.ML)
days.items.forEach { day -> println("${day.date}: ${day.total.value} ${day.total.unit.value}") }
```

## Weight

Log a measurement in pounds (10–1,000) or kilograms (4.5–453.6). It is stored and returned in the unit you send. `Weight` and `WeightUnit` are in the `glucose` package:

```kotlin
import ai.january.partner.glucose.Weight
import ai.january.partner.glucose.WeightUnit

val measurement = user.weightLogs.create(Weight(154.5, WeightUnit.POUNDS))
```

`measuredAt` works like `consumedAt`. Every measurement is kept, and the list shows one weight per local day: the one with the latest `measuredAt`. A later measurement on the same day replaces what that day shows; one backdated to earlier that day doesn't.

```kotlin
val days = user.weightLogs.list("2026-09-01", "2026-09-30")
days.items.forEach { day -> println("${day.date}: ${day.weight.value} ${day.weight.unit.value}") }
```

Creating a weight log isn't idempotent either: a retried create records the measurement again. Weight logs have no ID and can't be updated or deleted; to change what a day shows, log a newer measurement for that day.

## Ranges

`start` and `end` are inclusive `YYYY-MM-DD` dates in the client's timezone ([Days](/client-sdks/android-sdk/concepts/user-context#days)), at most five years back. A range further back fails with the code `date_range_too_large`. When more than 100 days in the range have entries, only the most recent 100 are returned; to read older days, move `end` back. Days with nothing logged are absent, and an empty list is a normal result.

To cover a longer period, such as a year for a chart, request consecutive ranges of 90 days or fewer and join the results, as the example app does.


# Glucose prediction

`user.glucose.predict` estimates the glucose curve a meal will produce, without a sensor. The token needs the `glucose:read` scope. Predictions are informational, not diagnosis or treatment guidance. This example uses `user`, the scoped client from [User identity and timezone](/client-sdks/android-sdk/concepts/user-context), and a `portion` from [Food discovery and servings](/client-sdks/android-sdk/concepts/food-lifecycle).

```kotlin
import ai.january.partner.glucose.ActivityLevel
import ai.january.partner.glucose.GlucoseImpact
import ai.january.partner.glucose.GlucosePredictionProfile
import ai.january.partner.glucose.Height
import ai.january.partner.glucose.HeightUnit
import ai.january.partner.glucose.PredictGlucoseRequest
import ai.january.partner.glucose.Sex
import ai.january.partner.glucose.Weight
import ai.january.partner.glucose.WeightUnit
import java.time.OffsetDateTime

val profile = GlucosePredictionProfile(
    age = 35.0,
    sex = Sex.MALE,
    height = Height(70.0, HeightUnit.INCHES),
    weight = Weight(175.0, WeightUnit.POUNDS),
    activityLevel = ActivityLevel.MODERATELY_ACTIVE,
    healthConditions = emptyList(),
)

val result = user.glucose.predict(
    PredictGlucoseRequest(
        userProfile = profile,
        foods = listOf(portion.selection),
        startTime = OffsetDateTime.now(),
    ),
)

result.prediction.forEach { point -> println("+${point.minutes} min: ${point.value} mg/dL") }
val impactLabel = when (result.impact) {
    GlucoseImpact.LOW -> "Low impact"
    GlucoseImpact.MEDIUM -> "Medium impact"
    GlucoseImpact.HIGH -> "High impact"
    else -> null // No grade.
}
```

`result.prediction` holds a point every 15 minutes: `minutes` after `startTime` and the predicted `value` in mg/dL. `result.impact` is `GlucoseImpact.LOW`, `MEDIUM`, or `HIGH`, or null when there's no grade. `result.chart.min` and `max` are suggested Y-axis bounds in mg/dL (a target range, not the curve's lowest and highest values); either can be null.

The prediction depends on the meal's local time of day, so the client's timezone matters. Without one, the SDK sends UTC ([Timezone](/client-sdks/android-sdk/concepts/user-context#timezone)).

`age` is in whole years; a fractional age fails with `ErrorCategory.VALIDATION` before anything is sent. Height takes inches or centimeters, and weight pounds or kilograms. Show imperial height as feet plus inches, not one field of raw inches.

To personalize the prediction, pass `cgmData` together with the `consumedFoods` eaten during that period, covering at least five full days; less is rejected. Leave both out for a standard prediction.


# Reference

The complete API surface, models, and operational details.

Call every resource through the scoped client from `january.forUser(...)`. The optional `endUserId` fields on request models exist only for direct calls on the unscoped client, and the scoped client overwrites them.

* [Client and resources](/client-sdks/android-sdk/reference/client-and-resources): constructors, the token provider types, and each resource's operations.
* [Foods API](/client-sdks/android-sdk/reference/foods-api): food requests, responses, and the portion helper.
* [Restaurants and food analysis API](/client-sdks/android-sdk/reference/discovery-and-scanning-api): restaurant search, food analysis, and the native scanner.
* [Logs and glucose API](/client-sdks/android-sdk/reference/user-resources-api): food, water, and weight logs, and glucose prediction.
* [Models and enums](/client-sdks/android-sdk/reference/models-and-enums): identifiers, enum constants, and model fields.
* [Retries and token lifecycle](/client-sdks/android-sdk/reference/retries-and-lifecycle): token caching, refresh, and backoff.
* [Error handling](/client-sdks/android-sdk/reference/error-handling): `JanuaryException`, error categories, and cancellation.
* [Compatibility and permissions](/client-sdks/android-sdk/reference/compatibility): requirements, bundled libraries, and manifest permissions.
* [Testing and support](/client-sdks/android-sdk/reference/testing-and-support): testing your integration, updating, and reporting issues.
* [Troubleshooting](/client-sdks/android-sdk/reference/troubleshooting): common build and runtime failures.
* [Changelog](/client-sdks/android-sdk/reference/changelog): changes in each release.


# Client and resources

`JanuaryPartnerClient` is the entry point. Create one per signed-in account ([Client lifecycle](/client-sdks/android-sdk/concepts/client-lifecycle)) with one of these constructors:

| Constructor                                           | Use                                                                               |
| ----------------------------------------------------- | --------------------------------------------------------------------------------- |
| `withClientTokenProvider(provider, tokenRetryPolicy)` | Recommended. The SDK fetches, caches, and refreshes tokens through your provider. |
| `withClientToken(clientToken)`                        | Fixed token. Your app refreshes it and creates a new client before it expires.    |

```kotlin
JanuaryPartnerClient.withClientToken(clientToken: String): JanuaryPartnerClient

JanuaryPartnerClient.withClientTokenProvider(
    provider: JanuaryTokenProvider,
    tokenRetryPolicy: JanuaryTokenRetryPolicy = JanuaryTokenRetryPolicy(),
): JanuaryPartnerClient

fun interface JanuaryTokenProvider {
    suspend fun fetchClientToken(): JanuaryClientToken
}

data class JanuaryClientToken(
    val token: String,
    val expiresIn: Long,
)

class JanuaryTokenProviderException(
    message: String,
    val retryable: Boolean = false,
    cause: Throwable? = null,
) : Exception
```

`JanuaryClientToken.fromJson(json)` reads `token` and either `expires_in` or `expiresIn`, and throws when either is missing. The provider must return a token that isn't blank and has more than 60 seconds left ([Retries and token lifecycle](/client-sdks/android-sdk/reference/retries-and-lifecycle)).

| Resource       | Operations                                                              |
| -------------- | ----------------------------------------------------------------------- |
| `foods`        | `autocomplete`, `search`, `get`, `lookupBarcode`, `suggestAlternatives` |
| `restaurants`  | `search`, `searchMenuItems`, `getMenuItems`                             |
| `foodAnalysis` | `analyzePhoto`, `analyzeDescription`, `correct`                         |
| `foodLogs`     | `create`, `list`, `get`, `getSummary`, `update`, `delete`               |
| `waterLogs`    | `create`, `list`, `delete`                                              |
| `weightLogs`   | `create`, `list`                                                        |
| `glucose`      | `predict`                                                               |

All network operations are `suspend` functions.

```kotlin
fun forUser(context: PartnerUserContext): JanuaryPartnerUserClient
fun forUser(
    endUserId: PartnerUserId,
    timezone: String? = null,
): JanuaryPartnerUserClient
```

`forUser` returns a lightweight `JanuaryPartnerUserClient` with the same seven resources, each applying one `PartnerUserContext` (its `context` property) ([User identity and timezone](/client-sdks/android-sdk/concepts/user-context)).

Local helpers: `FoodSearchItem.portion(...)` ([Foods API](/client-sdks/android-sdk/reference/foods-api#portion-helper)) and `PhotoScanImage.dataUri(...)`. The Compose scanner UI is `JanuaryFoodScanner` ([Restaurants and food analysis API](/client-sdks/android-sdk/reference/discovery-and-scanning-api#native-scanner)).


# Foods API

All operations are `suspend` functions and throw as described in [Error handling](/client-sdks/android-sdk/reference/error-handling).

## Operations

```kotlin
suspend fun autocomplete(request: AutocompleteFoodsRequest): AutocompleteFoodsResponse
suspend fun search(request: SearchFoodsRequest): FoodSearchResults
suspend fun get(request: GetFoodRequest): FoodSearchItem
suspend fun lookupBarcode(request: LookupFoodByBarcodeRequest): FoodSearchResults
suspend fun suggestAlternatives(
    request: SuggestFoodAlternativesRequest,
): SuggestFoodAlternativesResponse
```

## Requests and defaults

| Request                          | Fields                                                                                                                                                                                              |
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `AutocompleteFoodsRequest`       | `query: String`; `category: AutocompleteFoodCategory? = null`; `limit: Int = 8`; `endUserId: PartnerUserId? = null`                                                                                 |
| `SearchFoodsRequest`             | `query: String`; `category: FoodCategory? = null`; `limit: Int = 10` (1–50); `endUserId: PartnerUserId? = null`; `offset: Int = 0`                                                                  |
| `GetFoodRequest`                 | `foodId: FoodId`; `endUserId: PartnerUserId? = null`                                                                                                                                                |
| `LookupFoodByBarcodeRequest`     | `upc: String`; `endUserId: PartnerUserId? = null`                                                                                                                                                   |
| `SuggestFoodAlternativesRequest` | `foodId: String` (a `Long` constructor remains); `dietRestrictions: List<DietRestriction> = emptyList()`; `dietPreferences: List<DietPreference> = emptyList()`; `endUserId: PartnerUserId? = null` |

Autocomplete limits are 1–20, and its query may contain at most 64 characters. Search requires a query of 1–256 characters that isn't blank, a limit from 1–50, and an offset of 0 or more.

## Responses

`AutocompleteFoodsResponse.items` contains `FoodSuggestion`: `id`, `name`, optional `brandName`, optional `imageUrl`, and optional `NutritionFacts`.

`FoodSearchResults` contains `items: List<FoodSearchItem>` and `totalCount`, the number of items in this response (not the total number of matches). Each item has its ID, name, brand, complete `NutritionFacts` (`nutrients`), flattened macro fields, glycemic values, photo URL, and `servings`. `lookupBarcode` returns one item.

`ServingOption` fields are `id: ServingId`, `quantity`, `unit`, `scalingFactor`, optional `weightGrams`, and `isPrimary`.

Alternatives returns `alternatives: List<AlternativeFood>`. Meal descriptions go to `foodAnalysis.analyzeDescription` ([Restaurants and food analysis API](/client-sdks/android-sdk/reference/discovery-and-scanning-api#food-analysis)).

## Portion helper

```kotlin
fun FoodSearchItem.portion(
    servingId: ServingId? = null,
    quantity: Double? = null,
): FoodPortion
```

Without a serving ID, the primary or first serving is selected. `quantity` is an amount in the serving's unit, and without one the portion is one serving; `selection` sends the number of servings ([Quantity and servings](/client-sdks/android-sdk/concepts/food-lifecycle#quantity-and-servings)). Quantity must be finite, positive, and at most 10,000. Failures throw `FoodPortionException` with a `reason` of type `FoodPortionError`: `NO_SERVINGS`, `SERVING_NOT_FOUND`, `INVALID_SERVING`, or `INVALID_QUANTITY`. [Models and enums](/client-sdks/android-sdk/reference/models-and-enums#foods) lists the `FoodPortion` fields.


# Restaurants and food analysis API

## Restaurants

```kotlin
suspend fun search(request: SearchRestaurantsRequest): SearchRestaurantsResponse
suspend fun searchMenuItems(
    request: SearchRestaurantsRequest,
): SearchRestaurantMenuItemsResponse
suspend fun getMenuItems(
    request: GetRestaurantMenuItemsRequest,
): GetRestaurantMenuItemsResponse
```

`SearchRestaurantsRequest` fields:

| Field       | Type and default                                |
| ----------- | ----------------------------------------------- |
| `query`     | `String`, required, 1–256 characters, not blank |
| `latitude`  | `Double`, required, −90…90                      |
| `longitude` | `Double`, required, −180…180                    |
| `radius`    | `Double = 8000.0`, in meters, range 1…50,000    |
| `limit`     | `Int = 10`, range 1…100                         |
| `endUserId` | `PartnerUserId? = null`                         |

`SearchRestaurantsResponse` has `items: List<Restaurant>` and `totalCount`, the number of restaurants returned. Restaurant fields are `type`, `id`, `name`, and optional chain, distance, city, and address metadata. Menu search returns `RestaurantMenuItem` values with the restaurant name, optional nutrition, distance, and photo data, and `servings`.

`GetRestaurantMenuItemsRequest` takes `restaurantId`, `limit` (default `100`, range 1–100), `offset` (default `0`), and optional `endUserId`. `GetRestaurantMenuItemsResponse` contains only `items: List<RestaurantMenuEntry>`, with no `totalCount`: advance the offset by the number of items returned while a full page comes back. An empty page ends the menu, including for a restaurant with no menu on record. An unknown restaurant fails with `ErrorCategory.NOT_FOUND` ([Restaurants](/client-sdks/android-sdk/guides/restaurants)).

## Food analysis

```kotlin
suspend fun analyzePhoto(request: ScanFoodPhotoRequest): FoodScan
suspend fun analyzeDescription(
    request: SearchFoodsByNaturalLanguageRequest,
): FoodScan
suspend fun correct(request: CorrectPhotoScanRequest): FoodScan
```

`ScanFoodPhotoRequest` has `image: String` (a data URI or an http(s) URL), optional `endUserId`, and optional `reasoningEffort`. Left out, the API uses its default, the reasoning-based analyzer (the same as `AnalysisEffort.XHIGH`); `AnalysisEffort.NONE` selects the standard analyzer. The result shape and cost are the same either way. Food-analysis requests wait at least 120 seconds for their answer. To prepare camera bytes, use `ScanFoodPhotoRequest.fromImageData(imageData, endUserId, maxDimension = 1000, jpegQuality = 70, reasoningEffort = null)` or `PhotoScanImage.dataUri(...)`; both work on the calling thread.

`SearchFoodsByNaturalLanguageRequest(query, endUserId = null)` takes the meal description, at most 512 characters.

`CorrectPhotoScanRequest(analysis, instruction, endUserId = null)` takes the complete prior `FoodScan`, unchanged, and a plain-language `instruction` (at most 1,000 characters). The `(mealName, detections, userInput)` constructor is deprecated.

`FoodScan` contains `totalNutrients`, `detections`, and an optional `mealName` (null for a description). `detections` is empty when nothing was recognized. Food analysis doesn't return a glucose impact; use `glucose.predict` for that. Each `FoodDetection` contains a `DetectedFood` and an optional `confidenceScore`.

`DetectedFood` has `id`, `name`, `brandName`, `nutrients`, `serving`, and `quantity`. `serving` is the selected catalog serving (`ServingSummary` with `id`, `quantity`, `unit`, and `weightGrams`, where `quantity` is the size of one serving), and `quantity` is how many of that serving were eaten. `nutrients` are already scaled to `quantity`. The API always returns `id`, `serving.id`, and `quantity`; the SDK types are nullable only for values you build yourself ([Log the result](/client-sdks/android-sdk/guides/photo-scanning#log-the-result)).

Food alternatives (`foods.suggestAlternatives`) return `AlternativeFood` values with `servings: List<ServingSummary>` to read the nutrition against.

## Native scanner

```kotlin
@Composable
fun JanuaryFoodScanner(
    userClient: JanuaryPartnerUserClient,
    modifier: Modifier = Modifier,
    configuration: JanuaryFoodScannerConfiguration = JanuaryFoodScannerConfiguration(),
    onResult: (JanuaryFoodScannerResult) -> Unit,
    onCancel: () -> Unit,
)

@Composable
fun JanuaryFoodScanner(
    client: JanuaryPartnerClient,
    modifier: Modifier = Modifier,
    endUserId: PartnerUserId? = null,
    configuration: JanuaryFoodScannerConfiguration = JanuaryFoodScannerConfiguration(),
    onResult: (JanuaryFoodScannerResult) -> Unit,
    onCancel: () -> Unit,
)
```

Use the `userClient` overload. `JanuaryFoodScannerConfiguration` defaults to photo and barcode modes (`enabledModes`), photo first (`initialMode`), a maximum image dimension of 1,000, and JPEG quality 70. Results are `JanuaryFoodScannerResult.Photo(image, analysis)` or `JanuaryFoodScannerResult.Barcode(value, food)`, where `food` is the full food.

`JanuaryFoodScannerController(userClient, configuration)` exposes `analyzePhoto(ByteArray)` and `lookupBarcode(String)` for your own scanner UI. `analyzePhoto` prepares the image on the calling thread, so call it from `Dispatchers.Default`. `lookupBarcode` also fetches the full food; an unmatched barcode fails with `ErrorCategory.NOT_FOUND`.


# Logs and glucose API

The scoped client applies one identity and timezone to every call on this page ([User identity and timezone](/client-sdks/android-sdk/concepts/user-context)).

## Scoped food logs

```kotlin
suspend fun create(
    foods: List<FoodSelection>,
    timestampUtc: String? = null,
    name: String? = null,
): FoodLog
suspend fun list(start: String, end: String): ListFoodLogsResponse
suspend fun get(id: String): FoodLog
suspend fun getSummary(
    start: String,
    end: String,
    groupBy: FoodLogSummaryGrouping = FoodLogSummaryGrouping.DAY,
    weekStart: WeekStart = WeekStart.MONDAY,
): FoodLogSummary
suspend fun update(
    id: String,
    foods: List<FoodSelection>? = null,
    timestampUtc: String? = null,
    name: String? = null,
): FoodLog
suspend fun delete(id: String): DeleteFoodLogResponse
```

`timestampUtc` is an ISO 8601 date-time with any offset and defaults to now. `start` and `end` are inclusive `YYYY-MM-DD` dates in the client's timezone; `list` spans at most 60 days. Log IDs are UUID strings. `update` sends only the fields you set; with none set, it throws `ErrorCategory.VALIDATION` before any request. `create` isn't idempotent ([Food logs](/client-sdks/android-sdk/guides/food-logs)).

`FoodLog` has a nullable `id`, `foods`, `timestampUtc` (UTC), and optional `name` ([Models and enums](/client-sdks/android-sdk/reference/models-and-enums#food-logs)). `list` returns `items` and `totalCount`, the number of logs returned. `get` returns one `FoodLog`. `delete` returns `Unit` (`DeleteFoodLogResponse` is an alias for it).

`getSummary` groups the logs in the inclusive range (at most 366 days) into `buckets`, one per local calendar day or week, each with `startDate`, `endDate`, `logsCount`, `daysWithLogs`, and summed `nutrients`. Empty periods are included with zero counts. `totals` covers the whole range, and `averagePerLoggedDay` divides the totals by the number of days that have a log. `nutrients` is sparse: read `logsCount` to tell an empty bucket from one whose logs had no nutrition data.

The unscoped `january.foodLogs` has matching request-object methods:

```kotlin
suspend fun create(request: CreateFoodLogRequest): FoodLog
suspend fun list(request: ListFoodLogsRequest): ListFoodLogsResponse
suspend fun get(request: GetFoodLogRequest): FoodLog
suspend fun getSummary(request: GetFoodLogSummaryRequest): FoodLogSummary
suspend fun update(request: UpdateFoodLogRequest): FoodLog
suspend fun delete(request: DeleteFoodLogRequest): DeleteFoodLogResponse
```

Each request takes `user: PartnerUserContext`; the other fields match the scoped signatures.

## Scoped water logs

```kotlin
suspend fun create(amount: WaterAmount, consumedAt: String? = null): WaterLog
suspend fun list(start: String, end: String, unit: VolumeUnit): ListWaterLogsResponse
suspend fun delete(id: String): DeleteWaterLogResponse
```

`WaterAmount(value, unit)` is 1–811.5 `VolumeUnit.FL_OZ`, 0.1–101.4 `VolumeUnit.CUP`, or 30–24,000 `VolumeUnit.ML`. An end user's total is capped at 24 L per UTC day ([Water](/client-sdks/android-sdk/guides/water-and-weight-logs#water)). `consumedAt` is an ISO 8601 date-time with any offset and defaults to now. `create` isn't idempotent. `WaterLog` has `id`, `amount` (as logged), and `consumedAt` in UTC. `list` returns `items`, one `DailyWaterTotal(date, total: Volume)` per local calendar day that has water logged, oldest first, in the requested `unit`, rounded to one decimal place. `delete` also succeeds for an unknown or already-deleted log. The unscoped `january.waterLogs` takes `CreateWaterLogRequest`, `ListWaterLogsRequest`, and `DeleteWaterLogRequest`, each with `user: PartnerUserContext`.

## Scoped weight logs

```kotlin
suspend fun create(weight: Weight, measuredAt: String? = null): WeightLog
suspend fun list(start: String, end: String): ListWeightLogsResponse
```

`Weight(value, unit)` is 10–1,000 `WeightUnit.POUNDS` or 4.5–453.6 `WeightUnit.KILOGRAMS`, stored and returned in the unit it was sent in. `measuredAt` is an ISO 8601 date-time with any offset and defaults to now. `create` isn't idempotent. `WeightLog` has `weight` and `measuredAt` in UTC. `list` returns `items`, one `DailyWeight(date, weight)` per local calendar day that has a weight, oldest first; when several were logged on one day, the one with the latest `measuredAt` is returned. Weight logs have no ID and can't be updated or deleted. The unscoped `january.weightLogs` takes `CreateWeightLogRequest` and `ListWeightLogsRequest`, each with `user: PartnerUserContext`.

For water and weight, `start` and `end` are inclusive `YYYY-MM-DD` dates in the client's timezone, at most five years back (`date_range_too_large` otherwise). When more than 100 days in the range have entries, the most recent 100 are returned.

## Glucose

```kotlin
suspend fun predict(request: PredictGlucoseRequest): GlucosePrediction
```

`PredictGlucoseRequest` fields:

| Field           | Type and default                                            |
| --------------- | ----------------------------------------------------------- |
| `userProfile`   | `GlucosePredictionProfile`, required                        |
| `foods`         | `List<FoodSelection>`, required                             |
| `startTime`     | `OffsetDateTime`, required                                  |
| `cgmData`       | `List<CgmReading>? = null`; send with `consumedFoods`       |
| `consumedFoods` | `List<ConsumedHistoricalFood>? = null`; send with `cgmData` |
| `endUserId`     | `PartnerUserId? = null`                                     |
| `timezone`      | `String? = null`                                            |

`user.glucose.predict(request)` replaces the request's `endUserId` and `timezone` with the scoped context's. With no timezone, the SDK sends `UTC`. The response has `prediction` (points every 15 minutes, each with `minutes` after `startTime` and `value` in mg/dL), a nullable `impact` (`GlucoseImpact`), and `chart` with suggested Y-axis bounds `min` and `max` ([Glucose prediction](/client-sdks/android-sdk/guides/glucose-prediction)).


# Models and enums

## Identifiers and context

`PartnerUserId(String)` rejects a blank value. `FoodId(String)` and `ServingId(String)` are value classes; `.value` returns the string, and `FoodId(Long)` and `ServingId(Long)` build one from a number. `PartnerUserContext` has a required `endUserId` and an optional IANA `timezone`.

## Foods

* `FoodCategory`: `GENERIC`, `BRANDED`, `RECIPE` (`GENERAL` is a deprecated alias of `GENERIC`)
* `AutocompleteFoodCategory`: `GENERIC`, `BRANDED` (`GENERAL` is a deprecated alias of `GENERIC`)
* `DietPreference`: `VEGETARIAN`, `VEGAN`, `KETO`, `PALEO`, `PESCATARIAN`, `LOW_CARBOHYDRATE`, `HIGH_PROTEIN`, `KOSHER`, `HALAL`
* `DietRestriction`: `GLUTEN`, `LACTOSE`, `YEAST`, `TREE_NUTS`, `PEANUTS`, `DAIRY`, `EGGS`, `SULFITES`, `SOY`, `WHEAT`, `SHELLFISH`, `FISH`, `MUSHROOMS`, `SESAME`, `MONOSODIUM_GLUTAMATE`, `CAFFEINE`, `FODMAPS`

`NutritionFacts` has optional `NutrientAmount(value, unit)` fields: `calories`, `protein`, `carbohydrates`, `netCarbohydrates`, `totalFat`, `transFat`, `saturatedFat`, `fiber`, `totalSugars`, `addedSugars`, `cholesterol`, `calcium`, `iron`, `potassium`, `sodium`, and `vitaminD`. `CompleteScanNutritionFacts`, used by food analysis and alternatives, has `calories`, `protein`, `carbohydrates`, `netCarbohydrates`, `totalFat`, `saturatedFat`, `fiber`, `totalSugars`, `addedSugars`, and `sodium`.

`FoodPortion` has `foodId`, `serving` (the `ServingOption`), `quantity` (an amount in the serving's unit), `nutrition` (scaled `NutritionFacts`), `totalWeightGrams`, `glycemicIndex`, `glycemicLoad` (scaled), and `selection` ([Quantity and servings](/client-sdks/android-sdk/concepts/food-lifecycle#quantity-and-servings)).

`FoodSelection(id: String, serving: ServingSelection(id: String, quantity: Double))` is what `foodLogs.create`, `foodLogs.update`, and `glucose.predict` take. Its `quantity` is the number of servings eaten.

`ServingSummary(id, quantity, unit, weightGrams)` is the catalog serving on a detected or alternative food; `quantity` is the size of one serving, and `weightGrams` is its weight when the catalog knows it.

* `AnalysisEffort`: `NONE`, `XHIGH`

## Food logs

`FoodLog` has `id` (nullable; a log without one can't be fetched, updated, or deleted), `foods: List<LoggedFood>`, `timestampUtc` (UTC), and optional `name`.

`LoggedFood` has `id` (the food ID), `name`, `brandName`, `imageUrl`, `glycemicIndex`, `glycemicLoad`, `nutrients` (already scaled to the amount eaten), `consumedServing`, and `servingDetails`:

* `ConsumedServing(id, quantity)`: the serving and how many of it were eaten.
* `ServingDetails(id, quantity, unit, weightGrams)`: that serving's definition. The amount eaten is `consumedServing.quantity × servingDetails.quantity`, in `servingDetails.unit`.
* `FoodLogSummaryGrouping`: `DAY`, `WEEK`
* `WeekStart`: `MONDAY`, `SUNDAY`

## Water and weight logs

* `VolumeUnit`: `FL_OZ`, `ML`, `CUP` (a US cup, 8 fl oz)

`WaterAmount(value, unit: VolumeUnit)` is the input to a water log and the amount stored on a `WaterLog`; `Volume(value, unit)` is a daily total in the requested unit. Weight logs use `Weight(value, unit: WeightUnit)` from the `ai.january.partner.glucose` package. A unit this SDK version doesn't know is reported as `ErrorCategory.DECODING`.

## Glucose

```kotlin
GlucosePredictionProfile(
    age: Double,
    sex: Sex,
    height: Height,
    weight: Weight,
    activityLevel: ActivityLevel? = null,
    healthConditions: List<MedicalCondition>? = null,
)
```

`age` is in whole years; a fractional value throws `JanuaryException` with `ErrorCategory.VALIDATION`.

* `Sex`: `MALE`, `FEMALE`
* `HeightUnit`: `INCHES`, `CENTIMETERS`
* `WeightUnit`: `POUNDS`, `KILOGRAMS`
* `ActivityLevel`: `SEDENTARY`, `LIGHTLY_ACTIVE`, `MODERATELY_ACTIVE`, `VERY_ACTIVE`
* `MedicalCondition`: `TYPE_2_DIABETES`, `PREDIABETES`
* `GlucoseImpact`: `LOW`, `MEDIUM`, `HIGH`. It's a value class, so a grade this SDK version doesn't know keeps its string in `value`.

The older profile constructor that takes raw `height` and `weight` numbers reads them as inches and pounds. Let users enter feet and inches or centimeters, and pounds or kilograms, then build typed values.

`GlucosePrediction` has `prediction: List<GlucosePredictionPoint(minutes, value)>`, `impact: GlucoseImpact?`, and `chart: GlucoseChart(min, max)` ([Glucose prediction](/client-sdks/android-sdk/guides/glucose-prediction)).

## Errors

`JanuaryException`, `ErrorCategory`, and the local exceptions are on [Error handling](/client-sdks/android-sdk/reference/error-handling).


# Retries and token lifecycle

A client created with `withClientTokenProvider` manages its token in memory:

* It caches the token and refreshes it 60 seconds before it expires.
* It rejects a token that is blank or has 60 seconds or less left (`ErrorCategory.AUTHENTICATION`).
* It combines concurrent refreshes into one provider call.
* It retries the provider with bounded exponential backoff when the provider throws `JanuaryTokenProviderException(retryable = true)`. Any other exception fails at once.
* On HTTP `401` with `code: "token_expired"`, it refreshes the token and replays the request once.

The cache belongs to the client and is shared by all its `forUser` scopes ([Client lifecycle](/client-sdks/android-sdk/concepts/client-lifecycle)).

## Backoff

The default policy makes nine attempts in total, with ±20% jitter. Nominal delays between attempts are 1, 2, 4, 8, 8, 8, 8, and 8 seconds:

```kotlin
import ai.january.partner.JanuaryTokenRetryPolicy
import java.time.Duration

val policy = JanuaryTokenRetryPolicy(
    maximumAttempts = 9,
    initialDelay = Duration.ofSeconds(1),
    multiplier = 2.0,
    maximumDelay = Duration.ofSeconds(8),
    jitterRatio = 0.2,
)
```

If your token endpoint keeps failing with retryable errors, the default policy spends about 47 seconds in backoff, plus each attempt's own timeout (up to 20 seconds with the sample provider's timeouts), before the call fails. Pass a smaller policy as `tokenRetryPolicy` to `withClientTokenProvider` for interactive screens, or `JanuaryTokenRetryPolicy.NONE` when your provider already retries.

```kotlin
import ai.january.partner.JanuaryPartnerClient
import ai.january.partner.JanuaryTokenRetryPolicy

val january = JanuaryPartnerClient.withClientTokenProvider(
    provider = tokenProvider,
    tokenRetryPolicy = JanuaryTokenRetryPolicy(maximumAttempts = 3),
)
```

The policy covers fetching a token only. January API responses such as other `401`s, `403`, validation errors, rate limits, and server errors surface immediately ([Error handling](/client-sdks/android-sdk/reference/error-handling)).


# Error handling

Network operations throw `JanuaryException` with `category`, `message`, `httpStatus`, `cause`, and, when the API returned them, its stable error `code` and `requestId`. Branch on `code` where it matters, then on `category`:

```kotlin
import ai.january.partner.ErrorCategory
import ai.january.partner.JanuaryException
import ai.january.partner.foods.SearchFoodsRequest
import android.util.Log

try {
    user.foods.search(SearchFoodsRequest("banana"))
} catch (error: JanuaryException) {
    when {
        error.code == "request_limit_exceeded" || error.code == "credit_limit_exceeded" ->
            showQuotaReached() // Don't retry before the allowance resets.
        error.category == ErrorCategory.RATE_LIMITED -> showRetryLater()
        error.category == ErrorCategory.TRANSPORT ||
            error.category == ErrorCategory.TIMEOUT -> showOffline()
        error.category == ErrorCategory.VALIDATION -> showInputError(error.message.orEmpty())
        error.category == ErrorCategory.AUTHORIZATION -> {
            // A missing scope or disabled client tokens: an integration bug.
            Log.e("January", "code=${error.code} requestId=${error.requestId}")
            showGenericError()
        }
        else -> showGenericError()
    }
}
```

| Category         | Cause                                                                                                                                                                           |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VALIDATION`     | HTTP 400, 409, or 422, or a check the SDK makes before sending. Fix the request; sending it again unchanged gets the same answer.                                               |
| `AUTHENTICATION` | HTTP 401 that a token refresh didn't fix, or the token provider failed. Its `cause` holds the provider's exception, so an offline device can show up here when a token is due.  |
| `AUTHORIZATION`  | HTTP 403, for example `scope_insufficient` when the token lacks a scope.                                                                                                        |
| `NOT_FOUND`      | HTTP 404, for example an unknown barcode, restaurant, or food log.                                                                                                              |
| `RATE_LIMITED`   | HTTP 429. `rate_limited` clears after a short wait; `request_limit_exceeded` and `credit_limit_exceeded` last until the allowance resets. The SDK doesn't expose `Retry-After`. |
| `TIMEOUT`        | The request timed out, or HTTP 504.                                                                                                                                             |
| `TRANSPORT`      | The network failed, or the API returned an unexpected status.                                                                                                                   |
| `DECODING`       | The response couldn't be read, for example a unit this SDK version doesn't know.                                                                                                |
| `SERVER`         | Other HTTP 5xx. Retry later with backoff.                                                                                                                                       |

`code` carries the API's error identifier, such as `daily_water_limit_exceeded` or `date_range_too_large` (both `VALIDATION`). The [REST error table](/rest-api/api-overview#errors) lists every code. Handle a code you don't recognize by its category.

The SDK refreshes and replays a request once on `401 token_expired`, and retries a failing token provider as [Retries and token lifecycle](/client-sdks/android-sdk/reference/retries-and-lifecycle) describes. It retries nothing else. Don't wrap calls in an unbounded retry loop.

## Errors thrown before a request

These come from local checks and aren't `JanuaryException`:

| Exception                                 | When                                                                                                                               |
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `IllegalArgumentException`                | A blank `PartnerUserId`, or a food or water log ID that isn't a UUID.                                                              |
| `java.time.format.DateTimeParseException` | A malformed `YYYY-MM-DD` date or ISO 8601 date-time, such as a list range or `timestampUtc`.                                       |
| `FoodPortionException`                    | `portion(...)` got an invalid serving or quantity ([Portion helper](/client-sdks/android-sdk/reference/foods-api#portion-helper)). |
| `VoiceCaptureException`                   | `VoiceCaptureSession.startListening()` couldn't start ([Voice capture](/client-sdks/android-sdk/guides/voice-capture)).            |

## Cancellation

Canceling the coroutine that called the SDK cancels the HTTP request and rethrows its `CancellationException` unchanged. Rethrow it if you catch `Exception`, and don't retry it. A `CancellationException` thrown by your token provider itself is reported as `JanuaryException` with category `AUTHENTICATION`, because the provider runs inside the HTTP pipeline.


# Compatibility and permissions

## Requirements

| Requirement             | Value                                                                                                                                                                          |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Minimum Android         | API 24                                                                                                                                                                         |
| Compile SDK             | 36 or later                                                                                                                                                                    |
| Core library desugaring | Required in every app that uses the SDK, whatever its `minSdk`, with `desugar_jdk_libs` 2.1.5 or later ([Installation](/client-sdks/android-sdk/getting-started/installation)) |
| Java                    | 17 source and target                                                                                                                                                           |
| Kotlin                  | 2.1 or later. AGP 9 compiles Kotlin itself; with AGP 8, apply the `org.jetbrains.kotlin.android` plugin.                                                                       |
| Distribution            | Maven Central, `ai.january:january-sdk-android:0.3.2`                                                                                                                          |

The SDK is verified with Gradle 9.5.1 and Android Gradle Plugin 9.2.1.

## Libraries

The SDK brings in these libraries. Gradle raises your app's versions to at least these:

| Library                                | Version    |
| -------------------------------------- | ---------- |
| Jetpack Compose BOM                    | 2026.06.00 |
| CameraX                                | 1.6.1      |
| ML Kit barcode scanning                | 17.3.0     |
| Activity Compose                       | 1.12.4     |
| Lifecycle runtime Compose              | 2.10.0     |
| Retrofit                               | 3.0.0      |
| Moshi                                  | 1.15.2     |
| Kotlin standard library and reflection | 2.2.10     |

Coroutines (`kotlinx-coroutines-core` 1.10.2) are a runtime dependency only, so add `kotlinx-coroutines-android` to your app to call the SDK ([Installation](/client-sdks/android-sdk/getting-started/installation)).

## Permissions

The SDK's manifest adds three permissions to your app:

* `INTERNET`, for every January request.
* `CAMERA`, for `JanuaryFoodScanner`, which uses CameraX for photos and ML Kit for barcodes. The scanner requests it at runtime and shows its own denied and settings states.
* `RECORD_AUDIO`, for `VoiceCaptureSession`. Your app requests it at runtime, after the user taps a microphone control. Recognition uses Android's speech recognition service; the SDK doesn't send audio or transcripts to January or keep an audio file.

If your app doesn't use the scanner or voice capture, remove the permission it doesn't need in your app's manifest:

```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <uses-permission android:name="android.permission.RECORD_AUDIO" tools:node="remove" />
    <uses-permission android:name="android.permission.CAMERA" tools:node="remove" />
</manifest>
```

The SDK is licensed under Apache-2.0.


# Testing and support

## Test your integration

The SDK has no base-URL override, and its resource classes are final, so you can't point it at a mock server or subclass it. Put the SDK behind your own interface and fake that interface in tests. The model classes are public data classes, and `JanuaryException` has a public constructor, so fakes can return realistic results and errors.

```kotlin
import ai.january.partner.ErrorCategory
import ai.january.partner.JanuaryException
import ai.january.partner.JanuaryPartnerUserClient
import ai.january.partner.foods.FoodSearchResults
import ai.january.partner.foods.SearchFoodsRequest

interface FoodSearch {
    suspend fun search(query: String): FoodSearchResults
}

class JanuaryFoodSearch(private val user: JanuaryPartnerUserClient) : FoodSearch {
    override suspend fun search(query: String) = user.foods.search(SearchFoodsRequest(query))
}

class OfflineFoodSearch : FoodSearch {
    override suspend fun search(query: String): FoodSearchResults =
        throw JanuaryException(ErrorCategory.TRANSPORT, "Offline")
}
```

Before shipping, check these on a device or emulator:

1. The token endpoint succeeds, and a failing endpoint surfaces as `ErrorCategory.AUTHENTICATION` after the retries you configured.
2. Concurrent requests at startup make one token request.
3. A `token_expired` response fetches a new token and replays once.
4. Leaving a screen cancels its requests.
5. Autocomplete → search → `get` shows every serving.
6. Camera denied and granted, a photo scan, and a barcode lookup.
7. Signing out and in as another account shows only the new account's data ([Client lifecycle](/client-sdks/android-sdk/concepts/client-lifecycle)), and changing the timezone moves entries to the right days.

## Versioning and updates

Released versions are on Maven Central and in the [changelog](/client-sdks/android-sdk/reference/changelog). Pin an exact version, read the changelog before you update, and rerun the checks above.

## Support report

Include the SDK version, Android, AGP, and Java versions, the failing operation, `JanuaryException.category`, `code`, `requestId`, the HTTP status, and steps to reproduce. Leave out tokens, API keys, meal images, nutrition records, and health profiles.


# Troubleshooting

## Gradle cannot find the SDK

Confirm your `settings.gradle.kts` has `mavenCentral()` inside the `dependencyResolutionManagement { repositories { ... } }` block and that the dependency names an exact released version, then run the `dependencyInsight` command from [Installation](/client-sdks/android-sdk/getting-started/installation).

## The build requires core library desugaring

`Dependency 'ai.january:january-sdk-android:…' requires core library desugaring to be enabled` means your app module hasn't turned on desugaring. Every app that uses the SDK needs it, whatever its `minSdk` ([Installation](/client-sdks/android-sdk/getting-started/installation)).

## Cleartext traffic is blocked

Requests fail with `ErrorCategory.AUTHENTICATION` (with the sample provider, after about 47 seconds of token retries), and the exception's `cause` chain ends in `CLEARTEXT communication to 10.0.2.2 not permitted by network security policy`. Android blocks `http://` URLs such as the local token relay's. Allow cleartext in debug builds only, with the debug manifest from [First request](/client-sdks/android-sdk/getting-started/quick-start).

## Token provider fails

Check that the app has a token endpoint URL and a valid app session, that the URL uses HTTPS outside local development, and that the endpoint returns a 2xx response with `token` and `expires_in` (or `expiresIn`) greater than 60 seconds. The SDK has no default endpoint. The underlying failure is in the `JanuaryException`'s `cause`.

## Provider is called repeatedly

The token may be inside the 60-second refresh window, the provider may be returning a nearly expired token, or requests may be getting `401 token_expired`. Concurrent refreshes normally share one call. Create one `JanuaryPartnerClient` per signed-in account, not one per request ([Client lifecycle](/client-sdks/android-sdk/concepts/client-lifecycle)).

## Requests act as the previous account

A new `forUser` scope doesn't change the account: the client keeps the token it cached for the previous one. Create a new `JanuaryPartnerClient` when the account changes ([Client lifecycle](/client-sdks/android-sdk/concepts/client-lifecycle)).

## Authentication is rejected

Confirm the token was minted for the signed-in account. The SDK removes `January-End-User-ID` from January requests; the header only matters on requests to your own token endpoint. Only `token_expired` is refreshed and replayed automatically.

## `AUTHORIZATION` or `scope_insufficient`

The token lacks the scope the operation needs. Mint it with the scopes for every feature the app uses ([Scopes](/client-sdks/android-sdk/getting-started/backend-token-endpoint#scopes)). `403 forbidden` when minting means **Enable client tokens** is off in the Developer Dashboard.

## Food picker has incomplete servings

Call `foods.get` after the user selects a search result. Autocomplete and search results are discovery data and may not list every serving.

## Camera is blank or denied

Check the merged manifest for `CAMERA`, test the runtime permission, and use a device or emulator with a camera. `JanuaryFoodScanner` uses CameraX; it isn't the system photo picker.

## Voice capture fails with `RECOGNIZER_UNAVAILABLE`

The device has no speech recognition service. Many emulator images lack one; use a physical device or an emulator image with Google Play.

## Photo scan is too large or rotated

Use `PhotoScanImage.dataUri` rather than the original camera bytes.

For anything else, send a [support report](/client-sdks/android-sdk/reference/testing-and-support#support-report).


# Changelog

## 0.3.2 - 2026-09-24

* Fixed: `FoodPortion.selection` sent the portion's amount in the serving's unit as the number of servings, so a portion of a serving whose quantity isn't 1 (such as 6 oz or 100 g) was logged that many times over. It now sends the number of servings

## 0.3.1 - 2026-09-23

* Food, water, and weight logs send and read the API's `created_at` and keep `timestampUtc`, `consumedAt`, and `measuredAt`; 0.3.0's food, water, and weight logs don't work with the current API, so upgrade to 0.3.1
* Water logs take 0.1–101.4 cups

## 0.3.0 - 2026-09-23

* Breaking: `AlternativeFood.id`, `ServingOption.id`, `RestaurantMenuEntry.id`, and `LoggedFood.id` are non-null; the API always returns these catalog IDs
* Photo analysis uses the API's default, the reasoning-based analyzer, unless `reasoningEffort` is set; `AnalysisEffort.NONE` selects the standard analyzer
* Food analyses wait at least 120 seconds for their answer
* HTTP 409 `conflict` is reported as `ErrorCategory.VALIDATION`
* Water logs (`waterLogs.create`, `list`, `delete`) and weight logs (`weightLogs.create`, `list`) per end user, with daily totals and latest daily weights
* Client tokens can carry the `water_logs:read`, `water_logs:write`, `weight_logs:read`, and `weight_logs:write` scopes; a token endpoint that mints least-privilege tokens must add them for these operations
* `ServingSummary.weightGrams`, the weight of one catalog serving
* Corrections send the prior scan in the API's new request shape; `foodLogs.update` rejects an empty patch before any network call
* `glucose.predict` rejects a fractional `GlucosePredictionProfile.age` with `ErrorCategory.VALIDATION`; the API takes whole years
* The example app has a Tracking tab for one day's food logs, nutrient totals, water, and weight

## 0.2.2 - 2026-09-16

* Voice capture waits two seconds of silence before ending a capture; `VoiceCaptureSession(endOfSpeechSilence = …)` tunes it

## 0.2.1 - 2026-09-16

* Minimum Android version lowered from API 26 to API 24 (apps enable core library desugaring)

## 0.2.0 - 2026-09-16

* Breaking: `DetectedFood` exposes `serving` and `quantity` instead of `servings`, matching the current January API; `0.1.x` fails to decode photo scans
* Food-log summaries per day or week with `foodLogs.getSummary`
* Optional reasoning-based photo analysis with `ScanFoodPhotoRequest.reasoningEffort`
* `SearchFoodsRequest` gains `offset` for paging and accepts `limit` up to 50

## 0.1.2 - 2026-09-14

* Token-provider failures surface as `JanuaryException` (`ErrorCategory.AUTHENTICATION`) instead of crashing the process

## 0.1.1 - 2026-09-03

* Compiled with Kotlin 2.1.20 for compatibility with current React Native and Expo Android toolchains

## 0.1.0 - 2026-09-02

* Coroutine-first Android SDK for API 26+, published to Maven Central
* Provider-managed short-lived tokens with single-flight refresh
* Nine-attempt bounded exponential backoff with jitter
* User-scoped foods, restaurants, photo scanning, food log, and glucose clients
* Food autocomplete, full food details, and local portion calculations
* Photo preparation and native photo/barcode scanner
* Reusable microphone speech recognition with live RMS, partial text, and stable lifecycle state
* Typed imperial and metric glucose-profile measurements
* Shared, white-label-ready Jetpack Compose demo components
* Restaurant menu-item search, and paginated restaurant-menu lookup by restaurant ID (`getMenuItems`)
* Single food log retrieval and no-content deletion aligned with API `v1.2`


# React Native SDK

The January React Native SDK gives iOS and Android apps one TypeScript API for foods, restaurants, food analysis, voice capture, food, water, and weight logs, and glucose prediction.

* [Overview](/client-sdks/react-native-sdk/overview): what the SDK does and what it requires.
* [Getting started](/client-sdks/react-native-sdk/getting-started): install, authenticate, and make a first request.
* [Core concepts](/client-sdks/react-native-sdk/concepts): the client lifecycle, user identity and timezone, and food portions.
* [Guides](/client-sdks/react-native-sdk/guides): one page per feature.
* [Reference](/client-sdks/react-native-sdk/reference): methods, errors, models, platform details, and troubleshooting.


# Overview

Build food discovery, restaurant search, food analysis, voice capture, food, water, and weight logging, and glucose-prediction experiences from one typed TypeScript API. The package uses January's native Swift SDK on iOS and Kotlin SDK on Android through a React Native TurboModule.

## What you can build

* Food autocomplete, search, barcode lookup, food details, and alternatives
* Nearby restaurant and menu-item discovery
* Food analysis from a description, an image URL, or a base64 data URI
* Voice capture that turns speech into a food query or meal description
* Food-log creation, listing, summaries, updates, and deletion
* Water logs with daily totals, and weight logs with the latest weight per day
* Personalized glucose-impact predictions

## Requirements

| Component         | Requirement                                                                                                                             |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| React Native      | 0.83 or later (New Architecture, always on since 0.82)                                                                                  |
| React             | 19.2 or later                                                                                                                           |
| iOS               | 15.1 or later                                                                                                                           |
| Android           | API 24 or later, compileSdk 36; every app enables core library desugaring                                                               |
| Android toolchain | JDK 17–21                                                                                                                               |
| Expo              | Expo SDK 55 or later, in a development build (not Expo Go)                                                                              |
| React Native Web  | Not supported; use `@januaryai/web-sdk`                                                                                                 |
| Your backend      | An endpoint that returns client tokens ([Backend token endpoint](/client-sdks/react-native-sdk/getting-started/backend-token-endpoint)) |

## Start here

1. [Install the package](/client-sdks/react-native-sdk/getting-started/installation).
2. [Add the token endpoint to your backend](/client-sdks/react-native-sdk/getting-started/backend-token-endpoint), or run the token relay for now.
3. [Write the token provider and create the client](/client-sdks/react-native-sdk/getting-started/authentication).
4. [Make your first request](/client-sdks/react-native-sdk/getting-started/quick-start).
5. [Turn a search result into a food you can log](/client-sdks/react-native-sdk/concepts/food-hydration-and-portions).

```ts
import { JanuaryClient } from '@januaryai/react-native';

const january = new JanuaryClient({
  endUserId: user.id,
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
  clientTokenProvider: tokenProvider,
});

const results = await january.foods.search({ query: 'greek yogurt' });
```


# Getting started

Work through these in order:

1. [Installation](/client-sdks/react-native-sdk/getting-started/installation): add the package to a React Native or Expo app.
2. [Backend token endpoint](/client-sdks/react-native-sdk/getting-started/backend-token-endpoint): what your backend returns to the app, and how to keep the API key off the device.
3. [Authentication](/client-sdks/react-native-sdk/getting-started/authentication): write the token provider and create the client.
4. [First request](/client-sdks/react-native-sdk/getting-started/quick-start): search for a food and confirm the setup works.
5. [Example app](/client-sdks/react-native-sdk/getting-started/example-app): run the SDK's example app against the token relay.


# Installation

Check the [requirements](/client-sdks/react-native-sdk/overview#requirements) first, then install the package with `npm install @januaryai/react-native` or `yarn add @januaryai/react-native` and follow the React Native or the Expo steps below.

The package brings the January iOS and Android SDK versions it was tested with ([pinned versions](/client-sdks/react-native-sdk/reference/platform-and-security#pinned-native-versions)). Don't add `January` or `ai.january:january-sdk-android` to your app yourself.

## React Native

### iOS

```sh
npx pod-install
npx react-native run-ios
```

### Android

Enable core library desugaring in `android/app/build.gradle`. The January Android SDK uses `java.time`, and every app that depends on it must enable desugaring, whatever its `minSdkVersion`:

```groovy
android {
    compileOptions {
        coreLibraryDesugaringEnabled true
    }
}

dependencies {
    coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.5"
}
```

Then rebuild:

```sh
npx react-native run-android
```

React Native 0.83 projects set `minSdkVersion = 24` in `android/build.gradle`, the SDK's minimum; if yours is lower, raise it to 24. Build with JDK 17–21.

## Expo

The SDK has native code, so it runs in a development build (Expo SDK 55 or later), not in Expo Go.

1. Install the package:

   ```sh
   npx expo install @januaryai/react-native
   ```
2. Add `"@januaryai/react-native"` to `expo.plugins` in `app.json`. The plugin enables core library desugaring, which every Android build needs.

   ```json
   { "expo": { "plugins": ["@januaryai/react-native"] } }
   ```
3. Regenerate the native projects and build:

   ```sh
   npx expo prebuild --clean
   npx expo run:ios    # or: npx expo run:android
   ```

Config plugins run only during prebuild. If you commit `ios/` and `android/` and don't run prebuild, follow the [React Native](#react-native) steps instead. Expo SDK 55 sets `minSdkVersion` to 24 by default; don't lower it with `expo-build-properties`.

Rebuild the development build whenever you install or upgrade the SDK.

## Confirm linking

```ts
import { getNativeModuleVersion } from '@januaryai/react-native';

console.log(getNativeModuleVersion()); // "0.3.0" when linked, null when not
```

`null` means the running binary lacks the native module. Run `npx pod-install` (React Native) or `npx expo prebuild --clean` (Expo), then rebuild the app. Restarting Metro is not enough.

Next: [Backend token endpoint](/client-sdks/react-native-sdk/getting-started/backend-token-endpoint).


# Backend token endpoint

The app gets client tokens from an endpoint on your backend. That endpoint is the same for every January SDK; build it as described in [Your token endpoint](/docs/authentication#your-token-endpoint).

{% hint style="info" %}
No backend yet? Run the [token relay](/docs/authentication#develop-with-the-token-relay) and continue to [Authentication](/client-sdks/react-native-sdk/getting-started/authentication). Come back before launch.
{% endhint %}

What matters for React Native:

* **Response.** Return January's `201` body unchanged. The React Native SDK reads only `expiresIn` (seconds), not January's `expires_in`, so the token provider on the [Authentication](/client-sdks/react-native-sdk/getting-started/authentication) page maps the field. A token that expires within 60 seconds is rejected.
* **Scopes.** The app's `JanuaryClient` uses one token for every call, so mint it with the scopes of every feature the app uses. See the [scope table](/rest-api/authentication#client-token-scopes).
* **Switch.** Minting fails with `403 forbidden` until **Enable client tokens** is on in the [Developer Dashboard](https://dashboard.january.ai/dashboard/client-tokens).

## Keep the API key off the device

Your API key (`sk-…`) stays on your backend. Never ship it in a build you distribute, whether in:

* JavaScript or TypeScript source, or anything Metro bundles;
* an `EXPO_PUBLIC_` variable or a `.env` file (Expo copies `EXPO_PUBLIC_` values into the bundle);
* native source, `Info.plist`, `BuildConfig`, or Gradle properties;
* remote configuration delivered to the app.

`developmentApiKey` is for debug builds on your own machine only; see [Development API key](/client-sdks/react-native-sdk/getting-started/authentication#development-api-key).

Next: [Authentication](/client-sdks/react-native-sdk/getting-started/authentication).


# Authentication

The client authenticates with a token provider: an async function that gets a client token from your [backend token endpoint](/client-sdks/react-native-sdk/getting-started/backend-token-endpoint).

## Token provider

Put the provider in its own module. The other samples import it as `tokenProvider`.

```ts
import type { JanuaryTokenProvider } from '@januaryai/react-native';
import { getSessionToken } from './session'; // your app's session

const TOKEN_URL = 'https://api.example.com/january/client-token';

// The SDK retries a provider error only when it carries `retryable: true`.
const providerError = (message: string, retryable: boolean) =>
  Object.assign(new Error(message), { retryable });

export const tokenProvider: JanuaryTokenProvider = async (endUserId) => {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 15_000);
  let response: Response;
  try {
    response = await fetch(TOKEN_URL, {
      method: 'POST',
      headers: {
        // Read the session on every call, not once at startup.
        Authorization: `Bearer ${await getSessionToken()}`,
        // Only the token relay reads this header. Your production endpoint
        // takes the user from the session and ignores it.
        'January-End-User-ID': endUserId,
      },
      signal: controller.signal,
    });
  } catch {
    throw providerError('Token endpoint unreachable or timed out', true);
  } finally {
    clearTimeout(timer);
  }

  const { status } = response;
  if (!response.ok) {
    const retryable = status === 408 || status === 429 || status >= 500;
    throw providerError(`Token endpoint returned ${status}`, retryable);
  }

  // January's body has `expires_in`; this SDK reads `expiresIn`.
  const body = await response.json().catch(() => ({}));
  const expiresIn = body.expiresIn ?? body.expires_in;
  if (typeof body.token !== 'string' || typeof expiresIn !== 'number') {
    throw providerError('Token endpoint returned an unreadable body', false);
  }
  return { token: body.token, expiresIn };
};
```

During development, point `TOKEN_URL` at the [token relay](/docs/authentication#develop-with-the-token-relay). Switching to your endpoint changes only the URL and the session credential.

## Create the client

```ts
import { JanuaryClient } from '@januaryai/react-native';
import { tokenProvider } from './januaryTokenProvider';

const january = new JanuaryClient({
  endUserId: user.id,
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
  clientTokenProvider: tokenProvider,
});
```

[User identity and timezone](/client-sdks/react-native-sdk/concepts/user-identity-and-timezone) explains both options. Create the client where your app holds the signed-in session, not at module scope; see [Client lifecycle](/client-sdks/react-native-sdk/concepts/client-lifecycle).

## Token refresh and retries

* The SDK keeps the token in memory until 60 seconds before it expires. Concurrent calls share one refresh.
* On `401 token_expired`, it gets a new token and replays the call once.
* A provider error with `retryable: true` is retried up to 8 more times, waiting 1, 2 and 4 seconds, then 8 seconds each time. A call can wait about 47 seconds, plus the time the attempts take, before it fails.
* Any other provider error fails the call at once. It rejects with `client_token_provider_failed` on iOS or `authentication` on Android; see [Errors](/client-sdks/react-native-sdk/reference/errors).

## Development API key

In a debug build on your own machine, `developmentApiKey` can replace `clientTokenProvider`. The two options are mutually exclusive.

```ts
const january = new JanuaryClient({
  endUserId: 'local-demo-user',
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
  developmentApiKey: process.env.EXPO_PUBLIC_JANUARY_API_KEY ?? '',
});
```

In this mode the SDK sends `endUserId` to January as the `January-End-User-ID` header.

{% hint style="warning" %}
Expo copies `EXPO_PUBLIC_` values into the JavaScript bundle. Release builds refuse this mode (`Development API-key authentication is available in debug builds only.`), but keep the variable out of any build you distribute. See [Keep the API key off the device](/client-sdks/react-native-sdk/getting-started/backend-token-endpoint#keep-the-api-key-off-the-device).
{% endhint %}

Next: [First request](/client-sdks/react-native-sdk/getting-started/quick-start).


# First request

This component creates a client, searches for a food, and shows the result. It uses the `tokenProvider` from [Authentication](/client-sdks/react-native-sdk/getting-started/authentication).

```tsx
import { useEffect, useState } from 'react';
import { Text } from 'react-native';
import { JanuaryClient } from '@januaryai/react-native';
import { tokenProvider } from './januaryTokenProvider';

export function FirstRequest({ userId }: { userId: string }) {
  const [status, setStatus] = useState('Searching…');

  useEffect(() => {
    const january = new JanuaryClient({
      endUserId: userId,
      timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
      clientTokenProvider: tokenProvider,
    });
    january.foods
      .search({ query: 'greek yogurt', category: 'generic', limit: 10 })
      .then(
        (results) => setStatus(`Found ${results.totalCount} foods`),
        (error: { code?: string; message: string }) =>
          setStatus(`${error.code ?? 'error'}: ${error.message}`)
      );
    return () => january.dispose();
  }, [userId]);

  return <Text>{status}</Text>;
}
```

Render it with the signed-in user's end-user ID. Any count, even 0, means the installation and authentication work. `scope_insufficient` means the token lacks `foods:read`. Other codes are in [Errors](/client-sdks/react-native-sdk/reference/errors), and [Troubleshooting](/client-sdks/react-native-sdk/reference/troubleshooting) covers the common setup failures.

To log a food, the app needs a serving and a quantity as well; see [Food details and portions](/client-sdks/react-native-sdk/concepts/food-hydration-and-portions).

Next: [Example app](/client-sdks/react-native-sdk/getting-started/example-app).


# Example app

The [SDK repository](https://github.com/January-ai/january-sdk-react-native) contains an Expo example app for iOS and Android that uses every part of the SDK, with the [token relay](/docs/authentication#develop-with-the-token-relay) standing in for your backend.

## Run it

You need Node.js 22 or later, and Xcode or Android Studio.

1. In the [Developer Dashboard](https://dashboard.january.ai), create an API key (**API keys → Create key**) and turn on **Enable client tokens** under [Client tokens](https://dashboard.january.ai/dashboard/client-tokens).
2. Start the relay, and paste the key when it asks:

   ```sh
   git clone https://github.com/January-ai/january-token-relay.git
   cd january-token-relay
   ./start.sh
   ```
3. In a second terminal, clone the SDK repository and run the example:

   ```sh
   git clone https://github.com/January-ai/january-sdk-react-native.git
   cd january-sdk-react-native
   cp example/.env.example example/.env
   corepack yarn install
   corepack yarn example ios    # or: corepack yarn example android
   ```

   The `.env` template points at the relay on port 8787. On Android, the command starts an emulator if none is running and forwards the relay into it.
4. Search for `banana`. The relay prints `minted=true status=201` when the app gets its first token.

To reach a relay hosted elsewhere, set `EXPO_PUBLIC_JANUARY_TOKEN_ENDPOINT` to its token URL and `EXPO_PUBLIC_DEMO_SESSION_TOKEN` to its relay token. Setting `EXPO_PUBLIC_JANUARY_API_KEY` instead skips the relay in debug builds; keep that build on your machine ([Development API key](/client-sdks/react-native-sdk/getting-started/authentication#development-api-key)).

## What each tab shows

| Tab      | SDK calls                                                                                                                                      | Source in `example/src`                                    |
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| Search   | `foods.autocomplete`, `search`, `lookupBarcode`, `get`, `suggestAlternatives`; `foodAnalysis.analyzeDescription`; `restaurants.*`; voice input | `App.tsx`, `FoodDetailScreen.tsx`, `RestaurantScreens.tsx` |
| Scan     | `foodAnalysis.analyzePhoto`, `correct`                                                                                                         | `ScanScreen.tsx`, `scanCorrection.ts`                      |
| Tracking | `foodLogs.getSummary`, `list`; `waterLogs.*`; `weightLogs.*`                                                                                   | `TrackingScreen.tsx`, `TrackingCharts.tsx`                 |
| Logs     | `foodLogs.*`                                                                                                                                   | `FoodLogsScreen.tsx`                                       |
| Glucose  | `glucose.predict`                                                                                                                              | `GlucoseScreen.tsx`                                        |

Files worth copying:

* [`localDate.ts`](https://github.com/January-ai/january-sdk-react-native/blob/main/example/src/localDate.ts): the device timezone, local `YYYY-MM-DD` dates, and noon timestamps for past days.
* [`relayTokenProvider.ts`](https://github.com/January-ai/january-sdk-react-native/blob/main/example/src/relayTokenProvider.ts): a token provider for the relay.
* [`VoiceInputButton.tsx`](https://github.com/January-ai/january-sdk-react-native/blob/main/example/src/VoiceInputButton.tsx): a tap-to-start, tap-to-stop microphone button.

Next: [Client lifecycle](/client-sdks/react-native-sdk/concepts/client-lifecycle).


# Core concepts

* [Client lifecycle](/client-sdks/react-native-sdk/concepts/client-lifecycle): one client per signed-in user, created and disposed with the session.
* [User identity and timezone](/client-sdks/react-native-sdk/concepts/user-identity-and-timezone): what `endUserId` does, and which calendar days list calls cover.
* [Food details and portions](/client-sdks/react-native-sdk/concepts/food-hydration-and-portions): turn a search result into a food, serving, and quantity you can log.


# Client lifecycle

Create one `JanuaryClient` per signed-in user and share it across screens. Each client has its own native client and token cache and never changes user. On sign-out or an account switch, dispose the client and create a new one. Do the same if the user's timezone changes.

## In React

```ts
import { useEffect, useState } from 'react';
import { JanuaryClient } from '@januaryai/react-native';
import { tokenProvider } from './januaryTokenProvider';

/** The signed-in user's client; undefined while signed out. */
export function useJanuaryClient(userId: string | undefined) {
  const [client, setClient] = useState<JanuaryClient>();

  useEffect(() => {
    if (!userId) return;
    const next = new JanuaryClient({
      endUserId: userId,
      timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
      clientTokenProvider: tokenProvider,
    });
    setClient(next);
    return () => {
      setClient(undefined);
      next.dispose();
    };
  }, [userId]);

  return client;
}
```

Call the hook once, in the component that owns the signed-in session, and pass the client down through a context. It creates a client for each end-user ID and disposes it when the ID changes or the component unmounts.

Don't create the client in `useMemo`. Strict Mode runs effect cleanups once in development, which would dispose a client the next render still uses. Don't create it at module scope either: the constructor throws when the native module isn't linked ([Troubleshooting](/client-sdks/react-native-sdk/reference/troubleshooting)).

## Disposal

`dispose()` releases the native client and stops its token requests. After that, every call rejects with `This JanuaryClient has been disposed.`. Calling `dispose()` again does nothing.


# User identity and timezone

```ts
const january = new JanuaryClient({
  endUserId: user.id,
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
  clientTokenProvider: tokenProvider,
});
```

## End-user ID

`endUserId` is required. It is the stable, opaque [end-user ID](/docs/authentication) your backend binds into the client token: your own account ID, never an email address or another value that can change.

The token decides whose logs are read and written. The SDK passes `endUserId` to your token provider, and sends it to January as the `January-End-User-ID` header only in `developmentApiKey` mode.

## Timezone

Pass the device's IANA timezone, as above, unless you store one per user. It sets the calendar days for food-log lists and summaries and for water and weight lists, and it is sent with glucose predictions. [Days and timezones](/rest-api/api-overview#days-and-timezones) explains how January groups logs into days.

If you omit it or pass an invalid name, the result depends on the platform:

|         | Omitted             | Invalid name                                                           |
| ------- | ------------------- | ---------------------------------------------------------------------- |
| iOS     | The device timezone | `new JanuaryClient` throws `timezone must be a valid IANA identifier.` |
| Android | UTC                 | Accepted; calls that send it fail with `invalid_request`               |

Build `YYYY-MM-DD` dates in the same timezone. `new Date().toISOString().slice(0, 10)` is the UTC date, which in the Americas is already tomorrow by the evening:

```ts
// The device's calendar date as YYYY-MM-DD.
const localDate = (d = new Date()) =>
  `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
```

## Log times

`timestampUTC` (food logs), `consumedAt` (water) and `measuredAt` (weight) are the REST API's `created_at`: when the meal was eaten, the water drunk, or the weight measured. Send ISO 8601 with an offset, or omit it to mean now. Responses return it in UTC.


# Food details and portions

Logging a food or predicting glucose takes a `FoodSelection`: a food ID, a serving ID, and a quantity. Build one in three steps:

1. Find the food with `foods.search`, `foods.autocomplete`, or `foods.lookupBarcode`.
2. Fetch the full food with `foods.get`. Search and autocomplete results can carry less detail.
3. Pick one of its servings and a quantity greater than zero.

```ts
import type { FoodSelection, JanuaryClient } from '@januaryai/react-native';

export async function selectFood(
  january: JanuaryClient,
  query: string,
  quantity = 1
): Promise<FoodSelection | undefined> {
  const { items } = await january.foods.search({ query });
  const match = items[0]; // in your UI, the result the user picked
  if (!match) return undefined;

  const food = await january.foods.get({ foodId: match.id });
  const serving = food.servings.find((s) => s.isPrimary) ?? food.servings[0];
  if (!serving?.id) return undefined;

  return { id: food.id, serving: { id: serving.id, quantity } };
}
```

`quantity` is how many of that serving were eaten: for a "6 oz" serving, `1` is 6 oz and `0.5` is 3 oz. Take serving IDs from `food.servings` as returned; don't build them from labels.

A [food analysis](/client-sdks/react-native-sdk/guides/meal-analysis) detection already carries a serving and a quantity, so it can be logged without these steps.


# Guides

* [Foods](/client-sdks/react-native-sdk/guides/foods): autocomplete, search, barcode lookup, and alternatives.
* [Restaurants](/client-sdks/react-native-sdk/guides/restaurants): nearby restaurants, menu items, and a restaurant's menu.
* [Food analysis](/client-sdks/react-native-sdk/guides/meal-analysis): foods and nutrition from a photo or a description.
* [Voice capture](/client-sdks/react-native-sdk/guides/voice-capture): turn speech into a food query with the device's recognizer.
* [Food logs](/client-sdks/react-native-sdk/guides/food-logs): create, list, summarize, update, and delete meals.
* [Water and weight logs](/client-sdks/react-native-sdk/guides/water-and-weight-logs): log water and weight, and read them back per day.
* [Glucose prediction](/client-sdks/react-native-sdk/guides/glucose-prediction): predict a meal's glucose curve for a user profile.


# Foods

Every call on this page needs the `foods:read` scope. To turn a result into something you can log, see [Food details and portions](/client-sdks/react-native-sdk/concepts/food-hydration-and-portions).

## Autocomplete

```ts
const { items: suggestions } = await january.foods.autocomplete({
  query: 'greek yog',
  category: 'generic',
  limit: 8,
});
```

Autocomplete takes `category: 'generic'` or `'branded'`. Search also takes `'recipe'`.

## Search and fetch the full food

```ts
const results = await january.foods.search({
  query: 'greek yogurt',
  category: 'generic',
  limit: 20,
});

const first = results.items[0];
if (first) {
  const food = await january.foods.get({ foodId: first.id });
}
```

Search and autocomplete results can leave out fields the full food has, so render optional fields defensively.

## Barcode lookup

Barcode coverage is US-only. A code issued outside the United States (for example GS1 prefixes 73, 64, 54, or 93) isn't in the database, and the call rejects with `not_found`. Fall back to text search:

```ts
try {
  const { items } = await january.foods.lookupBarcode({ upc: '012345678905' });
  showFoods(items);
} catch (error) {
  if ((error as { code?: string }).code === 'not_found') {
    // Not in the database: offer text search instead.
  }
}
```

## Alternatives

```ts
const { alternatives } = await january.foods.suggestAlternatives({
  foodId: food.id,
  dietRestrictions: ['gluten'],
  dietPreferences: ['high_protein'],
});
```


# Restaurants

Every call on this page needs the `restaurants:read` scope.

## Search nearby

Search restaurants, or menu items across nearby restaurants, by query and coordinates. `radius` is in meters and defaults to 8,000; `limit` defaults to 10.

```ts
const restaurants = await january.restaurants.search({
  query: 'mediterranean',
  latitude: 40.741,
  longitude: -73.989,
  radius: 5_000,
});

const menuItems = await january.restaurants.searchMenuItems({
  query: 'chicken bowl',
  latitude: 40.741,
  longitude: -73.989,
});
```

The SDK takes coordinates but doesn't request location permission or track location. Ask for permission in your app before reading the device's position.

## A restaurant's menu

`getMenuItems` returns one page of a restaurant's menu: `limit` defaults to 100 (the maximum) and `offset` to 0. The response has no total, so keep paging until a page comes back shorter than `limit`:

```ts
import type { JanuaryClient, RestaurantMenuEntry } from '@januaryai/react-native';

export async function loadMenu(january: JanuaryClient, restaurantId: string) {
  const items: RestaurantMenuEntry[] = [];
  const limit = 100;
  for (let offset = 0; ; offset += limit) {
    const page = await january.restaurants.getMenuItems({ restaurantId, limit, offset });
    items.push(...page.items);
    if (page.items.length < limit) return items;
  }
}

const nearest = restaurants.items[0];
if (nearest) {
  const menu = await loadMenu(january, nearest.id);
}
```


# Food analysis

Every call on this page needs the `food_analysis:write` scope. An analysis can take tens of seconds, so show progress.

## Analyze a description

```ts
const scan = await january.foodAnalysis.analyzeDescription({
  query: 'two eggs, avocado toast, and black coffee',
});
```

`analyzeDescription` takes only `query`.

## Analyze a photo

`analyzePhoto` takes a remote image URL or a base64 data URI. The SDK has no camera UI; this sample uses `expo-image-picker`:

```ts
import * as ImagePicker from 'expo-image-picker';

const { granted } = await ImagePicker.requestCameraPermissionsAsync();
const picked = granted
  ? await ImagePicker.launchCameraAsync({ base64: true, quality: 0.8 })
  : undefined;
const asset = picked && !picked.canceled ? picked.assets[0] : undefined;
if (asset?.base64) {
  const scan = await january.foodAnalysis.analyzePhoto({
    image: `data:${asset.mimeType ?? 'image/jpeg'};base64,${asset.base64}`,
  });
}
```

Keep photos to about 1,000 px on the longest edge, the size the iOS SDK's `PhotoScanImage` sends. Resize with an image library such as `expo-image-manipulator` before you encode the photo.

`analyzePhoto` uses the reasoning-based analyzer by default. Pass `reasoningEffort: 'none'` for the standard one; the result shape and cost are the same.

## Log a detection

Each detection's `food` is ready to log:

| Field                                        | Meaning                                                  |
| -------------------------------------------- | -------------------------------------------------------- |
| `food.serving.id`                            | The catalog serving to log                               |
| `food.serving.quantity`, `food.serving.unit` | The size of one serving                                  |
| `food.quantity`                              | Servings eaten; becomes `FoodSelection.serving.quantity` |
| `food.nutrients`                             | Already scaled to `food.quantity`                        |

The API always returns `food.id`, `food.serving.id`, and `food.quantity`, but the TypeScript types mark them optional, so guard them:

```ts
const foods: FoodSelection[] = scan.detections.flatMap(({ food }) =>
  food.id && food.serving.id && food.quantity != null
    ? [{ id: food.id, serving: { id: food.serving.id, quantity: food.quantity } }]
    : []
);
```

Pass `foods` to [`foodLogs.create`](/client-sdks/react-native-sdk/guides/food-logs).

## Correct a result

Send the complete analysis and an instruction:

```ts
const corrected = await january.foodAnalysis.correct({
  analysis: scan,
  instruction: 'The drink was unsweetened and there was only one slice of toast',
});
```


# Voice capture

`VoiceCaptureSession` records from the microphone and returns a transcript using the device's own speech recognizer: Apple Speech on iOS and Android `SpeechRecognizer` on Android. Use it for explicit, user-driven input such as speaking a food name or a meal description.

The SDK does not send audio to January. On iOS the recording is buffered in a temporary file on the device and deleted once transcription finishes or the capture is canceled; on Android audio streams to the recognizer with no file. Recognition runs through the platform's speech service, which may process audio off the device under Apple's or Google's own terms; say so in your privacy disclosures.

## Capture and transcribe

Create one session per input, subscribe before starting, and start and stop it from the user's taps. `start()` resolves as soon as the microphone is live, so the transcript comes from `stop()` on the next tap:

```ts
import { VoiceCaptureError, VoiceCaptureSession } from '@januaryai/react-native';

const voice = new VoiceCaptureSession({ locale: 'en-US' });

const unsubscribe = voice.subscribe((snapshot) => {
  setLevel(snapshot.audioLevel);
  setPartial(snapshot.partialTranscript); // Android only
  if (snapshot.error) showMessage(snapshot.error.code); // for example no_match
  if (snapshot.result) void finish(); // Android ends a capture after a pause
});

function report(error: unknown) {
  if (error instanceof VoiceCaptureError) showMessage(error.code);
}

// The microphone button's onPress: tap to start, tap again to stop.
async function onMicPress() {
  if (voice.snapshot.state === 'recording') return finish();
  await voice.start().catch(report); // resolves once the microphone is live
}

async function finish() {
  try {
    setQuery((await voice.stop()).transcript);
  } catch (error) {
    report(error);
  }
}

// On unmount: unsubscribe(); voice.dispose();
```

A complete React button is in the example app's [`VoiceInputButton.tsx`](https://github.com/January-ai/january-sdk-react-native/blob/main/example/src/VoiceInputButton.tsx).

## Methods

| Member                | Behavior                                                                                                                                                                                                                                                                                           |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `isSupported`         | `false` on devices without a speech recognizer, such as some Android emulator images. Check it before showing a microphone.                                                                                                                                                                        |
| `subscribe(listener)` | Calls `listener` with the current snapshot, then on every change. Returns an unsubscribe function.                                                                                                                                                                                                 |
| `start()`             | Valid in `idle`. Asks for permission if needed, then resolves once the microphone is live (`recording`).                                                                                                                                                                                           |
| `stop()`              | In `recording`, resolves with the result once the recognizer has the final text. In `idle` after the recognizer ended the capture itself, returns its `result` or rejects with its `error`. Rejects with `invalid_state` while permission is pending, during another `stop()`, or with no capture. |
| `cancel()`            | Discards the active capture. Safe in any state.                                                                                                                                                                                                                                                    |
| `dispose()`           | Cancels, releases the native session, and removes subscribers.                                                                                                                                                                                                                                     |
| `snapshot`            | The latest snapshot.                                                                                                                                                                                                                                                                               |

## Snapshots, results, and errors

| Snapshot field      | Meaning                                                           |
| ------------------- | ----------------------------------------------------------------- |
| `state`             | `idle`, `requestingPermission`, `recording`, or `processing`      |
| `audioLevel`        | Microphone level from 0 to 1                                      |
| `durationMs`        | Recording length so far                                           |
| `partialTranscript` | Best text so far; Android only, empty on iOS                      |
| `result`            | Set in `idle` when the recognizer ended the capture with text     |
| `error`             | Set in `idle` when the recognizer ended the capture with an error |

A result has `transcript` (trimmed) and `durationMs`. Failures are `VoiceCaptureError` with a stable `code`: `unsupported`, `permission_denied`, `recognizer_unavailable`, `recording_failed`, `transcription_failed`, `no_match`, `invalid_state`, `cancelled`, or `unknown`.

## Permissions

iOS needs `NSMicrophoneUsageDescription` and `NSSpeechRecognitionUsageDescription` in `Info.plist` (Expo: `ios.infoPlist` in `app.json`). The SDK asks for both when `start()` runs.

On Android, the January Android SDK's manifest declares `android.permission.RECORD_AUDIO`, which merges into your app. `start()` requests it at runtime and rejects with `permission_denied` if the user declines, so the app does not need its own `PermissionsAndroid` call.


# Food logs

Food logs need the `food_logs:write` scope to create, update, and delete, and `food_logs:read` to list and summarize. Each entry in `foods` is a `FoodSelection` from [Food details and portions](/client-sdks/react-native-sdk/concepts/food-hydration-and-portions) or from a [food analysis](/client-sdks/react-native-sdk/guides/meal-analysis#log-a-detection).

## Create

```ts
const created = await january.foodLogs.create({
  name: 'Breakfast',
  timestampUTC: new Date().toISOString(), // optional; omitted means now
  foods: [selection],
});
```

Creating is not idempotent. After a create that timed out, list the day before you retry, or the meal may be logged twice.

## List and summarize

Dates are inclusive `YYYY-MM-DD` days in the client's timezone; see [User identity and timezone](/client-sdks/react-native-sdk/concepts/user-identity-and-timezone). A list spans at most 60 days:

```ts
const { items } = await january.foodLogs.list({ start: '2026-09-01', end: '2026-09-07' });
```

A summary adds up nutrients per day or per week over at most 366 days. Weeks start on Monday unless you pass `weekStart: 'sunday'`:

```ts
const summary = await january.foodLogs.getSummary({
  start: '2026-09-01',
  end: '2026-09-30',
  groupBy: 'week',
  weekStart: 'sunday',
});
summary.buckets.forEach((week) =>
  console.log(week.startDate, week.logsCount, week.nutrients.calories?.value)
);
```

A longer range rejects with `date_range_too_large`.

## Update and delete

A log's `id` is optional in the TypeScript types, and a listed log rarely has none, so guard it:

```ts
if (created.id) {
  await january.foodLogs.update({ id: created.id, name: 'Late breakfast' });
  await january.foodLogs.delete(created.id);
}
```

An update must change at least one of `foods`, `timestampUTC`, or `name`; the SDK rejects an empty update before sending it. Deleting is idempotent: an unknown or already deleted ID also succeeds.

The React Native SDK has no `foodLogs.get`. To read one log, list its day.

## Grams eaten

When January knows it, each logged food carries `servingDetails.weightGrams`, the weight of one catalog serving. The grams eaten are `consumedServing.quantity` times that weight.


# Water and weight logs

Water logs need the `water_logs:write` scope to create and delete and `water_logs:read` to list; weight logs need `weight_logs:write` and `weight_logs:read`.

List dates are inclusive `YYYY-MM-DD` days in the client's timezone ([User identity and timezone](/client-sdks/react-native-sdk/concepts/user-identity-and-timezone)), so the same `start` and `end` read back one day. A list returns at most 100 days, the most recent when more match; move `end` back to read older days. A `start` more than five years back rejects with `date_range_too_large`.

## Water

Log an amount in `fl_oz`, `ml`, or `cup` (a US cup of 8 fl oz). This helper reports the daily cap and returns the new log, whose `id` you need to delete it:

```ts
import type { VolumeUnit } from '@januaryai/react-native';

async function logWater(value: number, unit: VolumeUnit, consumedAt?: string) {
  try {
    return await january.waterLogs.create({ amount: { value, unit }, consumedAt });
  } catch (error) {
    if ((error as { code?: string }).code === 'daily_water_limit_exceeded') {
      showMessage('That would pass 24 L for the day.');
      return undefined;
    }
    throw error;
  }
}

const log = await logWater(8, 'fl_oz'); // consumedAt omitted: now
```

* **Past days.** To log for an earlier day, send local noon of that day, which stays on that day in the device's timezone:

  ```ts
  // Local noon on a YYYY-MM-DD day, as an ISO 8601 instant.
  function noonOn(day: string): string {
    const [year, month, date] = day.split('-').map(Number) as [number, number, number];
    return new Date(year, month - 1, date, 12).toISOString();
  }

  await logWater(250, 'ml', noonOn('2026-09-10'));
  ```
* **Daily cap.** An end user's water is capped at 24 L (about 811 fl oz) per UTC calendar day of `consumedAt`, whatever offset you send. Lists group days in the client's timezone, so near midnight a listed day's total can differ from what the cap counted.
* **Retries.** Creating is not idempotent. After a create that timed out, list the day before you retry; a retry records the water twice and counts it twice toward the cap.
* **Deletes.** Deleting is idempotent: an unknown or already deleted log also succeeds. A deleted amount no longer counts toward the cap.

  ```ts
  if (log) await january.waterLogs.delete(log.id);
  ```

A list returns one total per day that has water, oldest first, in the unit you ask for (`fl_oz` by default) whatever units the logs used, rounded to one decimal place:

```ts
const { items } = await january.waterLogs.list({
  start: '2026-09-01',
  end: '2026-09-07',
  unit: 'cup',
});
items.forEach((day) => console.log(day.date, day.total.value, day.total.unit));
```

## Weight

Log a measurement in `lb` or `kg`. Every measurement is kept, and a list shows one weight per day: the one with the latest `measuredAt` that day. A measurement backdated to earlier that day doesn't change what the day shows. Weight logs have no ID and can't be updated or deleted. Creating is not idempotent either: a retried create records the measurement twice, which the list shows once.

```ts
await january.weightLogs.create({
  weight: { value: 150, unit: 'lb' },
  measuredAt: new Date().toISOString(), // optional; omitted means now
});

const { items } = await january.weightLogs.list({
  start: '2026-09-01',
  end: '2026-09-30',
});
items.forEach((day) => console.log(day.date, day.weight.value, day.weight.unit));
```

## Accepted values

| Log    | Accepted per entry                                  |
| ------ | --------------------------------------------------- |
| Water  | 1–811.5 `fl_oz`, 30–24,000 `ml`, or 0.1–101.4 `cup` |
| Weight | 10–1,000 `lb`, or 4.5–453.6 `kg`                    |

The SDK rejects a value that isn't positive, or an unknown unit, before sending the request; January enforces the ranges. The minimums don't convert exactly into one another, so send the unit the user entered.


# Glucose prediction

`glucose.predict` predicts the glucose curve for a meal from the foods eaten, the time, and a user profile. It needs the `glucose:read` scope. Each food is a `FoodSelection` from [Food details and portions](/client-sdks/react-native-sdk/concepts/food-hydration-and-portions), and the client's timezone ([User identity and timezone](/client-sdks/react-native-sdk/concepts/user-identity-and-timezone)) is sent with the prediction.

{% hint style="warning" %}
Glucose predictions and profile inputs are health data. Keep request and response values out of analytics, crash reports, and general-purpose logs.
{% endhint %}

```ts
const { prediction, impact, chart } = await january.glucose.predict({
  foods: [selection],
  startTime: new Date().toISOString(),
  userProfile: {
    age: 38,
    sex: 'female',
    height: { unit: 'in', value: feet * 12 + inches },
    weight: { unit: 'kg', value: 67 },
    activityLevel: 'moderately_active',
    healthConditions: [],
  },
});
```

* `age` is in whole years.
* Height (`in` or `cm`) and weight (`lb` or `kg`) units are independent. Show imperial height as feet plus inches, and send `feet * 12 + inches`.

| Result field             | Meaning                                                                                    |
| ------------------------ | ------------------------------------------------------------------------------------------ |
| `prediction`             | The curve at 15-minute intervals: `minutes` after `startTime` and `value` in mg/dL         |
| `impact`                 | `low`, `medium`, or `high`; may be absent                                                  |
| `chart.min`, `chart.max` | Suggested y-axis bounds in mg/dL, not the curve's lowest and highest values; may be absent |

{% hint style="info" %}
Predictions are informational. Don't present them as a diagnosis or as treatment guidance.
{% endhint %}


# Reference

* [Client and resources](/client-sdks/react-native-sdk/reference/client-and-resources): client options, resources, and other exports.
* [API reference](/client-sdks/react-native-sdk/reference/api-reference): every method with its request and result types.
* [Errors](/client-sdks/react-native-sdk/reference/errors): what a failed call rejects with, and what to retry.
* [Models and validation](/client-sdks/react-native-sdk/reference/models-and-validation): exported types and the checks made before a request is sent.
* [Platform and security](/client-sdks/react-native-sdk/reference/platform-and-security): native dependencies and the SDK versions each release pins.
* [Testing](/client-sdks/react-native-sdk/reference/testing): mock the SDK in unit tests and test on a device.
* [Troubleshooting](/client-sdks/react-native-sdk/reference/troubleshooting): fixes for the SDK's error messages and common setup failures.
* [Changelog](/client-sdks/react-native-sdk/reference/changelog): release notes.


# Client and resources

`JanuaryClient` is the package's entry point.

```ts
new JanuaryClient({ endUserId, timezone, clientTokenProvider });
```

| Option                | Type                   | Notes                                                                                                                                                           |
| --------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `endUserId`           | `string`               | Required. See [User identity and timezone](/client-sdks/react-native-sdk/concepts/user-identity-and-timezone).                                                  |
| `timezone`            | `string`               | An IANA name; recommended. Same page.                                                                                                                           |
| `clientTokenProvider` | `JanuaryTokenProvider` | `(endUserId) => Promise<{ token, expiresIn }>`; see [Authentication](/client-sdks/react-native-sdk/getting-started/authentication).                             |
| `developmentApiKey`   | `string`               | Debug builds only; replaces `clientTokenProvider`. See [Development API key](/client-sdks/react-native-sdk/getting-started/authentication#development-api-key). |

`clientTokenProvider` and `developmentApiKey` are mutually exclusive in the TypeScript types.

| Resource       | Purpose                                                        |
| -------------- | -------------------------------------------------------------- |
| `foods`        | Autocomplete, search, get a food, barcode lookup, alternatives |
| `restaurants`  | Restaurant search, menu-item search, restaurant menus          |
| `foodAnalysis` | Description analysis, photo analysis, corrections              |
| `foodLogs`     | Create, list, summarize, update, and delete food logs          |
| `waterLogs`    | Log water, read daily totals, delete a log                     |
| `weightLogs`   | Log a weight, read the latest weight per day                   |
| `glucose`      | Personalized glucose prediction                                |

`dispose()` releases the client; see [Client lifecycle](/client-sdks/react-native-sdk/concepts/client-lifecycle).

Other exports:

* `getNativeModuleVersion()` returns the package version, such as `"0.3.0"`, when the native module is linked, and `null` when it isn't ([Confirm linking](/client-sdks/react-native-sdk/getting-started/installation#confirm-linking)).
* `VoiceCaptureSession` and `VoiceCaptureError`; see [Voice capture](/client-sdks/react-native-sdk/guides/voice-capture).
* `FoodCategory`, with the category strings `generic`, `branded`, and `recipe` as constants.


# API reference

Every method returns a promise. A failed call rejects as described in [Errors](/client-sdks/react-native-sdk/reference/errors).

## Foods

| Method                      | Request                          | Result                            |
| --------------------------- | -------------------------------- | --------------------------------- |
| `foods.autocomplete`        | `AutocompleteFoodsRequest`       | `AutocompleteFoodsResponse`       |
| `foods.search`              | `SearchFoodsRequest`             | `FoodSearchResults`               |
| `foods.get`                 | `GetFoodRequest`                 | `FoodSearchItem`                  |
| `foods.lookupBarcode`       | `LookupFoodByBarcodeRequest`     | `FoodSearchResults`               |
| `foods.suggestAlternatives` | `SuggestFoodAlternativesRequest` | `SuggestFoodAlternativesResponse` |

## Restaurants

| Method                        | Request                         | Result                              |
| ----------------------------- | ------------------------------- | ----------------------------------- |
| `restaurants.search`          | `SearchRestaurantsRequest`      | `SearchRestaurantsResponse`         |
| `restaurants.searchMenuItems` | `SearchRestaurantsRequest`      | `SearchRestaurantMenuItemsResponse` |
| `restaurants.getMenuItems`    | `GetRestaurantMenuItemsRequest` | `GetRestaurantMenuItemsResponse`    |

## Food analysis

| Method                            | Request                     | Result     |
| --------------------------------- | --------------------------- | ---------- |
| `foodAnalysis.analyzeDescription` | `AnalyzeDescriptionRequest` | `FoodScan` |
| `foodAnalysis.analyzePhoto`       | `AnalyzePhotoRequest`       | `FoodScan` |
| `foodAnalysis.correct`            | `CorrectPhotoScanRequest`   | `FoodScan` |

## Logs and glucose

| Method                | Request                    | Result                   |
| --------------------- | -------------------------- | ------------------------ |
| `foodLogs.create`     | `CreateFoodLogRequest`     | `FoodLog`                |
| `foodLogs.list`       | `ListFoodLogsRequest`      | `FoodLogList`            |
| `foodLogs.getSummary` | `GetFoodLogSummaryRequest` | `FoodLogSummary`         |
| `foodLogs.update`     | `UpdateFoodLogRequest`     | `FoodLog`                |
| `foodLogs.delete`     | log ID string              | `void`                   |
| `waterLogs.create`    | `CreateWaterLogRequest`    | `WaterLog`               |
| `waterLogs.list`      | `ListWaterLogsRequest`     | `ListWaterLogsResponse`  |
| `waterLogs.delete`    | log ID string              | `void`                   |
| `weightLogs.create`   | `CreateWeightLogRequest`   | `WeightLog`              |
| `weightLogs.list`     | `ListWeightLogsRequest`    | `ListWeightLogsResponse` |
| `glucose.predict`     | `PredictGlucoseRequest`    | `GlucosePrediction`      |

## Voice capture

`VoiceCaptureSession` (`isSupported`, `snapshot`, `subscribe`, `start`, `stop`, `cancel`, `dispose`) wraps the native speech recognizers; see [Voice capture](/client-sdks/react-native-sdk/guides/voice-capture). `stop()` resolves with `VoiceCaptureResult`; failures are `VoiceCaptureError` with a stable `code`.


# Errors

A failed call rejects with an `Error` carrying `code` and `message`. Branch on `code`; the message wording can change.

| `error.code`                                                                                                                    | Meaning                                                                                                                                                                                       |
| ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| An API code, such as `not_found`, `scope_insufficient`, `date_range_too_large`, `daily_water_limit_exceeded`, or `rate_limited` | January returned an error; see [REST errors](/rest-api/api-overview#errors)                                                                                                                   |
| `client_token_provider_failed` (iOS), `authentication` (Android)                                                                | Your token provider threw a non-retryable error, or its retries ran out ([Token refresh and retries](/client-sdks/react-native-sdk/getting-started/authentication#token-refresh-and-retries)) |
| `invalid_client_token`, `invalid_client_token_expiration` (iOS), `authentication` (Android)                                     | The provider returned an empty token, or one that expires within 60 seconds                                                                                                                   |
| `transport`, `timeout`, `decoding`                                                                                              | A network failure, a timeout, or a response the SDK couldn't read                                                                                                                             |
| `bridge_error`                                                                                                                  | The native client isn't configured, or the native layer rejected an argument                                                                                                                  |
| `january_error`                                                                                                                 | Any other native failure                                                                                                                                                                      |
| none                                                                                                                            | The TypeScript wrapper rejected the input before sending it (the message names the field), or the client was disposed                                                                         |

When January's response carries no code, `error.code` is the native error category instead. Most match across platforms (`validation`, `authentication`, `authorization`, `server`), but not-found and rate-limit errors are `notFound` and `rateLimited` on iOS and `not_found` and `rate_limited` on Android.

The constructor throws instead of rejecting: `new JanuaryClient` fails synchronously when the module isn't linked, the timezone is invalid (iOS), or `developmentApiKey` is used in a release build. See [Troubleshooting](/client-sdks/react-native-sdk/reference/troubleshooting) for each message.

## Retries

Besides the token-provider retries, the SDK retries only one thing: after `401 token_expired` it gets a new token and replays the call once. Retry `rate_limited` and `5xx` codes yourself, with backoff; the rejection doesn't include `Retry-After`. Never retry `request_limit_exceeded` or `credit_limit_exceeded` before your billing period resets. Before you retry a create that timed out, check whether it was recorded ([food logs](/client-sdks/react-native-sdk/guides/food-logs#create), [water and weight logs](/client-sdks/react-native-sdk/guides/water-and-weight-logs#water)).

## Example

```ts
function messageFor(error: unknown): string {
  const { code, message } = error as { code?: string; message?: string };
  switch (code) {
    case 'rate_limited':
    case 'service_unavailable':
    case 'transport':
    case 'timeout':
      return 'January is busy or unreachable. Try again in a moment.';
    case 'client_token_provider_failed': // iOS
    case 'authentication': // Android
      return 'Could not get a January token. Check your connection and sign-in.';
    case 'scope_insufficient':
      return 'This feature is not enabled for your account.';
    default:
      return message ?? 'Something went wrong.';
  }
}

try {
  await january.foods.search({ query: 'greek yogurt' });
} catch (error) {
  showMessage(messageFor(error));
}
```

Voice capture has its own error type, `VoiceCaptureError`; see [Voice capture](/client-sdks/react-native-sdk/guides/voice-capture#snapshots-results-and-errors).




---

[Next Page](https://docs.january.ai/llms-full.txt/1)

