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.

advanced ·16 min read ·Python ·Updated 3 Sep 2026

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/create
curl -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:

FieldMeaning
quantityTotal physical units across all warehouses
quantities[].quantityPhysical units in one warehouse
offered_stockWhat 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 2xx inside 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.

A Stockpilot engineer talking through an integration with a developer

Stuck on something

Ask a person, not a search box

The API surface is wide, and some of it only makes sense once someone explains why it works that way. If a payload is not doing what you expect, or you are weighing two approaches, say so and we will look at it with you.