Webhooks
Kicket le avisa a tu sistema cuando una venta o un producto cambia, con un POST firmado. Así no tenés que consultar a cada rato.
Configurar un endpoint
- En Integraciones › API de Kicket, en la parte de Webhooks, tocá Nuevo endpoint.
- Poné la dirección de tu sistema (tiene que ser
https://, pública y en el puerto 443 o 8443), elegí el entorno y los eventos que querés recibir. - Copiá el secreto (
whsec_…): con él verificás que cada aviso viene de Kicket. - Tocá Enviar prueba: te llega un
test.ping. En Ver entregas ves cada intento, con el status que contestó tu sistema, y podés reintentar uno a mano.
Los eventos
| Evento | Qué es | Cuándo sale |
|---|---|---|
sale.closed | Venta cobrada | La primera vez que una venta queda cobrada (CLOSED). Una ronda de mesa abierta (OPEN) no avisa. |
sale.updated | Venta modificada después del cobro | Una venta ya avisada cambió: una edición después del cobro, una devolución parcial, el medio de pago corregido. |
sale.canceled | Venta anulada | Una venta se anuló sin devolver plata. |
sale.refunded | Venta devuelta | Una venta se anuló y se devolvió la plata. |
sale.deleted | Venta eliminada | Una venta se borró. `data` es la lápida. |
product.updated | Producto o combo modificado | Un producto o combo se creó o cambió (no hay `product.created`). Agotarse no cuenta: es stock. |
product.deleted | Producto o combo eliminado | Un producto o combo se borró. `data` es la lápida. |
Además, test.ping llega sólo cuando tocás «Enviar prueba». Un aviso sale, en general, uno o dos minutos después del cambio.
test.ping: la cuenta de prueba es una cadena de ficción con su historia ya cargada, y no tiene ventas nuevas. Para probar tu receptor con el cuerpo de una venta, armalo con el ejemplo de esta página o con un GET /sales/{id} de la cuenta de prueba, firmalo con tu secreto y mandátelo vos.El cuerpo
Un JSON con el tipo, el momento y el objeto tal cual lo devuelve la API (una venta, un producto o una lápida):
{
"type": "sale.closed",
"timestamp": "2026-10-27T13:03:51.204Z",
"data": {
"id": "6720a3f1c9e2b4d7a1f3e8c2",
"object": "sale",
"status": "CLOSED",
"total": 9810,
"…": "la venta entera, como en GET /sales/{id}"
}
}updated_at, o pedí el objeto por id.La firma
Cada aviso sigue la especificación Standard Webhooks y trae tres headers:
| Header | Qué es |
|---|---|
webhook-id | El id del mensaje (msg_…). Es el mismo en cada reintento: deduplicá por él. |
webhook-timestamp | Los segundos desde 1970 de este intento. |
webhook-signature | v1, + el HMAC-SHA256 en base64 de <webhook-id>.<webhook-timestamp>.<cuerpo>, con la clave que sale de decodificar en base64 lo que va después de whsec_. Puede traer varias separadas por espacio. |
Cómo se verifica
- Leé el cuerpo CRUDO, sin parsear: se firma byte por byte, y un JSON re-armado no coincide.
- Rechazá si
webhook-timestampestá a más de 5 minutos de tu reloj. - Calculá la firma esperada y comparala en tiempo constante con cada firma
v1del header. - Si alguna coincide, es de Kicket. Si no, contestá 400 y no hagas nada.
import { createHmac, timingSafeEqual } from 'node:crypto'
// secreto: el whsec_… del endpoint. headers: los del pedido (en minúscula, como los da Node).
// cuerpo: el cuerpo CRUDO, tal cual llegó (antes de JSON.parse): se firma byte por byte.
export function verificarWebhook(secreto, headers, cuerpo) {
const id = headers['webhook-id']
const ts = headers['webhook-timestamp']
const firmas = headers['webhook-signature']
if (!id || !ts || !firmas || !/^\d+$/.test(ts)) return false
// Más de 5 minutos de diferencia con tu reloj: se rechaza (frena que alguien reenvíe uno viejo).
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
const clave = Buffer.from(secreto.replace(/^whsec_/, ''), 'base64')
const esperada = createHmac('sha256', clave).update(`${id}.${ts}.${cuerpo}`).digest()
// Puede haber varias firmas separadas por espacio (mientras rotás el secreto): alcanza con que una coincida.
return firmas.split(' ').some((parte) => {
const [version, valor] = parte.split(',')
if (version !== 'v1' || !valor) return false
const recibida = Buffer.from(valor, 'base64')
return recibida.length === esperada.length && timingSafeEqual(recibida, esperada)
})
}Un receptor completo:
import { createServer } from 'node:http'
import { verificarWebhook } from './verificar.mjs'
const SECRETO = process.env.KICKET_WEBHOOK_SECRET // el whsec_… que te mostró el panel
createServer((req, res) => {
const partes = []
req.on('data', (p) => partes.push(p))
req.on('end', () => {
const cuerpo = Buffer.concat(partes).toString('utf8')
if (req.method !== 'POST' || !verificarWebhook(SECRETO, req.headers, cuerpo)) {
res.writeHead(400).end()
return
}
// Contestá enseguida (menos de 10 s) y procesá después: si tardás, se cuenta como fallido y se reintenta.
res.writeHead(200).end()
const evento = JSON.parse(cuerpo)
// Deduplicá por webhook-id: un reintento trae el mismo id.
encolar(req.headers['webhook-id'], evento)
})
}).listen(3000)standardwebhooks en npm o PyPI), también sirve: pasale el secreto tal cual, con su whsec_.Rotar el secreto
Al rotar el secreto en el panel, durante 24 horas cada aviso va firmado con los dos (dos firmas en el header): tenés ese tiempo para cargar el nuevo sin perder ninguno.
Respuestas y reintentos
- Contestá cualquier 2xx en menos de 10 segundos. Recibí, guardá y contestá; el trabajo pesado, después.
- Cualquier otra cosa (otro status, una redirección, un timeout, un error de red) es un intento fallido. Se reintenta con estas esperas: 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h (8 intentos en total, en poco más de un día). Si contestás 429 o 503 con
Retry-After, se respeta si pide esperar más. - Después del último intento la entrega queda fallida; la podés reintentar a mano desde el panel.
- Si contestás 410, el endpoint se desactiva (tu sistema dice que esa dirección ya no existe). Si falla 5 días seguidos, también se desactiva solo. Se reactiva desde el panel.
- Como un aviso puede llegar más de una vez, deduplicá por
webhook-id. - Un endpoint que falla o que tarda más de 5 segundos en contestar se frena hasta la vuelta siguiente (un minuto): lo suyo queda en la cola sin gastar intentos, y los avisos de los demás siguen saliendo. Contestar rápido es lo que hace que te lleguen todos al toque.
- «Enviar prueba» y «Reintentar» tienen un tope de 10 por minuto por cuenta.
Webhooks y sincronización, juntos
Lo más robusto es usar los dos: el webhook te avisa al toque y la sincronización incremental cada tanto (por ejemplo, cada 15 minutos) levanta lo que un aviso perdido no trajo. Un webhook no reemplaza a la sincronización: si tu sistema estuvo caído más de un día, lo que falte lo trae updated_since.