Documentation E-commerce

E-commerce API

Integrate an online store with SnowBee: read the product catalogue and inventory for the store's assortment, create sales orders and click & collect reservations, manage B2C customers, and receive events when products or inventory change.

This guide assumes you have read Get started: how to authenticate, the base URL per scope, and the conventions (IDs, decimals, errors, rate limits) that apply to every request.

What you need

Request a token with scope: "ecom:read" for reads, scope: "ecom:write" for mutations, or scope: "ecom:read ecom:write" for both. Omit resource. One token can access every eCom store selected on the client, with the same permissions for each store. All endpoints on this page live under:

https://api.snowbee.no/v1/tenants/TENANT_ID/ecom_stores/ECOM_STORE_ID/

Permissions

ecom:read covers all GET operations and the query operations POST /carts/:cartId/delivery_options and POST /carts/:cartId/pickup_points. These POST operations look up delivery information without changing the cart.

ecom:write covers mutations, including creating and updating carts, checkout, sales-order creation, customer and loyalty-enrollment updates, payment-capture and refund confirmations, and triggering product or inventory events. Write permission does not grant reads; a full storefront integration needs both scopes. Each request also checks that its eCom Store ID is included in the token's selected stores. An unselected store or missing operation scope returns 403.

The legacy ecom token request remains supported for clients with both read and write grants; see Get started.

Everything you can read is scoped to the eCom store's assortment: GET /products lists only products with at least one SKU on the assortment, and GET /products/:productId answers 404 for a product that exists in SnowBee but is not on it.

API Endpoints Overview

Settings

EndpointDescription
GET /settingsRead integration settings, including the tenant time zone

Product Catalog

EndpointDescription
GET /productsList all product IDs in the ecom store assortment
GET /products/:productIdGet product details with SKUs, images, and prices
GET /skus/:skuId/inventoryGet inventory availability by location
GET /campaignsList active and planned campaigns with SKU pricing
POST /products/trigger_eventsQueue ProductUpdated events for initial sync
POST /inventory/trigger_eventsQueue InventoryUpdated events for initial sync

Product Schema (Reference Data)

EndpointDescription
GET /product_schema/brandsList all brands
GET /product_schema/product_categoriesList product categories
GET /product_schema/main_product_groupsList main product groups
GET /product_schema/sub_product_groupsList sub product groups
GET /product_schema/product_conceptsList product concepts
GET /product_schema/exclusivity_levelsList exclusivity levels
GET /product_schema/product_colorsList colors with color codes
GET /product_schema/sizesList sizes with sort order
GET /product_schema/dynamic_attributesList custom attributes
GET /product_schema/dynamic_attributes_optionsList options for dynamic attributes
GET /product_schema/unit_of_measuresList units of measure

Orders

EndpointDescription
POST /ordersCreate a new sales order
GET /orders/:orderIdOrNumberGet order details by ID or order number, including invoices and return invoices
POST /orders/:orderId/refunds/:refundId/confirmConfirm or report failure of a refund for a return invoice

Customers

EndpointDescription
PUT /customers/:customerIdCreate or update customer (idempotent)
GET /customers/:customerIdGet customer details
PUT /customers/:customerId/addressesUpdate customer delivery and invoice addresses
GET /customers/:customerId/ordersGet customer order history, including the store's guest orders delivered to the customer's email
GET /customers/by_email/:emailFind a customer by email
PUT /push-customerCreate/update customer by phone number

Click & Collect

EndpointDescription
POST /click_and_collectCreate a click & collect reservation
GET /retail_storesList retail stores for click & collect

Catalog listing Experimental

EndpointDescription
GET /products/searchList and search sellable products as summaries with names, image, prices and availability; filter, sort and page with a cursor

Store Experimental

EndpointDescription
GET /storeThe store's name, currency, the loyalty program member prices refer to, and whether checkout takes payment

Carts Experimental

