Skip to main content

ERP and accounting sync

Sokisoko pushes confirmed orders and issued invoices out to your ERP and takes stock and master data in, over signed, idempotent connections. Connections are set up under Settings → ERP sync by an admin; this page is for the person on the other end.

Providers​

ProviderDirectionAuthNeeds platform credentials
Custom webhook (generic_webhook)Orders and invoices out; inventory inHMAC-SHA256 with a per-connection secretNo
OdooOrders out; customers, products, stock and prices inAPI keyNo
SAP S/4HANA (OData) and SAP Business One (Service Layer)Orders out; products, stock per plant/warehouse, business partners and prices in; live availability and price at checkoutClient credentials / sessionNo
QuickBooks Online, Xero, Dynamics 365 Business CentralInvoices and customers, with app-level inbound webhooksOAuth 2 consentYes — see Configuration

Any system with an HTTP endpoint can integrate through the custom webhook; the named providers wrap each system's own API.

Outbound: the custom webhook​

Once a connection has an endpoint and a secret, the hourly sweep (or Sync now in the console) pushes each confirmed order and issued invoice that has not been delivered yet:

POST <your endpoint> HTTP/1.1
Content-Type: application/json
X-Webhook-Signature: <hex HMAC-SHA256 of the body, keyed with the connection secret>
Idempotency-Key: <stable 32-char key derived from entity + operation>

{ …the order or invoice document… }
  • The signature is verified exactly as for webhooks; X-Sokisoko-Signature is sent too for older consumers.
  • The idempotency key is the same every time the same document is pushed, so a retry after a timeout must not create a second record on your side.
  • Answer 2xx. If your response body is JSON with an external_id, it is stored as the record's external reference and shown in the console's provenance panel. Any other status marks the push failed and it is retried on the next sweep.
  • Every attempt is logged under Settings → ERP sync → sync log with direction, entity, operation, status and error — that log shows you the exact document body a connection sends.

Inbound: stock from your system​

Each connection has an inbound URL the ERP can call:

POST /webhooks/erp/{connectionID} HTTP/1.1
Content-Type: application/json
X-Webhook-Signature: <hex HMAC-SHA256 of the body, keyed with the connection secret>

{ "event_id": "wh-2026-09-05-00042", "entity_type": "inventory", "sku": "M12-HEX-40", "quantity_on_hand": "1450" }
  • event_id and entity_type are required. The event is deduplicated on event_id once applied successfully, so redelivering is safe; a failed apply is not deduplicated and will be retried when you resend.
  • entity_type: "inventory" sets the product's on-hand quantity by SKU. Other entity types are accepted and logged as skipped.
  • If the connection has a secret, unsigned or badly signed calls are refused with 401 bad_signature. Set a secret.

The OAuth providers use a single app-level inbound URL instead (/webhooks/erp/provider/{provider}) verified with the provider's own signature scheme; their events are routed to the right connection by the source account in the body.

Agent push: an ERP we cannot reach​

When the ERP only answers inside the customer's network, an agent runs beside it and pushes what it reads. This is how the SokiSoko ERP Bridge feeds SAP Business One. There is no inbound firewall rule, and no ERP credential ever leaves the customer's network — the agent holds it.

POST /admin/erp/connections/{connectionID}/ingest HTTP/1.1
Authorization: Bearer tgk_… # API key scoped to erp.manage, nothing else
Content-Type: application/json

{
"products": [{ "ExternalID": "A100", "SKU": "A100", "Name": "Hex bolt M12×40", "BaseUnit": "EA",
"Attributes": { "barcode": "6001234567890", "item_group": "104", "brand": "Unbrako", "pack_size": "100" } }],
"stock": [{ "SKU": "A100", "ExternalID": "A100", "Plant": "01", "QuantityOnHand": "1450" }],
"prices": [{ "SKU": "A100", "Currency": "KES", "Amount": "12.50", "MinQty": "1" }],
"customers":[{ "ExternalID": "C0042", "Name": "Acme Works", "TaxID": "P051…" }],
"codes": [{ "Kind": "warehouse", "Code": "01", "Name": "Main store" },
{ "Kind": "item_group", "Code": "104", "Name": "Fasteners" },
{ "Kind": "manufacturer", "Code": "7", "Name": "Unbrako" },
{ "Kind": "supplier", "Code": "V1004", "Name": "Totem Fasteners Ltd" }],
"cursors": { "product": "2026-09-14T08:15:00Z", "stock": "2026-09-14T08:15:00Z" },
"agent": { "Version": "1.4.0", "Error": "" }
}

