# rkeeper.ro analytics — external data API For developers building an application that reads a restaurant client's sales figures through rkeeper.ro. Version 1. Base URL: ``` https://analytics.rkeeper.ro ``` --- ## What is new Newest first. Nothing here removes or changes an existing field - see section 8 on stability - so an integration written against an earlier entry keeps working. | Date | Change | |---|---| | 2026-09-08 | `build`: waiter, station and hour can now be combined with menu item, category and classification. `table` added. `guests` added to every row. Only payment type remains restricted. | | 2026-08-24 | `dim_c`: a third grouping level on `build`. Unknown query parameters are now refused with `400` instead of ignored. | | 2026-08-24 | `GET /api/v1/build` and `GET /api/v1/fields`: any two columns, grouped, with the measures for that grain. **Station** is one of the columns. | | 2026-08-24 | Owners issue their own keys from their cabinet - see section 2. | | 2026-08-20 | First release: `whoami`, `freshness`, `restaurants`, `reports`, `report/{name}`, `series/{name}`. | --- ## 1. In one minute ```bash curl -H "Authorization: Bearer YOUR_ACCESS_CODE" \ https://analytics.rkeeper.ro/api/v1/whoami ``` ```json { "client": { "tenant_id": 1042, "name": "ZenSushi" }, "access": "read-only", "limits": { "max_rows": 5000, "max_days": 366 } } ``` If that works, everything else will. If it does not, section 3 lists every reason it can fail and what each one means. --- ## 2. Getting a key **Your key is an access code issued by rkeeper.ro for your application.** It is a 16-character string like `9f2c81a4de07b365`. The restaurant owner issues it themselves, in their own cabinet: **Settings → Connection → Data API for developers → New key**. They name it after your application, and the code is shown once. Ask them for a key named after your application. If they would rather rkeeper.ro did it, the account manager can. ### Do not ask the owner for the code they log in with It will not work — codes are not API-enabled by default, and one issued for a person to type is not automatically a machine credential. That is deliberate, and it protects you as much as the client: - **The owner's login code opens the cabinet.** Not just the figures: what counts as revenue, item classification, the email schedule. A credential in your config file, your CI, your logs and your error tracker should not be able to change how a client's revenue is calculated. - **A code issued for you can be revoked without touching anyone else.** If you rotate, or your contract ends, or a laptop is lost, that one code dies. Nobody is logged out and no other integration breaks. - **Your calls are attributable.** Every request is logged against the code that made it. When a client asks "who read my takings on the 14th", the answer names your application rather than shrugging. One code per application. If you run staging and production, ask for two. ### Look after it It is a bearer credential: whoever holds it can read that client's figures. Keep it out of source control, out of URLs (it belongs in a header, and query strings end up in server logs and browser history), and out of anything that runs in a browser — a key in front-end JavaScript is a key you have published. If you think it has leaked, tell rkeeper.ro. Revoking takes seconds. --- ## 3. Authentication Send the code on **every** request. There is no login step and no session: the API is stateless, so there is nothing to expire and nothing to refresh. Either header works. Pick one: ``` Authorization: Bearer 9f2c81a4de07b365 X-Access-Code: 9f2c81a4de07b365 ``` `Authorization` wins if you send both. ### One key, one client A code belongs to exactly one client. There is no parameter for choosing another, and no call that lists other clients. If your application serves several restaurant groups, hold one code per group and pick the code, not a client id. ### What can go wrong | Status | Meaning | What to do | |---|---|---| | `401` | No code sent, or the code is not valid | Check the header name and that the code has not been revoked. | | `403` | The code is valid but not enabled for the API | You have been given the owner's login code rather than one issued for your application. Ask for the right one. | | `403` | The client is not active | The client's account is suspended. Nothing you can fix. | | `429` | Rate limit reached | Back off — see section 7. | | `400` | Your request is malformed, or has a parameter this endpoint does not take | The `detail` field says exactly what, and lists what is accepted. | | `404` | No such report | `GET /api/v1/reports` lists the valid names. | | `5xx` | Our problem | Retry with backoff; if it persists, tell us. | **Unknown parameters are refused, not ignored.** A misspelled `venue_id` would otherwise silently widen your answer to every restaurant, and you would have no way of knowing. Every error is JSON with a `detail` field written for a human: ```json { "detail": "That period is longer than 366 days. Ask for it in shorter runs." } ``` --- ## 4. Endpoints All are `GET`. All are read-only. Nothing in this API changes anything. ### `GET /api/v1/whoami` Which client the key belongs to, and the limits in force. Useful as a health check and as the first call in a setup wizard. ### `GET /api/v1/freshness` **Read this before you build anything on a schedule.** ```json { "latest_trading_day": "2026-08-18", "last_loaded_at": "2026-08-19T07:04:11Z" } ``` - `latest_trading_day` — the most recent day we hold any checks for. - `last_loaded_at` — when data last arrived from the client's system. Data arrives once or twice a day, not continuously, and a restaurant's trading day does not end at midnight — a late shift closes in the small hours and belongs to the previous day. **So an empty answer for yesterday at 06:00 is not an error.** It means the day has not reached us yet. Poll `freshness` and act when `latest_trading_day` advances, rather than assuming a fixed time. An integration that fetches at 02:00 and reports zero takings will be reported to us as a bug in our figures, and it will be a bug in yours. ### `GET /api/v1/restaurants` ```json { "restaurants": [ { "venue_id": 11, "name": "Centru" }, { "venue_id": 22, "name": "Mall" } ] } ``` Use `venue_id` for filtering. Names change; ids do not. You will not see the client's organisational root here ("Head office" and its equivalents). R-Keeper carries it as a venue row, it is not a restaurant, and it is excluded everywhere in this API. ### `GET /api/v1/reports` The reports available, with a description of each. Do not hard-code the list; ask. ### `GET /api/v1/report/{report}` The rows of one report. | Parameter | Default | Notes | |---|---|---| | `date_from` | yesterday | `YYYY-MM-DD` | | `date_to` | `date_from` | `YYYY-MM-DD`, inclusive | | `venue_ids` | all | comma-separated, e.g. `11,22` | ```bash curl -H "Authorization: Bearer $CODE" \ "https://analytics.rkeeper.ro/api/v1/report/earnings?date_from=2026-08-01&date_to=2026-08-07&venue_ids=11,22" ``` ```json { "report": "earnings", "date_from": "2026-08-01", "date_to": "2026-08-07", "venue_ids": [11, 22], "truncated": false, "rows": [ { "key": "11", "label": "Centru", "sublabel": "", "revenue_net": "18634.02", "revenue_incl": "22174.48", "vat": "3540.46", "discount": "412.00", "quantity": null, "check_count": 1284 } ] } ``` `key`, `label` and `sublabel` mean different things per report — see section 5. A column that does not apply to a report is `null`, not zero: the voids report has no revenue because a void is not revenue. If `truncated` is `true` you have hit `max_rows`; ask for a shorter period or one restaurant at a time. ### `GET /api/v1/series/{report}` The same report as a value per day. Same parameters. ```json { "report": "earnings", "date_from": "2026-08-01", "date_to": "2026-08-03", "series": [ { "day": "2026-08-01", "value": "2640.10" }, { "day": "2026-08-02", "value": "3110.55" }, { "day": "2026-08-03", "value": "2894.00" } ] } ``` Days with no trade are absent, not zero. ### `GET /api/v1/fields` The columns `build` can group by. ```json { "fields": [ { "field": "venue", "name": "Restaurant", "grain": "both" }, { "field": "station", "name": "Station", "grain": "check" }, { "field": "item", "name": "Menu item", "grain": "line" } ] } ``` `grain` says which pairs work, and it is not arbitrary: - **`line`** — a property of a dish sold: item, category, classification. - **`check`** — payment type. A check can be paid several ways. - **`both`** — restaurant, day, day of week, hour, waiter, station, table. A check has exactly one of each, so every dish on it inherits them. **Only payment type cannot be paired with a dish field.** A dish on a check paid half in cash and half on a card belongs to neither half, so "item by payment type" has no true answer and is refused rather than estimated. Waiter by item, station by category, hour by item - all work, because a check has one waiter, one station, one closing time. `guests` is on every row. At line grain it is the guests on the checks that contributed, counted once per check however many dishes it had. At check grain - with payment type - a check paid two ways appears under each type with its guests, exactly as `check_count` does; neither is additive across payment types. Read this endpoint rather than hard-coding the list. Fields get added. ### `GET /api/v1/build` ```bash curl -H "Authorization: Bearer $CODE" \ "https://analytics.rkeeper.ro/api/v1/build?dim_a=station&dim_b=payment&date_from=2026-08-01&date_to=2026-08-07" ``` | Parameter | Required | Notes | |---|---|---| | `dim_a`, `dim_b` | yes | two different fields from `/api/v1/fields` | | `dim_c` | no | a third field, for three levels in one response | | `date_from` | no | defaults to yesterday | | `date_to` | no | defaults to `date_from` | | `venue_ids` | no | comma-separated | ```json { "dim_a": "station", "dim_b": "payment", "grain": "check", "date_from": "2026-08-01", "date_to": "2026-08-07", "rows": [ { "a": "Bar 1", "b": "Card", "paid": "84.50", "check_count": 2 } ] } ``` With `dim_c` each row gains a `c`. Without it there is no `c` at all, rather than an empty column to ignore. All three must be of the same kind - see the grain rule above. **The measures change with the grain, and the ones that do not apply are absent rather than zero** - a zero would read as "none". - `grain: "line"` → `revenue_net`, `revenue_incl`, `vat`, `discount`, `quantity`, `check_count` - `grain: "check"` → `paid`, `check_count` `paid` is money taken - VAT and tips included, change excluded - and does not equal `revenue_incl`. There is no revenue figure at check grain and no money-taken figure at line grain; offering either would be inventing it. This is the same function the owner's own report constructor uses, so you and they see the same numbers for the same two columns. --- ## 5. What the rows mean | Report | `label` | `sublabel` | Has money | |---|---|---|---| | `earnings` | restaurant | — | yes | | `earnings_by_classification` | classification | "not counted as revenue" when it is excluded | yes | | `items_sales` | menu item | its POS category | yes | | `items_by_category` | POS category | — | yes | | `waiters` | waiter | restaurant | yes | | `voids` | menu item | restaurant | no — `quantity` and `check_count` only | | `discounts` | menu item | restaurant | yes | ### Money - **`revenue_net`** — excluding VAT and after discounts. This is the figure to use for "revenue" unless you have a reason not to. - **`revenue_incl`** — what the guest actually paid, VAT included. - **`vat`**, **`discount`** — the components. **All money is a decimal string**, e.g. `"18634.02"`, never a JSON number. Parse it into your language's decimal type. Parsing it as a float and summing a month of it will produce a figure that is wrong in the third decimal place and impossible to reconcile against the client's own reports — which is the one thing an integration must never do. Currency is the client's own; there is one per client. `whoami` does not return it today. If you need it, tell us and we will add it. ### Revenue is a client-side decision Each client decides which classifications count as revenue — tips usually do not, delivery fees might, packaging varies. `revenue_net` on every report already respects that client's settings, so two clients can report different totals from identical sales, correctly. `earnings_by_classification` is the one report that shows excluded money too, marked in `sublabel`. It is the right report for "where did the money go", and the wrong one for summing into a revenue figure. ### Trading day, not calendar day `trading_day` is the restaurant's own. A check closed at 02:30 on the 15th usually belongs to the 14th. Do not re-derive days from timestamps; use the ones we give you. --- ## 6. Worked example Pull last week's takings per restaurant, only once the data is in. ```python import datetime as dt import os from decimal import Decimal import requests BASE = "https://analytics.rkeeper.ro" SESSION = requests.Session() SESSION.headers["Authorization"] = f"Bearer {os.environ['RK_ACCESS_CODE']}" def get(path, **params): r = SESSION.get(f"{BASE}{path}", params=params, timeout=30) if r.status_code >= 400: raise RuntimeError(f"{r.status_code}: {r.json().get('detail')}") return r.json() def latest_day(): """The most recent day we hold. None if nothing has arrived.""" day = get("/api/v1/freshness")["latest_trading_day"] return dt.date.fromisoformat(day) if day else None def week_by_restaurant(end): start = end - dt.timedelta(days=6) data = get("/api/v1/report/earnings", date_from=start.isoformat(), date_to=end.isoformat()) # Decimal, not float. These numbers get reconciled against the # client's own reports. return {row["label"]: Decimal(row["revenue_net"] or "0") for row in data["rows"]} day = latest_day() if day is None: print("nothing loaded for this client yet") else: for name, revenue in sorted(week_by_restaurant(day).items()): print(f"{name:<20} {revenue:>12,.2f}") ``` Note what it does **not** do: it never assumes today minus one. It asks what we have and works back from that. --- ## 7. Rate limits, and being a good citizen Each key has a limit, **60 requests a minute** by default. Over it you get `429`. There is no penalty beyond the refusal — wait and continue. Retry on `429` and `5xx` with exponential backoff, starting at a second and giving up after five attempts. Do not retry `4xx` other than `429`: the request is wrong and will stay wrong. Ask for what you need: - **Do** ask for a date range in one call rather than a call per day. - **Do** cache. The figures for a closed day do not change. - **Don't** poll every minute. Data arrives once or twice a day; poll `freshness` every 15–30 minutes at most and act on change. - **Don't** re-fetch history on every run. Fetch since your last `latest_trading_day` and keep what you have. If you need a higher limit, ask — with a note about what you are building. Raising it is a column, not a negotiation. --- ## 8. Stability - Fields will be **added**. Ignore ones you do not recognise rather than failing on them. - Fields will not be removed or change meaning inside `/api/v1/`. - New reports may appear. Read `/api/v1/reports` rather than hard-coding. - A breaking change means `/api/v2/`, and `/api/v1/` keeps working while you move. --- ## 9. Support Tell us: the endpoint, the parameters, the time (with timezone), the status code and the `detail` you got back. **Never send us the access code** — we can find every one of your calls from the endpoint and the time, and a code in a support ticket is a code that needs revoking. Figures that look wrong: check `freshness` first, then whether the client has changed which classifications count as revenue. Those two explain almost every case before it reaches us. ---