Ir al contenido

Webhooks

Con los webhooks, Posdata le avisa a tu servidor cuando pasa algo en tu negocio (una venta completada, una factura emitida, un cierre de caja) en vez de que tú consultes la API a cada rato. Están incluidos en el plan Pro, igual que la API.

Los endpoints se crean y administran solo en cuenta.posdata.so → Desarrolladores → Webhooks; no hay endpoints de la API para gestionarlos. Así, una API key filtrada nunca puede desviar tus eventos a otro servidor.

  1. Elige agregar endpoint, escribe la URL de tu servidor y marca los eventos que quieres recibir.
  2. Confirma con el código de 6 dígitos que llega al correo de la cuenta. La cuenta recibe además un correo de aviso de seguridad (también cuando se cambia la URL de un endpoint).
  3. Copia el secreto del endpoint (empieza por whsec_). Solo se muestra una vez. Lo necesitas para verificar las firmas.
  4. Usa Enviar evento de prueba para comprobar que tu servidor recibe y verifica bien.

Reglas de la URL:

  • Solo https:// y el puerto 443 (el estándar). No se permiten otros puertos ni http://.
  • Un nombre de dominio público (no una dirección IP, ni localhost, ni dominios internos como .local).
  • Sin usuario ni contraseña en la URL y sin fragmento (#…).
  • No se siguen redirecciones: una respuesta 3xx cuenta como entrega fallida.

Una cuenta puede tener hasta 5 endpoints. Desde cuenta también puedes pausarlos y reanudarlos, editarlos, eliminarlos y ver el historial de entregas de cada uno.

Evento Cuándo se envía
sale.created Una venta nueva llegó a la nube (la subió una caja). Una sola vez por venta.
sale.updated Una caja subió un cambio de una venta existente. Puede repetirse.
sale.completed La venta quedó completada. Una sola vez por venta.
sale.cancelled La venta quedó anulada. Una sola vez por venta.
product.created Se creó un producto, desde una caja o por la API. Una sola vez por producto.
product.updated Se editó un producto o una de sus variantes, desde una caja o por la API. Puede repetirse.
customer.created Se creó un cliente, desde una caja o por la API. Una sola vez por cliente.
customer.updated Se editó un cliente, desde una caja o por la API. Puede repetirse.
inventory.movement.created Se registró un movimiento de inventario, desde una caja o por la API (incluye el initial_stock de un producto nuevo). Una sola vez por movimiento.
invoice.issued Un documento de la venta quedó emitido (status: issued), por ejemplo cuando la DIAN acepta una factura electrónica o un documento POS electrónico. Una sola vez por documento.
invoice.rejected La DIAN rechazó un documento de la venta. Una sola vez por documento.
cash_session.closed Se cerró un turno de caja. Una sola vez por turno.
webhook.test Solo cuando usas Enviar evento de prueba en cuenta. No se puede suscribir, nunca trae datos reales (data.object es { "object": "test", … }) y no aparece en GET /events.

Cosas que conviene saber:

  • Una sola vez por objeto significa que, aunque una caja reintente subir la misma venta (lo hace si se le corta la conexión), el evento se registra una vez. Los *.updated sí pueden llegar varias veces para el mismo objeto, incluso sin cambios visibles: trátalos como «vuelve a leer este objeto».

  • Los eventos de ventas, clientes, productos, movimientos y turnos que nacen en una caja se generan cuando esa caja sincroniza. Una caja sin internet produce sus eventos al reconectarse.

  • invoice.issued e invoice.rejected pueden tardar unos segundos más que el resto: el veredicto de la DIAN se revisa periódicamente.

  • Una misma venta puede producir varios eventos (sale.created y sale.completed, por ejemplo). El orden de entrega no está garantizado: usa created_at del evento y el estado del objeto, no el orden de llegada.

  • Las eliminaciones y las categorías no producen eventos en esta versión.

Un POST con Content-Type: application/json y este cuerpo:

{
"id": "5b0f3c1e-8a4d-4c7a-9d2e-1f6b7a8c9d0e",
"type": "sale.completed",
"created_at": "2026-09-28T15:04:05.000Z",
"api_version": "2026-09-28",
"data": {
"object": {
"object": "sale",
"id": "a1b2c3d4-0000-4000-8000-000000000001",
"status": "completed",
"total": 25900
}
}
}
  • id: el id del evento. Es el mismo si el evento se reintenta: úsalo para no procesarlo dos veces.
  • data.object: el objeto tal como lo devuelve la API (GET /sales/{id}, GET /products/{id}…) en el momento del evento. En el ejemplo está recortado; los objetos completos están en la referencia.
  • api_version: la versión del formato del evento. Hoy es 2026-09-28.

Y estos headers:

Header Qué trae
Posdata-Signature La firma: t=<unix>,v1=<firma> (dos v1 durante una rotación del secreto).
Posdata-Event-Id El id del evento.
Posdata-Event-Type El type del evento.
Posdata-Delivery-Id El id de la entrega a este endpoint. Se mantiene en los reintentos y te sirve para buscarla en el historial de entregas de cuenta.
User-Agent Posdata-Webhooks/1.0 (+https://developers.posdata.so)

Cualquiera que conozca tu URL puede enviarle peticiones. Verifica cada entrega antes de confiar en ella:

  1. Lee el header Posdata-Signature. Tiene la forma t=1790608000,v1=de13e2… y, durante una rotación del secreto, dos valores v1.
  2. Descarta la entrega si t (segundos Unix) está a más de 5 minutos de tu hora actual: eso impide que alguien reenvíe una entrega vieja que haya capturado.
  3. Calcula HMAC-SHA256 con tu secreto (completo, incluido el prefijo whsec_) sobre el texto `${t}.${cuerpo}`, donde cuerpo son los bytes exactos que recibiste, y pásalo a hexadecimal en minúsculas.
  4. Acepta la entrega si cualquiera de los v1 coincide, comparando en tiempo constante.
verify.mjs
import { createHmac, timingSafeEqual } from 'node:crypto';
/**
* Verifies the Posdata-Signature header of a webhook delivery.
*
* @param {string | undefined} header Value of the Posdata-Signature header.
* @param {string | Buffer} rawBody The request body EXACTLY as received (never re-serialized JSON).
* @param {string} secret Your endpoint secret, including the "whsec_" prefix.
* @param {{ toleranceSec?: number, nowSec?: number }} [options]
* @returns {boolean} true when the timestamp is recent and at least one v1 signature matches.
*/
export function verifyPosdataSignature(header, rawBody, secret, options = {}) {
const toleranceSec = options.toleranceSec ?? 300;
const nowSec = options.nowSec ?? Math.floor(Date.now() / 1000);
if (!header) return false;
let timestamp = null;
const signatures = [];
for (const part of header.split(',')) {
const eq = part.indexOf('=');
if (eq <= 0) continue;
const key = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === 'v1' && value) signatures.push(value);
}
if (timestamp === null || signatures.length === 0) return false;
// Reject old (or future) deliveries: this is what stops replays.
if (Math.abs(nowSec - timestamp) > toleranceSec) return false;
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, 'utf8');
const expected = createHmac('sha256', secret)
.update(Buffer.concat([Buffer.from(`${timestamp}.`, 'utf8'), body]))
.digest('hex');
const expectedBuf = Buffer.from(expected, 'utf8');
// During a secret rotation the header carries two v1 values: accept either.
let matched = false;
for (const candidate of signatures) {
const candidateBuf = Buffer.from(candidate, 'utf8');
if (candidateBuf.length === expectedBuf.length && timingSafeEqual(candidateBuf, expectedBuf)) {
matched = true;
}
}
return matched;
}

