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.
Crear un endpoint
Sección titulada «Crear un endpoint»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.
- Elige agregar endpoint, escribe la URL de tu servidor y marca los eventos que quieres recibir.
- 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).
- Copia el secreto del endpoint (empieza por
whsec_). Solo se muestra una vez. Lo necesitas para verificar las firmas. - 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 nihttp://. - 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.
Tipos de evento
Sección titulada «Tipos de evento»| 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
*.updatedsí 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.issuedeinvoice.rejectedpueden 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.createdysale.completed, por ejemplo). El orden de entrega no está garantizado: usacreated_atdel evento y el estado del objeto, no el orden de llegada. -
Las eliminaciones y las categorías no producen eventos en esta versión.
Qué recibe tu servidor
Sección titulada «Qué recibe tu servidor»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 es2026-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) |
Verifica la firma
Sección titulada «Verifica la firma»Cualquiera que conozca tu URL puede enviarle peticiones. Verifica cada entrega antes de confiar en ella:
- Lee el header
Posdata-Signature. Tiene la format=1790608000,v1=de13e2…y, durante una rotación del secreto, dos valoresv1. - 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. - Calcula
HMAC-SHA256con tu secreto (completo, incluido el prefijowhsec_) sobre el texto`${t}.${cuerpo}`, dondecuerposon los bytes exactos que recibiste, y pásalo a hexadecimal en minúsculas. - Acepta la entrega si cualquiera de los
v1coincide, comparando en tiempo constante.
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:
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);import hashlibimport hmacimport time
def verify_posdata_signature(header, raw_body, secret, tolerance_sec=300, now_sec=None): """Verify the Posdata-Signature header of a webhook delivery.
raw_body must be the request body bytes exactly as received. secret is your endpoint secret, including the "whsec_" prefix. """ if not header: return False if now_sec is None: now_sec = int(time.time()) if isinstance(raw_body, str): raw_body = raw_body.encode("utf-8")
timestamp = None signatures = [] for part in header.split(","): key, sep, value = part.partition("=") if not sep or not key.strip(): continue key, value = key.strip(), value.strip() if key == "t" and value.isdigit(): timestamp = int(value) elif key == "v1" and value: signatures.append(value) if timestamp is None or not signatures: return False # Reject old (or future) deliveries: this is what stops replays. if abs(now_sec - timestamp) > tolerance_sec: return False
signed = str(timestamp).encode("utf-8") + b"." + raw_body expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest() # During a secret rotation the header carries two v1 values: accept either. matched = False for candidate in signatures: if hmac.compare_digest(candidate, expected): matched = True return matchedCon Flask, por ejemplo: verify_posdata_signature(request.headers.get("Posdata-Signature"), request.get_data(), secret).
<?php
/** * Verify the Posdata-Signature header of a webhook delivery. * * $rawBody must be the request body exactly as received: file_get_contents('php://input'). * $secret is your endpoint secret, including the "whsec_" prefix. */function verify_posdata_signature(?string $header, string $rawBody, string $secret, int $toleranceSec = 300, ?int $nowSec = null): bool{ if ($header === null || $header === '') { return false; } $nowSec = $nowSec ?? time();
$timestamp = null; $signatures = []; foreach (explode(',', $header) as $part) { $eq = strpos($part, '='); if ($eq === false || $eq === 0) { continue; } $key = trim(substr($part, 0, $eq)); $value = trim(substr($part, $eq + 1)); if ($key === 't' && ctype_digit($value)) { $timestamp = (int) $value; } elseif ($key === 'v1' && $value !== '') { $signatures[] = $value; } } if ($timestamp === null || count($signatures) === 0) { return false; } // Reject old (or future) deliveries: this is what stops replays. if (abs($nowSec - $timestamp) > $toleranceSec) { return false; }
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); // During a secret rotation the header carries two v1 values: accept either. $matched = false; foreach ($signatures as $candidate) { if (hash_equals($expected, $candidate)) { $matched = true; } } return $matched;}
// Usage:// $ok = verify_posdata_signature($_SERVER['HTTP_POSDATA_SIGNATURE'] ?? null, file_get_contents('php://input'), getenv('POSDATA_WEBHOOK_SECRET'));// if (!$ok) { http_response_code(400); exit; }// http_response_code(200); // answer fast, process in the backgroundEstos ejemplos se prueban automáticamente contra firmas generadas por el mismo código que usa Posdata para enviar los webhooks.
Responde rápido y procesa después
Sección titulada «Responde rápido y procesa después»- 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
iddel evento. Un reintento, o un evento que también recuperaste conGET /events, llega con el mismoid.
Reintentos
Sección titulada «Reintentos»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.
Desactivación automática
Sección titulada «Desactivación automática»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.
Reconcilia con GET /events
Sección titulada «Reconcilia con GET /events»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):
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.
Rotar el secreto
Sección titulada «Rotar el secreto»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.