# 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](https://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

| 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). `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.created`    | Se 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.negative`   | Un 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](https://developers.posdata.so/stock/#stock-negativo).                                                                                                      |
| `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, 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](https://developers.posdata.so/stock/#consultar-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](https://developers.posdata.so/referencia/objetos/).
- `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)`                                                                            |

## 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`).

**Node.js**

```js
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:

```js
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);
```

**Python**

```python
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)`.

**PHP**

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

| 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

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

```sh
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.

---

Fuente: https://developers.posdata.so/webhooks/ · Índice para agentes: https://developers.posdata.so/llms.txt
