Cellarion API

Read your cellar, take bottles out, post sensor readings. Version 1.278.0. Download the OpenAPI 3.1 document.

The stable REST API for your own Cellarion data: your cellars and the ones shared with you, bottles, racks, personal fields, statistics and notifications. You reach it with a personal API token. This page describes the version of the server it is served from; the raw OpenAPI 3.1 document is at /api/openapi.json.

Which interface should I use?

You are building Use
A dashboard, a home-automation integration, a script This REST API, starting with GET /api/inventory
Something that should react when the cellar changes The event stream, GET /api/events/stream, then refetch
An AI assistant (Claude, ChatGPT and others) MCP: see Connect an AI assistant
A temperature or humidity sensor Climate ingest, POST /api/climate/ingest

The API gives you your own data. It does not expose Cellarion's AI features (label scanning, enrichment, the cellar chat); those stay in the app, and nothing in this reference spends AI credits.

Getting started

  1. Create a token. In the app, open Settings → API tokens and create one with the fewest scopes you need: read for a dashboard, plus consume if your integration takes bottles out. The token is shown once; store it like a password.

  2. Pick the base URL. https://cellarion.app, or your own server's address if you self-host. A self-hosted server can run an older version (see Stability and versioning).

  3. Make a first call.

    export CELLARION_TOKEN=cel_…
    curl -H "Authorization: Bearer $CELLARION_TOKEN" "https://cellarion.app/api/auth/whoami"
    curl -H "Authorization: Bearer $CELLARION_TOKEN" "https://cellarion.app/api/inventory"
    
  4. Or use a ready-made client.

    import aiohttp
    from pycellarion import CellarionClient
    
    async with aiohttp.ClientSession() as session:
        client = CellarionClient(session, "https://cellarion.app", token="cel_…")
        result = await client.get_inventory()
        print(len(result.data["bottles"]), "bottles")
    

Authentication and scopes

Send the token in the Authorization header: Authorization: Bearer cel_…. Never put it in a URL, where it ends up in logs and browser history.

Scope Allows
read Reading your cellars and the ones shared with you: bottles, racks, personal fields, statistics, notifications, and the event stream
consume Taking bottles out of the cellar
climate Posting sensor readings. Device tokens get it when you add a climate device in the app
write AI assistants over MCP only; it opens no REST operation

A request outside the token's scopes answers 403. An unknown or revoked token answers 401.

Errors

Every error is JSON: {"error": "A human-readable message"}, sometimes with a machine-readable code.

Status Meaning What to do
400 The request is malformed Fix the request
401 No token, or it is invalid or revoked Ask the user for a new token; retrying will not help
403 The token lacks the scope, or the user lacks the role in that cellar Ask for a token with the right scope
404 Not found, or not visible to this user Check the id
409 The state moved on, e.g. the bottle already left the cellar Refetch before trying again
429 Rate limited Wait Retry-After seconds
5xx Server trouble Retry later, with backoff

Rate limits

Every answer carries RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (seconds until the window resets) and RateLimit-Policy (e.g. 200;w=900: 200 requests per 900 seconds). A 429 also carries Retry-After. Today that is about 200 reads per 15 minutes per address, 60 writes per 15 minutes, and 60 inventory reads per 10 minutes per user. These numbers can change: read the headers rather than hard-coding them.

Caching with ETag

GET /api/inventory answers with an ETag. Send it back as If-None-Match; when nothing changed you get 304 Not Modified with no body, at almost no cost on either side. A good pattern is to poll the inventory every few minutes with If-None-Match, or to refetch when the event stream says something changed.

Pagination

GET /api/bottles takes limit (1 to 200, default 30) and offset (default 0). The answer gives total, count, limit and skip (the offset of this page); page until skip + count reaches total. For the whole cellar at once, use GET /api/inventory instead.

Live updates

GET /api/events/stream is a Server-Sent Events stream. It sends stats_changed, notification or climate when your data changes: it says that something changed, not what, so refetch what you show. A : hb comment arrives every 25 seconds; reconnect with backoff after 90 seconds of silence. Authenticate with the Authorization header (a browser EventSource cannot send headers; use a client that can).

Revoking a token

When a user removes your integration, revoke its token with the token itself: DELETE /api/tokens/self. Users can also revoke any token under Settings → API tokens.

Stability and versioning

What is in this reference is stable:

Self-hosted servers update on their owner's schedule, so they can be older than cellarion.app. Read a server's version from GET /api/health, or treat a 404 or 403 on a documented endpoint as "not supported on this server" and carry on without it.

Being removed