Uso con Express:

server.mjs
import express from 'express';
import { verifyPosdataSignature } from './verify.mjs';
const app = express();
const secret = process.env.POSDATA_WEBHOOK_SECRET; // whsec_...
// express.raw keeps the exact bytes: the signature is computed over them.
app.post('/webhooks/posdata', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyPosdataSignature(req.header('Posdata-Signature'), req.body, secret);
if (!ok) return res.status(400).send('invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
// Answer fast; do the real work in the background (queue, job, etc.).
res.status(200).send('ok');
queueForProcessing(event);
});
function queueForProcessing(event) {
// Deduplicate by event.id: the same event can arrive more than once.
console.log('Posdata event', event.id, event.type);
}
app.listen(3000);

Estos ejemplos se prueban automáticamente contra firmas generadas por el mismo código que usa Posdata para enviar los webhooks.

  • Responde con un 2xx en menos de 10 segundos. Pasado ese tiempo, la entrega cuenta como fallida.
  • Cualquier código que no sea 2xx (incluidos los 3xx) cuenta como fallo. Posdata no guarda el cuerpo de tu respuesta.
  • Haz el trabajo pesado después de responder: guarda el evento en una cola o una tabla y procésalo aparte.
  • Deduplica por id del evento. Un reintento, o un evento que también recuperaste con GET /events, llega con el mismo id.

Si una entrega falla, Posdata la reintenta con esta espera entre intentos:

Intento 1 2 3 4 5 6 7 8
Espera desde el anterior inmediato 1 min 5 min 30 min 2 h 5 h 10 h 10 h

Son 8 intentos en unas 28 horas. Si el octavo falla, esa entrega queda como fallida y no se reintenta más; puedes recuperar el evento con GET /events.

Cada endpoint tiene su propio turno: uno que falla o responde lento no retrasa las entregas a tus otros endpoints.

Si un endpoint acumula 20 fallos seguidos durante más de 24 horas, Posdata lo desactiva, deja de intentar sus entregas pendientes y envía un correo a la cuenta. Cuando arregles tu servidor, reactívalo desde cuenta (puedes usar Enviar evento de prueba antes, también con el endpoint desactivado). Los eventos de mientras estuvo desactivado no se reenvían solos: recupéralos con GET /events.

Si la cuenta deja de tener el plan Pro, las entregas pendientes se descartan sin reintentos, y los eventos que ocurran mientras no tenga Pro no se registran.

Todo evento (salvo webhook.test) queda en un registro que puedes consultar con la API durante 30 días, aunque no tengas endpoints configurados (necesitas el permiso events:read):

Ventana de terminal
curl "https://api.posdata.so/public/v1/events?type=sale.completed&created_since=2026-09-27T00:00:00Z&limit=100" \
-H "Authorization: Bearer $POSDATA_API_KEY"

Cada evento trae el mismo id, type, created_at y data.object que la entrega del webhook. Un buen patrón: procesa los webhooks en tiempo real y, una vez al día, recorre GET /events del último día procesando los id que no tengas.

Desde cuenta, Rotar secreto (con el código por correo) genera un secreto nuevo que se muestra una sola vez. Durante las 24 horas siguientes, cada entrega trae dos firmas v1: la del secreto nuevo primero y la del anterior después. Como tu verificación acepta cualquiera de las dos, puedes desplegar el secreto nuevo con calma. Pasadas las 24 horas, solo se firma con el nuevo.