Developers

Sync API

Keep SalesBlitz in step with your ERP.

Your ERP sends its products, prices and stock levels, named by your own codes. Each call says how things are now, so it is always safe to send again.

Version 1.0Issued 8 Oct 2026

Base URL

https://salesblitz.app/api/v1/sync/

On every request

Authorization: Bearer sbk_…

The five calls

  1. GETpingChecks the key and names its company.
  2. GETwarehousesLists the depots and vans, with their codes.
  3. PUTproductsCreates or updates up to 500 products by code.
  4. PUTpricesReplaces one price list's prices and quantity bands, whole.
  5. 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
Contents

Quick start

  1. 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.

  2. 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.

  3. 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".

  4. 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.

  5. 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
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

/** A thin client for the SalesBlitz sync API. Java 17+, Jackson 2. */
public class SalesBlitz {

    /** SalesBlitz refused the whole call; the message says why. */
    public static class Refused extends RuntimeException {
        public Refused(String message) {
            super(message);
        }
    }

    private static final String BASE = "https://salesblitz.app/api/v1/sync/";

    private final HttpClient http = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();
    private final ObjectMapper json = new ObjectMapper();
    private final String key;

    public SalesBlitz(String key) {
        this.key = key; // sbk_ and 40 letters and digits
    }

    /** One call; returns the envelope's data. */
    public JsonNode call(String method, String path, Object body) throws IOException, 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) {
            throw new IOException("SalesBlitz answered " + response.statusCode() + "; send the call again.");
        }
        JsonNode envelope = json.readTree(response.body());
        if (response.statusCode() == 401) {
            throw new SecurityException("Key refused: " + envelope.path("message").asText());
        }
        if (!envelope.path("response").asBoolean()) {
            throw new Refused(envelope.path("message").asText());
        }
        return envelope.path("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.

MessageWhy
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.

Every answer
{ "response": true, "message": "Operation Successful", "data": … }

"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.

One row's result
{ "code": "CK330", "status": "UPDATED", "message": null }

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.

data fieldMeaning
companystringThe name of the company the key belongs to.
keystringThe key's name, as given when it was created.
Response
{
  "response": true,
  "message": "Operation Successful",
  "data": { "company": "Cailo Distribution", "key": "ERP connector" }
}
uses SalesBlitz.java
SalesBlitz sync = new SalesBlitz(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() + "'");
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.

FieldMeaning
codestringThe code stock levels name in warehouseCode.
namestringThe warehouse's name in the portal.
vanbooleantrue when the warehouse belongs to a seller: a van.
sellerstring or nullThe seller's name for a van; null for a depot.
Response
{
  "response": true,
  "message": "Operation Successful",
  "data": [
    { "code": "WH01", "name": "Harare Main", "van": false, "seller": null },
    { "code": "VAN07", "name": "Van 07", "van": true, "seller": "Tendai Moyo" }
  ]
}
uses SalesBlitz.java
// 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);
}
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.

FieldRules
codestringrequiredThe 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 products4 to 255 characters. Required for a new product; optional afterwards.
taxCodestringnew productsA tax's code, as set in Settings › Taxes. An unknown code rejects the row.
barcodestring or nulloptionalAny text. null clears it.
categorystring or nulloptionalAn 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 nulloptional4 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.
activebooleanoptionalfalse makes the product unsellable: off the handsets, the cart and the price lists, but kept in history and stock. true makes it sellable again.
Request body
[
  { "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": null }
]
Response
{
  "response": true,
  "message": "Operation Successful",
  "data": [
    { "code": "CK330", "status": "UNCHANGED", "message": null },
    { "code": "FN500", "status": "CREATED", "message": null },
    { "code": "SP330", "status": "UPDATED", "message": null }
  ]
}

A new product

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 = new ArrayList<>(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 = new HashMap<>();
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(""));
}
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

FieldRules
priceListCodestringrequiredThe list's code. A code SalesBlitz doesn't know yet makes a new list.
namestringnew listsRequired the first time. On an existing list, a different name renames it.
currencystringrequiredAn active currency's ISO code, such as USD or ZWG. An existing list keeps its currency: naming another refuses the call.
pricesarrayrequiredUp to 500 rows: the whole list.

Each row of prices

FieldRules
productCodestringrequiredAn existing product's code.
minQuantityintegeroptionalWhere this price's band starts, 1 to 999,998. Defaults to 1.
pricestringrequiredThe unit price including VAT, as everywhere in SalesBlitz. Greater than zero, at most 2 decimals.
Request body
{
  "priceListCode": "RETAIL",
  "name": "Retail",
  "currency": "USD",
  "prices": [
    { "productCode": "CK330", "price": "0.50" },
    { "productCode": "CK330", "minQuantity": 24, "price": "0.45" },
    { "productCode": "FN500", "price": "0.80" }
  ]
}
Response
{
  "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.

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 23
                Map.of("productCode", "CK330", "minQuantity", 24, "price", "0.45"),  // 24 and up
                Map.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(""));
}
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.

