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
  1. Crear un webhook
  2. Los eventos
  3. Lo que llega a tu servidor
  4. Verificar la firma
  5. Reintentos y desactivación
  6. Probar, revisar y rotar

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>" }
  • url debe empezar con https://. 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.
  • secret se 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

EventoCuándoCampos de data
job.completedUn entrenamiento terminó bien.job_id, session_id
job.failedUn entrenamiento falló.job_id, session_id, error
purchase_order.approvedSe aprobó una orden.po_log_id, po_number, warehouse, warehouse_id, sku_count, total_units, total_value, approved_amount, decided_by
purchase_order.rejectedSe rechazó una orden.Los de la orden + decided_by
purchase_order.sentSe envió una orden.Los de la orden + sent_at
purchase_order.cancelledSe canceló una orden.Los de la orden + cancelled_at, cancelled_by
stockout.imminentUn 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_riskUn 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.fulfilledUn pedido comprometido se cumplió.commitment_id, sku, warehouse, warehouse_id, customer, delivery_date, quantity, fulfilled_at
inventory_sync.run_finishedTerminó una corrida de sincronización.profile_id, profile_name, run_id, status, created, updated, unchanged, rejected
inventory_sync.blockedUna sincronización se detuvo y necesita a alguien.profile_id, profile_name, run_id, health, reason_code
inventory_sync.recoveredUna 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-Signature
t=<segundos Unix>,v1=<firma>. Ver abajo.

Verificar la firma

  1. Lee el cuerpo crudo, en bytes, antes de interpretar el JSON. Si lo vuelves a serializar, la firma no coincide.
  2. Separa t y v1 de la cabecera X-StockAI-Signature.
  3. Rechaza la entrega si t se aleja más de 300 segundos de tu reloj (protege contra repeticiones).
  4. Calcula HMAC-SHA256 con tu secret sobre t + . + el cuerpo crudo, en hexadecimal.
  5. Compara con v1 en tiempo constante (hmac.compare_digest, crypto.timingSafeEqual).
  6. Responde 2xx rápido y haz el trabajo después. Deduplica por 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
// })

Reintentos y desactivación

IntentoCuándo
1En cuanto ocurre el evento
21 minuto después
35 minutos después
415 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, un 408 o un 429.
  • Cualquier otro código (un 4xx, una redirección 3xx) falla en el acto, sin reintentos: no va a cambiar esperando.
  • Agotados los intentos, la entrega queda failed en 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 por status, 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.