Documentation Demand planning

Demand Planning API

The Demand Planning API lets a planning partner push two kinds of data into SnowBee for every retail store in a tenant:

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

  1. 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.
  2. Token. POST .../oauth2/v2.0/token with scope: "demand_planning" and no resource — the scope covers every store in the tenant. It can share a token with other explicitly granted API scopes; see Get started.
  3. Read the limits. GET /limits tells you how many items a batch may hold and how large it may be. Size your batches from it, not from this page.
  4. Map your stores. GET /retail_stores (and GET /ecom_stores if you forecast online sales) gives the store numbers uploads are keyed on. SKU numbers are the ones you already have from SnowBee data.
  5. Upload one batch with PUT /sales_forecast_batches/{uuid}; expect 202 and a status body.
  6. Read it back with GET /sales_forecast_batches/{uuid}. Within a few minutes it moves from PENDING to COMPLETED with counts.

Base URL for everything below:

https://api.snowbee.no/v1/tenants/TENANT_ID/demand_planning/

Endpoints

EndpointDescription
PUT /sales_forecast_batches/:batchIdUpload a batch of sales forecasts
GET /sales_forecast_batches/:batchIdStatus of a sales forecast batch
PUT /replenishment_parameter_batches/:batchIdUpload a batch of reorder points and maximum inventories
GET /replenishment_parameter_batches/:batchIdStatus of a replenishment parameter batch
GET /sales_forecastsThe sales forecasts SnowBee currently holds, per store and SKU
GET /replenishment_parametersThe reorder points and maximum inventories SnowBee currently uses, with lock state
GET /retail_storesRetail stores with the store numbers used in uploads
GET /ecom_storesEcom stores with the store numbers used in sales forecast uploads
GET /limitsPublished 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:

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": {}
}
FieldRequiredDescription
generatedAtyesTimestamp 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, batchTotaltogether or not at all1-based position of this batch in the run and the run's batch count. Lets SnowBee see that a run arrived incomplete.
itemCountyesMust equal the length of items. A cheap truncation check.
itemsyesThe items; shape depends on the batch kind (below)
extnoAn 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; the expected horizon runs to the end of next year, up to 731 days). 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.

Each retail store must be switched on for updates from the API in SnowBee (Update from API in the retail autoreplenishment settings). It is off by default. Items for a store that is not switched on are skipped without error and reported in counts.apiUpdatesDisabled, so uploading for every store is always safe. updateFromApi in GET /retail_stores and GET /replenishment_parameters shows which stores are switched on.

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.

An item whose values match what the store already uses is not written and is reported in counts.unchanged. The SKU keeps following its assortment defaults in SnowBee until you send different values for it.

Uploading a batch

Reference: PUT /sales_forecast_batches/{batchId}.

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: gzip

Generate the batch id yourself (any UUID). This is what makes a retry safe:

SituationResponse
New batch id202 Accepted, body is the batch status, Location header points to it
Same batch id, identical payload200 OK with the existing status; nothing is stored twice. Retry blindly after a timeout.
Same batch id, different payload409 Conflict, BATCH_ID_REUSED. Send corrections as a new run (below).
Format error anywhere in the document400 listing every error with its path; nothing is stored
A limit exceeded413 naming the limit and the maximum
Wrong Content-Type or unsupported Content-Encoding415

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 (1,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 500 requests per minute per API client (see Getting Started) applies. At 1,000 items per batch a run of 100,000 store–SKU forecasts is 100 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 1,000-item forecast batch with a two-year (731-day) horizon is about 5.7 MB of JSON and about 1.3 MB gzipped; the recommended body size assumes gzip. Batch size is bounded by bytes as much as by items, so shorter horizons fit more items in the same body. Read the current values from GET /limits rather than hard-coding them.

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
  }
}
CodeMeaning
MISSING_FIELDA required field is absent or null
INVALID_TYPEWrong JSON type, or an empty string where a key is required
INVALID_TIMESTAMPNot an ISO 8601 timestamp, or missing the offset (generatedAt)
INVALID_DATENot an ISO 8601 calendar date (horizonStartDate, horizonEndDate)
INVALID_DECIMALNot a non-negative decimal string with at most 4 decimals
ITEM_COUNT_MISMATCHitemCount differs from the number of items
BATCH_INDEX_SHAPE, BATCH_INDEX_RANGEbatchIndex and batchTotal not set together, or index outside 1..total
STORE_KEY_SHAPENot exactly one of retailStoreNumber and ecomStoreNumber
HORIZON_START_TOO_OLD, HORIZON_EMPTY, HORIZON_TOO_LONGHorizon starts more than 7 days ago, has no days, or has more than maxHorizonDays
HORIZON_LENGTH_MISMATCHhorizonEndDate is not horizonStartDate plus quantities.length - 1 days
REORDER_POINT_NOT_BELOW_MAXIMUMreorderPoint is not less than maximumInventory
DUPLICATE_KEY_IN_BATCHThe 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

Reference: GET /sales_forecast_batches/{batchId}.

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
}
progressMeaning
PENDINGStored, not yet processed
PROCESSINGBeing applied
COMPLETEDEvery item applied
COMPLETED_WITH_REJECTED_ITEMSApplied, but some items were rejected; see counts and rejectedItems. Alert on this state.
FAILEDNothing from the batch was applied; errorMessage says why

After processing, counts holds items, applied, unchanged, superseded, locked, apiUpdatesDisabled 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. unchanged, superseded, locked and apiUpdatesDisabled are counts, not errors: the values already matched, a newer run had already been applied for that key, a SnowBee user has locked the values, or the store is not switched on for updates from the API.

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

Reference: GET /sales_forecasts.

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

Reference: GET /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,
      "updateFromApi": true,
      "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. updateFromApi is the store's setting, the same on every line of the store; uploads for a store where it is false are skipped. Switching it does not change the lines' modifiedTime, so read it from GET /retail_stores rather than relying on modifiedSince.

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

Limits

Reference: GET /limits.

GET https://api.snowbee.no/v1/tenants/TENANT_ID/demand_planning/limits

Current values; read them from the endpoint rather than copying them:

LimitRecommendedHard (413)
Items per batch1,00020,000
Days per forecast horizon731731
Request body as sent1 MB10 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 }
}