Apariencia
Webhooks
Un webhook avisa a tu sistema en el momento en que pasa algo en Safety Panel, en lugar de que tu sistema tenga que consultar cada tanto con updated_since. Lo típico es un ERP que descuenta stock en cuanto se firma una entrega de EPP.
Cada aviso es un POST a una dirección https:// de tu sistema. Lleva solo el tipo de evento y el id del registro: para conocer el registro, tu sistema lo lee por la API con la misma key. Así hay un solo formato de cada recurso, el de Recursos.
Para crear uno, ver Recibir eventos con un webhook.
Eventos
| Evento | Cuándo se envía | Cómo se lee |
|---|---|---|
ppe_delivery.signed | El empleado firmó una entrega de EPP, en su teléfono o en un tótem. | La entrega, con ppe_deliveries:read. |
ppe_delivery_return.created | Se registró una devolución de una entrega firmada. | La entrega de data.ppe_delivery_id, donde la devolución aparece en returns, con ppe_deliveries:read. |
certification.expiring | A una constancia vigente le quedan 30 días o menos para vencer. Se avisa una vez por constancia y fecha de vencimiento. | La constancia, con certifications:read. |
certification.expired | Una constancia venció, sola al pasar su fecha o porque alguien la marcó como vencida en el panel. | La constancia, con certifications:read. |
incident.created | Se cargó un incidente: un reporte de un empleado o una investigación abierta desde una plantilla. | El incidente, con incidents:read. |
En todos, data.id es el id del registro: GET /api/v1/ppe_deliveries/:id, /certifications/:id o /incidents/:id. En una devolución, data.id es el de la devolución y data.ppe_delivery_id el de su entrega.
Un webhook recibe solo los eventos que eligió y que su key puede leer. La key se comprueba en cada envío: si se revoca, si la API se apaga en la cuenta o si la key no tiene el permiso del evento, el aviso no sale.
Sobre certification.expiring
Safety Panel revisa una vez por día, en el día de la cuenta, las constancias vigentes que vencen dentro de los próximos 30 días y avisa las que todavía no avisó. Si un día no se revisa, se ponen al día al siguiente. Una constancia cargada con menos de 30 días de vigencia se avisa en la siguiente revisión. Si se extiende su vencimiento, se vuelve a avisar cuando la nueva fecha quede a 30 días.
Para conocer todas las que vencen pronto en un momento dado, leé las constancias con expires_before.
El aviso
http
POST /webhooks/safetypanel HTTP/1.1
Host: erp.ejemplo.com
Content-Type: application/json
User-Agent: SafetyPanel-Webhooks/1
SafetyPanel-Event-Id: 0b8f2c1e-6a3d-4f7b-9c2e-1d5a8e4f7b90
SafetyPanel-Event-Type: ppe_delivery.signed
SafetyPanel-Signature: t=1792675200,v1=5f1c…e9a2
{"id":"0b8f2c1e-6a3d-4f7b-9c2e-1d5a8e4f7b90","type":"ppe_delivery.signed","occurred_at":"2026-10-22T14:00:00Z","data":{"id":1234}}| Campo | Qué es |
|---|---|
id | El id del evento. Es el mismo en cada reintento: usalo para no procesar dos veces el mismo aviso. |
type | El evento, de la tabla de arriba. |
occurred_at | Cuándo ocurrió, en UTC. |
data | El id del registro (y, en una devolución, el de su entrega). Nada más. |
Respondé con cualquier código 2xx, cuanto antes: procesá el aviso después de responder. Lo que contestes en el cuerpo no se lee.
Verificar la firma
Cada aviso trae la cabecera SafetyPanel-Signature con dos partes: t, el momento del envío en segundos desde 1970, y v1, un HMAC-SHA256 en hexadecimal de t, un punto y el cuerpo tal como llegó, firmado con el secreto del webhook (empieza con whsec_).
Antes de procesar un aviso, comprobá que:
v1coincide con el HMAC que calculás vos, comparado en tiempo constante.tno tiene más de 5 minutos. Así un aviso capturado no se puede volver a enviar más tarde.
ruby
require "openssl"
def safetypanel_signature_valid?(body, header, secret, tolerance: 300)
parts = header.to_s.split(",").to_h { |part| part.split("=", 2) }
timestamp = Integer(parts["t"], exception: false)
return false if timestamp.nil? || (Time.now.to_i - timestamp).abs > tolerance
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{body}")
OpenSSL.secure_compare(expected, parts["v1"].to_s)
endjs
import crypto from "node:crypto";
export function safetypanelSignatureValid(body, header, secret, tolerance = 300) {
const parts = Object.fromEntries((header ?? "").split(",").map((part) => part.split("=", 2)));
const timestamp = Number.parseInt(parts.t, 10);
if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > tolerance) return false;
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
const received = Buffer.from(parts.v1 ?? "", "utf8");
return received.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected, "utf8"), received);
}Calculá la firma sobre el cuerpo crudo, antes de interpretarlo como JSON: volver a serializarlo cambia los espacios y la firma deja de coincidir.
Reintentos
Un aviso que no recibe un 2xx se reintenta. En total son cinco intentos: el primero en el momento y los demás 1 minuto, 5 minutos, 30 minutos y 2 horas después del anterior. Después se da por perdido. Una redirección (3xx) cuenta como falla y no se sigue.
Cada intento espera hasta 5 segundos para conectarse y hasta 10 para la respuesta, y no dura más de 15 en total: un intento que no recibe el código a tiempo cuenta como falla. Los avisos no llegan necesariamente en el orden en que ocurrieron: ordenalos por occurred_at si lo necesitás.
Si cinco avisos seguidos se dan por perdidos, el webhook se desactiva y deja de recibir eventos. El panel lo avisa en Configuración. Cuando tu sistema vuelva a responder, reactivalo desde Configuración → API → Webhooks. Los avisos de mientras estuvo desactivado no se reenvían: leé lo que cambió con updated_since.
A qué direcciones se envía
Solo a direcciones https:// con un dominio público. Safety Panel no envía a direcciones privadas, locales ni de la red interna, y lo comprueba en cada envío, también después de resolver el dominio. La dirección no puede llevar usuario ni contraseña: para autenticar el aviso está la firma.
Registro de envíos
Safety Panel guarda cada envío 30 días y después lo borra. No es un registro de cumplimiento: la evidencia es el registro mismo (la entrega firmada, la constancia, el incidente), que se conserva siempre.