Until now a read token could also reach other GET routes under /api/cellars, /api/bottles, /api/stats and /api/notifications that are not in this reference, such as /api/cellars/{id} or /api/stats/value-history. They are deprecated from 1 November 2026: their answers carry Deprecation, Sunset and Link headers, and from 1 May 2027 they answer tokens with 403. GET /api/inventory covers most of what they were used for; if something you need is missing, open an issue before then.

Security

Terms of use

Build whatever helps you and others enjoy a cellar. A few ground rules keep it fair:

We may revoke a token or block access that breaks these terms or puts users or the service at risk. These terms cover cellarion.app: the owner of a self-hosted server sets their own, and the Cellarion software stays open source under the AGPL-3.0.

Inventory

Everything in your cellars in one cheap, cacheable read. Most integrations need only this.

GET /api/inventory — The whole inventory in one read

Token scope: read

Every bottle still in your cellars and in the cellars shared with you, each with its wine, its drink window, where it is (rack, shelf, row, slot) and its personal fields. Start here: one call replaces paging the bottle list, and an unchanged poll costs almost nothing.

Send the ETag of the copy you hold as If-None-Match. When nothing changed you get 304 with no body. The tag moves when any of the data changes, and at least every 30 minutes.

Limited to 60 requests per user per 10 minutes, 304s included. Poll every few minutes, or refetch when the event stream says something changed.

  • If-None-Match (header): The `ETag` of the copy you hold, exactly as you received it.
{
  "schema": 1,
  "generatedAt": "2026-10-22T08:00:00.000Z",
  "cellars": [
    {
      "id": "66f0a1b2c3d4e5f6a7b8c902",
      "name": "Home",
      "role": "owner"
    },
    {
      "id": "66f0a1b2c3d4e5f6a7b8c903",
      "name": "Summer house",
      "role": "editor"
    }
  ],
  "racks": [
    {
      "id": "66f0a1b2c3d4e5f6a7b8c904",
      "cellar": "66f0a1b2c3d4e5f6a7b8c902",
      "name": "Wine fridge"
    }
  ],
  "wines": {
    "66f0a1b2c3d4e5f6a7b8c905": {
      "name": "Château Exemple",
      "producer": "Château Exemple",
      "type": "red",
      "colour": null,
      "country": "France",
      "region": "Pauillac",
      "appellation": "Pauillac",
      "classification": null,
      "grapes": [
        "Cabernet Sauvignon",
        "Merlot"
      ],
      "image": "/api/uploads/thumbs/processed/3f2b8c1e-4a5d-4e6f-9a7b-1c2d3e4f5a6b.webp.webp",
      "profile": {
        "body": "full",
        "acidity": "medium",
        "sweetness": "dry",
        "tannin": "high",
        "flavors": [
          "blackcurrant",
          "cedar"
        ],
        "foodPairings": [
          "lamb"
        ]
      }
    }
  },
  "bottles": [
    {
      "id": "66f0a1b2c3d4e5f6a7b8c906",
      "wine": "66f0a1b2c3d4e5f6a7b8c905",
      "pending": null,
      "cellar": "66f0a1b2c3d4e5f6a7b8c902",
      "vintage": "2015",
      "size": "750ml",
      "price": 350,
      "currency": "SEK",
      "rating": 92,
      "ratingScale": "100",
      "notes": "For a birthday",
      "purchaseDate": "2024-05-01",
      "purchaseLocation": "Wine shop",
      "addedAt": "2025-01-02T10:00:00.000Z",
      "opened": false,
      "maturity": "peak",
      "window": {
        "drinkFrom": 2022,
        "peakFrom": 2025,
        "peakUntil": 2032,
        "drinkTo": 2038
      },
      "location": {
        "rack": "66f0a1b2c3d4e5f6a7b8c904",
        "rackName": "Wine fridge",
        "position": 5,
        "shelf": 2,
        "row": 1,
        "col": 2
      },
      "place": null,
      "image": "/api/uploads/thumbs/processed/3f2b8c1e-4a5d-4e6f-9a7b-1c2d3e4f5a6b.webp.webp",
      "personal": [
        {
          "key": "Closure",
          "type": "text",
          "unit": null,
          "value": "cork",
          "level": "bottle",
          "by": "alice"
        }
      ]
    }
  ]
}

Live updates

Know when to refetch, without polling.

GET /api/events/stream — Server-sent events when your data changes

Token scope: read

A long-lived text/event-stream (Server-Sent Events). It says that something changed, not what: refetch what you show (for example GET /api/inventory with its ETag).

