Skip to content

Push master data

Your ERP owns the customers, the catalogue and the prices; Slflo takes them so reps can sell. You push each record by your own key — PUT /customers/CUST-1001, PUT /products/ITEM-1001. Slflo finds the record or creates it and remembers the pair. There is no id to store and no create-or-update decision to make: a retry after a lost answer is the same call, and it can’t make a duplicate.

Record Call Scope
Unit PUT /units/{ref} catalog:write
Product PUT /products/{ref} catalog:write
A product’s sellable units PUT /products/{ref}/units — the whole list catalog:write
Price list PUT /price-lists/{ref} prices:write
Prices PUT /price-lists/{ref}/prices?mode=merge|replace — at most 500 rows prices:write
Customer PUT /customers/{ref}, DELETE customers:write
Branch PUT /customers/{ref}/branches/{branch_ref}, DELETE customers:write
Balance PUT /customers/{ref}/balance balances:write
Many products, units or balances POST /batch/products, /batch/units, /batch/balances — at most 500 items as the single call

Every write needs an Idempotency-Key. Your record keys ({ref}) are 1–64 characters with no /.

A record that names another — a product its unit, a price its product and unit, a customer its price list — needs that one pushed first. Otherwise the answer is 422 UNKNOWN_REFERENCE, the call to make first is in the message, each missing key is in details, and nothing is written:

{"error": {"code": "UNKNOWN_REFERENCE",
"message": "Unknown product or unit in these prices: push them first; nothing was written.",
"details": [{"field": "product_ref", "value": "ITEM-9999"}],
"request_id": "req_01J9X4Q7T2M8P3NRT6V5WZ", "retryable": false}}

So: units → price lists → products → product units → prices → customers → branches → balances.

push_master_data.py
def push_all(api):
results = {}
for ref, name in UNITS.items():
push(api, results, f"/units/{ref}", {"name": name})
list_ref, list_body = PRICE_LIST
push(api, results, f"/price-lists/{list_ref}", list_body)
for ref, item in PRODUCTS.items():
push(api, results, f"/products/{ref}", {"name": item["name"], "base_unit_ref": "PCS", "active": True})
push(api, results, f"/products/{ref}/units", {"units": [
{"unit_ref": "PCS", "factor_to_base": 1, "order_default": True},
{"unit_ref": "CTN", "factor_to_base": item["pieces_per_carton"], "order_default": False},
]})
# The whole list in one call (at most 500 rows): replace removes the list's prices not in it.
prices = []
for ref, item in PRODUCTS.items():
piece = float(item["price"])
prices.append({"product_ref": ref, "unit_ref": "PCS", "price": piece, "valid_from": "2026-01-01"})
prices.append({"product_ref": ref, "unit_ref": "CTN", "price": round(piece * item["pieces_per_carton"], 2),
"valid_from": "2026-01-01"})
push(api, results, f"/price-lists/{list_ref}/prices", {"prices": prices}, query={"mode": "replace"})
as_of = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
for ref, customer in CUSTOMERS.items():
push(api, results, f"/customers/{ref}", {
"name": customer["name"], "address": customer["address"], "blocked": False,
"credit_limit": customer["credit_limit"], "price_list_ref": list_ref,
})
for branch_ref, branch_name in customer["branches"].items():
push(api, results, f"/customers/{ref}/branches/{branch_ref}", {"name": {"en": branch_name}, "blocked": False})
push(api, results, f"/customers/{ref}/balance", {
"balance": customer["balance"], "credit_limit": customer["credit_limit"], "as_of": as_of,
})
return results

The whole script is samples/python/push_master_data.py; run it twice against the sandbox:

Terminal window
python3 push_master_data.py

The first run answers created for each new record (and updated for PCS, which the sandbox company already had — see adoption below). The second answers unchanged for everything, prices {"written": 0, "unchanged": 4, "removed": 0}, and the balances updated. The sandbox is shared, so if another developer ran the script before you, your first run already looks like the second.

Each push sends a new Idempotency-Key. That is what lets the second run answer unchanged: a retry under the same key replays the first answer instead.

