For developers

Partner Sync API

Push your per-store stock into Stockwaka, and pull back the orders its WhatsApp storefront generates for you. Built so a retail chain can add WhatsApp ordering as an additional channel — without moving its stock ledger, changing its counter, or replacing anything it already runs.
Version
v1
Base URL
https://api.stockwaka.com/api/v1
Format
JSON over HTTPS
Auth
Bearer API key, scoped

1. The one rule

Your system is authoritative for stock. Stockwaka is a mirror.

A retail chain will not hand its stock ledger to a new vendor, and two systems each applying their own changes diverge permanently the first time one of them misses a message. So Stockwaka does not try to be a second ledger.

Concretely, that means three things:

  • A stock sync SETS an absolute on-hand figure. It never applies a change. Re-sending the same payload does nothing; a payload you dropped is corrected by the next one. There is no ordering requirement between pages and no reconciliation protocol to implement.
  • Stockwaka holds only a short reservation window against your figure while a shopper pays — never a parallel count. See Reservations, below.
  • Stockwaka orders are requests you accept, not deductions imposed on you. You take an order in, acknowledge it, and your next sync carries the consequence back to us as an ordinary absolute count.

If you are ever tempted to send us what changed since last time, send the current count instead. The API rejects a negative quantity for exactly this reason.

2. Getting access

There is no self-service signup. A partner integration is set up with a person, because binding your store codes to the right shops is a decision nobody should make by guessing.

  1. 1Get in touch. Email support@bitasei.com with your company, roughly how many stores you run, and which system will be talking to us (SAP, Odoo, Microsoft Dynamics, an in-house ERP — it makes no difference to the API, but it tells us who to put you with).
  2. 2A short technical call. We agree which stores go live first, and which scopes each of your jobs needs.
  3. 3Your account and your stores are set up. One Stockwaka account for the chain, with one shop under it per store — created and named from your own store list, however many that is. If some of your branches already use Stockwaka on WhatsApp, nothing about their accounts changes.
  4. 4We issue your test key. It arrives once, over a secure channel, and starts with swp_test_. Stockwaka stores only a hash of it, so it can never be shown again — a lost key is replaced, not recovered.
  5. 5You build against the sandbox using the reference below. Start with GET /partner/whoami; if that returns your business name, everything else is downhill.
  6. 6Bind your store codes with PUT /partner/stores/:code, once per store. The call is idempotent, so it belongs in your deploy script rather than in a runbook.
  7. 7Pilot on a handful of real stores. We watch the first syncs together — the two numbers that matter early are unpricedCount and skipped, which between them tell you whether your export is mapping cleanly.
  8. 8Go live. We issue the production key (swp_live_) and you bring stores on as fast as you like. Nothing is all-or-nothing: a store whose code you have not bound yet is simply not synced, and binding it later needs no change on our side.

One key. Every store.

A key belongs to your company account, not to a store, and it reaches every shop under it. The storeCode on each request is an address, not a credential — it picks the shelf, while the key proves it is you asking. A chain of two hundred stores needs exactly as many keys as a chain of one.

The only reason to hold more than one key is to separate jobs, not stores: give your stock job stock:read and stock:write, your order collector orders:read and orders:ack. Then a bug in one cannot damage the other’s domain, and a leaked key is partial rather than total.

What your merchant sees

Nothing changes for them. Stockwaka stays what it was — voice notes on WhatsApp, the counter app, the storefront — and the stock they see is now kept in step with yours automatically. They keep full control of prices, and a price they set themselves is never overwritten by a sync that does not carry one.

3. Authentication

Send your key as a bearer token on every request.

Every request

Authorization: Bearer swp_live_...

A key looks like swp_<env>_<43 characters>, where env is live or test. The environment is part of the key so a test credential pasted into a production config is visibly wrong before it is used.

Scopes

Ask for the narrowest set each job needs. Your nightly stock push has no business reading your customers’ phone numbers.

ScopeGrants
stock:readList stores, and read back the stock we hold.
stock:writeBind store codes, and push stock.
orders:readPull the orders your storefront generated.
orders:ackAcknowledge an order into your own system.

GET /partner/whoami needs no scope at all — it is how you find out what you hold.

What a key can reach

A key resolves to exactly one account, and that account is read from the key itself.

