Webhooks
Have StockAI notify your system when something happens: which events exist, what they carry, how to verify the signature, when deliveries are retried and how to see each one.
On this page
A webhook is an https:// address of yours that StockAI POSTs to every time an event you subscribed to happens. They are created through the API or under Automation, "Automatic notices" tab. Creating, rotating, testing and re-enabling need a plan with the API; reading and deleting always work.
Creating a webhook
curl -X POST https://app.stockai.es/api/v1/webhooks \
-H "Authorization: Bearer $STOCKAI_KEY" -H "Content-Type: application/json" \
-d '{"url":"https://example.com/stockai-hook",
"events":["purchase_order.approved","stockout.imminent"]}'
# "data": { "id": "123c733f-b575-4326-ac35-574383fb219a",
# "url": "https://example.com/stockai-hook",
# "events": ["purchase_order.approved", "stockout.imminent"],
# "warehouse_ids": null,
# "secret": "<64 hex characters, shown only now>" }urlmust start withhttps://. Private, local and cloud-metadata addresses are refused, and StockAI does not follow redirects.events: at least one from the list below.warehouse_ids(optional): limits the webhook to some warehouses. Without it, it inherits its creator's scope.secretis shown only in this response. Keep it: it is how you verify each delivery.
On the screen: "Add webhook", type the "Endpoint URL (https://)", tick the "Events to subscribe" and save. "Webhook signing secret" appears, also shown only once.
The events
| Event | When | data fields |
|---|---|---|
job.completed | A training run finished successfully. | job_id, session_id |
job.failed | A training run failed. | job_id, session_id, error |
purchase_order.approved | An order was approved. | po_log_id, po_number, warehouse, warehouse_id, sku_count, total_units, total_value, approved_amount, decided_by |
purchase_order.rejected | An order was rejected. | The order's + decided_by |
purchase_order.sent | An order was sent. | The order's + sent_at |
purchase_order.cancelled | An order was cancelled. | The order's + cancelled_at, cancelled_by |
stockout.imminent | A SKU enters stockout risk (once on entering, not every day). | sku, warehouse, warehouse_id, signal, current_stock, coverage_days, reorder_point, detected_on |
commitment.at_risk | A customer commitment is at risk. | commitment_id, sku, warehouse, warehouse_id, customer, delivery_date, quantity, shortfall, latest_safe_order_date, order_date_passed |
commitment.fulfilled | A customer commitment was fulfilled. | commitment_id, sku, warehouse, warehouse_id, customer, delivery_date, quantity, fulfilled_at |
inventory_sync.run_finished | A sync run finished. | profile_id, profile_name, run_id, status, created, updated, unchanged, rejected |
inventory_sync.blocked | A sync stopped and needs someone. | profile_id, profile_name, run_id, health, reason_code |
inventory_sync.recovered | A failing sync works again. | Same as blocked |
GET /webhooks/events returns this list with the exact fields. A field with no value comes as null, never missing. data carries no email addresses, phone numbers or free text somebody typed.
What reaches your server
POST /stockai-hook HTTP/1.1
Content-Type: application/json
User-Agent: StockAI-Webhooks/1
X-StockAI-Event: purchase_order.approved
X-StockAI-Delivery: 17cca2cb-7b41-4d8d-9407-8610ed8a5a3a
X-StockAI-Signature: t=1792468800,v1=5f2c...e91a
{
"id": "evt_ced69c658fc14c5ea9d36307eda14da0",
"type": "purchase_order.approved",
"api_version": "2026-10-05",
"occurred_at": "2026-10-23T02:46:00Z",
"tenant_id": "ten_...",
"data": {
"po_log_id": "47e8f955-...", "po_number": 8, "warehouse": "principal",
"warehouse_id": "b30808fc-...", "sku_count": 3, "total_units": 420.0,
"total_value": 1310.0, "approved_amount": 1310.0, "decided_by": "..."
}
}id- The event id. It is the same across retries: deduplicate on it, because a delivery can arrive more than once.
X-StockAI-Delivery- The delivery id. Use it in your logs.
X-StockAI-Event- The event type, equal to
type. X-StockAI-Signaturet=<Unix seconds>,v1=<signature>. See below.
Verifying the signature
- Read the raw body, as bytes, before parsing the JSON. Re-serialise it and the signature will not match.
- Split
tandv1out of theX-StockAI-Signatureheader. - Reject the delivery if
tis more than 300 seconds from your clock (replay protection). - Compute HMAC-SHA256 with your
secretovert+.+ the raw body, in hex. - Compare with
v1in constant time (hmac.compare_digest,crypto.timingSafeEqual). - Answer
2xxquickly and do the work afterwards. Deduplicate onid.
import hashlib, hmac, time
def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
t, given = int(parts["t"]), parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance: # older than 5 minutes: replay
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, given)
# Flask: verify(SECRET, request.headers["X-StockAI-Signature"], request.get_data())
# Then deduplicate on the envelope's "id" before acting.import crypto from 'node:crypto'
export function verify(secret, header, rawBody, tolerance = 300) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')))
const t = Number(parts.t)
if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > tolerance) return false
const expected = crypto.createHmac('sha256', secret)
.update(t + '.').update(rawBody).digest('hex')
const a = Buffer.from(expected), b = Buffer.from(parts.v1)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
// Express: app.post('/stockai-hook', express.raw({ type: 'application/json' }), (req, res) => {
// if (!verify(SECRET, req.get('X-StockAI-Signature') || '', req.body)) return res.sendStatus(400)
// res.sendStatus(200) // answer fast; do the work afterwards
// })Retries and auto-disable
| Attempt | When |
|---|---|
| 1 | As soon as the event happens |
| 2 | 1 minute later |
| 3 | 5 minutes later |
| 4 | 15 minutes later |
| 5 (last) | 40 minutes later |
- Retried on a network error, a timeout (10 s), a
5xx, a408or a429. - Any other status (a
4xx, a3xxredirect) fails at once, with no retries: it will not change by waiting. - Once the attempts are spent, the delivery is
failedin the log. If your server is down for more than an hour, that event is lost: for what cannot be lost, also poll the API. - A webhook that fails every delivery on 3 different days switches itself off and the account is told. Turn it back on with "Re-enable" or
POST /webhooks/{id}/enable; events from while it was off are not replayed.
Testing, reviewing and rotating
# Send a test event (type webhook.test) and read the delivery log
curl -X POST https://app.stockai.es/api/v1/webhooks/$HOOK/test -H "Authorization: Bearer $STOCKAI_KEY"
curl "https://app.stockai.es/api/v1/webhooks/$HOOK/deliveries?status=failed&limit=50" \
-H "Authorization: Bearer $STOCKAI_KEY"
# [{ "event_id": "evt_ced69c...", "event_type": "webhook.test", "is_test": true,
# "status": "failed", "attempts": 1, "last_status_code": 405, "last_error": "http_405",
# "next_attempt_at": null, ... }]
# New secret (the old one stops verifying at once), switch back on, remove
curl -X POST https://app.stockai.es/api/v1/webhooks/$HOOK/rotate-secret -H "Authorization: Bearer $STOCKAI_KEY"
curl -X POST https://app.stockai.es/api/v1/webhooks/$HOOK/enable -H "Authorization: Bearer $STOCKAI_KEY"
curl -X DELETE https://app.stockai.es/api/v1/webhooks/$HOOK -H "Authorization: Bearer $STOCKAI_KEY"- "Send test"
- Sends a
webhook.testevent, signed like the real ones. - "Deliveries"
- The log, newest first: status (
pending,delivered,failed,abandoned), attempts, last status code, last error and next attempt. Filterable bystatus, up to 200 per page. - "Rotate secret"
- Issues a new secret and the old one stops verifying at once: update your receiver right away.
- "Re-enable"
- Turns back on a webhook that switched off for failing.
In the example, the test went to an address that does not accept POST: it answered 405, which is not retried, and the delivery was failed on the first attempt.
To connect webhooks to Slack, Google Sheets or Jira without programming, see No-code automations.