{"data": {"entity": "customer", "id": "C0000001", "external_ref": "CUST-1001", "result": "created"}}
result Means Phones download it again?
created New in Slflo Yes
updated Changed, or an existing record linked to your key Yes
unchanged Same data as last time — the record’s version didn’t move No
stale Your source_version is older than the one Slflo has; nothing written No
deleted A DELETE tombstoned it Yes (it disappears)

A price push answers counts instead: {"list_id", "external_ref", "written", "unchanged", "removed"}. A balance push answers updated or stale — it is taken at its as_of time every time.

Send everything every night if that’s simplest: what didn’t change answers unchanged, keeps its version, and no rep’s phone downloads it again. If you want to save the call too, keep a hash of what you last sent per record and skip the push when it’s equal.

Send your row’s version or last-modified time as source_version (a string; numbers compare as numbers, anything else as text — ISO times sort correctly). A push older than the one Slflo has answers stale and changes nothing, so two jobs racing can’t roll a record back.

master_data_answers.py — every answer on one customer
def answers(api):
seen = {}
body = {"name": {"en": "Nile Corner"}, "blocked": False, "source_version": "2026-10-05T10:40:00Z"}
seen["first push"] = put(api, "/customers/CUST-2001", body)["result"] # created
seen["same body again"] = put(api, "/customers/CUST-2001", body)["result"] # unchanged
newer = {**body, "name": {"en": "Nile Corner Market"}, "source_version": "2026-10-05T11:00:00Z"}
seen["newer version"] = put(api, "/customers/CUST-2001", newer)["result"] # updated
older = {**body, "name": {"en": "Old name"}, "source_version": "2026-10-05T09:00:00Z"}
seen["older version"] = put(api, "/customers/CUST-2001", older)["result"] # stale: nothing written
try:
put(api, "/price-lists/NOT-PUSHED-YET/prices", {"prices": [
{"product_ref": "ITEM-1001", "unit_ref": "PCS", "price": 10, "valid_from": "2026-01-01"}]})
except ApiError as refused:
seen["price before its list"] = f"{refused.status} {refused.code}" # 422 UNKNOWN_REFERENCE
return seen
{"first push": "created", "same body again": "unchanged", "newer version": "updated",
"older version": "stale", "price before its list": "422 UNKNOWN_REFERENCE"}

Reps propose new shops from the street and the office adds them. When your ERP opens the account, link it instead of creating a second shop: push your key with salflo_id, Slflo’s id for the shop (from the order you pulled, or GET /lookup).

master_data_answers.py — adopt
def adopt(api, ref, salflo_id):
"""Link a shop that was created in Slflo (by a rep or the office) to your account number,
instead of creating a second one."""
return put(api, f"/customers/{ref}", {"name": {"en": "Adopted shop"}, "blocked": False, "salflo_id": salflo_id})

The answer is updated with that id. From then on the shop answers to your key. If the shop is already linked to another of your accounts the answer is 409 EXTERNAL_REF_CONFLICT — one shop answers to one account. Pushing a customer key that is already the code of a shop in Slflo, without salflo_id, is also 409 EXTERNAL_REF_CONFLICT; the message gives the salflo_id to send. Units and products with the same code as one of Slflo’s are adopted by their code, no salflo_id needed.

PUT /price-lists/{ref}/prices takes at most 500 rows — product_ref, unit_ref, price, valid_from (and valid_to, min_price). A row is keyed by product, unit and valid_from.

  • mode=merge (the default) adds and updates the rows you send and leaves the list’s other prices alone.
  • mode=replace also removes the list’s prices you pushed before that aren’t in this call. Use it only when the whole list fits in one call; with more than 500 rows, push in several merge calls.

Batches: 1,000 products and 2,000 prices in 6 calls

Section titled “Batches: 1,000 products and 2,000 prices in 6 calls”

A batch counts as one request against your rate limit. POST /batch/products takes {"items": [{"ref": "ITEM-1001", "body": {…the PUT body…}}]}; each item is applied on its own and gets its own result, so one bad item fails only itself.

