Build a pull connector
هذا المحتوى غير متوفر بلغتك بعد.
A pull connector is a job your ERP runs every few minutes. Each run reads the orders that reps took since the last run, books one sales order for each, and tells Slflo what it did. Done this way, nothing is missed and nothing is booked twice — even when the job dies halfway, the network drops an answer, or the office sends a refused order again.
every 5 minutes: pull new orders → stage them → book each once → ack eachBefore you start: a token with orders:read and orders:write
(sandbox), and the company in ERP mode API
— only then do pre-sale orders wait for your ERP as ERP_PENDING.
The code on this page is from samples/python/pull_orders.py and samples/csharp/PullOrders.cs.
Our build runs both against a real Slflo on every change, so what you copy is what we test.
Both use the small client from Retries and token rotation.
-
Keep a staging table with a unique key on
(id, version, line_number).An order that changes while you read comes back later with a higher
version; that is a new row. The same page read twice — because the job crashed before saving its watermark — inserts nothing. The unique key is what makes re-reading harmless, so put it in the database, not in your code.pull_orders.py — staging def open_staging():os.makedirs(STATE_DIR, exist_ok=True)db = sqlite3.connect(os.path.join(STATE_DIR, "staging.db"))db.executescript("""create table if not exists watermark (stream text primary key, version integer not null);create table if not exists order_line (order_id text not null,version integer not null,line_number integer not null,document text not null, -- the order as pulledbooked integer not null default 0,primary key (order_id, version, line_number) -- the unique key that makes re-reads harmless);""")return dbdef stage(db, order):"""One row per line under (id, version, line_number); a second copy inserts nothing."""lines = [line["line_number"] for line in order["lines"]] or [0]db.executemany("insert or ignore into order_line (order_id, version, line_number, document) values (?, ?, ?, ?)",[(order["id"], order["version"], n, json.dumps(order)) for n in lines],) -
Pull from your watermark, only orders waiting for your ERP.
Ask for
filter[status]=ERP_PENDINGafter the highestversionyou have staged, and followmeta.next_version_afteruntilmeta.has_moreisfalse. Save the watermark only once its page is staged.pull_orders.py — pull def pull(api, db, recover=False):"""Stream style: from the watermark, only orders waiting for the ERP.Recover: from zero, only orders not acked at their current version."""query = {"filter[ack]": "pending"} if recover else {"filter[status]": "ERP_PENDING"}pulled = 0for page in api.pages("/orders", query, after=0 if recover else watermark(db)):for order in page["data"]:stage(db, order)pulled += 1if not recover:save_watermark(db, page["meta"]["next_version_after"])db.commit() # the watermark moves only once its page is stagedreturn pulledTerminal window curl -s "$SLFLO_BASE/orders?filter%5Bstatus%5D=ERP_PENDING&filter%5Bversion_after%5D=18342&per_page=100" \-H "Authorization: Bearer $SLFLO_TOKEN"PullOrders.cs // Pull connector: every Slflo order waiting for the ERP becomes one ERP sales order, then an ack.using System.Text.Json.Nodes;namespace Slflo.Samples;public sealed class Staging{public long Watermark { get; set; }// "(id, version, line)" -> the order as pulled, and whether it has been bookedpublic Dictionary<string, StagedLine> Lines { get; set; } = new();}public sealed class StagedLine{public string OrderId { get; set; } = "";public long Version { get; set; }public string Document { get; set; } = "";public bool Booked { get; set; }}public static class PullOrders{public static async Task<Dictionary<string, int>> RunAsync(SlfloClient api){var staging = StateFiles.Load<Staging>("staging-cs.json");var erp = FakeErp.Open();var result = new Dictionary<string, int> { ["pulled"] = 0, ["created"] = 0, ["found"] = 0, ["accepted"] = 0, ["rejected"] = 0 };// 1. Pull from the watermark, only orders waiting for the ERP, and stage every line.while (true){var page = await api.GetAsync("/orders", new Dictionary<string, object>{["filter[status]"] = "ERP_PENDING", ["filter[version_after]"] = staging.Watermark, ["per_page"] = 100,});foreach (var order in page["data"]!.AsArray()){var id = order!["id"]!.GetValue<string>();var version = order["version"]!.GetValue<long>();var lines = order["lines"]!.AsArray().Select(l => l!["line_number"]!.GetValue<int>()).DefaultIfEmpty(0);foreach (var line in lines)staging.Lines.TryAdd($"{id}|{version}|{line}", new StagedLine { OrderId = id, Version = version, Document = order.ToJsonString() });result["pulled"]++;}staging.Watermark = Math.Max(staging.Watermark, page["meta"]!["next_version_after"]!.GetValue<long>());StateFiles.Save("staging-cs.json", staging); // the watermark moves only once its page is stagedif (!page["meta"]!["has_more"]!.GetValue<bool>()) break;}// 2. Book each staged order once: find before create, then ack.var unbooked = staging.Lines.Values.Where(l => !l.Booked).GroupBy(l => (l.OrderId, l.Version)).OrderBy(g => g.Key.Version);foreach (var group in unbooked){var order = JsonNode.Parse(group.First().Document)!;var account = order["customer"]!["external_ref"]?.GetValue<string>() ?? order["customer"]!["code"]!.GetValue<string>();object answer;if (FakeErp.OnHold(account)){answer = new { outcome = "REJECTED", reason_code = "CUSTOMER_ON_HOLD", message = $"Customer {account} is on hold in the ERP" };result["rejected"]++;}else{var number = erp.FindSalesOrder(group.Key.OrderId);if (number is null) { number = erp.CreateSalesOrder(group.Key.OrderId); result["created"]++; }else result["found"]++;answer = new { outcome = "ACCEPTED", external_ref = number, external_number = number };result["accepted"]++;}try{await api.PostAsync($"/orders/{group.Key.OrderId}/ack", answer, $"{group.Key.OrderId}@{group.Key.Version}");}catch (ApiErrorException refused) when (refused.Code is "ALREADY_ACKNOWLEDGED" or "ORDER_NOT_AWAITING_ACK"){// Answered elsewhere, or no longer waiting: nothing to do.}foreach (var line in group) line.Booked = true;StateFiles.Save("staging-cs.json", staging);}return result;}} -
Book one sales order per Slflo order — find before you create.
Put the Slflo order
idin a field of the sales order you can search by (in Dynamics 365,CustomersOrderReference). Before creating, look for a sales order that already carries it. If the job died after creating the sales order but before the ack, the next run finds it instead of creating a twin. Take the account fromcustomer.external_ref— your own key — and each line’s item fromproduct.external_ref.pull_orders.py — book and ack def book(api, db, erp):result = {"created": 0, "found": 0, "accepted": 0, "rejected": 0, "already_answered": 0}staged = db.execute("select distinct order_id, version, document from order_line where booked = 0 order by version").fetchall()for order_id, version, document in staged:order = json.loads(document)account = order["customer"]["external_ref"] or order["customer"]["code"]if fake_erp.on_hold(account):answer = {"outcome": "REJECTED", "reason_code": "CUSTOMER_ON_HOLD","message": f"Customer {account} is on hold in the ERP"}result["rejected"] += 1else:# Find before create: a run that died after creating the sales order but# before the ack finds it here instead of creating a twin.number = fake_erp.find_sales_order(erp, order["id"])if number:result["found"] += 1else:number = fake_erp.create_sales_order(erp, order["id"], account, order["totals"]["gross"])result["created"] += 1answer = {"outcome": "ACCEPTED", "external_ref": number, "external_number": number}result["accepted"] += 1try:# The key names the order and the version answered, so a retry is a replay# and an order sent again (a new version) gets a fresh answer.api.post(f"/orders/{order_id}/ack", answer, key=f"{order_id}@{version}")except ApiError as refused:if refused.code not in ("ALREADY_ACKNOWLEDGED", "ORDER_NOT_AWAITING_ACK"):raiseresult["already_answered"] += 1 # answered elsewhere, or no longer waitingdb.execute("update order_line set booked = 1 where order_id = ? and version = ?", (order_id, version))db.commit()return result -
Ack every order you booked — or refused.
ACCEPTED with your sales order number moves the order to
ERP_CONFIRMEDand stores your number on it. REJECTED moves it toERP_FAILEDand shows yourmessageto the office. Use"<order id>@<version>"as theIdempotency-Key: a retry of a lost answer replays, and an order sent again (a new version) gets a fresh answer.POST
/orders/ord_01J9X/ackنافذة طرفيّة curl -X POST 'https://api.slflo.com/api/integration/v1/orders/ord_01J9X/ack' \-H 'Authorization: Bearer $SLFLO_TOKEN' \-H 'Idempotency-Key: ord_01J9X@18342' \-H 'Content-Type: application/json' \-d '{"outcome":"ACCEPTED","external_ref":"SO-0012345","external_number":"SO-0012345"}'Response
HTTP 200{"data": {"id": "ord_01J9X","number": "SO-000123","status": "ERP_CONFIRMED","version": 18343,"result": "applied","ack": {"outcome": "ACCEPTED","external_ref": "SO-0012345","external_number": "SO-0012345","acked_version": 18343,"acked_at": "2026-10-05T10:42:00Z"}}}
Run it
Section titled “Run it”export SLFLO_TOKEN='sfi_live_…' SLFLO_BASE='https://api.slflo.com/api/integration/v1'python3 pull_orders.pyThe last line is what the run did. With two orders waiting for your ERP:
{"accepted": 2, "already_answered": 0, "created": 2, "found": 0, "mode": "run", "pulled": 2, "rejected": 0}Run it again straight away and it pulls nothing and books nothing. On the hosted sandbox the first run pulls nothing either — the day’s two orders are already delivered — until we place a new order for you (what needs us).
A refused order, sent again, then accepted
Section titled “A refused order, sent again, then accepted”Your ERP will sometimes refuse an order — the account is on credit hold, an item is blocked. Refuse it with a reason the office can act on; don’t leave it unanswered.
-
Your run acks it REJECTED with a
reason_codeand amessage:POST
/orders/ord_01J9X/ackنافذة طرفيّة curl -X POST 'https://api.slflo.com/api/integration/v1/orders/ord_01J9X/ack' \-H 'Authorization: Bearer $SLFLO_TOKEN' \-H 'Idempotency-Key: ord_01J9X@18342' \-H 'Content-Type: application/json' \-d '{"outcome":"REJECTED","reason_code":"CUSTOMER_ON_HOLD","message":"Customer CUST-1002 is on hold in the ERP"}'Response
HTTP 200{"data": {"id": "ord_01J9X","number": "SO-000123","status": "ERP_FAILED","version": 18343,"result": "applied","ack": {"outcome": "REJECTED","reason_code": "CUSTOMER_ON_HOLD","message": "Customer CUST-1002 is on hold in the ERP","external_ref": null,"external_number": null,"acked_version": 18343,"acked_at": "2026-10-05T10:42:00Z"}}} -
On the order page the office sees Refused by the ERP with your message. Once the cause is fixed (the hold lifted), someone allowed to deliver orders chooses Send to ERP again.
-
The order is
ERP_PENDINGagain, at a higher version, so your next pull sees it. -
Your run books it — find-before-create finds nothing, because the refusal created nothing — and acks it ACCEPTED. The key
id@versionis new, so this is a new answer, not a replay.
Try it with the stand-in ERP: put the order’s account on hold, then lift it. ERP_ON_HOLD takes
accounts, comma-separated — the customer’s external_ref, or its Slflo code when your ERP
hasn’t pushed it. The middle step happens in the console — on the
hosted sandbox, ask us to send the order again.
ERP_ON_HOLD=CUST-1002 python3 pull_orders.py # {"rejected": 1, ...}# the office: Send to ERP againpython3 pull_orders.py # {"pulled": 1, "created": 1, ...}When it goes wrong
Section titled “When it goes wrong”| What you see | Why | What to do |
|---|---|---|
409 ERP_MODE_NOT_API on every ack |
The company isn’t in ERP mode API, so orders don’t wait for you | Ask us to switch it; until then read invoices instead (by seq) |
| No orders arrive, though reps sell | Van sales never wait for the ERP — they’re delivered and invoiced on the spot | Read them as invoices; only pre-sale orders come through here |
409 ALREADY_ACKNOWLEDGED |
You answered this order before, differently | Treat it as done. A new answer is taken only after the office sends it again |
409 ORDER_NOT_AWAITING_ACK |
Someone answered it (another job, a manual call), or it was cancelled | Nothing to book. Pull filter[ack]=pending for what still waits |
409 EXTERNAL_REF_CONFLICT |
Your sales order number is already on another Slflo order | Each record answers to one number of yours. Look for two jobs writing to the same sequence |
401 mid-run |
The token was revoked or expired | Stop and alert. The next run with a good token finds what this one created (rotation) |
| The job died after creating sales orders | Power cut, deployment | Just run again: find-before-create finds them and acks them |
| Staging purged, or the ERP database restored | The watermark points past orders you never booked | Recover from filter[ack]=pending |
The rest of the sample
Section titled “The rest of the sample”The stand-in for your ERP, which you replace with your ERP’s own calls:
"""A stand-in for your ERP's books, so the samples run end to end.
Replace these functions with your ERP's own calls (D365 services, SAP BAPIs, Odoo RPC ...).It keeps sales orders and journals in STATE_DIR/erp.db. Accounts listed in ERP_ON_HOLD(comma-separated) are refused, the way a credit department's hold refuses them."""import osimport sqlite3
STATE_DIR = os.environ.get("STATE_DIR", "state")
def connect(): os.makedirs(STATE_DIR, exist_ok=True) db = sqlite3.connect(os.path.join(STATE_DIR, "erp.db")) db.executescript( """ create table if not exists sales_order ( number integer primary key autoincrement, customer_reference text not null unique, -- the Slflo order id account text not null, total text not null ); create table if not exists journal ( number integer primary key autoincrement, kind text not null, -- invoice, payment, return, payment-reversal source_id text not null, source_version integer not null, seq integer, amount text not null, unique (kind, source_id, source_version) ); """ ) return db
def on_hold(account): return account in {a.strip() for a in os.environ.get("ERP_ON_HOLD", "").split(",") if a.strip()}
def find_sales_order(db, customer_reference): row = db.execute("select number from sales_order where customer_reference = ?", (customer_reference,)).fetchone() return f"SO-{row[0]:07d}" if row else None
def create_sales_order(db, customer_reference, account, total): cursor = db.execute( "insert into sales_order (customer_reference, account, total) values (?, ?, ?)", (customer_reference, account, total), ) db.commit() return f"SO-{cursor.lastrowid:07d}"
def post_journal(db, kind, source_id, source_version, amount, seq=None): """One journal per document version; posting the same version again returns the first.""" row = db.execute( "select number from journal where kind = ? and source_id = ? and source_version = ?", (kind, source_id, source_version), ).fetchone() if row is None: row = ( db.execute( "insert into journal (kind, source_id, source_version, seq, amount) values (?, ?, ?, ?, ?)", (kind, source_id, source_version, seq, amount), ).lastrowid, ) db.commit() return f"JRN-{row[0]:06d}"
def journal_of(db, kind, source_id): row = db.execute( "select number from journal where kind = ? and source_id = ? order by source_version limit 1", (kind, source_id), ).fetchone() return f"JRN-{row[0]:06d}" if row else None
def booked_seqs(db, kind): return [r[0] for r in db.execute("select distinct seq from journal where kind = ? and seq is not null order by seq", (kind,))]The complete connector — orders, invoices, payments, returns and master data in one program — is the Kotlin reference connector.