Nothing you send in a URL, query string or body can change which account you are acting as. A store code belonging to another business does not exist as far as your key is concerned — it returns 404, not someone else’s data.

Failures

StatusMeaning
401Missing, malformed, unknown or revoked key.
403The key is valid but lacks a required scope, or the Stockwaka account is not active.

A 403 never means “rotate your key”. Read the message — it names the scope you are short of.

4. Stores

Stockwaka calls a shop a branch. You call it a store, with your own code (SW-IKJ-01). Binding the two is a one-time setup step per store.

GET/partner/storesstock:read

Lists every shop on the account, including ones you have not bound yet.

Response

[
  { "storeCode": "SW-MAIN",   "branchId": "primary",                  "name": "Lekki",         "isPrimary": true,  "isActive": true },
  { "storeCode": "SW-IKJ-01", "branchId": "66f1a0c2e4b0a1d2c3e4f5b1", "name": "Ikeja",         "isPrimary": false, "isActive": true },
  { "storeCode": null,        "branchId": "66f1a0c2e4b0a1d2c3e4f5b2", "name": "Yaba",          "isPrimary": false, "isActive": true }
]

branchId is opaque. One shop on every account is the main shop, and its branchId is the literal string "primary" rather than an id. That is not a placeholder — it is a real, permanent handle you can use anywhere a branchId is accepted.

PUT/partner/stores/:externalCodestock:write

Binds your store code to one shop. Idempotent — safe to run on every deploy.

Request

# by the id from GET /partner/stores  (recommended for scripts)
curl -X PUT "$BASE/partner/stores/SW-IKJ-01" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"branchId": "66f1a0c2e4b0a1d2c3e4f5b1"}'

# the main shop
curl -X PUT "$BASE/partner/stores/SW-MAIN" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"branchId": "primary"}'

# by name — convenient by hand; refused if it matches more than one shop
curl -X PUT "$BASE/partner/stores/SW-YAB-01" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"branchName": "Yaba"}'

A code already bound to a different shop on the same account returns 409 and names the shop holding it. Re-bind that one first.

5. Stock sync

POST/partner/stock/syncstock:write

The main endpoint. An absolute per-store upsert.

Request

curl -X POST "$BASE/partner/stock/sync" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "storeCode": "SW-IKJ-01",
    "items": [
      { "externalRef": "SKU-88213", "name": "Amlodipine 5mg 30s", "quantity": 42,
        "sellingPrice": 2400, "costPrice": 1800, "barcode": "6154001234567",
        "unit": "pack", "category": "Medicines" },
      { "externalRef": "SKU-88214", "quantity": 0 }
    ]
  }'

Response

{
  "storeCode": "SW-IKJ-01",
  "branch": "Ikeja",
  "total": 2, "created": 0, "updated": 2, "skipped": 0,
  "unpricedCount": 0,
  "skippedSamples": [],
  "reserved": 3,
  "sellable": 39,
  "syncedAt": "2026-08-18T09:00:00.000Z"
}

Item fields

FieldRequiredNotes
externalRefstrongly recommendedYour SKU — the join key. See below.
nameon first sight of a SKUThe product's name as your customers should see it.
quantityno (defaults to 0)Absolute on-hand. Integer, never negative.
sellingPricenoWhole naira. Omit to leave the merchant's own price alone.
costPricenoWhole naira.
barcodenoEAN/UPC, for scanning at the counter.
unitnopack, bottle, carton…
categorynoBrowsing group shown to shoppers.

Any field we do not recognise is ignored, not rejected — adding a column to your export will never break the integration.

externalRef is the join key

Send your own stable SKU on every line. Stockwaka matches an incoming row against the catalogue in this order: externalRef, then barcode, then the product name.

Matching on name alone is fragile. The first time you correct a product’s description, a name-matched sync would create a second row and split that product’s stock across both. A SKU cannot do that.

You do not need to migrate an existing shop.

On the first sync we match by barcode or name and adopt your SKU onto the row we found. Every sync after that matches on the SKU and is immune to renames on either side. Once a SKU is known, you may push counts without repeating the name.

Once a SKU is known

{ "storeCode": "SW-IKJ-01", "items": [{ "externalRef": "SKU-88213", "quantity": 17 }] }