push_prices_in_batches.py
def load(api, products, prices):
api.put("/units/PCS", {"name": {"en": "Piece"}}, key=f"{RUN}:unit:PCS")
api.put("/units/CTN", {"name": {"en": "Carton"}}, key=f"{RUN}:unit:CTN")
api.put(f"/price-lists/{LIST}", {"name": {"en": "Wholesale"}, "currency": "EGP"}, key=f"{RUN}:list")
pushed, failed = Counter(), []
for n, chunk in chunks(products):
answer = api.post("/batch/products", {"items": chunk}, key=f"{RUN}:products:{n}")
for item in answer["data"]:
if item["ok"]:
pushed[item["result"]["result"]] += 1 # created / updated / unchanged / stale
else:
failed.append((item["ref"], item["error"]["code"])) # this item only; the rest went in
# merge (the default) adds and updates rows and leaves the list's other prices alone,
# so a list longer than 500 rows can go in several calls.
written = unchanged = calls = 0
for n, chunk in chunks(prices):
answer = api.put(f"/price-lists/{LIST}/prices", {"prices": chunk},
key=f"{RUN}:prices:{n}", query={"mode": "merge"})["data"]
written, unchanged, calls = written + answer["written"], unchanged + answer["unchanged"], calls + 1
return {"products": dict(pushed), "failed": failed,
"prices": {"calls": calls, "written": written, "unchanged": unchanged}}
{"failed": [], "prices": {"calls": 4, "unchanged": 0, "written": 2000}, "products": {"created": 1000}}

The second run: {"products": {"unchanged": 1000}, "prices": {"calls": 4, "unchanged": 2000, "written": 0}}.

A batch answers 200 even when some items failed; read each item:

POST /batch/products

Terminal window
curl -X POST 'https://api.slflo.com/api/integration/v1/batch/products' \
-H 'Authorization: Bearer $SLFLO_TOKEN' \
-H 'Idempotency-Key: nightly-2026-10-07:products:0' \
-H 'Content-Type: application/json' \
-d '{"items":[{"ref":"ITEM-1001","body":{"name":{"en":"Cola 330ml"},"base_unit_ref":"PCS","active":true}},{"ref":"ITEM-1002","body":{"name":{"en":"Water 1.5l"},"base_unit_ref":"box","active":true}}]}'

Response

HTTP 200
{
"data": [
{
"ref": "ITEM-1001",
"ok": true,
"result": {
"entity": "product",
"id": "P0000001",
"external_ref": "ITEM-1001",
"result": "unchanged"
},
"error": null
},
{
"ref": "ITEM-1002",
"ok": false,
"result": null,
"error": {
"code": "UNKNOWN_REFERENCE",
"message": "Unknown unit box: PUT /units/box first.",
"details": []
}
}
],
"meta": {
"applied": 1,
"failed": 1
}
}

PUT /customers/{ref}/balance with balance (what the customer owes in your books), credit_limit and as_of — the time your balance was taken, as an ISO time. Slflo adds the invoices it issued after as_of and takes off the payments collected after it, as long as you haven’t acked them as accepted yet, so the rep sees what is really owed. Returns are not counted: push a new balance once you have booked a credit note. A balance older than the last one pushed answers stale.

