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
  1. Creating a webhook
  2. The events
  3. What reaches your server
  4. Verifying the signature
  5. Retries and auto-disable
  6. Testing, reviewing and rotating

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>" }
  • url must start with https://. 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.
  • secret is 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

EventWhendata fields
job.completedA training run finished successfully.job_id, session_id
job.failedA training run failed.job_id, session_id, error
purchase_order.approvedAn order was approved.po_log_id, po_number, warehouse, warehouse_id, sku_count, total_units, total_value, approved_amount, decided_by
purchase_order.rejectedAn order was rejected.The order's + decided_by
purchase_order.sentAn order was sent.The order's + sent_at
purchase_order.cancelledAn order was cancelled.The order's + cancelled_at, cancelled_by
stockout.imminentA 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_riskA customer commitment is at risk.commitment_id, sku, warehouse, warehouse_id, customer, delivery_date, quantity, shortfall, latest_safe_order_date, order_date_passed
commitment.fulfilledA customer commitment was fulfilled.commitment_id, sku, warehouse, warehouse_id, customer, delivery_date, quantity, fulfilled_at
inventory_sync.run_finishedA sync run finished.profile_id, profile_name, run_id, status, created, updated, unchanged, rejected
inventory_sync.blockedA sync stopped and needs someone.profile_id, profile_name, run_id, health, reason_code
inventory_sync.recoveredA 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-Signature
t=<Unix seconds>,v1=<signature>. See below.

Verifying the signature

  1. Read the raw body, as bytes, before parsing the JSON. Re-serialise it and the signature will not match.
  2. Split t and v1 out of the X-StockAI-Signature header.
  3. Reject the delivery if t is more than 300 seconds from your clock (replay protection).
  4. Compute HMAC-SHA256 with your secret over t + . + the raw body, in hex.
  5. Compare with v1 in constant time (hmac.compare_digest, crypto.timingSafeEqual).
  6. Answer 2xx quickly and do the work afterwards. Deduplicate on id.
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

AttemptWhen
1As soon as the event happens
21 minute later
35 minutes later
415 minutes later
5 (last)40 minutes later
  • Retried on a network error, a timeout (10 s), a 5xx, a 408 or a 429.
  • Any other status (a 4xx, a 3xx redirect) fails at once, with no retries: it will not change by waiting.
  • Once the attempts are spent, the delivery is failed in 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.test event, 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 by status, 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.