On connect: retry: 5000, then event: ready. Events: stats_changed (data: {"reason": "…"}), notification (data: {"id": "…", "type": "…"}) and climate (data: {"device": "…"}). Events are coalesced over about 2 seconds. A : hb comment arrives every 25 seconds; treat 90 seconds of silence as a lost connection and reconnect with backoff.

A stream lasts at most 24 hours and closes when the token is revoked. At most 5 streams per user (429 beyond). New event names may appear; ignore the ones you don't know.

Cellars and bottles

Cellars, filtered bottle lists, one bottle, racks.

GET /api/cellars — Your cellars and the ones shared with you

Token scope: read

{
  "count": 1,
  "cellars": [
    {
      "_id": "66f0a1b2c3d4e5f6a7b8c902",
      "name": "Home",
      "description": "The basement",
      "user": "66f0a1b2c3d4e5f6a7b8c901",
      "userRole": "owner",
      "userColor": null,
      "createdAt": "2025-01-02T10:00:00.000Z",
      "updatedAt": "2025-01-02T10:00:00.000Z"
    }
  ]
}

GET /api/bottles — Bottles in the cellars you own, filtered and paged

Token scope: read

Bottles in cellars you own (shared cellars are not included; GET /api/inventory covers both). By default only bottles still in the cellar. For everything at once, prefer GET /api/inventory.

  • limit (query)
  • offset (query): Items to skip. `skip` is accepted as a synonym.
  • status (query): `consumed` = drank, gifted, sold or other.
  • maturity (query): One or more of `not-ready`, `early`, `peak`, `late`, `declining`, `none`, comma-separated.
  • search (query): Words that must all appear (accents ignored) in the wine name, producer, grapes, country, region, appellation, type, notes or location.
  • type (query): Wine type, e.g. `red`. Comma-separated for several.
  • vintage (query): One or more vintages, comma-separated.
  • sort (query): A leading `-` sorts descending.
{
  "bottles": {
    "count": 1,
    "total": 1,
    "limit": 30,
    "skip": 0,
    "items": [
      {
        "_id": "66f0a1b2c3d4e5f6a7b8c906",
        "cellar": "66f0a1b2c3d4e5f6a7b8c902",
        "wineDefinition": {
          "_id": "66f0a1b2c3d4e5f6a7b8c905",
          "name": "Château Exemple",
          "producer": "Château Exemple",
          "type": "red",
          "colour": null,
          "country": {
            "_id": "66f0a1b2c3d4e5f6a7b8c908",
            "name": "France"
          },
          "region": {
            "_id": "66f0a1b2c3d4e5f6a7b8c909",
            "name": "Pauillac"
          },
          "appellation": "Pauillac",
          "classification": null,
          "grapes": [
            {
              "_id": "66f0a1b2c3d4e5f6a7b8c90a",
              "name": "Cabernet Sauvignon",
              "displayName": "Cabernet Sauvignon"
            }
          ],
          "image": "/api/uploads/processed/3f2b8c1e-4a5d-4e6f-9a7b-1c2d3e4f5a6b.webp"
        },
        "pendingWineRequest": null,
        "vintage": "2015",
        "bottleSize": "750ml",
        "price": 350,
        "currency": "SEK",
        "rating": 92,
        "ratingScale": "100",
        "status": "active",
        "location": "Basement, box 3",
        "notes": "For a birthday",
        "purchaseDate": "2024-05-01T00:00:00.000Z",
        "purchaseLocation": "Wine shop",
        "openedAt": null,
        "addedToCellarAt": "2025-01-02T10:00:00.000Z",
        "createdAt": "2025-01-02T10:00:00.000Z",
        "updatedAt": "2025-01-02T10:00:00.000Z",
        "defaultImageUrl": null,
        "vintageImageUrl": null,
        "pendingImageUrl": null,
        "otherVintageImageUrl": null
      }
    ]
  }
}

GET /api/bottles/{bottleId} — One bottle