What you see Why What to do
422 UNKNOWN_REFERENCE A key you named was never pushed Push it first — details lists each one, the message names the call
409 EXTERNAL_REF_CONFLICT on a customer The key is the code of an existing shop, or the salflo_id is linked to another key Send salflo_id to adopt it, or fix the duplicate account
409 BRANCH_ON_ROUTE on DELETE The branch is still on a rep’s route The office takes it off the route first
422 INVALID_REQUEST: “at most 500” More than 500 prices or batch items Split into several calls (merge for prices)
422 INVALID_REQUEST on a key Empty, longer than 64 characters, or contains / Use another key; a branch is addressed under its customer
422 INVALID_REQUEST on a value A rule on the data: a negative price, valid_to before valid_from, two default units, no name The message names it; fix the row
Everything stale Your source_version went backwards (a clock change, a new numbering) Send versions that only grow
422 IDEMPOTENCY_KEY_REUSED The same key with a different body Use a new key per call per run, as the samples do
push_master_data.py
"""Push the ERP's master data to Slflo by the ERP's own keys, in the order references need.
python3 push_master_data.py
Units → price list → products → sellable units → prices → customers → branches → balances.
A key named before it was pushed is 422 UNKNOWN_REFERENCE and nothing is written, so the order
matters. Run it as often as you like: what didn't change answers "unchanged" and costs nothing.
"""
import sys
import uuid
from datetime import datetime, timezone
from slflo import Slflo, TokenRefused, report
# What your ERP holds. In a real connector these come from your ERP's tables.
UNITS = {"PCS": {"en": "Piece", "ar": "قطعة"}, "CTN": {"en": "Carton", "ar": "كرتونة"}}
PRICE_LIST = ("RETAIL-EGP", {"name": {"en": "Retail", "ar": "تجزئة"}, "currency": "EGP"})
PRODUCTS = {
"ITEM-1001": {"name": {"en": "Cola 330ml", "ar": "كولا ٣٣٠ مل"}, "pieces_per_carton": 24, "price": "10.00"},
"ITEM-1002": {"name": {"en": "Water 1.5l", "ar": "مياه ١٫٥ لتر"}, "pieces_per_carton": 6, "price": "7.50"},
}
CUSTOMERS = {
"CUST-1001": {"name": {"en": "Al Amal Market", "ar": "سوق الأمل"}, "address": "12 Tahrir St, Giza",
"credit_limit": 100000, "balance": 12500, "branches": {"02": "Dokki branch"}},
"CUST-1002": {"name": {"en": "Delta Kiosk", "ar": "كشك الدلتا"}, "address": "4 Nile Corniche, Cairo",
"credit_limit": 50000, "balance": 0, "branches": {}},
}
# One key per call per run. The client's retries reuse it, so a lost answer is replayed, never
# applied twice; the next run uses new keys and so gets real answers ("unchanged").
RUN = uuid.uuid4().hex[:12]
def push(api, results, path, body, query=None):
answer = api.put(path, body, key=f"md:{RUN}:{path}", query=query)["data"]
results[path] = answer.get("result") or {k: answer[k] for k in ("written", "unchanged", "removed")}
def push_all(api):
results = {}
for ref, name in UNITS.items():
push(api, results, f"/units/{ref}", {"name": name})
list_ref, list_body = PRICE_LIST
push(api, results, f"/price-lists/{list_ref}", list_body)
for ref, item in PRODUCTS.items():
push(api, results, f"/products/{ref}", {"name": item["name"], "base_unit_ref": "PCS", "active": True})
push(api, results, f"/products/{ref}/units", {"units": [
{"unit_ref": "PCS", "factor_to_base": 1, "order_default": True},
{"unit_ref": "CTN", "factor_to_base": item["pieces_per_carton"], "order_default": False},
]})
# The whole list in one call (at most 500 rows): replace removes the list's prices not in it.
prices = []
for ref, item in PRODUCTS.items():
piece = float(item["price"])
prices.append({"product_ref": ref, "unit_ref": "PCS", "price": piece, "valid_from": "2026-01-01"})
prices.append({"product_ref": ref, "unit_ref": "CTN", "price": round(piece * item["pieces_per_carton"], 2),
"valid_from": "2026-01-01"})
push(api, results, f"/price-lists/{list_ref}/prices", {"prices": prices}, query={"mode": "replace"})
as_of = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
for ref, customer in CUSTOMERS.items():
push(api, results, f"/customers/{ref}", {
"name": customer["name"], "address": customer["address"], "blocked": False,
"credit_limit": customer["credit_limit"], "price_list_ref": list_ref,
})
for branch_ref, branch_name in customer["branches"].items():
push(api, results, f"/customers/{ref}/branches/{branch_ref}", {"name": {"en": branch_name}, "blocked": False})
push(api, results, f"/customers/{ref}/balance", {
"balance": customer["balance"], "credit_limit": customer["credit_limit"], "as_of": as_of,
})
return results
def main():
try:
results = push_all(Slflo())
except TokenRefused as stop:
report({"stopped_by": stop.code})
return 3
for path, result in results.items():
print(f"{path}: {result}", file=sys.stderr)
report(results)
return 0
if __name__ == "__main__":
sys.exit(main())