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.
What to push, and the scope it needs
Section titled “What to push, and the scope it needs”| 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 /.
Push in the order references need
Section titled “Push in the order references need”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.
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 resultsThe whole script is samples/python/push_master_data.py; run it twice against the sandbox:
python3 push_master_data.pyThe 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.
What each push answers
Section titled “What each push answers”{"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.
Unchanged costs nothing
Section titled “Unchanged costs nothing”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.
source_version stops a race
Section titled “source_version stops a race”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.
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"}Adopting a record that started in Slflo
Section titled “Adopting a record that started in Slflo”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).
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.
Prices: merge or replace
Section titled “Prices: merge or replace”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=replacealso 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 severalmergecalls.
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.
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
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 }}Balances
Section titled “Balances”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.
When it goes wrong
Section titled “When it goes wrong”| 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 |
The whole script
Section titled “The whole script”"""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 ordermatters. Run it as often as you like: what didn't change answers "unchanged" and costs nothing."""import sysimport uuidfrom 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())