Absolute, not delta

  • Sending the same payload twice leaves the quantity unchanged. Retry freely.
  • A lower number is applied as-is. It is a count, not an increment.
  • quantity: 0 is how you say “none left”. There is deliberately no delete: a zeroed product keeps its price, barcode and SKU, so restocking it later is one more sync rather than a re-setup.
  • A negative quantity is rejected for the whole request, with nothing written, because it is evidence the payload is deltas — in which case every other row in it is wrong too.

Response fields worth alerting on

FieldMeaning
unpricedCountProducts now in the catalogue with no selling price. They cannot be sold online. If your export has a price column, map it — this is the field to alert on.
skippedRows we could not use. Never fails the batch; skippedSamples carries up to five human-readable reasons for your logs.
reservedUnits at this store currently held against open orders.
sellableUnits a shopper could order right now (on-hand minus reserved).

Partial success

Individual bad rows are skipped, counted and reported — they never fail the batch. A contract error (no storeCode, items not a list, over the size cap, a negative or non-numeric quantity) rejects the whole request and writes nothing, because it means the payload as a whole cannot be trusted.

6. Stock read-back

GET/partner/stockstock:read

What Stockwaka currently holds, for reconciliation.

Request

curl "$BASE/partner/stock?storeCode=SW-IKJ-01&limit=200" \
  -H "Authorization: Bearer $KEY"

Response

{
  "items": [
    {
      "externalRef": "SKU-88213",
      "name": "Amlodipine 5mg 30s",
      "storeCode": "SW-IKJ-01",
      "branchId": "66f1a0c2e4b0a1d2c3e4f5b1",
      "quantity": 42, "reserved": 3, "available": 39,
      "sellingPrice": 2400, "costPrice": 1800,
      "barcode": "6154001234567", "unit": "pack", "category": "Medicines",
      "updatedAt": "2026-08-18T09:00:00.000Z"
    }
  ],
  "nextCursor": "66f1a0c2e4b0a1d2c3e4f5c9"
}
QueryDefaultNotes
storeCodeall storesOmit to walk the whole estate in one pass.
limit100Max 200. Values above are clamped, not rejected.
cursorPass the previous page’s nextCursor.

Page until nextCursor is null. An item present here but absent from your system is usually one the merchant added themselves on WhatsApp — it will have externalRef: null.

7. Reservations, and why your count and ours can differ

This is the one place your count and ours legitimately differ, and the reserved and sellable fields exist to explain it.

When a shopper places an order, Stockwaka reserves the units for a short window — 15 minutes, extended to at most 24 hours once they say they have paid — so two shoppers cannot buy the same last pack. Nothing is deducted: the units are still on your shelf and still in your count.

your on-hand:  42        (what you sent)
reserved:       3        (held by open orders)
sellable:      39        (what a shopper can order right now)

A reservation survives your sync. If you push quantity: 20 while three units are reserved, the result is 20 on hand with 3 still held and 17 sellable. Your figure is authoritative for on-hand; the reservation is ours.

Reservations release automatically when an order is confirmed, cancelled or expires. The only lasting effect on your side is the confirmed sale, which appears as an order in the pull below.

8. Orders

GET/partner/ordersorders:read

A change feed, ordered by when each order last changed. Poll it with the cursor you last received.

Request

# first ever pull
curl "$BASE/partner/orders?limit=100" -H "Authorization: Bearer $KEY"

# every pull after that
curl "$BASE/partner/orders?limit=100&cursor=$CURSOR" -H "Authorization: Bearer $KEY"

Response

{
  "orders": [
    {
      "reference": "7QM4P2",
      "status": "confirmed",
      "storeCode": "SW-IKJ-01",
      "branchId": "66f1a0c2e4b0a1d2c3e4f5b1",
      "customerName": "Mrs Adebayo",
      "customerPhone": "2348012345678",
      "customerNote": "12 Allen Avenue, opposite GTBank",
      "fulfillmentType": "delivery",
      "items": [
        { "externalRef": "SKU-88213", "name": "Amlodipine 5mg 30s",
          "quantity": 2, "unit": "pack", "unitPrice": 2400, "lineTotal": 4800 }
      ],
      "totalNGN": 4800, "taxNGN": 0,
      "placedAt": "2026-08-18T08:41:00.000Z",
      "confirmedAt": "2026-08-18T08:52:00.000Z",
      "fulfilledAt": null, "cancelledAt": null,
      "createdAt": "2026-08-18T08:39:00.000Z",
      "updatedAt": "2026-08-18T08:52:00.000Z",
      "ackedAt": null, "externalOrderId": null
    }
  ],
  "nextCursor": "2026-08-18T08:52:00.000Z|66f1a0c2e4b0a1d2c3e4f5d3"
}
QueryDefaultNotes
cursorOpaque. Always prefer this. Wins over since if both are sent.
sinceISO 8601. For a first pull only.
storeCodeall stores
limit50Max 200.

