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

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

  • retailStoreNumber — the retail store's store number (number in GET /retail_stores)
  • ecomStoreNumber — the ecom store's number (number in GET /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": {}
}
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; 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: 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 (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
  }
}
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

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, 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 generatedAt per run, strictly increasing between runs; corrections are new runs
  • batchIndex/batchTotal set on every batch so an incomplete run is visible
  • Every batch id remembered until its PUT has answered 202 or 200, so a crash mid-run resumes with the same ids
  • 429, timeouts and 5xx retried with the same batch id; 400/413 never retried unchanged
  • Batch status polled a few minutes after a run and COMPLETED_WITH_REJECTED_ITEMS / FAILED alerted 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 rejects MANUAL ones (REPLENISHMENT_TYPE_MANUAL). Reading GET /replenishment_parameters first 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_stores and SnowBee data, not typed by hand

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 batch5,00020,000
Days per forecast horizon3062
Request body as sent1 MB10 MB
Request body after decompression50 MB
Compression ratio100: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 }
}