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.
- 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).
- 2A short technical call. We agree which stores go live first, and which scopes each of your jobs needs.
- 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.
- 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. - 5You build against the sandbox using the reference below. Start with
GET /partner/whoami; if that returns your business name, everything else is downhill. - 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. - 7Pilot on a handful of real stores. We watch the first syncs together — the two numbers that matter early are
unpricedCountandskipped, which between them tell you whether your export is mapping cleanly. - 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.
| Scope | Grants |
|---|---|
stock:read | List stores, and read back the stock we hold. |
stock:write | Bind store codes, and push stock. |
orders:read | Pull the orders your storefront generated. |
orders:ack | Acknowledge 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
| Status | Meaning |
|---|---|
401 | Missing, malformed, unknown or revoked key. |
403 | The 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.
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.
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
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
| Field | Required | Notes |
|---|---|---|
externalRef | strongly recommended | Your SKU — the join key. See below. |
name | on first sight of a SKU | The product's name as your customers should see it. |
quantity | no (defaults to 0) | Absolute on-hand. Integer, never negative. |
sellingPrice | no | Whole naira. Omit to leave the merchant's own price alone. |
costPrice | no | Whole naira. |
barcode | no | EAN/UPC, for scanning at the counter. |
unit | no | pack, bottle, carton… |
category | no | Browsing 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: 0is 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
| Field | Meaning |
|---|---|
unpricedCount | Products 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. |
skipped | Rows we could not use. Never fails the batch; skippedSamples carries up to five human-readable reasons for your logs. |
reserved | Units at this store currently held against open orders. |
sellable | Units 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
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"
}| Query | Default | Notes |
|---|---|---|
storeCode | all stores | Omit to walk the whole estate in one pass. |
limit | 100 | Max 200. Values above are clamped, not rejected. |
cursor | — | Pass 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
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"
}| Query | Default | Notes |
|---|---|---|
cursor | — | Opaque. Always prefer this. Wins over since if both are sent. |
since | — | ISO 8601. For a first pull only. |
storeCode | all stores | |
limit | 50 | Max 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
| Status | Meaning |
|---|---|
pending_payment | Placed. Stock reserved, waiting for the shopper to pay. Do not fulfil. |
payment_claimed | The shopper says they have paid; the merchant is verifying. |
confirmed | Paid, and verified by the merchant. This is the one to take in. |
preparing / ready | Optional kitchen states for food merchants. Paid and still open. |
fulfilled | Handed over or delivered. Terminal. |
cancelled / expired | Dead. 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.
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"
}| Status | When |
|---|---|
400 | The payload breaks a rule — no storeCode, over the size cap, a negative quantity. Nothing was written. |
401 | Missing, malformed, unknown or revoked key. |
403 | Valid key, missing scope; or the Stockwaka account is not active. |
404 | An unrecognised store code, or an order reference not on this account. |
409 | A 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
| Limit | Value |
|---|---|
Items per stock/sync call | 1,000 |
limit on GET /partner/stock | 100 default, 200 max |
limit on GET /partner/orders | 50 default, 200 max |
| Store code length | 64 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.
10. Recommended integration shape
Setup, once
GET /partner/whoami # confirm the key and the account
GET /partner/stores # pull the shop list
PUT /partner/stores/:code # bind your codes — safe to re-run on every deployStock, on your own schedule
A full push nightly, plus a smaller push every 15–30 minutes for products that moved, is a good default. Both are the same call; there is no separate “incremental” mode, because an absolute figure needs none.
for each store:
for each page of ≤1000 items:
POST /partner/stock/sync
alert if unpricedCount > 0 or skipped > 0Orders, every 1–5 minutes
cursor = load_saved_cursor() # null on first ever run
loop:
page = GET /partner/orders?cursor={cursor}&limit=100
for order in page.orders:
if order.status == "confirmed" and order.ackedAt is null:
create_in_erp(order)
POST /partner/orders/{order.reference}/ack
save_cursor(page.nextCursor) # persist BEFORE the next request
if page.orders is empty: sleep and continuePersist the cursor durably. Losing it is recoverable — you can replay from the start and your ackedAt check will skip everything you already have — but it is avoidable.
Reconciliation, daily
GET /partner/stock per store, compared against your own count. Expect differences of exactly reserved units; anything else is worth investigating.
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.