DietlyAPI developer guide. Last reviewed: July 2026

Every DietlyAPI error explained, with handling recipes

DietlyAPI returns five error statuses in normal operation: 401, 403, 404, 422 and 429. Four of them carry detail as a string, and 422 carries detail as a list of objects. That single shape change is the most common cause of a crash in an otherwise working integration, because a handler written as err["detail"].lower() works perfectly until the first validation error. This page lists every status the API produces, the exact body it sends, what causes it, and the handling that belongs with it. Every example below was run against the live API.

StatusBodyRetry?
401{"detail": "Missing or invalid API key"}No, fix the request
403{"detail": "This API key is read-only. ..."}No, use a different key
404{"detail": "Barcode not found"}No, treat as empty state
422{"detail": [ {...} ]}, a listNo, fix the parameters
429{"detail": "Rate limit exceeded. Retry in N seconds."}Yes, after Retry-After

401: missing or invalid API key

The API expects a bearer token: Authorization: Bearer YOUR_KEY. You get a 401 when the header is absent, when the scheme is not Bearer, or when the key does not exist. The body is deliberately identical in all three cases so the endpoint cannot be used to probe which keys are real.

$ curl -s https://api.getdietly.com/nope
{"detail":"Missing or invalid API key"}

There is one non-obvious cause worth knowing, and it is the reason that example uses a nonsense path. Authentication runs before routing, so a URL that does not exist returns 401 rather than 404. If you are certain your key is valid and you are still getting 401, check the path before you check the key. The classic version of this mistake is inserting a version prefix: /v1/search returns 401 because there is no /v1 namespace, while /search works. The public read endpoints (/search, /barcode/{code}, /food/{id}) need no key at all, so a 401 from one of those is nearly always a typo in the path.

403: the key exists but is not allowed to do this

A 403 means authentication succeeded and authorisation failed. There are two forms, and the message tells you which.

{"detail":"This API key is read-only. Use a full-access key for this endpoint."}
{"detail":"Use your account key to manage dashboard settings."}

The first appears when a key created with read scope is used for anything that is not a GET. Read scope is the right default for anything you deploy to a server that only looks data up, and the restriction is the point rather than a limitation. The second appears when a named access key is used against /auth/ or /billing/ routes; account management uses the account key, not the per-service keys. Neither is retryable. Both are configuration mistakes that surface once, at integration time, and never again.

404: no such record

Direct lookups return 404 when nothing matches: GET /food/{id} for an unknown id, and GET /barcode/{code} for a barcode with no product. This is an ordinary outcome in a barcode scanner, not a failure, and it should never surface to a user as an error dialog.

$ curl -s https://api.getdietly.com/barcode/5099999999999
{"detail":"Barcode not found"}

Note the asymmetry with search: GET /search with no matches returns 200 and an empty array [], not a 404. Search answers "what matched", which can legitimately be nothing; a direct lookup answers "give me this record", which either exists or does not. Handle both, and treat both as the same empty state in your UI.

422: your parameters did not validate

This is the one that breaks parsers. On 422, detail is a list of validation objects, each with type, loc, msg and input. Every other error sends detail as a plain string.

$ curl -s "https://api.getdietly.com/search?q=a"
{"detail":[{"type":"string_too_short","loc":["query","q"],
            "msg":"String should have at least 2 characters",
            "input":"a","ctx":{"min_length":2}}]}

$ curl -s "https://api.getdietly.com/search?q=oats&limit=999"
{"detail":[{"type":"less_than_equal","loc":["query","limit"],
            "msg":"Input should be less than or equal to 50","input":"999"}]}

$ curl -s https://api.getdietly.com/food/abc
{"detail":[{"type":"int_parsing","loc":["path","food_id"],
            "msg":"Input should be a valid integer, unable to parse string as an integer",
            "input":"abc"}]}

The three rules worth encoding in your client: q is required and needs at least two characters, limit must be between 1 and 50, and a food id must be an integer. Validate those before you send, because a 422 still costs you a round trip. In particular, do not pass a raw search box through on every keystroke: a single-character query is guaranteed to 422, so debounce and require two characters locally.