FieldRules
asOfstringoptionalWhen 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.
levelsarrayrequiredUp to 500 levels.
levels[].warehouseCodestringrequiredA code from GET warehouses.
levels[].productCodestringrequiredAn existing product's code.
levels[].quantitystringrequiredWhat the warehouse holds now. Zero or more, at most 4 decimals.
Request body
{
  "asOf": "2026-10-08T14:00:00+02:00",
  "levels": [
    { "warehouseCode": "WH01", "productCode": "CK330", "quantity": "240" },
    { "warehouseCode": "VAN07", "productCode": "CK330", "quantity": "36" }
  ]
}
Response
{
  "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.

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(""));
}

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.

HTTPresponseMeaningWhat to do
200trueThe call ran. Read each row's status.Fix REJECTED rows and let the next sync send them.
200falseThe whole call was refused before any row was saved.Fix what message says. Sending it unchanged gets the same answer.
400falseThe body isn't readable JSON, or a field has the wrong type: "Invalid request body." or "Invalid value 'abc' for 'minQuantity'."Fix the body.
401falseThe key was refused (see Authentication).Check the key, or create a new one.
5xx, or no answerIt's unknown how far the call got.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

CallMessage
productsSend an array of products.
productsAt most 500 products per call; this one has 612.
pricesSend one price list.
pricespriceListCode is required.
pricescurrency is required.
pricesAt most 500 prices per call; this one has 612.
pricesUnknown currency 'ZAR'.
pricesA new price list needs a name.
pricesPrice list 'RETAIL' is in USD, not ZWG.
stockSend { "asOf": …, "levels": [ … ] }.
stockAt most 500 levels per call; this one has 612.
stockasOf is an ISO 8601 time with its offset, e.g. 2026-10-08T14:00:00+02:00.

Rejected rows

Products

MessageFix
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)

MessageFix
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 numberPrices are greater than zero.
A price can have at most two decimal placesRound 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

MessageFix
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

MessageFix
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
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

/** The hourly ERP to SalesBlitz sync: products, then prices, then stock. Java 17+, Jackson 2. */
public class SyncJob {

    /** Your side: read the ERP and return rows in the API's shapes. */
    public interface Erp {
        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. */
    public static class Refused extends RuntimeException {
        public Refused(String message) {
            super(message);
        }
    }

    private static final String BASE = "https://salesblitz.app/api/v1/sync/";
    private static final int BATCH = 500;
    private static final int ATTEMPTS = 3;

    private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
    private final ObjectMapper json = new ObjectMapper();
    private final String key;

    public SyncJob(String key) {
        this.key = key;
    }

    public void run(Erp erp) throws IOException, 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 = new ArrayList<>();
        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 = new ArrayList<>();
                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 = new ArrayList<>();
        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. */
    private JsonNode call(String method, String path, Object body) throws IOException, 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 too
                if (attempt == ATTEMPTS) {
                    throw failed;
                }
            }
            if (attempt < ATTEMPTS) {
                Thread.sleep(1000L << attempt);
            }
        }
        if (response.statusCode() >= 500) {
            throw new IOException("SalesBlitz answered " + response.statusCode() + " " + ATTEMPTS + " times.");
        }
        JsonNode envelope = json.readTree(response.body());
        if (response.statusCode() == 401) {
            throw new SecurityException("Key refused: " + envelope.path("message").asText());
        }
        if (!envelope.path("response").asBoolean()) {
            throw new Refused(envelope.path("message").asText());
        }
        return envelope.path("data");
    }

    private static void report(String what, List<JsonNode> results) {
        Map<String, Integer> counts = new TreeMap<>();
        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());
            }
        }
    }

    public static void main(String[] args) throws Exception {
        new SyncJob(System.getenv("SALESBLITZ_KEY")).run(new ErpAdapter()); // ErpAdapter: your Erp
    }
}

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

WhatLimit
Rows per call500 products, 500 stock levels, or 500 prices
One price list500 price rows, sent whole in one call
Product code3 to 255 characters
Product name4 to 255 characters
HS code4 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 _
PriceGreater than zero, at most 2 decimals, VAT included
minQuantityWhole number, 1 to 999,998
Stock quantityZero or more, at most 4 decimals
Key name (set in the portal)1 to 100 characters
RhythmHourly 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.