EndpointDescription
POST /cartsCreate an empty cart for the store
GET /carts/:cartIdGet a cart and its lines
POST /carts/:cartId/linesAdd a SKU to the cart (merges into an existing line for the same SKU)
PUT /carts/:cartId/lines/:lineIdSet a cart line's quantity
DELETE /carts/:cartId/lines/:lineIdRemove a line from the cart
PUT /carts/:cartId/customerBind the cart to a logged-in shopper's customer: priced on their membership, ordered in their name, merged with their other cart
DELETE /carts/:cartId/customerMake the cart anonymous again at logout
POST /carts/:cartId/delivery_optionsDelivery options for the cart to an address, priced live by the merchant's carrier
POST /carts/:cartId/pickup_pointsPickup points near an address for a carrier product
POST /carts/:cartId/payment_sessionOpen an Adyen Drop-in session for the cart plus the chosen freight
POST /carts/:cartId/checkoutTurn the cart into an order, verifying the payment when the store takes one

Building a web shop on these? Build a storefront walks the shopper's whole path.

A cart is deleted when it is checked out; the order is the record from then on. A cart nobody has touched for 30 days is deleted as well, so a stored cart id can stop resolving: treat 404 on a cart as "start a new cart", not as an error.

Example: Create an Order

Reference: POST /orders.

curl -X POST "https://api.snowbee.no/v1/tenants/TENANT_ID/ecom_stores/ECOM_STORE_ID/orders" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "customerId": "019c1234-5678-7abc-8def-123456789012",
    "deliveryAddress": {
      "name": "John Doe",
      "street": "Main Street 1",
      "postCode": "0123",
      "city": "Oslo",
      "countryIsoCode": "NO",
      "email": "john@example.com",
      "phone": "+4712345678"
    },
    "invoiceAddress": {
      "street": "Main Street 1",
      "postCode": "0123",
      "city": "Oslo",
      "countryIsoCode": "NO",
      "email": "john@example.com"
    },
    "lineItems": [
      {
        "skuId": "019c1234-5678-7abc-8def-123456789013",
        "quantity": 2,
        "netUnitPrice": 199.00,
        "vatRate": 25.00
      }
    ],
    "freight": {
      "freightAmount": 49.00,
      "shippingMethodName": "Standard Shipping",
      "providerData": "{\"trackingId\": \"ABC123\"}"
    }
  }'

Line Item Pricing

netUnitPrice must be the price the customer paid for one unit. Price each line the way the cash register does: apply the campaign price, apply any per-item markdown, and send the result. Do not send a list price and expect SnowBee to work out what was charged.

// Required: the price the customer paid
{ "skuId": "...", "quantity": 1, "netUnitPrice": 239.60, "vatRate": 25.00 }

netUnitOrderDiscountAmount is only for a reduction on top of the line's own price — a gift voucher or a coupon spread across the order's lines. Never use it to express a campaign price or any other markdown that belongs in netUnitPrice.

// Correct: campaign price in netUnitPrice, a 100,00 voucher share on top
{ "skuId": "...", "quantity": 1, "netUnitPrice": 239.60, "vatRate": 25.00,
  "netUnitOrderDiscountAmount": 100.00, "campaignCode": "OUTLET1-26" }
 
// Wrong: list price with the campaign markdown as a discount
{ "skuId": "...", "quantity": 1, "netUnitPrice": 479.20, "vatRate": 25.00,
  "netUnitOrderDiscountAmount": 239.60 }

The second is wrong even when it happens to total the right amount. The two fields answer two different questions — what the item cost, and what was taken off it — and once a campaign markdown and a voucher are added together into one figure, SnowBee cannot split them apart again. An order sent that way cannot show the customer their own price breakdown, campaign reporting misses the sale, and the discount appears with nothing to explain where it came from.

The older shape is still accepted so existing integrations keep working. It is not the contract for new work, and orders sent that way lose the information above permanently — SnowBee never reprices an order that has been settled with the customer.

Recording the Campaign a Line Was Sold Under

Send campaignCode on every line sold under a campaign. Use the code from GET /campaigns, together with that SKU's campaignPrice as the line's netUnitPrice:

{
  "skuId": "019c1234-5678-7abc-8def-123456789013",
  "quantity": 1,
  "netUnitPrice": 239.60,
  "vatRate": 25.00,
  "campaignCode": "SUMMER24"
}

