Webhooks
Que StockAI avise a tu sistema cuando pasa algo: qué eventos hay, qué traen, cómo verificar la firma, cuándo se reintenta y cómo ver cada entrega.
En esta página
Un webhook es una dirección https:// tuya a la que StockAI hace un POST cada vez que ocurre un evento al que te suscribiste. Se crean por API o en Automatización, pestaña «Avisos automáticos». Crear, rotar, probar y reactivar necesitan un plan con API; leer y borrar siempre se puede.
Crear un 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>" }urldebe empezar conhttps://. No se aceptan direcciones privadas, locales ni de metadatos de nube, y StockAI no sigue redirecciones.events: al menos uno de la lista de abajo.warehouse_ids(opcional): limita el webhook a algunas bodegas. Sin él, hereda el alcance de quien lo crea.secretse muestra solo en esta respuesta. Guárdalo: con él verificas cada entrega.
En la pantalla: «Agregar webhook», escribe la «URL del endpoint (https://)», marca los «Eventos a suscribir» y guarda. Aparece «Clave de firma del webhook», que también se muestra una sola vez.
Los eventos
| Evento | Cuándo | Campos de data |
|---|---|---|
job.completed | Un entrenamiento terminó bien. | job_id, session_id |
job.failed | Un entrenamiento falló. | job_id, session_id, error |
purchase_order.approved | Se aprobó una orden. | po_log_id, po_number, warehouse, warehouse_id, sku_count, total_units, total_value, approved_amount, decided_by |
purchase_order.rejected | Se rechazó una orden. | Los de la orden + decided_by |
purchase_order.sent | Se envió una orden. | Los de la orden + sent_at |
purchase_order.cancelled | Se canceló una orden. | Los de la orden + cancelled_at, cancelled_by |
stockout.imminent | Un SKU entra en riesgo de quiebre (una vez al entrar, no cada día). | sku, warehouse, warehouse_id, signal, current_stock, coverage_days, reorder_point, detected_on |
commitment.at_risk | Un pedido comprometido de un cliente está en riesgo. | commitment_id, sku, warehouse, warehouse_id, customer, delivery_date, quantity, shortfall, latest_safe_order_date, order_date_passed |
commitment.fulfilled | Un pedido comprometido se cumplió. | commitment_id, sku, warehouse, warehouse_id, customer, delivery_date, quantity, fulfilled_at |
inventory_sync.run_finished | Terminó una corrida de sincronización. | profile_id, profile_name, run_id, status, created, updated, unchanged, rejected |
inventory_sync.blocked | Una sincronización se detuvo y necesita a alguien. | profile_id, profile_name, run_id, health, reason_code |
inventory_sync.recovered | Una sincronización que fallaba volvió a funcionar. | Los mismos que blocked |
GET /webhooks/events devuelve esta lista con los campos exactos. Un campo sin valor viene como null, nunca falta. data no trae correos, teléfonos ni texto libre que alguien escribió.
Lo que llega a tu servidor
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- El id del evento. Es el mismo en todos los reintentos: deduplica por él, porque una entrega puede llegar más de una vez.
X-StockAI-Delivery- El id de la entrega. Úsalo para tus logs.
X-StockAI-Event- El tipo de evento, igual a
type. X-StockAI-Signaturet=<segundos Unix>,v1=<firma>. Ver abajo.
Verificar la firma
- Lee el cuerpo crudo, en bytes, antes de interpretar el JSON. Si lo vuelves a serializar, la firma no coincide.
- Separa
tyv1de la cabeceraX-StockAI-Signature. - Rechaza la entrega si
tse aleja más de 300 segundos de tu reloj (protege contra repeticiones). - Calcula HMAC-SHA256 con tu
secretsobret+.+ el cuerpo crudo, en hexadecimal. - Compara con
v1en tiempo constante (hmac.compare_digest,crypto.timingSafeEqual). - Responde
2xxrápido y haz el trabajo después. Deduplica porid.
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
// })Reintentos y desactivación
| Intento | Cuándo |
|---|---|
| 1 | En cuanto ocurre el evento |
| 2 | 1 minuto después |
| 3 | 5 minutos después |
| 4 | 15 minutos después |
| 5 (último) | 40 minutos después |
- Se reintenta ante un error de red, un tiempo de espera agotado (10 s), un
5xx, un408o un429. - Cualquier otro código (un
4xx, una redirección3xx) falla en el acto, sin reintentos: no va a cambiar esperando. - Agotados los intentos, la entrega queda
faileden el log. Si tu servidor está caído más de una hora, ese evento se pierde: para lo que no se puede perder, consulta también la API. - Un webhook que falla todas sus entregas en 3 días distintos se apaga solo y la cuenta recibe un aviso. Reactívalo con «Reactivar» o
POST /webhooks/{id}/enable; los eventos de mientras estuvo apagado no se reenvían.
Probar, revisar y rotar
# 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"- «Enviar prueba»
- Manda un evento
webhook.test, firmado igual que los reales. - «Entregas»
- El log, lo más nuevo primero: estado (
pending,delivered,failed,abandoned), intentos, último código, último error y próximo intento. Filtrable porstatus, hasta 200 por página. - «Rotar clave»
- Emite una clave nueva y la vieja deja de verificar en el acto: actualiza tu receptor enseguida.
- «Reactivar»
- Enciende un webhook que se apagó por fallar.
En el ejemplo, la prueba fue a una dirección que no acepta POST: respondió 405, que no se reintenta, y la entrega quedó failed al primer intento.
Para conectar webhooks a Slack, Google Sheets o Jira sin programar, mira Automatizaciones sin código.