Ir al contenido
docs

Webhooks

Recibe en tu servidor los eventos de tu negocio, firmados, con reintentos y un registro para reconciliar.

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 (con o sin facturación electrónica), igual que la API.

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.

  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.

Tipos de evento

EventoCuándo se envía
sale.createdUna venta nueva llegó a la nube (la subió una caja). Una sola vez por venta.
sale.updatedUna caja subió un cambio de una venta existente. Puede repetirse.
sale.completedLa venta quedó completada. Una sola vez por venta.
sale.cancelledLa venta quedó anulada. Una sola vez por venta.
product.createdSe creó un producto, desde una caja o por la API. Una sola vez por producto.
product.updatedSe editó un producto o una de sus variantes, desde una caja o por la API. Puede repetirse.
customer.createdSe creó un cliente, desde una caja o por la API. Una sola vez por cliente.
customer.updatedSe editó un cliente, desde una caja o por la API. Puede repetirse.
inventory.movement.createdSe registró un movimiento de inventario, desde una caja o por la API (incluye el initial_stock de un producto nuevo). data es el stock_movement, con su unit_cost y, en los ajustes con motivo hechos en la app, su reason_code (null en los demás). Una sola vez por movimiento.
inventory.count.createdSe registró un conteo físico de inventario, desde una caja (en la app o con una hoja de conteo) o por la API (POST /inventory/counts). data.object es un stock_count: revisa su status (applied = es la nueva base del stock; historical = ya había un conteo más reciente y el stock no cambió; pending = una caja lo registró antes de que la cuenta pasara al stock en la nube y se tendrá en cuenta cuando pase). Solo conteos físicos (source: count, count_sheet, initial o api): no se envía por el stock de partida que suben las cajas (migration, upgrade). Una sola vez por conteo.
inventory.stock.negativeUn nivel de stock que tiene un conteo quedó por debajo de cero: una caja vendió unidades que ya no estaban (casi siempre, una venta hecha sin internet). data.object es el stock_level justo después del cambio. Una vez por alerta: si el nivel sigue negativo con más ventas no se repite; vuelve a enviarse solo si el nivel regresa a 0 o más y vuelve a bajar. También puede llegar cuando la cuenta pasa al stock en la nube. Mira Stock negativo.
invoice.issuedUn 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.rejectedLa DIAN rechazó un documento de la venta. Una sola vez por documento.
cash_session.closedSe cerró un turno de caja. Una sola vez por turno.
webhook.testSolo 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, conteos y turnos que nacen en una caja se generan cuando esa caja sincroniza. Una caja sin internet produce sus eventos al reconectarse.

  • No hay un evento por cada cambio de stock, y los cambios de stock que Posdata aplica en la nube no emiten product.updated. Para seguir el stock usa inventory.movement.created o GET /inventory/levels?updated_since=… (Cómo funciona el stock).

  • 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.

Qué recibe tu servidor

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

JSON
{
  "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:

HeaderQué trae
Posdata-SignatureLa firma: t=<unix>,v1=<firma> (dos v1 durante una rotación del secreto).
Posdata-Event-IdEl id del evento.
Posdata-Event-TypeEl type del evento.
Posdata-Delivery-IdEl 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-AgentPosdata-Webhooks/1.0 (+https://developers.posdata.so)

Verifica la firma

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.
Usa el cuerpo crudo

La firma se calcula sobre los bytes exactos del cuerpo. Si tu framework ya convirtió el JSON en un objeto y lo vuelves a serializar, los espacios o el orden pueden cambiar y la firma no coincidirá. Lee el cuerpo sin procesar (express.raw, request.get_data(), php://input).

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);
verify.py
import hashlib
import hmac
import 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 matched

Con Flask, por ejemplo: verify_posdata_signature(request.headers.get("Posdata-Signature"), request.get_data(), secret).

verify.php
<?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 background

Estos 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

  • 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.

Reintentos

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

Intento12345678
Espera desde el anteriorinmediato1 min5 min30 min2 h5 h10 h10 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

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

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):

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.

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.