Sent that way, the line is stored exactly as a campaign sale at the cash register: the sale counts towards the campaign, and the campaign is the line's price source.

What you sendWhat SnowBee records
campaignCode, and netUnitPrice equal to the campaign's campaignPrice for that SKUCorrect. The sale counts towards the campaign and the campaign is the line's price source
campaignCode, and any other netUnitPriceRejected with 400. Put the campaign price in netUnitPrice and any further reduction, such as a voucher, in netUnitOrderDiscountAmount
No campaignCode on a campaign saleIncorrect. SnowBee falls back to matching the price against campaigns covering the SKU, which only succeeds when the match is exact and unambiguous. Anything else is recorded as a plain sale

A campaign sale with no campaign on its lines is invisible to everything that reports by campaign — picking filters, pending sales order and purchase planning reports — even when the campaign is the whole reason the sale happened. Nothing recovers it afterwards: SnowBee does not guess a campaign from a line's price.

The code must belong to a released campaign covering the SKU at the sale instant, and the line must be priced at that campaign's campaignPrice, or the request is rejected with 400.

A THREE_FOR_TWO campaign is named the same way, and priced at the basket's answer. Such an offer sets no unit price of its own — what a third unit is worth depends on the whole basket — so apply the offer yourself and send each line's blended per-unit price as netUnitPrice, exactly as a cash register does: three units at 100,00 with one free is netUnitPrice 66,67, not 100,00 with a discount alongside. The price must not exceed the SKU's normal price (which is what campaignPrice returns for such a campaign), and netUnitOrderDiscountAmount stays reserved for reductions beyond the offer — a voucher, a coupon share. The offer's value lands on the lines it applies to, never spread across the whole order: a discount that lands where it was earned keeps the order's own figures right, and a line sent this way is stored exactly as a 3-for-2 sale at the cash register, so the price breakdown and campaign reporting read the two alike.

Line Item SKU Identification

You can identify SKUs in line items using either skuId or skuNumber:

// Using SKU ID (UUID)
{ "skuId": "019c1234-5678-7abc-8def-123456789013", "quantity": 2, ... }
 
// Using SKU number (your product code)
{ "skuNumber": "PROD-BLK-M", "quantity": 2, ... }

At least one of skuId or skuNumber must be provided for each line item.

Freight / Shipping Information

You can optionally include freight information with your order using the freight object:

{
  "freight": {
    "freightAmount": 49.00,
    "shippingMethodName": "Standard Shipping",
    "providerData": "{\"trackingId\": \"ABC123\", \"serviceCode\": \"EXPRESS\"}"
  }
}
FieldTypeDescription
freightAmountdecimal, nullableFreight/shipping cost amount
shippingMethodNamestring, nullableName of the shipping method selected by the customer
providerDatastring, nullableOpaque provider-specific data (e.g. JSON from a freight provider like Profrakt). SnowBee stores this as-is without parsing it.

All fields are optional. The freight object itself is also optional. When an order has freight information, it is returned in the GET /orders/:orderIdOrNumber response.

Order Constraints

Example: Click & Collect Reservation

A reservation parks one or more SKUs in a retail store for the shopper to collect and pay for over the counter. Pick the store from GET /retail_stores; GET /skus/:skuId/inventory lists the same stores with their available quantity, so a product page can show where a variant is in stock.

Reference: POST /click_and_collect.

curl -X POST "https://api.snowbee.no/v1/tenants/TENANT_ID/ecom_stores/ECOM_STORE_ID/click_and_collect" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "retailStoreId": "019c1234-5678-7abc-8def-123456789012",
    "reservationName": "Ola Nordmann",
    "reservationPhone": "+4712345678",
    "reservationEmail": "ola@example.com",
    "lineItems": [
      {
        "skuId": "019c1234-5678-7abc-8def-123456789013",
        "quantity": "1"
      }
    ]
  }'

Response (202 Accepted):

{
  "id": "019c1234-5678-7abc-8def-123456789014"
}