Token scope: read

  • bottleId (path, required)
{
  "bottle": {
    "_id": "66f0a1b2c3d4e5f6a7b8c906",
    "cellar": "66f0a1b2c3d4e5f6a7b8c902",
    "wineDefinition": {
      "_id": "66f0a1b2c3d4e5f6a7b8c905",
      "name": "Château Exemple",
      "producer": "Château Exemple",
      "type": "red",
      "colour": null,
      "country": {
        "_id": "66f0a1b2c3d4e5f6a7b8c908",
        "name": "France"
      },
      "region": {
        "_id": "66f0a1b2c3d4e5f6a7b8c909",
        "name": "Pauillac"
      },
      "appellation": "Pauillac",
      "classification": null,
      "grapes": [
        {
          "_id": "66f0a1b2c3d4e5f6a7b8c90a",
          "name": "Cabernet Sauvignon",
          "displayName": "Cabernet Sauvignon"
        }
      ],
      "image": "/api/uploads/processed/3f2b8c1e-4a5d-4e6f-9a7b-1c2d3e4f5a6b.webp"
    },
    "pendingWineRequest": null,
    "vintage": "2015",
    "bottleSize": "750ml",
    "price": 350,
    "currency": "SEK",
    "rating": 92,
    "ratingScale": "100",
    "status": "active",
    "location": "Basement, box 3",
    "notes": "For a birthday",
    "purchaseDate": "2024-05-01T00:00:00.000Z",
    "purchaseLocation": "Wine shop",
    "openedAt": null,
    "addedToCellarAt": "2025-01-02T10:00:00.000Z",
    "createdAt": "2025-01-02T10:00:00.000Z",
    "updatedAt": "2025-01-02T10:00:00.000Z",
    "defaultImageUrl": null,
    "vintageImageUrl": null,
    "pendingImageUrl": null,
    "otherVintageImageUrl": null
  },
  "userRole": "owner",
  "rackInfo": {
    "rackId": "66f0a1b2c3d4e5f6a7b8c904",
    "rackName": "Wine fridge",
    "position": 5
  },
  "defaultImageUrl": null,
  "vintageImageUrl": null,
  "pendingImageUrl": null,
  "otherVintageImageUrl": null
}

GET /api/racks — The racks in one cellar

Token scope: read

With a token, racks are read in summary form: send summary=1. Slots carry bottle ids only.

  • cellar (query, required)
  • summary (query, required)
{
  "racks": [
    {
      "_id": "66f0a1b2c3d4e5f6a7b8c904",
      "cellar": "66f0a1b2c3d4e5f6a7b8c902",
      "name": "Wine fridge",
      "group": null,
      "type": "cabinet",
      "rows": 2,
      "cols": 3,
      "typeConfig": {
        "shelfRows": [
          1,
          1
        ]
      },
      "isModular": false,
      "slots": [
        {
          "position": 5,
          "bottle": "66f0a1b2c3d4e5f6a7b8c906"
        }
      ],
      "createdAt": "2025-01-02T10:00:00.000Z",
      "updatedAt": "2025-01-02T10:00:00.000Z"
    }
  ]
}

Drinking

Take a bottle out of the cellar.

POST /api/bottles/{bottleId}/consume — Take a bottle out of the cellar

Token scope: consume

Marks the bottle drunk (or gifted, sold, other). Needs the consume scope and editor access to the cellar. Send ifActive: true so a bottle that already left the cellar is never overwritten.

  • bottleId (path, required)
{
  "bottle": {
    "_id": "66f0a1b2c3d4e5f6a7b8c906",
    "cellar": "66f0a1b2c3d4e5f6a7b8c902",
    "wineDefinition": {
      "_id": "66f0a1b2c3d4e5f6a7b8c905",
      "name": "Château Exemple",
      "producer": "Château Exemple",
      "type": "red",
      "colour": null,
      "country": {
        "_id": "66f0a1b2c3d4e5f6a7b8c908",
        "name": "France"
      },
      "region": {
        "_id": "66f0a1b2c3d4e5f6a7b8c909",
        "name": "Pauillac"
      },
      "appellation": "Pauillac",
      "classification": null,
      "grapes": [
        {
          "_id": "66f0a1b2c3d4e5f6a7b8c90a",
          "name": "Cabernet Sauvignon",
          "displayName": "Cabernet Sauvignon"
        }
      ],
      "image": "/api/uploads/processed/3f2b8c1e-4a5d-4e6f-9a7b-1c2d3e4f5a6b.webp"
    },
    "pendingWineRequest": null,
    "vintage": "2015",
    "bottleSize": "750ml",
    "price": 350,
    "currency": "SEK",
    "rating": 92,
    "ratingScale": "100",
    "status": "drank",
    "location": "Basement, box 3",
    "notes": "For a birthday",
    "purchaseDate": "2024-05-01T00:00:00.000Z",
    "purchaseLocation": "Wine shop",
    "openedAt": null,
    "addedToCellarAt": "2025-01-02T10:00:00.000Z",
    "createdAt": "2025-01-02T10:00:00.000Z",
    "updatedAt": "2025-01-02T10:00:00.000Z",
    "defaultImageUrl": null,
    "vintageImageUrl": null,
    "pendingImageUrl": null,
    "otherVintageImageUrl": null,
    "consumedAt": "2026-10-22T19:30:00.000Z",
    "consumedReason": "drank"
  }
}

Statistics

Totals and drink-window counts for dashboards.