The full schema is in the API reference under adminErpIngest. What the agent must get right:

  • Everything is a string. Quantities and money cross as decimal strings so nothing is lost to float rounding.
  • Field names are matched case-insensitively but underscores are not ignored. SKU, sku and Sku are one field; ExternalID and externalID are one field; external_id is not.
  • ExternalID is the join key, not the SKU. A renamed SKU still finds its product.
  • Cursors mark your place. Send the highest watermark you read per entity (product, customer, stock, price). We store it and only advance one whose records all applied, so a rejected batch leaves it where it was. On SAP Business One that watermark is UpdateDate/UpdateTime.
  • A batch is all-or-nothing. Any record that fails rejects the batch with 502 and moves no cursor: resend the same batch unchanged. Applying is idempotent, so a resend after a timeout cannot double anything.
  • codes is what makes mapping a pick. Without it staff map bare numbers. Send the warehouse, item-group, manufacturer and supplier lists on every run, or at least daily — they are small. A mapped item group files the product into that category; the manufacturer name reaches the product as its brand and the supplier as its supplier.
  • agent is the heartbeat. Push every interval even when there is nothing to send: the empty batch is how a healthy bridge stays visible. If the ERP refused you, put its own message in Error and push anyway — that message is what the store's admins see. It is logged once when it starts and once when it clears, and half an hour of total silence notifies them.
  • Keep the API key narrow. erp.manage only, one key per bridge, rotated like any other.

Outbound the same way: the agent pulls​

An ERP we cannot reach cannot be pushed to either, so the agent pulls what has to go the other way — buyers, orders, invoices — and reports what the ERP made of each:

GET  /admin/erp/connections/{connectionID}/outbox?limit=50
POST /admin/erp/connections/{connectionID}/outbox/ack
{ "items": [ { "entity_type": "order", "entity_id": 8812, "external_id": "SO-100" },
{ "entity_type": "invoice", "entity_id": 44, "error": "B1: period is closed" } ] }
  • Documents come in dependency order: a buyer before the order that names it. An order names its buyer by customer_id; when that buyer is in the same batch, create it first and use the id you gave it.
  • A document keeps being offered until it is acked with an external_id, so pulling twice is safe and a crash loses nothing. Acking with an error instead leaves it queued and puts the ERP's own words in the sync log, where the store's staff can read them.
  • idempotency_key is stable across pulls. Carry it into the ERP — SAP Business One's NumAtCard is the natural home — so a retry after a timeout cannot create a second order.
  • Nothing is offered twice to the same connection once acked, and an ack for another store's record is ignored.

The sales trail: quote in the ERP, Sokisoko follows​