The id is the reservation's reference in SnowBee; the store sees it as a parked click & collect cart. customerId is optional: without it, an existing B2C customer is matched by reservationEmail, then reservationPhone, and otherwise the store's walk-in customer is used.

Click & Collect pricing

A reservation is paid in the collecting store, so that store prices it: its own current sales price and campaigns, applied when the reservation arrives and again when the cash register opens it. A line may carry the webshop's netUnitPrice (excluding VAT) and vatRate, but neither is a pricing input. Both are optional; when omitted, the store's price and the product's VAT rate in the store's country are used as the initial values. They are required only for a SKU the store has no sales price for, and the request is rejected with 400 if they are missing then.

Click & Collect Constraints

Example: List and Search Products Experimental

GET /products/search returns the store's sellable products -- published in the assortment, released and carrying a current sales price -- one page at a time. Without a query the default sort is newest; with one it is relevance. Every dimension filter is repeatable, so a category page is one call:

Reference: GET /products/search.

curl "https://api.snowbee.no/v1/tenants/TENANT_ID/ecom_stores/ECOM_STORE_ID/products/search?mainProductGroupId=GROUP_ID&sort=price_asc&limit=24" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
{
  "currencyIsoCode": "NOK",
  "items": [
    {
      "id": "019c1234-5678-7abc-8def-123456789012",
      "productNumber": "100234",
      "productName": { "value": { "NO": "Vinterjakke" } },
      "brand": { "id": "019c1234-5678-7abc-8def-123456789040", "name": { "value": { "NO": "Norr" } } },
      "productCategory": { "id": "019c1234-5678-7abc-8def-123456789041", "name": { "value": { "NO": "Klær" } } },
      "mainProductGroup": { "id": "019c1234-5678-7abc-8def-123456789042", "name": { "value": { "NO": "Jakker" } } },
      "subProductGroup": { "id": "019c1234-5678-7abc-8def-123456789043", "name": { "value": { "NO": "Vinterjakker" } } },
      "mainImageUrl": "https://imagedelivery.net/ACCOUNT/IMAGE_ID/shopifyProductImage",
      "price": { "price": "2999", "campaignPrice": "2499", "loyaltyMemberPrice": null },
      "offers": [],
      "inStock": true,
      "newFrom": "2026-09-01",
      "newTo": null
    }
  ],
  "nextCursor": "bmV3ZXN0...",
  "previousCursor": null
}

