Reporting API
The Reporting API gives your reporting tools (Power BI, Excel, a spreadsheet script or your own code) the same figures as the Reports page in the portal: stock on hand, stock-outs, stock value, expiring lots, open purchase orders and stock activity, as flat, typed datasets. It is read-only, it is included on every plan (Shared, Dedicated and Enterprise) with no add-on to buy, and it works the same way on every plan.
Three things have similar names:
- The Reports page in the portal (Reports in the portal sidebar) shows these figures on screen, with a CSV download per card.
- The Reporting menu inside OpenBoxes is part of the OpenBoxes application itself and reads your live OpenBoxes data.
- The Reporting API (this page) returns the Reports page's data to software, with an API key.
The scheduled CSV export is available on every plan: a zip of these datasets as CSV files every day, week or month, with a link emailed to you to download it from the portal. It uses the same CSV columns as this page, does not count against this API's allowance and needs no API key.
Power BI and Excel can read the Reporting API today with our Power Query recipe: see Power BI and Excel. Looker Studio cannot call the Reporting API; today you upload a scheduled export's CSV file to it by hand: see Looker Studio. The Looker Studio connector and the Power BI connector are coming soon: we are building them and they are not available yet, with no date. Any other tool that can call a web address with a header can read the Reporting API directly (it can answer in CSV).
Where the data comes from#
The Reporting API does not read your live OpenBoxes database. Once a day, after 04:00 in your account's time zone, we check your stock, prices, purchase orders and activity and keep a copy of the result. The Reporting API serves that copy. Every row says which daily check it comes from (captureLocalDate, capturedAt, capturedAtUtc and zone), so a figure is always "as of the daily check", never "right now". A change you make in OpenBoxes shows up after the next daily check.
History goes back as far as we have checks for your account: daily rows for the last 90 days, and monthly rows for up to 13 months. The start of your series is published per dataset (earliest in the index, seriesStart in each response).
Base URL and your API key#
The Reporting API lives on the platform API host, not on your instance subdomain:
https://api.openboxes.cloud/api/v1/reporting
Your instance subdomain does not serve the Reporting API. A request to https://{your-subdomain}.openboxes.cloud/api/v1/reporting is answered by your OpenBoxes instance with its own "Page Not Found" page (status 404, HTML, not a JSON error), and no reporting data. There is no account or subdomain parameter: the account is always the one your key belongs to.
Send an API key in the X-API-Key header, exactly as for the rest of the API (see Authentication):
curl -H "X-API-Key: $OB_API_KEY" \
https://api.openboxes.cloud/api/v1/reporting/stock-on-hand
- Keys go in the header, never in the URL. A request with a parameter named
apiKey,api_key,keyorX-API-Key, or with a whole key (obc_…followed by.and the secret) in any parameter name or value, written plainly or percent-encoded, is refused with400 api_key_in_urland is not processed. A URL can end up in logs and browser history, so if you have put a key in one, revoke that key in the portal and create a new one. - Create a key with the
reportingscope. Scopes are recorded and not yet enforced, so today any key bound to your account works. Once scopes are enforced (no date yet) the Reporting API will accept keys with thereporting,read,writeoradminscope and refuse a key that records no scopes, so create areportingkey now. - A key must be bound to your account. A key created before keys were bound to an account is refused with
403 api_key_no_tenant_binding. Create a new one. - Only the account owner creates API keys. Invited teammates can open the API keys page, but they cannot list, create or revoke keys there.
- A portal sign-in is not accepted: a request carrying a portal session instead of a key gets
401 api_key_required. - Revoking a key stops it within seconds; a key's expiry date takes effect within about 5 minutes (see Authentication).
The Reporting API answers GET (and HEAD, which is a GET without the body). Any other method is refused.
Every path must be written exactly as shown on this page. A trailing slash (/api/v1/reporting/stock-on-hand/), a percent-encoded character or an extra path segment returns an error instead of data.
Quantities by default, values when the account owner allows it#
By default a key returns quantities only. Every money field (stock value, prices, value at risk and order amounts) is present in every row but empty, and valueState says HIDDEN.
To add values to a key, the account owner turns on Include values for it: tick Include values when creating the key, or open the key's menu on the API keys page and choose Include values. The portal asks you to confirm, because anyone holding the key can then read your stock value, prices and order amounts. Only the account owner can turn this on, and only for a key they created themselves. Stop including values in the same menu turns it off again; responses stop carrying values within 30 seconds.
A key includes values only while the person who created it is still the account owner. If ownership of the account moves to someone else, keys created by the previous owner return quantities only (valuesHidden is not_owner), and the new owner can create a key of their own with Include values turned on.
Every response tells you where it stands:
valuesHidden |
What it means |
|---|---|
null |
Values are included (valuesIncluded is true). |
key |
Include values is off for this key. |
not_owner |
Include values is on, but this key was not created by the current account owner. |
unavailable |
We could not check the key's setting just now; money fields are empty and valueState is VALUES_CHECK_UNAVAILABLE. Try again in a minute. |
valuesHiddenLine carries the same explanation as a sentence. If a scheduled refresh must never store empty values, add values=required to the request: when the setting cannot be checked you get 503 unavailable with Retry-After: 30 instead of a response with empty money fields. (key and not_owner still answer normally, with HIDDEN.)
Money is shown at each product's price per unit in OpenBoxes (valueLabel), without a currency symbol. Purchase-order amounts carry each order's own currency code and are never added up across currencies.
Endpoints#
| Method and path | Returns |
|---|---|
GET /api/v1/reporting |
The dataset index: every dataset, its grains, parameters, limits and where your data starts |
GET /api/v1/reporting/{dataset} |
Rows, as JSON or CSV |
GET /api/v1/reporting/{dataset}/schema |
The dataset's ordered, typed field list (add grain=monthly for a dataset's monthly rows) |
The datasets#
| Dataset | One row per | Kind |
|---|---|---|
summary |
the whole account: the Reports page's tiles as one row | latest check, or date= |
stock-on-hand |
location and product on the check day | latest check, or date= |
stock-history |
location, product and day (grain=daily) or month (grain=monthly) |
date range |
stock-value-history |
day or month, for the whole account | the stored series |
lots-expiring |
location, product and lot | latest check, or date= |
purchase-orders-open |
purchase order and currency | latest order check |
activity |
location, product, day (or month) and activity category | date range |
summary is always exactly one row (no rows at all before your first daily check) and is never paged. Use it for totals rather than adding up rows yourself: each row's value is rounded to the cent, so adding up the rows can differ from summary.stockValue by a few cents. Open orders in summary are counted as distinct orders, while purchase-orders-open has one row per order and currency.
The index and the schema#
GET /api/v1/reporting lists every dataset with what a connector needs to build a request: its grains and defaultGrain, its parameters (name, type, allowed values, default, whether it may repeat), the page limits, the dateField to use as a date dimension, and earliest, the first day (or month) your account has data for. A dataset with daily and monthly rows also publishes earliestByGrain. The index also says whether values are included for your key.
GET /api/v1/reporting/{dataset}/schema returns the same parameters plus fields, the dataset's columns in order, each with its name, type (string, integer, decimal, boolean, date, datetime or enum, with the allowed values for an enum), whether it can be empty, whether it is a money field, and a description. Money fields are listed whether or not your key includes values: the shape of a dataset never depends on the key.
Fields are only ever added. New fields are only ever added at the end of a dataset's field list. Within schemaVersion 1 a field is never renamed, removed, reordered or given a different type. A breaking change would ship as schemaVersion 2 on new paths, announced in advance.
Parameters#
Every dataset accepts:
| Parameter | Values | Default |
|---|---|---|
format |
json or csv |
json |
limit |
rows per page: 1 to 1,000 for JSON, 1 to 10,000 for CSV | 1,000 (JSON), 10,000 (CSV) |
cursor |
the nextCursor of the previous page; empty or absent means the first page |
none |
locationId |
only this location (for purchase orders, the destination location) | all locations |
values |
required (datasets with money fields only; see above) |
none |
A parameter a dataset does not know is refused with 400 unknown_parameter; a value that is not valid (a bad date, a limit above the maximum, a parameter given twice) with 400 invalid_parameter. Nothing is silently clamped.
| Dataset | Its own parameters |
|---|---|
summary |
date: a day with a completed daily check in the last 90 days (default: the latest check) |
stock-on-hand |
date (as summary), productId, state (below) |
stock-history |
grain (daily or monthly, default daily), from, to, productId |
stock-value-history |
grain (daily, monthly or all, default daily) |
lots-expiring |
date (as summary), withinDays (0 to 365, default: the expiry window of your Stock alerts panel), includeExpired (true or false, default false) |
purchase-orders-open |
status (open, draft or all, default open, which leaves out drafts), overdue (true returns overdue orders only; false applies no filter) |
activity |
grain (daily or monthly, default daily), from, to, category (repeat it for up to 10 categories) |
A date with no completed daily check returns 404 no_check_on_date; leave date out to get the latest check.
state on stock-on-hand is named after the Reports page tiles:
state= |
Rows |
|---|---|
all (default) |
every product at every location |
stock_out |
at zero with a minimum set |
at_zero_no_minimum |
at zero with no minimum set |
below_minimum |
below their minimum, including stock-outs |
above_maximum |
above their maximum |
needs_attention |
at zero, or below a minimum |
Date ranges (stock-history and activity): with grain=daily, from and to are days inside the last 90 days, at most 92 days apart; by default the last 30 days up to the latest check. A to after the latest check is read as the latest check. With grain=monthly, any day is accepted and stands for its month; a range covers at most 13 months, and by default the last 13 months including the month in progress. A longer range is refused with 400 range_too_large.
Monthly rows. A monthly row is flagged partialMonth for the month in progress and for the month where the daily history ends. For day-by-day detail in the last 90 days, use grain=daily. On stock-history monthly rows, valueLast is the value on pricesCheckedAt. On stock-value-history, monthly rows cover only the months older than the 90-day daily window, and grain=all returns the monthly rows followed by the daily rows as one series with no missing days; there, valueDate is the day the value's prices come from, not a quantity day.
The response (JSON)#
{
"dataset": "stock-on-hand",
"grain": "daily",
"schemaVersion": 1,
"state": "OK",
"message": null,
"captureLocalDate": "2026-09-20",
"capturedAt": "2026-09-20T04:10:07+03:00",
"capturedAtUtc": "2026-09-20T01:10:07Z",
"zone": "Africa/Nairobi",
"seriesStart": "2026-09-18",
"monthlyThrough": null,
"valuesIncluded": false,
"valuesHidden": "key",
"valuesHiddenLine": "This key returns quantities only. To add stock value, prices and order amounts, turn on Include values for this key in the portal (API, API Keys). Only the account owner can do this.",
"valueLabel": "at each product's price per unit in OpenBoxes",
"stale": false,
"staleLine": null,
"gaps": [],
"rows": [ { "locationId": "…", "productId": "…", "quantityOnHand": 40 } ],
"page": { "limit": 1000, "returned": 1000, "rowsTotal": 2412, "nextCursor": "eyJ2Ijoz…" }
}
(The row above is shortened; a real row carries every field of its dataset.)
stateisOK,NO_CAPTURE(your first daily check has not run yet;messagesays when it will) orNO_DATA(purchase orders or activity: that part of the check has not completed yet). Both of the last two come with no rows.staleistruewhen the purchase-order or activity check is more than two days older than the stock check, withstaleLinesaying so.gaps(activity) lists the date ranges where we could not record activity.- Every field is in every row, in schema order. A field with no value is
null; nothing is ever left out. - Rows with money fields also carry
valuesIncluded,valueState,notMeasuredReasonandpricesCheckedAt(the price day the row's money uses).
`valueState`#
One list of values for every dataset (each dataset's schema lists the ones it can return):
valueState |
Meaning |
|---|---|
OK |
The money fields are filled in. |
NO_PRICE |
The price day has no price above zero for this row (a zero price is never shown as a zero value). |
NO_STOCK |
summary only: nothing had stock on the price day. |
NOT_IN_PRICE_CHECK |
stock-on-hand only: the product has no row on the price day (for example it was first stocked on the check day). |
NOT_MEASURED |
No price day applies; notMeasuredReason says why. |
NOT_CAPTURED |
stock-value-history only: no stock was recorded for that period. Either no daily check ran, or the check ran and found no stock (then capturedAt still says when it ran). |
HIDDEN |
Values are not included for this key. |
VALUES_CHECK_UNAVAILABLE |
We could not check whether this key includes values. |
notMeasuredReason is PRICES_NOT_YET_CHECKED (that day's price check has not run yet; it runs shortly after the stock check), PRICE_CHECK_FAILED, or NO_PRICE_CHECK_IN_7_DAYS. It is empty on monthly rows.
Money is rounded to 2 decimal places (half up); amc and monthsOfStock to 1. Decimals are written as plain numbers, never in exponent form.
Paging through a dataset#
Pages are walked with a cursor, not an offset. Ask for the first page without a cursor, then pass each response's page.nextCursor as cursor= until it is null. Keep every other parameter the same. In CSV the next cursor comes in the X-Next-Cursor response header instead (see CSV); in Power BI and Excel, page in JSON and read nextCursor from the body, as the Power Query recipe does.
cursor=""
while :; do
page=$(curl -s -H "X-API-Key: $OB_API_KEY" \
"https://api.openboxes.cloud/api/v1/reporting/stock-on-hand?limit=1000&cursor=$cursor")
echo "$page" | jq -c '.rows[]'
cursor=$(echo "$page" | jq -r '.page.nextCursor // empty')
[ -z "$cursor" ] && break
done
A walk is pinned on its first page, so it never mixes two days even if a daily check lands halfway through: the check day (or date range), the expiry window, the total row count (rowsTotal) and whether values are included stay the same for every page.
- If values are turned on or off for the key during a walk, the next page answers
409 values_changed. Start again without a cursor. - If a pinned day leaves the 90-day history before you finish, the next page answers
410 cursor_expired. Start again without a cursor. - A cursor only works for the request and the account it came from; anything else is
400 invalid_cursor.
Send pages one after another, not in parallel. Each of our servers handles one Reporting API request at a time for your account and queues a few more behind it. A request that finds that queue full, or waits too long, gets 429 reporting_busy with Retry-After: 5; wait and send it again. Several small reports (or several small Power BI tables) refreshing at once is fine: they take their turn. Once the allowance runs out they share it, so several large tables refreshing at once fail sooner.
CSV#
Add format=csv to get the same rows as CSV: RFC 4180, UTF-8, Content-Type: text/csv; charset=utf-8, downloaded as openboxes-<dataset>-<day>.csv. <day> is the day of the daily check the rows come from; for stock-history or activity with no rows in the range it is the end of the requested range, and before the first check (or the first activity check) it is latest. A CSV page holds up to 10,000 rows.
- The header row is the dataset's field names in schema order, the same whether or not the key includes values. Money cells are empty when values are not included, and
valueStatesays why. trueandfalsefor booleans; an empty unquoted cell fornull;""(a quoted empty cell) for an empty text value, so an empty lot number and a missing one can be told apart; dates, times and decimals as in JSON.- A text cell beginning with
=,+,-,@, a tab or a carriage return gets a leading apostrophe so a spreadsheet does not run it as a formula (a lot number-A12is'-A12in CSV and-A12in JSON). Numbers we compute are never changed. - What JSON carries around the rows comes back as response headers:
X-Report-State,X-Values-Hidden(when values are hidden),X-Stale: true(when set),X-Next-Cursor(absent on the last page),X-Rows-Total,X-Series-Start,X-Monthly-Through(stock-value-history) and, onactivity,X-Activity-Gaps(the recorded gap ranges inside your range, as2026-09-01/2026-09-03,2026-09-10/2026-09-10). - A
NO_CAPTUREorNO_DATAanswer is the header row only. - Errors are always JSON, even for a CSV request.
Your reporting allowance#
The Reporting API has its own allowance of 600 requests an hour per account, on every plan. It is separate from your plan's hourly API limit: Reporting API requests never use up your API limit, and your other API calls never use up the reporting allowance. The API add-on raises your API limit, not the reporting allowance.
It works like a bucket that holds 600 requests: you can use all 600 at once, and then one more comes back every 6 seconds, without ever holding more than 600. It is never reset on the hour. Every Reporting API response carries X-RateLimit-Bucket: reporting with X-RateLimit-Limit (600) and X-RateLimit-Remaining (how many requests you can send right now; the header can appear twice, so read the first value). X-RateLimit-Reset is also sent, but on a successful response it is simply one hour from now: it is not the moment your allowance refills, so do not wait for it. On a 429, Retry-After tells you how many seconds until the next request is allowed.
A worked example. Each page is one request, so what a refresh costs depends on the page size and format. A report that reads five datasets in JSON, each with fewer than 1,000 rows, uses at least 5 requests per refresh. If one of those datasets has 25,000 rows, it takes 25 JSON pages of 1,000 rows, so that refresh uses at least 29 requests; a tool that pages in CSV (10,000 rows a page) needs only 3 pages for it, so at least 7 in all. These are minimums: Power BI and Excel (Power Query) can run a query more than once during a single refresh, and every run costs requests again.
What counts. Each request counts against the reporting allowance, including ones we refuse (a 400 about your parameters, a 404, 405, 409 or 410 costs one request like any other). Two answers do not count: reporting_busy, and a 503 sent before any work was done (your instance is being set up or rebuilt, we could not check whether the key includes values, or our reporting service was briefly unavailable). A 503 sent after we started working on your page does count: a database query that took too long or failed, or a page that could not be built within 25 seconds. A request with an invalid key, and one refused with api_key_in_url, is refused before it reaches the allowance.
When the allowance runs out you get 429:
{
"error": "rate_limit_exceeded",
"bucket": "reporting",
"message": "Reporting API limit reached: 600 requests per hour. Your other API calls are not affected. Try again after the reset time.",
"limit": 600,
"reset_at": 1700003600
}
with a Retry-After header. Three different 429 answers can reach you, and each tells you what to do:
rate_limit_exceededwith"bucket": "reporting": the hourly allowance is used up. Wait forRetry-After.reporting_busy: other requests for your account are still being worked on. Wait 5 seconds and send pages one after another. This one does not count against the allowance.- An empty body: the platform's short-burst protection (a great many requests within one second). Wait a second and slow down.
Errors#
Errors are JSON: {"error": "<code>", "message": "<one sentence>"}. This is not the same shape as the OpenBoxes data API's errors.
| Status | error |
When |
|---|---|---|
400 |
api_key_in_url |
A key was put in the URL. Revoke it and send a new one in the X-API-Key header. |
400 |
unknown_parameter, invalid_parameter, invalid_cursor, range_too_large, invalid_format |
The request's parameters (see above). |
400 |
(empty body) | A path with an encoded character, such as %2F. Write the path exactly as documented. |
401 |
(empty body) | No key at all. |
401 |
unauthorized |
The key is invalid, revoked or expired. |
401 |
api_key_required |
A portal sign-in instead of an API key. |
402 |
account_suspended |
The account is suspended. Reporting data is available again once the account is active. |
403 |
api_key_no_tenant_binding |
The key is not bound to an account. Create a new key. |
403 |
insufficient_scope |
Once scopes are enforced: the key's scopes do not allow this request (a reporting key sending anything but GET gets this instead of 405). |
404 |
not_found |
A path not written exactly as documented, an unknown dataset, or a closed account. (A host other than api.openboxes.cloud does not serve the Reporting API at all: your instance subdomain answers with its own HTML "Page Not Found" page.) |
404 |
no_check_on_date |
No completed daily check on that date. |
405 |
method_not_allowed |
Not a GET or HEAD. |
409 |
values_changed |
Values were turned on or off for the key during a walk. Start again without a cursor. |
410 |
cursor_expired |
The walk's day has left the history. Start again without a cursor. |
429 |
rate_limit_exceeded, reporting_busy or an empty body |
See Your reporting allowance. |
500 |
internal_error |
Something went wrong on our side. Try again later, and contact support if it keeps happening. It counts against the allowance. |
503 |
unavailable |
Reporting data could not be loaded just now, or your instance is being set up or rebuilt. Wait for Retry-After (30 seconds) and try again. Some 503s count against the allowance (see What counts). |
Field reference#
In the tables below, (money) marks a field that is empty unless the key includes values. Every dataset's rows end with the day of the daily check they come from: captureLocalDate, capturedAt (in the row's time zone), capturedAtUtc and zone. For the exact, current list, ask the schema endpoint.
`summary`#
| # | Field | Type | Can be empty | Description |
|---|---|---|---|---|
| 1 | locationId |
string | yes | The location filter, or empty for all locations. |
| 2 | pairsTotal |
integer | no | Products with a stock row on the check day. |
| 3 | stockOuts |
integer | no | Products at zero with a minimum set. |
| 4 | atZeroNoMinimum |
integer | no | Products at zero with no minimum set. |
| 5 | belowMinimum |
integer | no | Products below their minimum, including stock-outs. |
| 6 | aboveMaximum |
integer | no | Products above their maximum. |
| 7 | productsWithMinimum |
integer | no | Products with a minimum set. |
| 8 | productsTotal |
integer | no | Products stocked. |
| 9 | expiryWindowDays |
integer | no | The expiry window of the Stock alerts panel, in days. |
| 10 | lotsExpiringWithinWindow |
integer | no | Lots expiring within that window, expired lots excluded. |
| 11 | openOrders |
integer | yes | Open purchase orders (distinct orders). Empty until the first order check. |
| 12 | draftOrders |
integer | yes | Draft purchase orders. |
| 13 | overdueOrders |
integer | yes | Open orders whose shipment date has passed. |
| 14 | purchaseOrdersCaptureLocalDate |
date | yes | The day of the order check the order counts come from. |
| 15 | purchaseOrdersStale |
boolean | yes | True when the order check is more than two days older than the stock check. |
| 16 | pairsWithStock |
integer | yes | Products with stock on the price day. |
| 17 | pricedPairsWithStock |
integer | yes | Of those, products with a price. |
| 18 | valuesIncluded |
boolean | no | True when the money fields of this row are filled in. |
| 19 | valueState |
enum: OK, NO_PRICE, NO_STOCK, NOT_MEASURED, HIDDEN, VALUES_CHECK_UNAVAILABLE |
no | Why the money fields hold what they hold. OK means they are filled in. |
| 20 | notMeasuredReason |
enum: PRICES_NOT_YET_CHECKED, PRICE_CHECK_FAILED, NO_PRICE_CHECK_IN_7_DAYS |
yes | Why the value is not measured, when valueState is NOT_MEASURED. |
| 21 | pricesCheckedAt |
date | yes | The price day: the newest day in the 7 days up to the check with prices. |
| 22 | stockValue (money) |
decimal | yes | Stock value on the price day, at each product's price per unit in OpenBoxes. |
| 23 | valueOfPairsNotInSnapshot (money) |
decimal | yes | Value of priced products present on the price day but not on the check day. Empty when there is no price day (pricesCheckedAt empty). |
| 24 | pricedPairsNotInSnapshot |
integer | yes | How many products that value covers. Empty when there is no price day. |
| 25 | valueLabel |
string | no | What the value means. |
| 26 | captureLocalDate |
date | no | The check day. |
| 27 | capturedAt |
datetime | no | When the check ran, in the account's zone. |
| 28 | capturedAtUtc |
datetime | no | The same instant in UTC. |
| 29 | zone |
string | no | The time zone the check used. |
`stock-on-hand`#
| # | Field | Type | Can be empty | Description |
|---|---|---|---|---|
| 1 | locationId |
string | no | OpenBoxes location id. |
| 2 | locationName |
string | yes | Location name at the daily check. |
| 3 | productId |
string | no | OpenBoxes product id. |
| 4 | productCode |
string | yes | Product code at the daily check. |
| 5 | productName |
string | yes | Product name at the daily check. |
| 6 | quantityOnHand |
integer | no | Quantity on hand at the check. |
| 7 | quantityAvailable |
integer | yes | Quantity available to promise at the check. |
| 8 | minQuantity |
integer | yes | Minimum set in OpenBoxes. |
| 9 | reorderQuantity |
integer | yes | Reorder quantity set in OpenBoxes. |
| 10 | maxQuantity |
integer | yes | Maximum set in OpenBoxes. |
| 11 | stockState |
enum: AT_ZERO_MIN_SET, AT_ZERO_NO_MIN, BELOW_MINIMUM, OK |
no | The row's stock state, first match wins in this order. |
| 12 | aboveMaximum |
boolean | no | True when a maximum is set and the quantity is above it. |
| 13 | daysAtZero30 |
integer | yes | Days at zero in the last 30 days checked. |
| 14 | daysChecked30 |
integer | yes | Days checked in the last 30 days. |
| 15 | daysAtZero90 |
integer | yes | Days at zero in the last 90 days checked. |
| 16 | daysChecked90 |
integer | yes | Days checked in the last 90 days. |
| 17 | insightComputedFor |
date | yes | The day the days-at-zero and months-of-stock figures describe. |
| 18 | mosState |
enum: OK, NOT_ENOUGH_HISTORY, TRANSFERS_ONLY, NO_USE |
yes | Whether months of stock could be worked out. |
| 19 | amc |
decimal | yes | Average monthly issues and consumption over 3 months, 1 decimal place. |
| 20 | monthsOfStock |
decimal | yes | Months of stock, 1 decimal place, when mosState is OK. |
| 21 | underOneMonth |
boolean | yes | True when the rounded months of stock is under 1. |
| 22 | mosGapMonth |
date | yes | The month whose activity is incomplete, when there is one. |
| 23 | includesImportedHistory |
boolean | yes | True when imported history feeds the figure. |
| 24 | priced |
boolean | no | True when the product has a price above zero on the price day. |
| 25 | valuesIncluded |
boolean | no | True when the money fields of this row are filled in. |
| 26 | valueState |
enum: OK, NO_PRICE, NOT_IN_PRICE_CHECK, NOT_MEASURED, HIDDEN, VALUES_CHECK_UNAVAILABLE |
no | Why the money fields hold what they hold. OK means they are filled in. |
| 27 | notMeasuredReason |
enum: PRICES_NOT_YET_CHECKED, PRICE_CHECK_FAILED, NO_PRICE_CHECK_IN_7_DAYS |
yes | Why the value is not measured, when valueState is NOT_MEASURED. |
| 28 | pricesCheckedAt |
date | yes | The price day: the newest day in the 7 days up to the check with prices. |
| 29 | quantityAtPricesCheckedAt |
integer | yes | Quantity on hand on the price day. |
| 30 | unitPrice (money) |
decimal | yes | The price per unit on the price day, 2 decimal places. |
| 31 | stockValue (money) |
decimal | yes | Quantity on the price day times the price that day, 2 decimal places. |
| 32 | captureLocalDate |
date | no | The day of the daily check this row comes from. |
| 33 | capturedAt |
datetime | no | When that check ran, in the zone of the row. |
| 34 | capturedAtUtc |
datetime | no | The same instant in UTC. |
| 35 | zone |
string | no | The time zone the check used. |
`stock-history`, `grain=daily`#
| # | Field | Type | Can be empty | Description |
|---|---|---|---|---|
| 1 | locationId |
string | no | OpenBoxes location id. |
| 2 | locationName |
string | yes | Location name at the daily check. |
| 3 | productId |
string | no | OpenBoxes product id. |
| 4 | productCode |
string | yes | Product code at the daily check. |
| 5 | productName |
string | yes | Product name at the daily check. |
| 6 | quantityOnHand |
integer | no | Quantity on hand at the check. |
| 7 | minQuantity |
integer | yes | Minimum set in OpenBoxes. |
| 8 | maxQuantity |
integer | yes | Maximum set in OpenBoxes. |
| 9 | priceCaptured |
boolean | no | True when that day's price check ran. |
| 10 | valuesIncluded |
boolean | no | True when the money fields of this row are filled in. |
| 11 | valueState |
enum: OK, NO_PRICE, NOT_MEASURED, HIDDEN, VALUES_CHECK_UNAVAILABLE |
no | Why the money fields hold what they hold. OK means they are filled in. |
| 12 | notMeasuredReason |
enum: PRICES_NOT_YET_CHECKED, PRICE_CHECK_FAILED |
yes | Why the value is not measured, when valueState is NOT_MEASURED. |
| 13 | pricesCheckedAt |
date | yes | The day of the price, which is the row's own day. |
| 14 | unitPrice (money) |
decimal | yes | The price per unit that day, 2 decimal places. |
| 15 | captureLocalDate |
date | no | The day of the daily check this row comes from. |
| 16 | capturedAt |
datetime | no | When that check ran, in the zone of the row. |
| 17 | capturedAtUtc |
datetime | no | The same instant in UTC. |
| 18 | zone |
string | no | The time zone the check used. |
`stock-history`, `grain=monthly`#
| # | Field | Type | Can be empty | Description |
|---|---|---|---|---|
| 1 | locationId |
string | no | OpenBoxes location id. |
| 2 | locationName |
string | yes | Location name at the daily check. |
| 3 | productId |
string | no | OpenBoxes product id. |
| 4 | productCode |
string | yes | Product code at the daily check. |
| 5 | productName |
string | yes | Product name at the daily check. |
| 6 | monthStart |
date | no | The first day of the month. |
| 7 | monthSource |
enum: ROLLUP, DAILY, ROLLUP_AND_DAILY |
no | Where the month comes from: the monthly rollup, the daily rows, or both for the month where the daily history ends. |
| 8 | partialMonth |
boolean | no | True for the month in progress and the month where the daily history ends. |
| 9 | lastObservedDate |
date | no | The last day in the month with a check. |
| 10 | daysObserved |
integer | no | Days folded into the row. |
| 11 | qohMin |
integer | no | Lowest quantity on hand in the month. |
| 12 | qohMax |
integer | no | Highest quantity on hand in the month. |
| 13 | qohLast |
integer | no | Quantity on hand on lastObservedDate. |
| 14 | valuesIncluded |
boolean | no | True when the money fields of this row are filled in. |
| 15 | valueState |
enum: OK, NO_PRICE, NOT_MEASURED, HIDDEN, VALUES_CHECK_UNAVAILABLE |
no | Why the money fields hold what they hold. OK means they are filled in. |
| 16 | notMeasuredReason |
enum: PRICES_NOT_YET_CHECKED, PRICE_CHECK_FAILED |
yes | Why the value is not measured, when valueState is NOT_MEASURED. Always empty on monthly rows. |
| 17 | pricesCheckedAt |
date | yes | The month's last day with prices. |
| 18 | unitPriceLast (money) |
decimal | yes | The price per unit on pricesCheckedAt, 2 decimal places. |
| 19 | valueLast (money) |
decimal | yes | The value on pricesCheckedAt: that day's quantity times that day's price. |
| 20 | captureLocalDate |
date | no | Equal to lastObservedDate. |
| 21 | capturedAt |
datetime | yes | Always empty on monthly rows. |
| 22 | capturedAtUtc |
datetime | yes | Always empty on monthly rows. |
| 23 | zone |
string | yes | The time zone of the check on that day, when known. |
`stock-value-history`#
The same columns for every grain; grain in each row says whether it is a day or a month.
| # | Field | Type | Can be empty | Description |
|---|---|---|---|---|
| 1 | grain |
enum: daily, monthly |
no | Whether the row is a day or a month. |
| 2 | periodStart |
date | no | The day, or the first day of the month. |
| 3 | periodEnd |
date | no | The day, or the last day of the month. For the month where the daily rows begin, the day before the first daily row. |
| 4 | valueDate |
date | yes | The day the value's prices come from. Not a quantity day. |
| 5 | measured |
boolean | no | True when a price check applies to the period. |
| 6 | pointState |
enum: OK, NO_PRICE, NOT_MEASURED, NOT_CAPTURED |
no | The point's own state, whatever the key's grant. |
| 7 | pairsWithStock |
integer | yes | Products with stock. |
| 8 | pricedPairsWithStock |
integer | yes | Of those, products with a price. |
| 9 | valuesIncluded |
boolean | no | True when the money fields of this row are filled in. |
| 10 | valueState |
enum: OK, NO_PRICE, NOT_MEASURED, NOT_CAPTURED, HIDDEN, VALUES_CHECK_UNAVAILABLE |
no | Why the money fields hold what they hold. OK means they are filled in. |
| 11 | notMeasuredReason |
enum: PRICES_NOT_YET_CHECKED, PRICE_CHECK_FAILED |
yes | Why the value is not measured, when valueState is NOT_MEASURED. Always empty on monthly rows. |
| 12 | pricesCheckedAt |
date | yes | Equal to valueDate when measured. |
| 13 | stockValue (money) |
decimal | yes | Stock value for the period, 2 decimal places. Empty when not measured, never zero. |
| 14 | captureLocalDate |
date | yes | Equal to valueDate. |
| 15 | capturedAt |
datetime | yes | When the check ran. Daily rows only. |
| 16 | capturedAtUtc |
datetime | yes | The same instant in UTC. Daily rows only. |
| 17 | zone |
string | yes | The time zone of the check. |
`lots-expiring`#
| # | Field | Type | Can be empty | Description |
|---|---|---|---|---|
| 1 | locationId |
string | no | OpenBoxes location id. |
| 2 | locationName |
string | yes | Location name at the daily check. |
| 3 | productId |
string | no | OpenBoxes product id. |
| 4 | productCode |
string | yes | Product code at the daily check. |
| 5 | productName |
string | yes | Product name at the daily check. |
| 6 | inventoryItemId |
string | no | OpenBoxes inventory item (lot) id. |
| 7 | lotNumber |
string | yes | Lot number. |
| 8 | expirationDate |
date | no | Expiry date. |
| 9 | daysToExpiry |
integer | no | Days from the check day to expiry. Negative means expired. |
| 10 | expiryBucket |
enum: EXPIRED, WITHIN_30, WITHIN_60, WITHIN_90, WITHIN_180, WITHIN_365 |
no | The expiry bucket. |
| 11 | withinWindow |
boolean | no | True when 0 <= daysToExpiry <= expiryWindowDays. |
| 12 | expiryWindowDays |
integer | no | The expiry window this walk uses, in days. |
| 13 | quantityOnHand |
integer | no | Quantity on hand in the lot. |
| 14 | valuesIncluded |
boolean | no | True when the money fields of this row are filled in. |
| 15 | valueState |
enum: OK, NO_PRICE, NOT_MEASURED, HIDDEN, VALUES_CHECK_UNAVAILABLE |
no | Why the money fields hold what they hold. OK means they are filled in. |
| 16 | notMeasuredReason |
enum: PRICES_NOT_YET_CHECKED, PRICE_CHECK_FAILED |
yes | Why the value is not measured, when valueState is NOT_MEASURED. |
| 17 | pricesCheckedAt |
date | yes | The check day, when its own prices exist. |
| 18 | unitPrice (money) |
decimal | yes | The price per unit on the check day, 2 decimal places. |
| 19 | valueAtRisk (money) |
decimal | yes | Quantity times the price per unit, 2 decimal places. |
| 20 | captureLocalDate |
date | no | The day of the daily check this row comes from. |
| 21 | capturedAt |
datetime | no | When that check ran, in the zone of the row. |
| 22 | capturedAtUtc |
datetime | no | The same instant in UTC. |
| 23 | zone |
string | no | The time zone the check used. |
`purchase-orders-open`#
| # | Field | Type | Can be empty | Description |
|---|---|---|---|---|
| 1 | orderId |
string | no | OpenBoxes order id. |
| 2 | orderNumber |
string | yes | Order number. |
| 3 | name |
string | yes | Order name. |
| 4 | status |
string | yes | OpenBoxes order status. |
| 5 | isDraft |
boolean | no | True for a draft order. |
| 6 | supplierName |
string | yes | Supplier name. |
| 7 | destinationLocationId |
string | yes | Destination location id. |
| 8 | destinationName |
string | yes | Destination location name. |
| 9 | dateOrdered |
date | yes | Date ordered. |
| 10 | expectedDate |
date | yes | Expected date. |
| 11 | expectedDateSource |
enum: shipment, ready, none |
no | Where the expected date comes from. |
| 12 | overdue |
boolean | no | True when the shipment date is before the check day. |
| 13 | linesOpen |
integer | no | Open lines in this currency. |
| 14 | linesUnpriced |
integer | no | Of those, lines with no price. |
| 15 | quantityOutstanding |
decimal | no | Quantity not yet received, in each. |
| 16 | quantityPerUnitMissing |
boolean | no | True when a line has no quantity per unit of measure. |
| 17 | currencyCode |
string | yes | ISO currency code. Empty means the default currency. |
| 18 | currencyLabel |
string | no | The currency code, or default currency. |
| 19 | stale |
boolean | no | True when the order check is more than two days older than the stock check. |
| 20 | valuesIncluded |
boolean | no | True when the money fields of this row are filled in. |
| 21 | valueState |
enum: OK, NO_PRICE, HIDDEN, VALUES_CHECK_UNAVAILABLE |
no | Why the money fields hold what they hold. OK means they are filled in. |
| 22 | notMeasuredReason |
enum: PRICES_NOT_YET_CHECKED, PRICE_CHECK_FAILED |
yes | Why the value is not measured, when valueState is NOT_MEASURED. Always empty on this dataset. |
| 23 | pricesCheckedAt |
date | yes | The day of the order check, when values apply. |
| 24 | valueNotReceived (money) |
decimal | yes | Value not yet received in this row's currency, 2 decimal places. Never add rows of different currencies. |
| 25 | captureLocalDate |
date | no | The day of the daily check this row comes from. |
| 26 | capturedAt |
datetime | no | When that check ran, in the zone of the row. |
| 27 | capturedAtUtc |
datetime | no | The same instant in UTC. |
| 28 | zone |
string | no | The time zone the check used. |
`activity`, `grain=daily`#
Activity has no money fields. transfer_in and transfer_out are included (rows are per location); moves inside one location are not.
| # | Field | Type | Can be empty | Description |
|---|---|---|---|---|
| 1 | locationId |
string | no | OpenBoxes location id. |
| 2 | locationName |
string | yes | Location name at the daily check. |
| 3 | productId |
string | no | OpenBoxes product id. |
| 4 | productCode |
string | yes | Product code at the daily check. |
| 5 | productName |
string | yes | Product name at the daily check. |
| 6 | activityDate |
date | no | The day of the activity. |
| 7 | category |
enum: received_from_supplier, returned_by_location, issued, consumed, returned_to_supplier, expired, damaged, adjusted_up, adjusted_down, count_correction_up, count_correction_down, stock_loaded_up, stock_loaded_down, stock_count, transfer_in, transfer_out |
no | The activity category. |
| 8 | label |
string | no | The category's label on the Reports page. |
| 9 | group |
enum: movement, lost, adjustment, count_correction, stock_loaded, count, transfer |
no | The category's group. |
| 10 | openboxesType |
string | yes | The OpenBoxes transaction type behind it. |
| 11 | quantity |
integer | yes | Quantity moved. Empty for count-only categories. |
| 12 | transactions |
integer | no | Transactions. |
| 13 | importedHistoryCount |
integer | no | Transactions from imported history. |
| 14 | suspect |
boolean | no | True when the figures look implausible. |
| 15 | inMonthsOfStock |
boolean | no | True when the category feeds months of stock. |
| 16 | onGapDay |
boolean | no | True when the day is in a recorded gap. |
| 17 | captureLocalDate |
date | yes | The day of the daily check this row comes from. |
| 18 | capturedAt |
datetime | yes | When that check ran, in the zone of the row. |
| 19 | capturedAtUtc |
datetime | yes | The same instant in UTC. |
| 20 | zone |
string | yes | The time zone the check used. |
`activity`, `grain=monthly`#
| # | Field | Type | Can be empty | Description |
|---|---|---|---|---|
| 1 | locationId |
string | no | OpenBoxes location id. |
| 2 | locationName |
string | yes | Location name at the daily check. |
| 3 | productId |
string | no | OpenBoxes product id. |
| 4 | productCode |
string | yes | Product code at the daily check. |
| 5 | productName |
string | yes | Product name at the daily check. |
| 6 | monthStart |
date | no | The first day of the month. |
| 7 | monthSource |
enum: ROLLUP, DAILY, ROLLUP_AND_DAILY |
no | Where the month comes from. |
| 8 | partialMonth |
boolean | no | True for the month in progress and the month where the daily history ends. |
| 9 | category |
enum: received_from_supplier, returned_by_location, issued, consumed, returned_to_supplier, expired, damaged, adjusted_up, adjusted_down, count_correction_up, count_correction_down, stock_loaded_up, stock_loaded_down, stock_count, transfer_in, transfer_out |
no | The activity category. |
| 10 | label |
string | no | The category's label on the Reports page. |
| 11 | group |
enum: movement, lost, adjustment, count_correction, stock_loaded, count, transfer |
no | The category's group. |
| 12 | openboxesType |
string | yes | The OpenBoxes transaction type behind it. |
| 13 | quantity |
integer | yes | Quantity moved. Empty for count-only categories. |
| 14 | transactions |
integer | no | Transactions. |
| 15 | importedHistoryCount |
integer | no | Transactions from imported history. |
| 16 | suspect |
boolean | no | True when the figures look implausible. |
| 17 | inMonthsOfStock |
boolean | no | True when the category feeds months of stock. |
| 18 | monthHasGap |
boolean | no | True when a recorded gap overlaps the month. |
| 19 | captureLocalDate |
date | no | The month's last recorded day. |
| 20 | capturedAt |
datetime | yes | Always empty on monthly rows. |
| 21 | capturedAtUtc |
datetime | yes | Always empty on monthly rows. |
| 22 | zone |
string | yes | The time zone of the check on that day, when known. |
Coming from the Reports page#
The API uses the Reports page's names, except where a flat row needs a clearer one:
| Reports page | Reporting API |
|---|---|
| stock-outs (at zero, minimum set) | summary.stockOuts |
| expiry window | expiryWindowDays |
| draft and overdue orders | summary.draftOrders, summary.overdueOrders |
| a row's stock state | stock-on-hand.stockState |
value state NO_PRICES |
valueState NO_PRICE |
| a value-over-time point's state | stock-value-history.pointState |
| monthly products with stock, priced products | pairsWithStock, pricedPairsWithStock |
| monthly value | stock-value-history.stockValue |
| outstanding quantity (as text) | quantityOutstanding (a number) |
Dedicated and Enterprise#
The Reporting API works the same way on Dedicated and Enterprise as on Shared: the same host, the same datasets, the same 600-an-hour allowance.
Related#
- Reporting overview: the Reports page, the Reporting API and the scheduled CSV export on one page.
- Reports page: the same figures on screen
- Authentication: creating and revoking API keys
- Rate Limits: your plan's API limit
- Power BI and Excel: the Power Query recipe
- Looker Studio: uploading the scheduled export's CSV files
