Con los webhooks, Kalmee avisa a tu programa al momento: envía un POST con JSON a la dirección que configures en Kalmee → Integraciones (API) cada vez que pasa algo en la cola o en las citas.
Añade una dirección https pública y elige los avisos. Kalmee te enseña una sola vez el secreto de firma, que empieza por whsec_. Con «Enviar prueba» recibes un evento ping.
Eventos
- Evento:
ticket.issuedCuándo: Se da un turno - Evento:
ticket.calledCuándo: Se llama un turno o se vuelve a llamar - Evento:
ticket.servedCuándo: Se termina de atender - Evento:
ticket.no_showCuándo: La persona no se presenta - Evento:
ticket.cancelledCuándo: Se anula o caduca al cerrar el día - Evento:
ticket.transferredCuándo: Se pasa a otro trámite o a otra mesa - Evento:
appointment.createdCuándo: Se reserva una cita - Evento:
appointment.cancelledCuándo: Se anula una cita - Evento:
appointment.rescheduledCuándo: Cambia la hora de una cita - Evento:
appointment.checked_inCuándo: La persona llega y confirma la cita - Evento:
pingCuándo: Pulsas «Enviar prueba» en Integraciones (API).
Contenido
Todos los eventos tienen la misma forma: id (único: sirve para descartar repetidos), type, api_version, occurred_at, org_id y data. No llevan nombre, correo, teléfono ni DNI: solo identificadores, el número de turno, la mesa, horas y estados.
Ejemplos:
{
"id": "evt_01f3a6c29b7e4d1a8c5f2e7b9d4a6f10",
"type": "ticket.called",
"api_version": "2026-10",
"occurred_at": "2026-10-20T06:14:03.000Z",
"org_id": "7a0c5f3e-2b1d-4c8e-9f6a-1d2e3f4a5b6c",
"data": {
"ticket": {
"id": "1b9e2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"number_label": "A023",
"status": "called",
"kind": "walkin",
"source": "in_person",
"office_id": "c41d8e2a-6b7f-4a1c-9d3e-5f6a7b8c9d0e",
"service_id": "9f00a1b2-c3d4-4e5f-8a6b-7c8d9e0f1a2b",
"ticket_type_id": null,
"appointment_id": null,
"desk_id": "55aa6b7c-8d9e-4f0a-b1c2-d3e4f5a6b7c8",
"desk_number": 4,
"call_count": 1,
"transfer_count": 0,
"issued_at": "2026-10-20T06:02:41.000Z",
"called_at": "2026-10-20T06:14:03.000Z",
"last_called_at": "2026-10-20T06:14:03.000Z",
"started_at": null,
"finished_at": null
},
"recall": false
}
}
{
"id": "evt_01f3a6c29b7e4d1a8c5f2e7b9d4a6f10",
"type": "appointment.created",
"api_version": "2026-10",
"occurred_at": "2026-10-20T06:14:03.000Z",
"org_id": "7a0c5f3e-2b1d-4c8e-9f6a-1d2e3f4a5b6c",
"data": {
"appointment": {
"id": "e2d4f6a8-b0c2-4d4e-8f6a-8b0c2d4e6f8a",
"status": "booked",
"source": "online",
"office_id": "c41d8e2a-6b7f-4a1c-9d3e-5f6a7b8c9d0e",
"service_id": "9f00a1b2-c3d4-4e5f-8a6b-7c8d9e0f1a2b",
"starts_at": "2026-10-22T07:20:00.000Z",
"ends_at": "2026-10-22T07:30:00.000Z",
"ticket_id": null,
"created_at": "2026-10-18T17:40:11.000Z",
"cancelled_at": null,
"checked_in_at": null
}
}
}
Cabeceras
POST /tu/direccion HTTP/1.1
Content-Type: application/json
User-Agent: Kalmee-Webhooks/1
Kalmee-Signature: t=1792476843,v1=5f2c8a…e9
Kalmee-Event-Id: evt_01f3a6c29b7e4d1a8c5f2e7b9d4a6f10
Kalmee-Event-Type: ticket.called
Kalmee-Delivery-Id: dlv_8d2e4f6a0b1c4d3e9f5a7b8c6d4e2f10
Kalmee-Signature: t (segundos Unix) y v1, la firma HMAC-SHA256.Kalmee-Event-Id: el mismo id del cuerpo.Kalmee-Event-Type: el mismo type del cuerpo.Kalmee-Delivery-Id: igual en todos los reintentos de un mismo envío.
Comprobar la firma
Calcula HMAC-SHA256 de «t.cuerpo» con el secreto entero (whsec_…) como clave y compáralo con v1 en tiempo constante. Usa el cuerpo tal como llega, sin volver a convertirlo a JSON, y rechaza las firmas de más de 5 minutos.
Node.js
// Node.js 18+ (Express): usa el cuerpo sin procesar, tal como llega.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.KALMEE_WEBHOOK_SECRET; // whsec_…
const app = express();
app.post("/kalmee/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("Kalmee-Signature") ?? "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
const signatures = header.split(",").filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(400);
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${req.body}`).digest("hex");
const ok = signatures.some((s) => s.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(204); // responde ya y procesa después
// descarta repetidos por event.id y trata event.type
});
PHP
<?php
// PHP 7.4+: usa el cuerpo sin procesar, tal como llega.
$secret = getenv('KALMEE_WEBHOOK_SECRET'); // whsec_…
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_KALMEE_SIGNATURE'] ?? '';
$t = 0;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't') { $t = (int) $value; }
if ($key === 'v1') { $signatures[] = $value; }
}
if ($t === 0 || abs(time() - $t) > 300) { http_response_code(400); exit; }
$expected = hash_hmac('sha256', $t . '.' . $body, $secret);
$ok = false;
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) { $ok = true; }
}
if (!$ok) { http_response_code(400); exit; }
$event = json_decode($body, true);
http_response_code(204); // responde ya y procesa después
// descarta repetidos por $event['id'] y trata $event['type']
Respuesta, reintentos y orden
Responde con un 2xx en menos de 10 segundos y haz el trabajo después. Si no, Kalmee reintenta al cabo de 1 min, 5 min, 30 min, 2 h, 6 h y 15 h (unas 24 horas en total, con un margen aleatorio). Las redirecciones no se siguen y un 410 Gone desactiva la dirección.
1 min → 5 min → 30 min → 2 h → 6 h → 15 h
El orden no está garantizado y un evento puede llegar más de una vez: descarta los repetidos por id y quédate con el occurred_at más reciente.
Si una dirección falla más de 15 veces seguidas durante al menos un día, Kalmee la desactiva y lo avisa en Integraciones (API).
Solo se envía a direcciones https públicas (puertos 443 u 8443), nunca a redes privadas o locales. El cuerpo de la respuesta no se lee ni se guarda.