Parameters: query (free text over number, name, brand, groups, sku numbers and barcodes -- a comma separates alternatives, a space requires every word), productId (repeatable UUIDs; a lookup rather than a search, for re-reading products you already know), brandId, productCategoryId, mainProductGroupId, subProductGroupId (all repeatable UUIDs), inStock (true lists only products with a sku available for ecom in the store's warehouse), sort (relevance, newest, name, price_asc, price_desc, optionally prefixed by in_stock to list available products first, as in in_stock,name), limit (default 24, max 100) and cursor.

Pass nextCursor back as cursor for the next page and previousCursor for the page before; each is null at the respective end of the listing. A cursor is only valid for the filters and sort it was issued for -- anything else answers 400 INVALID_CURSOR. Prices are gross, in the store's currency, and the campaign price is the one the cart will charge. loyaltyMemberPrice is the members-only campaign price, for shoppers who are members of the store's loyalty program (GET /store). offers lists the 3 for 2 campaigns covering the product, each with campaignType, name and whether it is members-only; such an offer has no unit price and is applied by the cart. Both fields are also on GET /products/:productId. Names are localized strings keyed by language code, as everywhere else in the catalog API. Dimension IDs for filters come from the product_schema endpoints above.

Example: Build a Cart Experimental

The cart stores what the shopper wants to buy and SnowBee prices it on every read -- the same sales prices, campaigns, 3 for 2 offers and VAT rules the cash register applies. Checking out turns the cart into an ordinary ecom order and deletes the cart. A store set up for web payment takes the payment through Adyen's Drop-in first.

A cart belongs to one ecom store and is reached only with that store's token, so a cart ID alone grants nothing. Keep the cart ID in the shopper's session (a cookie or your own storage) and never put it in a URL. Every cart call returns the complete cart, priced.

Create a cart:

Reference: POST /carts.

curl -X POST "https://api.snowbee.no/v1/tenants/TENANT_ID/ecom_stores/ECOM_STORE_ID/carts" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
{
  "id": "019c1234-5678-7abc-8def-123456789020",
  "progress": "ACTIVE",
  "lines": [],
  "pricing": { "currencyIsoCode": "NOK", "netAmount": "0", "vatAmount": "0", "grossAmount": "0", "complete": true }
}

Add two of a SKU, then add one more of the same SKU -- the cart still has one line, now with quantity 3:

Reference: POST /carts/{cartId}/lines.

curl -X POST "https://api.snowbee.no/v1/tenants/TENANT_ID/ecom_stores/ECOM_STORE_ID/carts/CART_ID/lines" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "skuId": "019c1234-5678-7abc-8def-123456789013", "quantity": 2 }'
{
  "id": "019c1234-5678-7abc-8def-123456789020",
  "progress": "ACTIVE",
  "lines": [
    {
      "id": "019c1234-5678-7abc-8def-123456789021",
      "skuId": "019c1234-5678-7abc-8def-123456789013",
      "quantity": 2,
      "pricing": {
        "vatRate": "25",
        "basicGrossUnitPrice": "299",
        "basicNetUnitPrice": "239.2",
        "netUnitPrice": "199.2",
        "grossUnitPrice": "249",
        "netLineAmount": "398.4",
        "grossLineAmount": "498",
        "campaign": {
          "id": "019c1234-5678-7abc-8def-123456789030",
          "code": "SUMMER24",
          "name": "Summer sale",
          "campaignType": "NORMAL",
          "netUnitPrice": "199.2",
          "applies": true
        }
      }
    }
  ],
  "pricing": { "currencyIsoCode": "NOK", "netAmount": "398.4", "vatAmount": "99.6", "grossAmount": "498", "complete": true }
}

Every money value is a string, never a JSON number: amounts carry at most two decimals, net unit prices up to four because that is how SnowBee stores them. basicGrossUnitPrice is the sku's ordinary price, grossUnitPrice what the shopper pays -- show both for a strike-through. For a THREE_FOR_TWO campaign the unit price is the blended price after the free unit is spread across the line; applies is false while the basket has fewer than three qualifying units.

Set a line's quantity with PUT .../lines/LINE_ID and a body of { "quantity": 1 }; remove it with DELETE .../lines/LINE_ID. Each line also carries sku -- sku number, barcode, product id, number and localized name, color and size names, main image URL -- so a cart page needs no further calls.

Check out with the shopper's addresses and, when the carrier's checkout UI has priced it, the same freight block POST /orders takes. The response is the order; the cart is gone afterwards, and repeating the call returns the same order:

Reference: POST /carts/{cartId}/checkout.

curl -X POST "https://api.snowbee.no/v1/tenants/TENANT_ID/ecom_stores/ECOM_STORE_ID/carts/CART_ID/checkout" \\
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \\
  -H "Content-Type: application/json" \\
  -d '{
    "deliveryAddress": { "name": "Kari Nordmann", "street": "Storgata 1", "postCode": "0155", "city": "Oslo", "countryIsoCode": "NO", "email": "kari@example.com", "phone": "+4790000000" },
    "invoiceAddress": { "name": "Kari Nordmann", "street": "Storgata 1", "postCode": "0155", "city": "Oslo", "countryIsoCode": "NO", "email": "kari@example.com" }
  }'
{ "orderId": "019c1234-5678-7abc-8def-123456789050", "orderNumber": "100042" }

Cart Constraints

Delivery options Experimental

Freight is priced by the merchant's carrier, through Profrakt, on the merchant's own agreements. SnowBee stores no shipping options and no prices: it asks Profrakt what the cart costs to deliver to the shopper's address and hands the answer on.

Reference: POST /carts/{cartId}/delivery_options.