GET /api/stats/overview — Statistics over the cellars you own

Token scope: read

Values in your preferred currency and rating scale. Cached for up to 30 minutes.

{
  "stats": {
    "overview": {
      "totalBottles": 42,
      "totalConsumed": 17,
      "uniqueWines": 30,
      "totalCellars": 1,
      "totalCountries": 6,
      "totalGrapes": 12,
      "totalValue": 15420,
      "currency": "SEK",
      "avgPrice": 367.14,
      "avgRating": 78,
      "targetRatingScale": "5",
      "oldestVintage": 2005,
      "newestVintage": 2023,
      "bottlesDrunk": 15,
      "bottlesGifted": 2,
      "bottlesSold": 0
    },
    "maturity": {
      "declining": 1,
      "late": 3,
      "peak": 12,
      "early": 9,
      "notReady": 10,
      "noProfile": 7
    },
    "pace": {
      "avgIntakePerYear": 24,
      "avgOutputPerYear": 18,
      "netPerYear": 6,
      "runway": 2
    },
    "byType": {
      "red": 25,
      "white": 12,
      "sparkling": 5
    },
    "byCountry": [
      {
        "name": "France",
        "count": 20
      },
      {
        "name": "Italy",
        "count": 9
      }
    ],
    "cellarBreakdown": [
      {
        "id": "66f0a1b2c3d4e5f6a7b8c902",
        "name": "Home",
        "bottleCount": 42,
        "value": 15420
      }
    ],
    "topProducers": [
      {
        "name": "Château Exemple",
        "count": 4
      }
    ],
    "urgencyLadder": [
      {
        "id": "66f0a1b2c3d4e5f6a7b8c907",
        "cellarId": "66f0a1b2c3d4e5f6a7b8c902",
        "name": "Exemple Blanc",
        "producer": "Domaine Exemple",
        "vintage": "2016",
        "type": "white",
        "price": 210,
        "status": "late"
      }
    ]
  }
}

Notifications

Restock suggestions, drink-window reminders and other notices.

GET /api/notifications — Your 30 newest notifications

Token scope: read

{
  "notifications": [
    {
      "_id": "66f0a1b2c3d4e5f6a7b8c90b",
      "type": "restock_alert",
      "title": "Restock Suggestion",
      "message": "You just finished your last bottle similar to Château Exemple. Time to restock?",
      "link": "/restock",
      "read": false,
      "createdAt": "2026-10-21T18:00:00.000Z"
    }
  ],
  "unreadCount": 1
}

GET /api/notifications/unread-count — The unread count, cheaply

Token scope: read

Poll this instead of the list; fetch the list when newestId or unreadCount changes.

{
  "unreadCount": 1,
  "newestId": "66f0a1b2c3d4e5f6a7b8c90b"
}

Climate sensors

Temperature and humidity from a sensor in your cellar.

POST /api/climate/ingest — Post sensor readings

Token scope: climate

For a temperature or humidity sensor in a cellar. Authenticate with the device token shown when you add a climate device in the app (it carries only the climate scope). Readings are validated one by one: the valid ones are kept, the others reported in errors. Wait intervalS seconds between posts.

{
  "accepted": 2,
  "rejected": 0,
  "intervalS": 300
}

Images

Thumbnails referenced by other answers.

GET /api/uploads/thumbs/{folder}/{file} — A bottle or wine thumbnail

No token needed

The image paths in GET /api/inventory point here. No token needed: the file names are random. Fetch only paths of exactly this shape.

  • folder (path, required)
  • file (path, required)

Tokens

The token itself: who it belongs to, and revoking it.

GET /api/auth/whoami — Which account this token belongs to

Token scope: read

The account id only, no personal details. Useful to check that a new token belongs to the same account as the old one.

{
  "id": "66f0a1b2c3d4e5f6a7b8c901"
}

DELETE /api/tokens/self — Revoke the token making this request

Any personal API token

Any personal token may revoke itself, whatever its scopes, for example when an integration is removed. Its event streams close too. A climate device token is retired by deleting the device in the app instead (403).

{
  "message": "Token revoked",
  "id": "66f0a1b2c3d4e5f6a7b8c90c",
  "name": "Home Assistant"
}

Server

Health, version and this document.

GET /api/health — Is the server up, and which version is it

No token needed

No token needed. A self-hosted server may run an older version than cellarion.app: read version, or treat a 404 or 403 on a documented path as "not supported here" and carry on without it.

{
  "status": "ok",
  "mongo": "connected",
  "version": "1.275.0"
}

GET /api/openapi.json — This document

No token needed

The OpenAPI 3.1 description of this server's public API, for its own version. No token needed.