Demand Planning API
Experimental. The contract is final and processing is live -- batches are validated, stored and applied, normally within a few minutes. The experimental label means the API is not yet in general availability: request and response shapes will not change without notice to integrated partners.
The Demand Planning API lets a planning partner push two kinds of data into SnowBee for every retail store in a tenant:
- Sales forecasts — expected gross sales quantity per store, SKU and day. SnowBee stores them and uses them in its own reports and replenishment logic. Forecasts ignore inventory and lead times; SnowBee applies those.
- Replenishment parameters — the reorder point and maximum inventory per retail store and SKU that SnowBee's automatic replenishment reads. The partner can also read the values SnowBee currently uses, including whether a SnowBee user has locked them.
SnowBee makes the decisions; the partner supplies the data. Nothing a partner uploads creates a purchase order or changes a price directly.
This guide assumes you have read Get started — authentication, the error envelope and the conventions for dates and decimals apply here unchanged.
Before your first batch
- Credentials. An API client with the Demand Planning permission, created by the tenant's SnowBee administrator, plus the Tenant ID. Ask for a test tenant first.
- Token.
POST .../oauth2/v2.0/tokenwithscope: "demand_planning"and noresource— the scope covers every store in the tenant. - Read the limits.
GET /limitstells you how many items a batch may hold and how large it may be. Size your batches from it, not from this page. - Map your stores.
GET /retail_stores(andGET /ecom_storesif you forecast online sales) gives the store numbers uploads are keyed on. SKU numbers are the ones you already have from SnowBee data. - Upload one batch with
PUT /sales_forecast_batches/{uuid}; expect202and a status body. - Read it back with
GET /sales_forecast_batches/{uuid}. Within a few minutes it moves fromPENDINGtoCOMPLETEDwith counts.
Base URL for everything below:
https://api.snowbee.no/v1/tenants/TENANT_ID/demand_planning/Endpoints
| Endpoint | Description |
|---|---|
PUT /sales_forecast_batches/:batchId | Upload a batch of sales forecasts |
GET /sales_forecast_batches/:batchId | Status of a sales forecast batch |
PUT /replenishment_parameter_batches/:batchId | Upload a batch of reorder points and maximum inventories |
GET /replenishment_parameter_batches/:batchId | Status of a replenishment parameter batch |
GET /sales_forecasts | The sales forecasts SnowBee currently holds, per store and SKU |
GET /replenishment_parameters | The reorder points and maximum inventories SnowBee currently uses, with lock state |
GET /retail_stores | Retail stores with the store numbers used in uploads |
GET /ecom_stores | Ecom stores with the store numbers used in sales forecast uploads |
GET /limits | Published size limits; read these instead of hard-coding them |
Field-level documentation lives in the API Reference under the tag Demand Planning (experimental).
Identifiers
Rows are identified by business keys, not SnowBee UUIDs:
retailStoreNumber— the retail store's store number (numberinGET /retail_stores)ecomStoreNumber— the ecom store's number (numberinGET /ecom_stores)skuNumber— the SKU number
All decimals are strings with at most 4 decimals ("4.7", "12", "0.25"), never JSON numbers.
Calendar days are ISO 8601 dates (2026-09-01) — a sales day is a day, not an instant, and a
date cannot be shifted by a time zone. Instants are ISO 8601 timestamps with an offset
(2026-08-28T03:12:00Z).
Batches and runs
A planning run is one execution of your model. A run is uploaded as one or more batches, each
a single PUT with a client-generated UUID. Every batch carries the same generatedAt, the
timestamp of the run.
{
"generatedAt": "2026-08-28T03:12:00Z",
"batchIndex": 12,
"batchTotal": 140,
"itemCount": 5000,
"items": [ ... ],
"ext": {}
}| Field | Required | Description |
|---|---|---|
generatedAt | yes | Timestamp of the run. Identical for all batches of one run. A later run must have a later value; SnowBee uses it to apply data in the right order when batches arrive late or out of order, and to ignore stale data. Two runs on the same day must therefore have different timestamps. |
batchIndex, batchTotal | together or not at all | 1-based position of this batch in the run and the run's batch count. Lets SnowBee see that a run arrived incomplete. |
itemCount | yes | Must equal the length of items. A cheap truncation check. |
items | yes | The items; shape depends on the batch kind (below) |
ext | no | An object for your own fields, on the envelope and on every item. SnowBee never interprets it. The rest of the document is reserved for SnowBee fields. |
Fields SnowBee does not know are accepted, never applied, and reported once per field name as an
UNKNOWN_FIELD warning in the batch status — so a misspelled field is visible instead of silently
ignored.
Sales forecast items
One item is the complete forecast for one store and SKU from this run. It replaces whatever
forecast SnowBee held for that store and SKU. Exactly one of retailStoreNumber and
ecomStoreNumber is set.
{
"retailStoreNumber": "1001",
"skuNumber": "10025",
"horizonStartDate": "2026-09-01",
"horizonEndDate": "2026-09-05",
"quantities": ["4.7", "3.1", "0", "5.25", "2"],
"ext": {}
}quantities[i] is the expected sales quantity on horizonStartDate plus i days, and
horizonEndDate must be horizonStartDate plus quantities.length - 1 days — the end date is
redundant on purpose, so a horizon of the wrong length is a 400 (HORIZON_LENGTH_MISMATCH)
instead of a silently shifted forecast. The horizon may not start more than 7 days in the past and
may hold at most maxHorizonDays days (see GET /limits; 30 days is the expected horizon). Days
you do not want to forecast are sent as "0" — a shorter array means a shorter horizon, not zero
sales.
A store and SKU that is absent from a run keeps the forecast it had, until its horizon has passed.
There is no way to delete a forecast through the API; use GET /sales_forecasts to see what SnowBee
holds and compare against your own model.
Replenishment parameter items
{
"retailStoreNumber": "1001",
"skuNumber": "10025",
"reorderPoint": "3",
"maximumInventory": "12",
"ext": {}
}reorderPoint must be less than maximumInventory. The values are written to the retail store's
store-specific assortment line for the SKU and are used by SnowBee's next replenishment run.
Replenishment parameters are retail-store only.
An item is skipped without error when a SnowBee user has locked the values for that store and
SKU. The skip is the contract: uploading for locked lines is always safe and reported in
counts.locked, and checking locked up front via GET /replenishment_parameters is optional --
a convenience for knowing which store-SKUs are not yours to plan, not a required step. It is rejected when the SKU is not in the store's
assortment — the API does not add SKUs to assortments — and when the store–SKU is replenished
manually (replenishmentType MANUAL, code REPLENISHMENT_TYPE_MANUAL): manual lines carry no
reorder point or maximum inventory in SnowBee, so uploaded parameters would have no effect there.
Uploading a batch
PUT https://api.snowbee.no/v1/tenants/TENANT_ID/demand_planning/sales_forecast_batches/0192a3b4-5c6d-7e8f-9012-3456789abcde
Authorization: Bearer YOUR_ACCESS_TOKEN
Content-Type: application/json
Content-Encoding: gzipGenerate the batch id yourself (any UUID). This is what makes a retry safe:
| Situation | Response |
|---|---|
| New batch id | 202 Accepted, body is the batch status, Location header points to it |
| Same batch id, identical payload | 200 OK with the existing status; nothing is stored twice. Retry blindly after a timeout. |
| Same batch id, different payload | 409 Conflict, BATCH_ID_REUSED. Send corrections as a new run (below). |
| Format error anywhere in the document | 400 listing every error with its path; nothing is stored |
| A limit exceeded | 413 naming the limit and the maximum |
Wrong Content-Type or unsupported Content-Encoding | 415 |
Compress with gzip when the body is larger than a few hundred kilobytes. SnowBee compares payloads by their decompressed content, so a retry may be sent compressed or not.
Corrections
A batch that was already accepted cannot be changed; a batch id is used once. To correct data,
send a new run: new batch ids, a generatedAt later than the run being corrected, and
batchIndex/batchTotal describing the correction run itself (a single corrected batch is a run
of 1, or omits them). Because the newer generatedAt wins per store and SKU, a correction run only
needs to contain the items that changed.
Chunking is required
A full run for a chain (every SKU in every store) is far too large for one request, and a dropped
connection at 80% of a multi-minute upload has no good recovery. Split the run into batches of at
most recommendedItemsPerBatch items (5,000 today). Each batch is a small request that succeeds or
fails on its own and is retried in a second.
import gzip
import json
import time
import uuid
from datetime import datetime, timezone
import requests
BASE_URL = "https://api.snowbee.no/v1/tenants/TENANT_ID/demand_planning"
TOKEN = "YOUR_ACCESS_TOKEN"
def upload_run(items, batch_size):
"""items: list of forecast item dicts (see 'Sales forecast items'). One call = one run."""
generated_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
batches = [items[i:i + batch_size] for i in range(0, len(items), batch_size)]
for index, batch_items in enumerate(batches, start=1):
batch_id = str(uuid.uuid4())
body = {
"generatedAt": generated_at,
"batchIndex": index,
"batchTotal": len(batches),
"itemCount": len(batch_items),
"items": batch_items,
}
payload = gzip.compress(json.dumps(body).encode("utf-8"))
while True:
response = requests.put(
f"{BASE_URL}/sales_forecast_batches/{batch_id}",
headers={
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json",
"Content-Encoding": "gzip",
},
data=payload,
timeout=60,
)
if response.status_code == 429:
time.sleep(int(response.headers.get("Retry-After", "5")))
continue # same batch_id, same body: safe to repeat
if response.status_code in (202, 200):
break
raise RuntimeError(f"batch {index}/{len(batches)} failed: {response.status_code} {response.text}")
limits = requests.get(f"{BASE_URL}/limits", headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30).json()
upload_run(items=forecast_items, batch_size=limits["recommendedItemsPerBatch"])A timeout or connection error is handled the same way as a 429: repeat the PUT with the same
batch id and body. If the first attempt did land, the repeat answers 200 and nothing is stored
twice.
Batches may be sent sequentially or with a few in parallel; the fair-use limit of 1,800 requests
per minute per API client (see Getting Started) applies. At 5,000 items per
batch a run of 700,000 store–SKU forecasts is 140 requests — well within one minute's allowance
even sent back to back. A 429 carries Retry-After; wait that long and retry the same batch id
with the same body.
A 5,000-item forecast batch with a 30-day horizon is about 1.3 MB of JSON and about 0.2 MB gzipped; the recommended body size assumes gzip. Without gzip, stay around 3,500 items.
Validation errors
A 400 lists every problem found in the document, with a path into the request and the item's
business keys, so one round trip is enough to fix an export:
{
"error": "VALIDATION_ERROR",
"message": "3 validation error(s); nothing was stored",
"details": {
"errors": [
{
"path": "items[17].quantities[3]",
"code": "INVALID_DECIMAL",
"message": "Expected a non-negative decimal string with at most 4 decimals, got \"4,7\"",
"retailStoreNumber": "1001",
"skuNumber": "10025"
},
{
"path": "items[212].horizonEndDate",
"code": "HORIZON_LENGTH_MISMATCH",
"message": "quantities has 30 days from 2026-09-01, so horizonEndDate must be 2026-09-30, got 2026-10-01",
"retailStoreNumber": "1001",
"skuNumber": "10480"
},
{
"path": "itemCount",
"code": "ITEM_COUNT_MISMATCH",
"message": "itemCount is 5000 but items has 4999 elements"
}
],
"errorsTruncated": false
}
}| Code | Meaning |
|---|---|
MISSING_FIELD | A required field is absent or null |
INVALID_TYPE | Wrong JSON type, or an empty string where a key is required |
INVALID_TIMESTAMP | Not an ISO 8601 timestamp, or missing the offset (generatedAt) |
INVALID_DATE | Not an ISO 8601 calendar date (horizonStartDate, horizonEndDate) |
INVALID_DECIMAL | Not a non-negative decimal string with at most 4 decimals |
ITEM_COUNT_MISMATCH | itemCount differs from the number of items |
BATCH_INDEX_SHAPE, BATCH_INDEX_RANGE | batchIndex and batchTotal not set together, or index outside 1..total |
STORE_KEY_SHAPE | Not exactly one of retailStoreNumber and ecomStoreNumber |
HORIZON_START_TOO_OLD, HORIZON_EMPTY, HORIZON_TOO_LONG | Horizon starts more than 7 days ago, has no days, or has more than maxHorizonDays |
HORIZON_LENGTH_MISMATCH | horizonEndDate is not horizonStartDate plus quantities.length - 1 days |
REORDER_POINT_NOT_BELOW_MAXIMUM | reorderPoint is not less than maximumInventory |
DUPLICATE_KEY_IN_BATCH | The same store and SKU appears twice in one batch |
Whether a store or SKU exists is not checked at upload. It is checked when the batch is processed and reported per item in the batch status, so one discontinued SKU never blocks a batch.
Batch status
GET https://api.snowbee.no/v1/tenants/TENANT_ID/demand_planning/sales_forecast_batches/0192a3b4-5c6d-7e8f-9012-3456789abcde{
"batchId": "0192a3b4-5c6d-7e8f-9012-3456789abcde",
"batchKind": "SALES_FORECAST",
"progress": "PENDING",
"generatedAt": "2026-08-28T03:12:00Z",
"batchIndex": 12,
"batchTotal": 140,
"itemCount": 5000,
"receivedTime": "2026-08-28T03:12:04.120Z",
"startedTime": null,
"completedTime": null,
"counts": null,
"rejectedItems": null,
"rejectedItemsTruncated": false,
"warnings": [
{ "code": "UNKNOWN_FIELD", "field": "items[].campaignQuantity", "count": 5000, "message": "Field is not part of the contract and was not applied" }
],
"errorMessage": null
}progress | Meaning |
|---|---|
PENDING | Stored, not yet processed |
PROCESSING | Being applied |
COMPLETED | Every item applied |
COMPLETED_WITH_REJECTED_ITEMS | Applied, but some items were rejected; see counts and rejectedItems. Alert on this state. |
FAILED | Nothing from the batch was applied; errorMessage says why |
After processing, counts holds items, applied, superseded, locked and rejected, and
rejectedItems lists the first 500 rejected items with their index, business keys and one of the
codes: UNKNOWN_RETAIL_STORE, RETAIL_STORE_NOT_RELEASED, UNKNOWN_ECOM_STORE,
ECOM_STORE_NOT_RELEASED, UNKNOWN_SKU, SKU_NOT_IN_ASSORTMENT, REPLENISHMENT_TYPE_MANUAL.
superseded and locked are counts, not errors: a newer run had already been applied for that
key, or a SnowBee user has locked the values.
Poll the status a few minutes after a run rather than after every batch; batches are processed in order per tenant.
Reading back what SnowBee holds
GET https://api.snowbee.no/v1/tenants/TENANT_ID/demand_planning/sales_forecasts?retailStoreNumber=1001&limit=1000{
"items": [
{
"retailStoreNumber": "1001",
"ecomStoreNumber": null,
"skuNumber": "10025",
"horizonStartDate": "2026-09-01",
"horizonEndDate": "2026-09-30",
"quantities": ["4.7", "3.1", "0", "..."],
"generatedAt": "2026-08-31T03:12:00Z",
"modifiedTime": "2026-08-31T03:14:11Z"
}
],
"nextCursor": null
}The current horizon per store and SKU, as applied from processed batches. Retail stores come first
in store number order, then ecom stores; a page never spans two stores, and nextCursor pages
through exactly like the replenishment parameter listing. Filter with retailStoreNumber or
ecomStoreNumber (not both). generatedAt is the planning run each horizon came from — if a
store and SKU shows an old generatedAt, your model stopped sending it and SnowBee still holds
the last horizon.
Use it to verify an upload end to end (upload, wait for COMPLETED, read back) and to detect
drift between your model and SnowBee without keeping your own shadow copy.
Reading replenishment parameters
GET https://api.snowbee.no/v1/tenants/TENANT_ID/demand_planning/replenishment_parameters?retailStoreNumber=1001&limit=1000{
"items": [
{
"retailStoreNumber": "1001",
"skuNumber": "10025",
"replenishmentType": "AUTO",
"reorderPoint": "3",
"maximumInventory": "12",
"parameterSource": "RETAIL_STORE_SPECIFIC",
"locked": false,
"modifiedTime": "2026-08-27T03:14:11Z"
}
],
"nextCursor": "MTAwMSAxMDAyNQ"
}Returns the values SnowBee actually uses, resolved the way replenishment resolves them: a
store-specific line if one exists (RETAIL_STORE_SPECIFIC), else the store's local assortment
default (LOCAL_ASSORTMENT), else its base assortment default (BASE_ASSORTMENT). locked is only
ever true for store-specific lines; uploads for a locked store and SKU are skipped.
Results are ordered by retail store number, then SKU number, and paged with nextCursor; a page
never spans two stores. modifiedSince (ISO 8601 with offset) returns only lines modified after that
time, so you can pick up locks and manual edits made in SnowBee without reading everything.
limit is at most 5,000.
Go-live checklist
- Batch sizes come from
GET /limits, not from constants in your code - One
generatedAtper run, strictly increasing between runs; corrections are new runs batchIndex/batchTotalset on every batch so an incomplete run is visible- Every batch id remembered until its
PUThas answered202or200, so a crash mid-run resumes with the same ids 429, timeouts and5xxretried with the same batch id;400/413never retried unchanged- Batch status polled a few minutes after a run and
COMPLETED_WITH_REJECTED_ITEMS/FAILEDalerted on - Locked and manually replenished lines need no handling on your side: upload everything, and the
batch result counts skipped locked lines (
counts.locked) and rejectsMANUALones (REPLENISHMENT_TYPE_MANUAL). ReadingGET /replenishment_parametersfirst is optional -- useful only to know in advance which store-SKUs are not yours to plan, never required for a correct upload - Store and SKU numbers taken from
GET /retail_storesand SnowBee data, not typed by hand
Limits
GET https://api.snowbee.no/v1/tenants/TENANT_ID/demand_planning/limitsCurrent values; read them from the endpoint rather than copying them:
| Limit | Recommended | Hard (413) |
|---|---|---|
| Items per batch | 5,000 | 20,000 |
| Days per forecast horizon | 30 | 62 |
| Request body as sent | 1 MB | 10 MB |
| Request body after decompression | — | 50 MB |
| Compression ratio | — | 100:1 |
A 413 names the limit that tripped:
{
"error": "LIMIT_EXCEEDED",
"message": "maxItemsPerBatch exceeded: max 20000, received 41003",
"details": { "limit": "maxItemsPerBatch", "max": 20000, "received": 41003 }
}