GETwarehousesLists the depots and vans, with their codes.
PUTproductsCreates or updates up to 500 products by code.
PUTpricesReplaces one price list's prices and quantity bands, whole.
PUTstockSets what up to 500 warehouses and vans hold.
Customers are not carried. Warehouses, taxes, categories and currencies are set up in the portal and named here by code.
Sync API v1
Samples in
Contents
Quick start
Create a key
A company administrator opens Settings › Integrations in the portal and chooses Create key, naming it after the connector that will use it. The key is shown once: copy it into the connector's settings. Lost keys can't be shown again, so revoke it and create another.
Prepare the portal
Give every tax a code in Settings › Taxes: products name their tax by it, and Settings › Integrations lists the taxes still without one. On the same Integrations page, choose a default supplier for new products (the sync can't create products without one), and check the default unit type and swap type. Categories, warehouses and currencies the ERP names must already exist.
Check the key
Call GET ping. It answers with the company's name and the key's name, so a connector can show "Connected to Cailo Distribution".
Map your sites
Call GET warehouses once and keep a mapping from each ERP site to a SalesBlitz warehouse code in the connector's settings.
Sync, in this order
Send products, then each price list, then stock: prices and stock name products that must already exist. Hourly is the expected rhythm; send more often if the ERP changes more often.
The helper every sample uses
The endpoint samples below call this small helper. It sends the key, turns the body into JSON, and returns the envelope's data, raising an error when the key is refused or the whole call is. Java uses the built-in HttpClient and Jackson; Node.js needs nothing beyond version 18; Python uses requests.
SalesBlitz.java
importcom.fasterxml.jackson.databind.JsonNode;importcom.fasterxml.jackson.databind.ObjectMapper;importjava.io.IOException;importjava.net.URI;importjava.net.http.HttpClient;importjava.net.http.HttpRequest;importjava.net.http.HttpResponse;importjava.time.Duration;/** A thin client for the SalesBlitz sync API. Java 17+, Jackson 2. */publicclassSalesBlitz{/** SalesBlitz refused the whole call; the message says why. */publicstaticclassRefusedextendsRuntimeException{publicRefused(String message){super(message);}}privatestaticfinalStringBASE="https://salesblitz.app/api/v1/sync/";privatefinalHttpClient http =HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();privatefinalObjectMapper json =newObjectMapper();privatefinalString key;publicSalesBlitz(String key){this.key = key;// sbk_ and 40 letters and digits}/** One call; returns the envelope's data. */publicJsonNodecall(String method,String path,Object body)throwsIOException,InterruptedException{HttpRequest request =HttpRequest.newBuilder(URI.create(BASE+ path)).timeout(Duration.ofSeconds(120)).header("Authorization","Bearer "+ key).header("Content-Type","application/json").header("Accept","application/json").method(method, body ==null?HttpRequest.BodyPublishers.noBody():HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body))).build();HttpResponse<String> response = http.send(request,HttpResponse.BodyHandlers.ofString());if(response.statusCode()>=500){thrownewIOException("SalesBlitz answered "+ response.statusCode()+"; send the call again.");}JsonNode envelope = json.readTree(response.body());if(response.statusCode()==401){thrownewSecurityException("Key refused: "+ envelope.path("message").asText());}if(!envelope.path("response").asBoolean()){thrownewRefused(envelope.path("message").asText());}return envelope.path("data");}}
salesblitz.mjs
// A thin client for the SalesBlitz sync API. Node 18+ (built-in fetch), no packages.constBASE='https://salesblitz.app/api/v1/sync/';constKEY= process.env.SALESBLITZ_KEY;// sbk_ and 40 letters and digits/** SalesBlitz refused the whole call; the message says why. */exportclassRefusedextendsError{}/** One call; resolves to the envelope's data. */exportasyncfunctioncall(method, path, body){const response =awaitfetch(BASE+ path,{
method,headers:{Authorization:`Bearer ${KEY}`,'Content-Type':'application/json',Accept:'application/json',},body: body ===undefined?undefined:JSON.stringify(body),signal: AbortSignal.timeout(120_000),});if(response.status >=500){thrownewError(`SalesBlitz answered ${response.status}; send the call again.`);}const envelope =await response.json();if(response.status ===401){thrownewError(`Key refused: ${envelope.message}`);}if(!envelope.response){thrownewRefused(envelope.message);}return envelope.data;}
salesblitz.py
"""A thin client for the SalesBlitz sync API. Python 3.9+, requests."""import os
import requests
BASE ="https://salesblitz.app/api/v1/sync/"
session = requests.Session()
session.headers.update({"Authorization":f"Bearer {os.environ['SALESBLITZ_KEY']}",# sbk_ and 40 letters and digits"Accept":"application/json",})classRefused(Exception):"""SalesBlitz refused the whole call; the message says why."""defcall(method, path, body=None):"""One call; returns the envelope's data."""
response = session.request(method, BASE + path, json=body, timeout=(10,120))if response.status_code >=500:raise requests.HTTPError(f"SalesBlitz answered {response.status_code}; send the call again.")
envelope = response.json()if response.status_code ==401:raise PermissionError(f"Key refused: {envelope['message']}")ifnot envelope["response"]:raise Refused(envelope["message"])return envelope["data"]
Give it the key through the environment (SALESBLITZ_KEY), never in source control.
Authentication
Every request carries the key as a bearer token. The key identifies the company, so no request names one.
Request header
Authorization: Bearer sbk_7Hq2xKfP…
Format
sbk_ followed by 40 letters and digits. The portal lists each key by its first 12 characters, such as sbk_7Hq2xKfP.
Reach
The sync endpoints under /api/v1/sync/ and nothing else, for one company only. A portal sign-in token doesn't work here, and a key works nowhere else.
Storage
SalesBlitz keeps only a fingerprint of the key, never the key itself.
Revoking
Immediate: the next call with a revoked key gets HTTP 401. Revoke a key from Settings › Integrations.
Last used
Every accepted call updates the key's Last used time in the portal, so unused keys are easy to spot.
When the key is refused
The answer is HTTP 401 with the usual envelope. Don't retry: nothing will change until the key does.
Message
Why
Unknown or revoked integration key.
No Authorization: Bearer … header, a token that isn't a key, a key that was never issued, or a revoked one.
This key's company is no longer active.
The key is valid but its company has been deactivated in SalesBlitz.
HTTP 401 body
{"response":false,"message":"Unknown or revoked integration key.","data":null}
Conventions
JSON in, JSON out
Send UTF-8 JSON with Content-Type: application/json. Every answer is the same envelope, and data holds what the endpoint returns.
"response": false means the whole call was refused and message says why: a batch over the limit, a price list in an unknown currency. See Errors and refusals.
Named by your codes
Everything is matched by code, never by SalesBlitz's internal ids: product code, warehouse code, price list code, tax code and the currency's ISO code. Codes are compared ignoring case and surrounding spaces, so "ck330 " is CK330. A blank string counts as no value.
Every call sets state
A call says how things are now ("this product looks like this", "this warehouse holds 240"), not what to change. Sending the same call again leaves things as the first one did, and its rows come back UNCHANGED. So a call that timed out is simply sent again: no retry bookkeeping, no idempotency keys.
Batches
At most 500 rows a call: products, stock levels, or one price list's prices. Split products and stock into as many calls as you need. A price list is the exception: it is sent whole in one call (see prices).
Money and quantities are strings
Send "0.50", "240", "12.5", not numbers, so no precision is lost between systems. Prices include VAT and have at most 2 decimals; stock quantities have at most 4 (kilograms and litres). minQuantity, a count of units, is a plain integer.
Leaving a field out isn't sending null
For an existing product, a field you leave out is left alone, while null (or "") clears an optional field. Send only what the ERP knows; never fill gaps with nulls.
Row results
The three PUT calls answer with one result per row, in the order sent. Each row is saved on its own: a rejected row never stops or undoes the others.
code is the row's code: the product code, or "WH01/CK330" (warehouse, then product) for a stock level. message is set on REJECTED rows and says what to fix; it also explains a product taken off a price list and an older stock level that was ignored. Otherwise it is null.
CREATED
Something new was made.
Products: a new product.
Prices: the product had no prices on this list before.
Stock: never.
UPDATED
What you sent differed from SalesBlitz, which now matches it.
Products: a field changed.
Prices: bands changed, or "Taken off the list".
Stock: a difference was posted, or the product was allowed in the warehouse.
UNCHANGED
It already matched; nothing was written.
Stock also answers this for a level older than one already applied, with a message.
REJECTED
Not saved. message says what to fix; the row keeps its old values.
Fix the data (often in the portal) and send it again with the next sync.
GET
/api/v1/sync/ping
Checks the key and tells you whose it is. Use it for a connector's "Test connection" button.
SalesBlitz sync =newSalesBlitz(System.getenv("SALESBLITZ_KEY"));JsonNode who = sync.call("GET","ping",null);System.out.println("Connected to "+ who.path("company").asText()+" as '"+ who.path("key").asText()+"'");
uses salesblitz.mjs
import{ call }from'./salesblitz.mjs';const who =awaitcall('GET','ping');
console.log(`Connected to ${who.company} as '${who.key}'`);
uses salesblitz.py
from salesblitz import call
who = call("GET","ping")print(f"Connected to {who['company']} as '{who['key']}'")
GET
/api/v1/sync/warehouses
The company's active warehouses, depots and vans, sorted by name. Warehouses are set up in the portal, not synced; this tells the connector which codes to use.
Field
Meaning
codestring
The code stock levels name in warehouseCode.
namestring
The warehouse's name in the portal.
vanboolean
true when the warehouse belongs to a seller: a van.
// Map each ERP site to one of these codes once, in your connector's settings.for(JsonNode w : sync.call("GET","warehouses",null)){String kind = w.path("van").asBoolean()?"van, "+ w.path("seller").asText():"depot";System.out.printf("%-8s %-24s %s%n", w.path("code").asText(), w.path("name").asText(), kind);}
uses salesblitz.mjs
// Map each ERP site to one of these codes once, in your connector's settings.for(const w ofawaitcall('GET','warehouses')){const kind = w.van ?`van, ${w.seller}`:'depot';
console.log(w.code.padEnd(8), w.name.padEnd(24), kind);}
uses salesblitz.py
# Map each ERP site to one of these codes once, in your connector's settings.for w in call("GET","warehouses"):
kind =f"van, {w['seller']}"if w["van"]else"depot"print(f"{w['code']:<8}{w['name']:<24}{kind}")
PUT
/api/v1/sync/products
Creates or updates products, matched by code. The body is an array of up to 500 products; the answer is one result per product.
Field
Rules
codestringrequired
The ERP's code, 3 to 255 characters. It is how the product is found, so it can't be changed through the sync: a different code is a different product. A new product's code can't be one a surtax already has on Fiscalizer.
namestringnew products
4 to 255 characters. Required for a new product; optional afterwards.
taxCodestringnew products
A tax's code, as set in Settings › Taxes. An unknown code rejects the row.
barcodestring or nulloptional
Any text. null clears it.
categorystring or nulloptional
An existing category's name or code (Settings › Product categories); an unknown one rejects the row. Categories are registered on Fiscalizer with their own code, so they are set up in the portal, like taxes. null clears it.
hsCodestring of digits or nulloptional
4 or 8 digits, the portal's rule. A zero-rated or exempt product needs 8 when the company is VAT registered or fiscalizes (ZIMRA), and a fiscalizing company needs one on every product. null clears it.
activebooleanoptional
false makes the product unsellable: off the handsets, the cart and the price lists, but kept in history and stock. true makes it sellable again.
Needs a name and a taxCode. What this API doesn't carry comes from the company's defaults on Settings › Integrations: the supplier, the unit type (PALLET, CASE, INNER_PACK or UNIT) and the swap type. It starts sellable, as one unit, with a reorder level of 100 and a maximum of 100,000, all of which the portal can change and the sync never touches again.
No default supplier, no new products
Until Settings › Integrations names a default supplier, every new product is rejected with "Set a default supplier in Settings › Integrations before the sync can create products." Updates to existing products still go through.
An existing product
Only the fields you send change: a field left out is left alone, and null clears an optional one. A product missing from a batch is never touched, and the sync never deletes a product: send "active": false to stop it being sold.
Fiscalization
New products, and changes to a name, tax, category or HS code, reach Fiscalizer through SalesBlitz's scheduled batch push, not one call per row, so a sync stays fast however many products it carries.
uses SalesBlitz.java
List<Map<String,Object>> batch =newArrayList<>(List.of(Map.of("code","CK330","name","Coke 330ml","taxCode","VAT","barcode","6001234567890","category","Soft drinks","hsCode","22021000","active",true),Map.of("code","FN500","name","Fanta Orange 500ml","taxCode","VAT")));// null clears an optional field (Map.of refuses nulls, so use a HashMap).Map<String,Object> noBarcode =newHashMap<>();
noBarcode.put("code","SP330");
noBarcode.put("barcode",null);
batch.add(noBarcode);for(JsonNode r : sync.call("PUT","products", batch)){System.out.printf("%-8s %-9s %s%n", r.path("code").asText(), r.path("status").asText(),
r.path("message").asText(""));}
results = call("PUT","products",[{"code":"CK330","name":"Coke 330ml","taxCode":"VAT","barcode":"6001234567890","category":"Soft drinks","hsCode":"22021000","active":True},{"code":"FN500","name":"Fanta Orange 500ml","taxCode":"VAT"},{"code":"SP330","barcode":None},# None clears an optional field])for r in results:print(f"{r['code']:<8}{r['status']:<9}{r['message']or''}")
PUT
/api/v1/sync/prices
Replaces one price list's prices. The body is one price list; the answer is one result per product.
The price list
Field
Rules
priceListCodestringrequired
The list's code. A code SalesBlitz doesn't know yet makes a new list.
namestringnew lists
Required the first time. On an existing list, a different name renames it.
currencystringrequired
An active currency's ISO code, such as USD or ZWG. An existing list keeps its currency: naming another refuses the call.
pricesarrayrequired
Up to 500 rows: the whole list.
Each row of prices
Field
Rules
productCodestringrequired
An existing product's code.
minQuantityintegeroptional
Where this price's band starts, 1 to 999,998. Defaults to 1.
pricestringrequired
The unit price including VAT, as everywhere in SalesBlitz. Greater than zero, at most 2 decimals.
{"response":true,"message":"Operation Successful","data":[{"code":"CK330","status":"UPDATED","message":null},{"code":"FN500","status":"CREATED","message":null},{"code":"SP330","status":"UPDATED","message":"Taken off the list"}]}
Quantity bands
A product's rows are its complete band set. Each band runs from its minQuantity up to one below the next band's, and the top band is open-ended. The lowest band must start at 1, and two rows for the same product and minQuantity reject that product.
CK330
0.501 to 23
0.4524 and up
FN500
0.801 and up
Replace means replace
A product on the list but not in prices is taken off it, and comes back UPDATED with the message "Taken off the list". So send each list whole, in one call: never split one list across calls, and an empty prices array empties the list. A rejected product keeps its old prices and stays on.
A new list
Starts today with no end date and applies to everyone. Narrow it in the portal by area, seller, customer type or dates: the ERP owns the numbers, the portal owns who a list applies to.
Results
One result per product, in the order products first appear in prices, followed by one for each product taken off the list. CREATED means the product had no prices on this list before. When the company's default list changes a product's unit price, Fiscalizer receives it through the scheduled batch push.
uses SalesBlitz.java
Map<String,Object> retail =Map.of("priceListCode","RETAIL","name","Retail",// needed only when the list is new"currency","USD","prices",List.of(Map.of("productCode","CK330","price","0.50"),// 1 to 23Map.of("productCode","CK330","minQuantity",24,"price","0.45"),// 24 and upMap.of("productCode","FN500","price","0.80")));for(JsonNode r : sync.call("PUT","prices", retail)){System.out.printf("%-8s %-9s %s%n", r.path("code").asText(), r.path("status").asText(),
r.path("message").asText(""));}
uses salesblitz.mjs
const results =awaitcall('PUT','prices',{priceListCode:'RETAIL',name:'Retail',// needed only when the list is newcurrency:'USD',prices:[{productCode:'CK330',price:'0.50'},// 1 to 23{productCode:'CK330',minQuantity:24,price:'0.45'},// 24 and up{productCode:'FN500',price:'0.80'},],});for(const{ code, status, message }of results){
console.log(code.padEnd(8), status.padEnd(9), message ??'');}
uses salesblitz.py
results = call("PUT","prices",{"priceListCode":"RETAIL","name":"Retail",# needed only when the list is new"currency":"USD","prices":[{"productCode":"CK330","price":"0.50"},# 1 to 23{"productCode":"CK330","minQuantity":24,"price":"0.45"},# 24 and up{"productCode":"FN500","price":"0.80"},],})for r in results:print(f"{r['code']:<8}{r['status']:<9}{r['message']or''}")
PUT
/api/v1/sync/stock
Sets how much each warehouse holds. The body is one object with up to 500 levels; the answer is one result per level.
Field
Rules
asOfstringoptional
When the ERP read these figures: ISO 8601 with its offset, such as 2026-10-08T14:00:00+02:00 or 2026-10-08T12:00:00Z. Strongly recommended.
{"response":true,"message":"Operation Successful","data":[{"code":"WH01/CK330","status":"UPDATED","message":null},{"code":"VAN07/CK330","status":"UNCHANGED","message":"Older than the level applied as of 2026-10-08T15:00+02:00."}]}
"The warehouse holds this much now"
SalesBlitz posts one stock adjustment for the difference, with the reason ERP sync, so stock history stays intact and the portal's stock card shows what each sync changed. A level equal to SalesBlitz's posts nothing and comes back UNCHANGED.
228SalesBlitz holds
+
+12adjustment, reason ERP sync
=
240your figure
Every warehouse, vans included
Levels apply to depots and vans alike, even while a seller is out selling: the ERP's figure is taken as the truth. A van that has sold since your ERP read its figure is set to your figure; those sales aren't subtracted. A sync and a van sale never interleave on the same warehouse: the level is read and adjusted under the lock a sale takes.
A product not yet allowed in the warehouse is linked to it automatically, so a product the ERP stocks there can be sold from there.
Late calls can't undo newer figures
With asOf, a level older than the last one applied for that warehouse and product is ignored: it comes back UNCHANGED with "Older than the level applied as of …". A retry that arrives after a newer sync does no harm. Without asOf, every level is applied as it arrives.
Results
Each level's code is "<warehouseCode>/<productCode>". Stock is never CREATED: a level is UPDATED when a difference was posted or the product was linked, UNCHANGED otherwise, or REJECTED.
uses SalesBlitz.java
// When the ERP read these figures: a late resend of an older call is then ignored.String asOf =DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(OffsetDateTime.now().truncatedTo(ChronoUnit.SECONDS));Map<String,Object> stock =Map.of("asOf", asOf,"levels",List.of(Map.of("warehouseCode","WH01","productCode","CK330","quantity","240"),Map.of("warehouseCode","VAN07","productCode","CK330","quantity","36")));for(JsonNode r : sync.call("PUT","stock", stock)){System.out.printf("%-14s %-9s %s%n", r.path("code").asText(), r.path("status").asText(),
r.path("message").asText(""));}
uses salesblitz.mjs
const results =awaitcall('PUT','stock',{asOf:newDate().toISOString(),// when the ERP read these figureslevels:[{warehouseCode:'WH01',productCode:'CK330',quantity:'240'},{warehouseCode:'VAN07',productCode:'CK330',quantity:'36'},],});for(const{ code, status, message }of results){
console.log(code.padEnd(14), status.padEnd(9), message ??'');}
uses salesblitz.py
from datetime import datetime
results = call("PUT","stock",{"asOf": datetime.now().astimezone().isoformat(timespec="seconds"),# when the ERP read these figures"levels":[{"warehouseCode":"WH01","productCode":"CK330","quantity":"240"},{"warehouseCode":"VAN07","productCode":"CK330","quantity":"36"},],})for r in results:print(f"{r['code']:<14}{r['status']:<9}{r['message']or''}")
Errors and refusals
Problems are reported at three levels: the key, the whole call, and each row. Messages are plain sentences meant to be shown to whoever fixes the data.
HTTP
response
Meaning
What to do
200
true
The call ran. Read each row's status.
Fix REJECTED rows and let the next sync send them.
200
false
The whole call was refused before any row was saved.
Fix what message says. Sending it unchanged gets the same answer.
400
false
The body isn't readable JSON, or a field has the wrong type: "Invalid request body." or "Invalid value 'abc' for 'minQuantity'."
Send the same call again. It's safe: every call sets state.
A refused call (HTTP 200)
{"response":false,"message":"At most 500 products per call; this one has 612.","data":null}
Whole-call refusals
Call
Message
products
Send an array of products.
products
At most 500 products per call; this one has 612.
prices
Send one price list.
prices
priceListCode is required.
prices
currency is required.
prices
At most 500 prices per call; this one has 612.
prices
Unknown currency 'ZAR'.
prices
A new price list needs a name.
prices
Price list 'RETAIL' is in USD, not ZWG.
stock
Send { "asOf": …, "levels": [ … ] }.
stock
At most 500 levels per call; this one has 612.
stock
asOf is an ISO 8601 time with its offset, e.g. 2026-10-08T14:00:00+02:00.
Rejected rows
Products
Message
Fix
A product code is 3 to 255 characters.
Send a longer or shorter code.
Set a default supplier in Settings › Integrations before the sync can create products.
Choose a default supplier in the portal.
A new product needs a name and a taxCode.
Send both the first time.
A product name is 4 to 255 characters.
Send a longer or shorter name.
Unknown tax code 'ZR'. Set it on the tax in Settings › Taxes.
Give the tax that code in the portal.
Unknown category 'Snacks'. Add it in Settings › Product categories.
Add the category, or send an existing one's name or code.
An HS code is digits only.
Send digits only.
An HS code must be 4 or 8 digits (ZIMRA requires 8 for zero-rated and exempt lines).
Send 4 or 8 digits.
A 0%/exempt product's HS code must be 8 digits (ZIMRA requires 8 for zero-rated and exempt lines).
Send all 8 digits.
active is true or false.
Send a boolean.
The surtax 'Sugar tax' already has the code … on Fiscalizer. Choose another product code.
The product code clashes with a surtax's; use another.
Prices (one result per product)
Message
Fix
productCode is required.
Every row names its product.
Unknown product 'CK331'.
Sync the product first.
minQuantity is a whole number from 1 to 999998.
Send a band start in range.
price must be a number, e.g. "0.50".
Send the price as a decimal string.
Invalid price, expected a positive number
Prices are greater than zero.
A price can have at most two decimal places
Round to cents.
Two prices for CK330 from quantity 24.
One row per band.
A product's lowest band starts at quantity 1.
Add a row without minQuantity (or with 1).
Stock
Message
Fix
warehouseCode and productCode are required.
Send both.
quantity must be a number, e.g. "240".
Send the quantity as a decimal string.
A stock level is never negative.
Send zero or more.
A stock level has at most 4 decimal places.
Round to 4 decimals.
Unknown warehouse 'WH09'.
Use a code from GET warehouses.
Unknown product 'CK331'.
Sync the product first.
Any row
Message
Fix
Could not be saved (…); send it again.
A passing problem on SalesBlitz's side. The next sync sends it again.
A complete sync job
A job to run every hour from a scheduler. It checks the key, sends products in batches of 500, each price list whole, then stock stamped with asOf, and prints a count of each status with every rejected row.
It sends a call again after a timeout or a server error, waiting a little longer each time, which is safe because every call sets state. It never retries a refusal: a 401 or "response": false needs a person to fix something. The Erp part (erp in Node.js and Python) is yours: it reads the ERP and returns rows in the shapes above.
SyncJob.java
importcom.fasterxml.jackson.databind.JsonNode;importcom.fasterxml.jackson.databind.ObjectMapper;importjava.io.IOException;importjava.net.URI;importjava.net.http.HttpClient;importjava.net.http.HttpRequest;importjava.net.http.HttpResponse;importjava.time.Duration;importjava.time.OffsetDateTime;importjava.time.format.DateTimeFormatter;importjava.time.temporal.ChronoUnit;importjava.util.ArrayList;importjava.util.List;importjava.util.Map;importjava.util.TreeMap;/** The hourly ERP to SalesBlitz sync: products, then prices, then stock. Java 17+, Jackson 2. */publicclassSyncJob{/** Your side: read the ERP and return rows in the API's shapes. */publicinterfaceErp{List<Map<String,Object>>products();List<Map<String,Object>>priceLists();List<Map<String,Object>>stockLevels();}/** SalesBlitz refused the whole call; sending it again won't help. */publicstaticclassRefusedextendsRuntimeException{publicRefused(String message){super(message);}}privatestaticfinalStringBASE="https://salesblitz.app/api/v1/sync/";privatestaticfinalintBATCH=500;privatestaticfinalintATTEMPTS=3;privatefinalHttpClient http =HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();privatefinalObjectMapper json =newObjectMapper();privatefinalString key;publicSyncJob(String key){this.key = key;}publicvoidrun(Erp erp)throwsIOException,InterruptedException{JsonNode who =call("GET","ping",null);System.out.printf("Syncing %s as '%s'%n", who.path("company").asText(), who.path("key").asText());// 1. Products first: prices and stock name them.List<Map<String,Object>> products = erp.products();List<JsonNode> results =newArrayList<>();for(int from =0; from < products.size(); from +=BATCH){call("PUT","products", products.subList(from,Math.min(from +BATCH, products.size()))).forEach(results::add);}report("Products", results);// 2. Each price list whole, in one call: a product left out is taken off the list.for(Map<String,Object> list : erp.priceLists()){try{List<JsonNode> listResults =newArrayList<>();call("PUT","prices", list).forEach(listResults::add);report("Price list "+ list.get("priceListCode"), listResults);}catch(Refused refused){System.out.printf("Price list %s refused: %s%n", list.get("priceListCode"), refused.getMessage());}}// 3. Stock, stamped with when the ERP read it, so a late resend never undoes a newer figure.String asOf =DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(OffsetDateTime.now().truncatedTo(ChronoUnit.SECONDS));List<Map<String,Object>> levels = erp.stockLevels();
results =newArrayList<>();for(int from =0; from < levels.size(); from +=BATCH){List<Map<String,Object>> batch = levels.subList(from,Math.min(from +BATCH, levels.size()));call("PUT","stock",Map.of("asOf", asOf,"levels", batch)).forEach(results::add);}report("Stock", results);}/** One call, sent again after a timeout or a server error: every call sets state, so resending is safe. */privateJsonNodecall(String method,String path,Object body)throwsIOException,InterruptedException{HttpRequest request =HttpRequest.newBuilder(URI.create(BASE+ path)).timeout(Duration.ofSeconds(120)).header("Authorization","Bearer "+ key).header("Content-Type","application/json").header("Accept","application/json").method(method, body ==null?HttpRequest.BodyPublishers.noBody():HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body))).build();HttpResponse<String> response =null;for(int attempt =1; attempt <=ATTEMPTS; attempt++){try{
response = http.send(request,HttpResponse.BodyHandlers.ofString());if(response.statusCode()<500){break;}}catch(IOException failed){// a timeout is an IOException tooif(attempt ==ATTEMPTS){throw failed;}}if(attempt <ATTEMPTS){Thread.sleep(1000L<< attempt);}}if(response.statusCode()>=500){thrownewIOException("SalesBlitz answered "+ response.statusCode()+" "+ATTEMPTS+" times.");}JsonNode envelope = json.readTree(response.body());if(response.statusCode()==401){thrownewSecurityException("Key refused: "+ envelope.path("message").asText());}if(!envelope.path("response").asBoolean()){thrownewRefused(envelope.path("message").asText());}return envelope.path("data");}privatestaticvoidreport(String what,List<JsonNode> results){Map<String,Integer> counts =newTreeMap<>();
results.forEach(r -> counts.merge(r.path("status").asText(),1,Integer::sum));System.out.println(what +": "+ counts);for(JsonNode r : results){if("REJECTED".equals(r.path("status").asText())){System.out.printf(" %s: %s%n", r.path("code").asText(), r.path("message").asText());}}}publicstaticvoidmain(String[] args)throwsException{newSyncJob(System.getenv("SALESBLITZ_KEY")).run(newErpAdapter());// ErpAdapter: your Erp}}
sync.mjs
// The hourly ERP to SalesBlitz sync: products, then prices, then stock. Node 18+, no packages.// Run: SALESBLITZ_KEY=sbk_... node sync.mjsimport{ erp }from'./erp.mjs';// your side: products(), priceLists(), stockLevels() in the API's shapesconstBASE='https://salesblitz.app/api/v1/sync/';constBATCH=500;constATTEMPTS=3;/** SalesBlitz refused the whole call; sending it again won't help. */classRefusedextendsError{}constsleep=(ms)=>newPromise((resolve)=>setTimeout(resolve, ms));/** One call, sent again after a timeout or a server error: every call sets state, so resending is safe. */asyncfunctioncall(method, path, body){for(let attempt =1;; attempt++){let response;try{
response =awaitfetch(BASE+ path,{
method,headers:{Authorization:`Bearer ${process.env.SALESBLITZ_KEY}`,'Content-Type':'application/json',Accept:'application/json',},body: body ===undefined?undefined:JSON.stringify(body),signal: AbortSignal.timeout(120_000),});}catch(failed){if(attempt >=ATTEMPTS)throw failed;// network failure or timeout}if(response && response.status <500){returnunwrap(response);}if(response && attempt >=ATTEMPTS){thrownewError(`SalesBlitz answered ${response.status}${ATTEMPTS} times.`);}awaitsleep(1000*2** attempt);}}asyncfunctionunwrap(response){const envelope =await response.json();if(response.status ===401)thrownewError(`Key refused: ${envelope.message}`);if(!envelope.response)thrownewRefused(envelope.message);return envelope.data;}function*chunks(rows){for(let from =0; from < rows.length; from +=BATCH)yield rows.slice(from, from +BATCH);}functionreport(what, results){const counts ={};for(const r of results) counts[r.status]=(counts[r.status]??0)+1;
console.log(`${what}:`, counts);for(const r of results){if(r.status ==='REJECTED') console.log(`${r.code}: ${r.message}`);}}const who =awaitcall('GET','ping');
console.log(`Syncing ${who.company} as '${who.key}'`);// 1. Products first: prices and stock name them.let results =[];for(const batch ofchunks(await erp.products())){
results.push(...(awaitcall('PUT','products', batch)));}report('Products', results);// 2. Each price list whole, in one call: a product left out is taken off the list.for(const list ofawait erp.priceLists()){try{report(`Price list ${list.priceListCode}`,awaitcall('PUT','prices', list));}catch(failed){if(!(failed instanceofRefused))throw failed;
console.log(`Price list ${list.priceListCode} refused: ${failed.message}`);}}// 3. Stock, stamped with when the ERP read it, so a late resend never undoes a newer figure.const asOf =newDate().toISOString();const levels =await erp.stockLevels();
results =[];for(const batch ofchunks(levels)){
results.push(...(awaitcall('PUT','stock',{ asOf,levels: batch })));}report('Stock', results);
sync.py
"""The hourly ERP to SalesBlitz sync: products, then prices, then stock. Python 3.9+, requests.
Run: SALESBLITZ_KEY=sbk_... python sync.py
"""import os
import time
from collections import Counter
from datetime import datetime
import requests
import erp # your side: products(), price_lists(), stock_levels() in the API's shapes
BASE ="https://salesblitz.app/api/v1/sync/"
BATCH =500
ATTEMPTS =3
session = requests.Session()
session.headers.update({"Authorization":f"Bearer {os.environ['SALESBLITZ_KEY']}","Accept":"application/json",})classRefused(Exception):"""SalesBlitz refused the whole call; sending it again won't help."""defcall(method, path, body=None):"""One call, sent again after a timeout or a server error: every call sets state, so resending is safe."""for attempt inrange(1, ATTEMPTS +1):try:
response = session.request(method, BASE + path, json=body, timeout=(10,120))if response.status_code <500:return unwrap(response)
failure = requests.HTTPError(f"SalesBlitz answered {response.status_code}{ATTEMPTS} times.")except(requests.ConnectionError, requests.Timeout)as error:
failure = error
if attempt < ATTEMPTS:
time.sleep(2** attempt)raise failure
defunwrap(response):
envelope = response.json()if response.status_code ==401:raise PermissionError(f"Key refused: {envelope['message']}")ifnot envelope["response"]:raise Refused(envelope["message"])return envelope["data"]defchunks(rows):for start inrange(0,len(rows), BATCH):yield rows[start:start + BATCH]defreport(what, results):print(f"{what}: {dict(Counter(r['status']for r in results))}")for r in results:if r["status"]=="REJECTED":print(f" {r['code']}: {r['message']}")defmain():
who = call("GET","ping")print(f"Syncing {who['company']} as '{who['key']}'")# 1. Products first: prices and stock name them.
results =[]for batch in chunks(erp.products()):
results += call("PUT","products", batch)
report("Products", results)# 2. Each price list whole, in one call: a product left out is taken off the list.for price_list in erp.price_lists():try:
report(f"Price list {price_list['priceListCode']}", call("PUT","prices", price_list))except Refused as refused:print(f"Price list {price_list['priceListCode']} refused: {refused}")# 3. Stock, stamped with when the ERP read it, so a late resend never undoes a newer figure.
as_of = datetime.now().astimezone().isoformat(timespec="seconds")
results =[]for batch in chunks(erp.stock_levels()):
results += call("PUT","stock",{"asOf": as_of,"levels": batch})
report("Stock", results)if __name__ =="__main__":
main()
A run prints something like this:
Output
Syncing Cailo Distribution as 'ERP connector'
Products: {CREATED=12, REJECTED=1, UNCHANGED=1190}
MZ750: Unknown tax code 'ZR'. Set it on the tax in Settings › Taxes.
Price list RETAIL: {UNCHANGED=410, UPDATED=3}
Price list WHOLESALE: {UNCHANGED=388}
Stock: {UNCHANGED=655, UPDATED=45}
What the sync leaves behind
Everything a sync writes can be traced in the portal.
Settings › Integrations
The time of the last call to each of products, prices and stock, each key's Last used time, and the taxes still without a code.
Audit log
One entry per PUT call, under Sync.Products, Sync.Prices or Sync.Stock, naming the key and counting the rows by status.
Stock card
Each adjustment the sync posted, with the reason ERP sync and, when the call sent one, its asOf time.
Price lists
The lists the sync made or changed, which the portal narrows by area, seller, customer type and dates.
Limits
What
Limit
Rows per call
500 products, 500 stock levels, or 500 prices
One price list
500 price rows, sent whole in one call
Product code
3 to 255 characters
Product name
4 to 255 characters
HS code
4 or 8 digits; 8 for zero-rated and exempt products when VAT registered or fiscalizing
Tax code (set in the portal)
1 to 20 letters, digits, - or _
Price
Greater than zero, at most 2 decimals, VAT included
minQuantity
Whole number, 1 to 999,998
Stock quantity
Zero or more, at most 4 decimals
Key name (set in the portal)
1 to 100 characters
Rhythm
Hourly is expected; more often is fine when the ERP changes more often
Ready to connect your ERP?
A company administrator creates the key in Settings › Integrations. Then work through the quick start.