def describe_error(response):
    # Return a readable message for any DietlyAPI error body.
    try:
        detail = response.json().get("detail")
    except ValueError:
        return f"HTTP {response.status_code}"
    if isinstance(detail, list):                      # 422 validation
        return "; ".join(
            f"{'.'.join(str(p) for p in item.get('loc', []))}: {item.get('msg')}"
            for item in detail
        )
    return str(detail)                                # 401, 403, 404, 429

429: rate limited

The limit is per minute. Anonymous and Starter traffic is capped at 30 requests per minute per IP, with an additional hourly cap that only a scraper would reach; Pro is 500 per minute and Scale 3,000 per minute, both counted per account rather than per IP. When you exceed it you get a 429 with a Retry-After header in seconds.

{"detail":"Rate limit exceeded. Retry in 12 seconds."}
Retry-After: 12

Requests made with a key also carry the full trio on both success and 429: X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. Anonymous traffic gets Retry-After only. If you are building a dashboard or a bulk sync, read X-RateLimit-Remaining on every response and slow down before you hit zero rather than after. That is the difference between a sync that finishes and one that spends its life in backoff.

A retry policy that fits these semantics

Only two of the five statuses are retryable, and one of them tells you exactly how long to wait. Everything else is a bug in your request that no amount of retrying will fix, which makes the policy short.

import time
import requests

RETRYABLE = {429, 500, 502, 503, 504}

def request_with_retry(session, method, url, tries=4, **kwargs):
    delay = 1.0
    for attempt in range(tries):
        r = session.request(method, url, timeout=10, **kwargs)
        if r.status_code not in RETRYABLE:
            return r
        if r.status_code == 429:
            wait = float(r.headers.get("Retry-After", delay))
        else:
            wait = delay
            delay *= 2                      # exponential backoff for 5xx only
        if attempt == tries - 1:
            return r
        time.sleep(wait)
    return r

Three details make this behave well in production. Honour Retry-After literally instead of applying your own backoff on top of it, because the server already knows when the window resets. Back off exponentially only for 5xx, where you have no information. And never retry 401, 403, 404 or 422: a retry loop on a 422 is an infinite loop that also burns your rate limit.

Deciding what is an error in your own domain

The status code tells you what happened at the transport level. Your application still has to decide what it means. A useful split is three buckets. Empty results covers 404 on a lookup and [] from a search, and belongs in the UI as "no match found", with an option to submit the product. Caller bugs covers 401, 403 and 422, and belongs in your logs and your tests, never in front of a user. Transient covers 429 and 5xx, and belongs in the retry path with a visible spinner rather than an error. Fold the first bucket into normal flow and most of the perceived unreliability of any API disappears.

One more thing worth handling explicitly: nutrients can be null on a perfectly successful 200. A label that did not declare fibre gives you "fiber_g": null. Treat that as unknown, never as zero, because rendering a confident 0 g for an undeclared nutrient is a data error your users will notice and the API will get blamed for.

Before you file a bug

Check the status page first: if the API is degraded, retries are the answer rather than code changes. Then reproduce with curl and no key against a public read endpoint, because that removes authentication, your HTTP client and your framework from the picture in one step. If a plain curl works and your client does not, the difference is in your request, and the response body will usually name the parameter.

Common questions

Why am I getting a 401 when my API key is valid?

Because authentication runs before routing, so a path that does not exist returns 401 rather than 404. The most common cause is adding a version prefix: /v1/search returns 401 while /search works. The public read endpoints need no key at all.

Why does my error handler crash on 422?

On 422 the detail field is a list of validation objects, while every other DietlyAPI error sends detail as a plain string. Code written as detail.lower() works until the first validation error and then raises.

What are the DietlyAPI rate limits?

Anonymous and Starter traffic gets 30 requests per minute per IP plus an hourly anti-scraping cap. Pro is 500 per minute and Scale 3,000 per minute, both counted per account. Exceeding the limit returns 429 with a Retry-After header in seconds.

Does an empty search return 404?

No. GET /search with no matches returns 200 and an empty array. Only direct lookups, GET /food/{id} and GET /barcode/{code}, return 404 when the record does not exist.