Tutorial 06
Sync offered stock to an ERP without double counting
Consume inventory.stock_changed, read the right field across multiple warehouses, and avoid the inbound double count that skews every naive integration.
Pushing stock into an ERP looks trivial until the seller adds a second warehouse. This tutorial builds a consumer that stays correct across warehouses, buffers and inbound purchase orders.
Read Receive your first webhook first if you have not set up a verified endpoint yet.
Subscribe
POST /webhooks/createcurl -s -X POST https://api.stockpilot.dev/webhooks/create \
-H "X-CLIENT-ID: $SP_CLIENT_ID" \
-H "X-CLIENT-SECRET: $SP_CLIENT_SECRET" \
-H "Content-Type: application/json" \
-d '{
"name": "erp stock sync",
"url": "https://erp-bridge.example.com/hooks/stock",
"event": "inventory.stock_changed",
"secret": "'"$WEBHOOK_SECRET"'"
}'
Leave warehouse_id off so you receive every change. Scoping to one warehouse filters which changes fire a delivery, but the body you receive is the full product payload either way.
What arrives
A single delivery describes one product across every warehouse it lives in:
{
"event": "inventory.stock_changed",
"delivery_id": "dl_2c9b40e1",
"data": {
"product_id": 90211,
"sku": "TSHIRT-BLK-M",
"quantity": 60,
"incoming": 120,
"offered_stock": 38,
"quantities": [
{ "warehouse_id": 1, "name": "Main", "quantity": 44, "inbound": 120, "sums_onto_offered": true },
{ "warehouse_id": 7, "name": "Amazon FBA", "quantity": 16, "inbound": 0, "sums_onto_offered": false }
]
}
}
Read the right number
This is where integrations go wrong. Three plausible fields mean three different things:
| Field | Meaning |
|---|---|
quantity | Total physical units across all warehouses |
quantities[].quantity | Physical units in one warehouse |
offered_stock | What Stockpilot advertises to sales channels: the sum of warehouses with sums_onto_offered: true, minus buffer stock |
For an ERP that drives availability, offered_stock is almost always the field you want. In the payload above, the FBA warehouse holds 16 units that Amazon fulfils directly, so they are excluded from the offer. Summing quantities[].quantity would tell your ERP 60 units are sellable when the real answer is 38.
Do not sum inbound across warehouses. On the default warehouse, per-warehouse inbound repeats the product level incoming, because purchase orders land there. Adding them together double counts every inbound unit. Use the product level incoming field.
There are no deltas
Every payload is current state. No before and after pair is sent, and none can be inferred, because deliveries are coalesced: changes to the same product in the same warehouse within roughly five seconds collapse into one delivery. Coalescing does not merge across warehouses.
The practical consequence is that your handler must be a setter, not an adjuster:
def apply(event):
data = event["data"]
erp.set_available(sku=data["sku"], units=data["offered_stock"]) # correct
# erp.adjust_available(sku, delta) # wrong, there is no delta
A consumer that survives production
import base64, hashlib, hmac, json, os
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
SECRET = os.environ["WEBHOOK_SECRET"].encode()
@app.post("/hooks/stock")
async def stock(request: Request):
raw = await request.body()
digest = hmac.new(SECRET, raw, hashlib.sha256).digest()
expected = base64.b64encode(digest).decode()
if not hmac.compare_digest(request.headers.get("X-Stockpilot-Signature", ""), expected):
raise HTTPException(401, "bad signature")
event = json.loads(raw)
# at-least-once delivery: dedupe before doing any work
if seen_before(event["delivery_id"]):
return {"ok": True}
queue.enqueue(sync_to_erp, event["data"]) # return fast, work later
return {"ok": True}
Four rules are doing the work here:
- Verify over raw bytes. Re-serialising the parsed body changes key order and breaks the signature.
- Dedupe on
delivery_id. Delivery is at-least-once, so the same event can arrive twice. - Return
2xxinside 10 seconds. An ERP write is far too slow to do inline. Queue it. - Treat the payload as current state. Set values, never adjust them.
Reconcile on a schedule
Webhooks are the fast path, not the only path. A nightly reconciliation catches anything missed during an outage on your side:
def reconcile(client):
page = 1
while True:
data = client.get("/inventory", params={"page": page, "page_size": 1000}).json()
for item in data["results"]:
erp.set_available(sku=item["sku"], units=item["offered_stock"])
if len(data["results"]) < 1000:
return
page += 1
Cross-check warehouse level numbers with GET /warehouses/get and GET /warehouses/{unique_id}/items when a discrepancy needs investigating.
When deliveries stop arriving
After five fully failed deliveries in 24 hours, a webhook is deactivated automatically. Your bridge should notice and recover:
hooks = client.get("/webhooks").json()
for hook in hooks:
if not hook["is_active"]:
client.post(f"/webhooks/{hook['id']}/reactivate")
alert(f"reactivated webhook {hook['id']}")
One more operational note worth passing to whoever runs your network: deliveries originate from Stockpilot’s worker infrastructure, not from the API gateway. If your ERP sits behind an IP allowlist, allowlisting the gateway addresses will not work. Ask for the worker egress ranges.
The complete contract, including the 15 attempt retry ladder, is in the webhooks guide.