curl -X POST "https://api.snowbee.no/v1/tenants/TENANT_ID/ecom_stores/ECOM_STORE_ID/carts/CART_ID/delivery_options" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "address": { "street": "Storgata 1", "postCode": "0155", "city": "Oslo", "countryIsoCode": "NO" } }'
{
  "options": [
    {
      "carrierCode": "bring2",
      "carrierName": "Bring",
      "carrierProductCode": "bring2_parcel_pickup_point",
      "carrierProductName": "Pakke til hentested",
      "carrierAgreementCode": "2728",
      "netFreightAmount": "79.00",
      "freightVatRate": "25",
      "grossFreightAmount": "98.75",
      "expectedDelivery": "12.05.2026",
      "workingDays": 2,
      "pickupPointRequired": true
    }
  ]
}

Options are sorted by gross freight. For an option with pickupPointRequired, list the pickup points near the address with POST /carts/:cartId/pickup_points (body: carrierCode, carrierProductCode, address) and let the shopper choose one. The chosen option's carrier fields, netFreightAmount and freightVatRate are the checkout's freight block, with the pickup point as carrierProductPayload: { "partnerNumber": "...", "partnerName": "..." }. Send the same freight block when opening the payment session, so the shopper authorises goods plus freight. Options answer 409 when the store's company has no Profrakt transport configuration.

Paying for the cart Experimental

A store set up for web payment takes the shopper's money before the order exists, and checkout refuses a cart that has not been paid. The credentials stay in SnowBee; the browser gets only what Adyen's Drop-in needs.

Reference: POST /carts/{cartId}/payment_session.

curl -X POST "https://api.snowbee.no/v1/tenants/TENANT_ID/ecom_stores/ECOM_STORE_ID/carts/CART_ID/payment_session" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "returnUrl": "https://shop.example/checkout/return", "shopperEmail": "ola@example.com", "freight": { ... } }'
{
  "paymentId": "019c1234-5678-7abc-8def-123456789020",
  "sessionId": "CS451F2AB1...",
  "sessionData": "Ab02b4c0!...",
  "clientKey": "test_ABC...",
  "environment": "test",
  "amount": "1198.00",
  "currencyIsoCode": "NOK"
}

Mount the Drop-in with sessionId, sessionData, clientKey and environment. When it reports the session complete, check out with the paymentId and the Drop-in's sessionResult:

{ "deliveryAddress": { ... }, "invoiceAddress": { ... }, "payment": { "paymentId": "019c1234-...-789020", "sessionResult": "X3XtfGC7..." } }

SnowBee verifies the session with Adyen and that the authorised amount is still what the cart costs including the freight sent with the checkout. A cart whose total changed after the session was opened answers 422; open a new session and pay again. A refused or expired session answers 422 as well. The authorisation is captured by SnowBee when the order is picked; nothing else is required of the storefront. A store without web payment configured answers 409 to the session call and 422 to a checkout that carries payment.

Example: Get Settings

Reference: GET /settings.

curl -X GET "https://api.snowbee.no/v1/tenants/TENANT_ID/ecom_stores/ECOM_STORE_ID/settings" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Response:

{
  "timeZone": "Europe/Oslo"
}

timeZone is the tenant's IANA time zone. It is configured when the tenant is created; changing the tenant time zone is not supported. This endpoint is read-only and uses the same e-commerce token and store permissions as the other endpoints. It is available even when the store has no campaigns.

Campaign boundaries and other tenant-local dates and times use this zone. Its UTC offset can vary with daylight saving; use the offset supplied on each resolved timestamp rather than assuming one fixed offset for the tenant.

Example: Get Campaigns

Reference: GET /campaigns.

curl -X GET "https://api.snowbee.no/v1/tenants/TENANT_ID/ecom_stores/ECOM_STORE_ID/campaigns" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Response:

{
  "campaigns": [
    {
      "id": "019c1234-5678-7abc-8def-123456789012",
      "code": "SUMMER24",
      "name": "Summer Sale 2024",
      "validFrom": "2024-06-01",
      "validTo": "2024-08-31",
      "validFromTime": "00:00",
      "validToTime": "24:00",
      "timeZone": "Europe/Oslo",
      "validFromAt": "2024-06-01T00:00:00+02:00",
      "validToAt": "2024-09-01T00:00:00+02:00",
      "memberLevels": [
        { "type": "NORMAL", "id": null, "code": null, "name": null },
        { "type": "CONFIGURED", "id": "019c1234-5678-7abc-8def-123456789014", "code": "GOLD", "name": "Gold" }
      ],
      "currencyIsoCode": "NOK",
      "status": "ACTIVE",
      "campaignType": "NORMAL",
      "skus": [
        {
          "skuId": "019c1234-5678-7abc-8def-123456789013",
          "campaignPrice": 299.00,
          "loyaltyProgramMembershipRequired": false
        }
      ],
      "loyaltyProgram": {
        "id": "019c1234-5678-7abc-8def-456789abcdef",
        "name": "Premium Members Club"
      }
    }
  ]
}

validFrom and validTo remain date-only fields (YYYY-MM-DD) for compatibility. The new validFromAt and validToAt fields are ISO 8601 datetimes with UTC offsets, resolved from the selected dates and times using the tenant's time zone. That zone is included as timeZone and is also available from GET /settings; changing it is not supported.

The campaign has one continuous validity window. validFromAt is inclusive and validToAt is exclusive: apply the campaign when validFromAt <= now < validToAt. Use these resolved datetimes for activation and expiry, rather than comparing the dates alone. validFromTime and validToTime use HH:mm; 24:00 means midnight on the next calendar date. The default 00:00 start and 24:00 end preserve full-day campaigns. For example, 4 October at 00:00 through 6 October at 24:00 ends exactly at midnight starting 7 October. An end of 21:00 makes the campaign inactive at 21:00 on its selected end date.

memberLevels lists the campaign's selected loyalty-program levels. CONFIGURED entries include the SnowBee level ID, provider code and display name. NORMAL explicitly selects program members with no configured provider level; it does not select non-members. An empty list adds no restriction on member level. The campaign's loyaltyProgram and each SKU's loyaltyProgramMembershipRequired still describe the existing membership requirement. Webshops can use the exported selections to restrict eligible shoppers; SnowBee's current pricing lookup distinguishes members from non-members and does not enforce individual levels.

For B2C stores, status and inclusion in this response follow these timestamps. B2B stores continue to follow the campaign's separate ordering-date window.

Product price responses also respect timed campaigns. Their upcomingPriceCalendar includes future changes later today as well as changes on later dates. Each activatesAt is the actual activation instant with the tenant's UTC offset, including daylight-saving changes. Refresh cached product prices at those instants rather than waiting until the next midnight.

Schedule activation and expiry locally using the resolved timestamps. Passing a boundary does not emit CampaignUpdated. Re-read campaign data when receiving campaign update notifications.

Campaign statuses:

Campaign types:

The campaignType is set on the campaign and applies to all of its SKUs — it decides how to read each SKU's campaignPrice.

Errors specific to this API

The error envelope and the generic codes (UNAUTHORIZED, FORBIDDEN, BAD_REQUEST, TOO_MANY_REQUESTS) are described in Get started. Codes you will see from the e-commerce endpoints:

Error CodeDescription
INVALID_REQUESTRequest validation failed (missing fields, invalid format)
BAD_REQUESTRequest body could not be parsed (invalid JSON, wrong types)
SKU_NOT_FOUNDSKU not found in store assortment. Message includes the SKU ID or number.
CUSTOMER_NOT_FOUNDCustomer with the specified ID does not exist
DUPLICATE_EMAIL, DUPLICATE_PHONEAnother customer already has this email address or phone number. Look that customer up instead of retrying. HTTP 409.
VALIDATION_ERRORA business rule rejected the change. Cart validation returns HTTP 422; an unhandled database business-rule rejection, such as a missing consignment price when creating an order, returns HTTP 400. The message explains the rule.
CONFLICTThe cart is no longer active and cannot be changed. HTTP 409.
NOT_FOUNDThe cart or cart line does not exist in this store. HTTP 404.
INVALID_UUIDA path ID is not a UUID
UNAUTHORIZEDMissing or invalid authentication token
FORBIDDENToken valid but lacks permission for this resource

Important Notes

Go-live checklist

Before pointing a production store at the API:

Next steps