Use the cursor, not `since`.

The cursor is an exact position in a total order; a timestamp is not, and two orders touched in the same millisecond can be missed by timestamp paging. An empty orders array means you are caught up — keep the cursor and reuse it.

Because this is a change feed, an order you have already seen reappears when its status changes. That is the point: you never miss a confirmation or a cancellation.

Order statuses

StatusMeaning
pending_paymentPlaced. Stock reserved, waiting for the shopper to pay. Do not fulfil.
payment_claimedThe shopper says they have paid; the merchant is verifying.
confirmedPaid, and verified by the merchant. This is the one to take in.
preparing / readyOptional kitchen states for food merchants. Paid and still open.
fulfilledHanded over or delivered. Terminal.
cancelled / expiredDead. The stock reservation was released.

Draft carts are never published. fulfillmentType is pickup, delivery or dine_in; for a delivery the address is in customerNote, for dine-in the table.

Line items carry externalRef where we know it. It is null for a product the merchant added themselves on WhatsApp — we will not invent a SKU your system has never issued.

POST/partner/orders/:reference/ackorders:ack

Records that your system has taken the order in. Idempotent.

Request

curl -X POST "$BASE/partner/orders/7QM4P2/ack" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"externalOrderId": "ERP-2026-00184"}'

externalOrderId is optional and stored so support can trace an order across both systems. A repeat ack returns alreadyAcked: true and changes nothing — including ackedAt, which stays the moment you first took the order. Acking does not advance an order’s updatedAt, so an acknowledged order will not come back on the next pull just because you acknowledged it.

An ack is not a fulfilment. It means “we have this”. The merchant marks the order fulfilled in Stockwaka when the goods are handed over.

There is deliberately no reject endpoint. If your system cannot fulfil an order, the merchant rejects it in Stockwaka and the reservation is released through the normal path — one place where an order dies, not two racing each other.

9. Errors and limits

Standard HTTP status codes, with a message written to be read by a human.

Response

{
  "statusCode": 400,
  "message": "\"items\" carried 1500 products, which is over the limit of 1000 per request. Split the store's catalogue into pages of 1000 or fewer…",
  "error": "Bad Request"
}
StatusWhen
400The payload breaks a rule — no storeCode, over the size cap, a negative quantity. Nothing was written.
401Missing, malformed, unknown or revoked key.
403Valid key, missing scope; or the Stockwaka account is not active.
404An unrecognised store code, or an order reference not on this account.
409A store code already bound to another shop, or an ambiguous branch name.

An unrecognised storeCode is a 404 and never falls back to the main shop.

If it did, renaming a store in your system would silently redirect that store’s whole catalogue onto another building’s shelf — and nothing would look wrong until a customer was sold something that is not there.

Retrying

Everything here is safe to retry: the sync sets absolute figures, the ack is idempotent, and reads have no side effects. Retry 5xx and network failures with exponential backoff; do not retry a 4xx without changing the request.

Limits

LimitValue
Items per stock/sync call1,000
limit on GET /partner/stock100 default, 200 max
limit on GET /partner/orders50 default, 200 max
Store code length64 characters

A large chain’s catalogue must be paged. Pages are independent — each is applied on its own, order does not matter, and a failed page can be re-sent alone. Send pages sequentially per store: two concurrent syncs of the same store would both be applied, and the last to land wins.

11. Support

Questions, a key rotation, or a new store code: email support@bitasei.com or call +234 907 888 4939.

Never send us your key.

Include your keyPrefix — the visible leading characters, which GET /partner/whoami returns — and the storeCode in question. That is enough for us to find the credential. If a key has been exposed, say so and we will revoke and reissue it the same day.