When the ERP is where staff price and invoice, the request goes to it and every document it becomes comes back. Nothing reaches the buyer by itself: the quote is delivered when a person presses Send in Sokisoko.

  1. Outbox offers the request. A submitted request is listed as an rfq document after the buyer it names: reference (RFQ-…), customer_external_id, lines[] with item_external_id, quantity, unit, notes, and the buyer's free text in notes. The agent raises it in the ERP — on SAP Business One a draft Sales Quotation (Drafts, DocObjectCode: oQuotations) with NumAtCard = the reference — and acks the id.
  2. Ingest documents[]. Each carries Kind (quotation, order, delivery, return, invoice, credit_note, payment), ExternalID (DocEntry), Number (DocNum), Reference (NumAtCard), CustomerExternalID (CardCode), Status, dates, Currency, totals, BaseKind + BaseExternalID (the document it was made from), Lines[] and, for payments, Method and Applied[] (InvoiceExternalID, Amount). A quotation may add ContactPhones[] and ContactEmails[], the people the ERP would send it to: they join the quote's recipients, so pressing Send in Sokisoko reaches them on WhatsApp and by email without anyone pasting. Poll documents by UpdateDate/UpdateTime like master data. The SAP bridge starts on the day it is installed; to backfill, put "document": "YYYY-MM-DD" in its cursors.json before starting it. In its read-only direct-SQL mode it sends orders, deliveries, invoices and payments, and raises nothing in the ERP, so requests and quotations need the Service Layer.
  3. What each becomes. A quotation whose Reference is one of our requests prices that request's draft quote (lines replaced, subtotal set) and alerts the quote desk: Priced in SAP — press Send. A quotation with no request becomes a draft for that customer. An order becomes an order (its base quotation's quote and request move to accepted); a delivery a shipment recorded as delivered (the ERP never reports an arrival), carrying the ShipTo address, the TrackingNumber (or the document number) and the warehouse of its first line; an invoice an invoice the buyer sees in the portal, on its order or, for a sale invoiced with no order, on the customer; a payment a captured payment that marks the invoice paid; a return a received return on its order; a credit note a credit note on its invoice (or return), taken off revenue. An invoice's Paid (paid to date, credits included) keeps its balance right: what is captured here is the larger of Paid and the payments mirrored, so nothing is counted twice. open_invoices_complete marks the end of a full pass over the ERP's open invoices; imported STATEMENT-<code> balances for customers the ERP holds are then void. movements[] records the ERP's stock journal (ID = SAP TransNum), each line once. An order keeps ShipTo and BillTo as its delivery and billing addresses, or the customer's own address when the document has none. Mirrored invoices are not chased by Sokisoko's overdue reminders: the ERP holds the receivable. Only an order made from one of our requests alerts the team (in the app and by email); other ERP sales and payments do not.
  4. Safe to resend. Every mirrored record keeps an external reference, so a document sent twice updates once. A quote already sent or accepted is never re-priced from behind; an unknown customer or item logs the document as skipped in the sync log and the batch continues.

Master data, authority and backfill​

For providers that sync master data (Odoo, SAP), inbound changes carry only an id; the connector fetches the record and upserts it by external reference. Two settings shape this, both in the console:

  • Sync policy — erp_authoritative (default) lets inbound updates overwrite local records; sokisoko_authoritative keeps local edits and only imports net-new records. A staff member can also claim or release authority per product or customer from the record's provenance panel.
  • Backfill — pull existing ERP master data into Sokisoko when connecting, through the same path as live events.
  • Plant → warehouse map (SAP) — routes polled stock per plant to the matching Sokisoko warehouse.

At checkout, SAP connections can answer live availability and per-customer price; the storefront falls back to the local mirror on timeout.

Accounting systems: payments and credit notes out, paid status in​

For quickbooks and xero connections the outbound sweep pushes, after the invoice, every captured payment on it (entity: payment, with invoice_external_id, amount, reference, date) and every credit note (entity: credit_note). QuickBooks receives a Payment linked to the invoice and a CreditMemo; Xero receives a Payment into the configured bank account (XERO_PAYMENT_ACCOUNT_CODE, default 090) and an authorised credit note, and Xero invoices are pushed authorised. Payments whose gateway equals the connection's provider are skipped, so money the accounting system recorded itself never bounces back.

Providers that implement BalancePoller are asked, on the hourly inbound poll or POST /admin/erp/connections/{id}/pull, what is still owed on the invoices pushed to them (ListSyncedOpenInvoices). An invoice reported settled is paid here through the same applyPaymentDoc path an ERP's incoming payment takes, keyed paid:<external id>, so a repeated answer books nothing twice.

Invoices and credit notes also carry KRA eTIMS fields (etims_status, receipt number, signature) when the tenant has registered a device; see the admin guide for what the signing job does.