Recipes / Sync orders to your ERP

Orders

Sync orders to your ERP

Drain a date range of orders with line items and addresses into your ERP, resolving every id the API emits along the way.

GET/api/orders GET/api/orders/{id} GET/api/orders/{id}/items GET/api/shops

The pattern behind our wholesale and picking integrations.

List the day's orders with a drain loop

The list is paginated: every page returns a count/total/data envelope plus your start/limit echoed back. Keep advancing start until it reaches total. Date ranges are inclusive, Europe/Amsterdam.

# drain all orders purchased on July 14th
START=0; TOTAL=1
while [ "$START" -lt "$TOTAL" ]; do
  PAGE=$(curl -s "https://api.storekeeper.me/api/orders?from=2026-07-14&to=2026-07-14&start=$START&limit=100" \
    -H "Authorization: Bearer $TOKEN")
  TOTAL=$(echo "$PAGE" | jq .total)
  START=$((START + $(echo "$PAGE" | jq .count)))
  echo "$PAGE" | jq -c '.data[]' >> orders.jsonl
done
{
  "from": "2026-07-14",
  "to": "2026-07-14",
  "start": 0,
  "limit": 100,
  "count": 100,
  "total": 143,
  "data": [
    {
      "id": 88231,
      "order_number": "WEB-2026-04412",
      "date_purchased": "2026-07-14T09:41:22+02:00",
      "pickup_date": null,
      "status": "complete",
      "sub_status": null,
      "shop_id": 12,
      "location_id": 14,
      "currency": "EUR",
      "value_wt": 64.35,
      "value_ex_wt": 59.04,
      "discount_value_wt": 0,
      "discount_value_ex_wt": 0,
      "is_internal": false,
      "is_paid": true,
      "is_shipped": true,
      "is_delivered": false,
      "customer_reference": null,
      "relation_data_id": 88440,
      "customer_name": "Sanne de Vries",
      "customer_email": "sanne@voorbeeld.nl",
      "backoffice_url": "https://bakkerijjanssen.storekeepercloud.com/#order/details/88231"
    }
  ]
}
Gotcha: filter what your ERP actually books. Pass is_internal=0 to exclude internal stock movements, and consider status (comma-separated, e.g. complete,shipped) so drafts and cancellations never reach your ledger.

Fetch each order with its addresses

The detail call returns the same header fields plus lightly-shaped billing and shipping addresses. A missing id is a clean 404, so a deleted order cannot silently import as an empty record.

curl -s https://api.storekeeper.me/api/orders/88231 -H "Authorization: Bearer $TOKEN"
{
  "id": 88231,
  "order_number": "WEB-2026-04412",
  "status": "complete",
  "relation_data_id": 88440,
  "value_wt": 64.35,
  "value_ex_wt": 59.04,
  "billing_address": {
    "name": "Sanne de Vries",
    "email": "sanne@voorbeeld.nl",
    "phone": "+31612345678",
    "address": { "street": "Kerkstraat", "streetnumber": "12", "zipcode": "1017 GL", "city": "Amsterdam", "country_iso2": "NL" }
  },
  "shipping_address": {
    "name": "Sanne de Vries",
    "email": "sanne@voorbeeld.nl",
    "phone": "+31612345678",
    "address": { "street": "Kerkstraat", "streetnumber": "12", "zipcode": "1017 GL", "city": "Amsterdam", "country_iso2": "NL" }
  }
}
Gotcha: the customer key on an order is relation_data_id, and it equals the id in /api/customers. Use it as the foreign key to your ERP's debtor records; customer_name and customer_email are display snapshots, not identity.

Fetch the line items and split by kind

Every line carries a kind: product, shipping, payment, or discount. An ERP usually books only kind=product plus maybe shipping; payment and discount lines are bookkeeping artifacts you handle separately. Money fields with _wt are incl VAT, the plain ones are excl.

curl -s https://api.storekeeper.me/api/orders/88231/items -H "Authorization: Bearer $TOKEN"
{
  "order_id": 88231,
  "count": 3,
  "truncated": false,
  "data": [
    { "id": 301122, "order_id": 88231, "kind": "product", "sku": "BROOD-VLB-800",
      "name": "Volkorenbrood 800g", "quantity": 2, "ppu": 3.12, "ppu_wt": 3.40,
      "price": 6.24, "price_wt": 6.80, "tax_rate_id": 52,
      "product_id": 4410, "shop_product_id": 9021, "pickup_date": null },
    { "id": 301123, "order_id": 88231, "kind": "product", "sku": "TAART-APPEL",
      "name": "Appeltaart groot", "quantity": 1, "ppu": 48.21, "ppu_wt": 52.55,
      "price": 48.21, "price_wt": 52.55, "tax_rate_id": 52,
      "product_id": 4477, "shop_product_id": 9088, "pickup_date": "2026-07-15" },
    { "id": 301124, "order_id": 88231, "kind": "shipping", "sku": null,
      "name": "Bezorging Amsterdam", "quantity": 1, "ppu": 4.13, "ppu_wt": 5.00,
      "price": 4.13, "price_wt": 5.00, "tax_rate_id": 55,
      "product_id": null, "shop_product_id": null, "pickup_date": null }
  ]
}

Resolve shop_id to a channel name

Orders carry a numeric shop_id (the sales channel: webshop, POS, marketplace). One cached call to /api/shops translates it to a name and, via location_id, to the physical branch.

curl -s https://api.storekeeper.me/api/shops -H "Authorization: Bearer $TOKEN"
{
  "count": 2,
  "total": 2,
  "data": [
    { "id": 12, "name": "Webshop", "alias": "webshop", "location_id": 14, "relation_data_id": null },
    { "id": 15, "name": "Kassa Centrum", "alias": "pos-centrum", "location_id": 14, "relation_data_id": 20110 }
  ]
}

Upsert idempotently so a re-run is safe

Key every imported record on the Storekeeper order id (stable, numeric) and store order_number for humans. On each sync, upsert by that key: insert when new, update when the status or payment flags changed. Re-running a whole day must then be a no-op, which also makes crash recovery trivial: just run the day again.

# per order, in your ERP importer
key      = storekeeper.order.id          # unique, never reused
display  = storekeeper.order.order_number
UPSERT erp_orders SET status, is_paid, is_shipped, totals WHERE external_id = key
Gotcha: orders mutate after purchase: is_paid, is_shipped, and status flip days later. Sync a trailing window (yesterday and today, for example) rather than only "new since last run", and let the upsert absorb the overlap.