# Webhooks

Recibe actualizaciones de pago, comprueba firmas y gestiona reintentos.

Source: https://docs.invoise.me/es/integration/webhooks/

Un webhook es una solicitud HTTP que Invoise envía a tu servidor cuando un pago cambia. Puedes empezar con [comprobaciones de estado GET](https://docs.invoise.me/es/payments/status/) y añadir webhooks cuando necesites actualizaciones automáticas.

## 1. Registra tu endpoint

```bash
curl -X POST 'https://platform.invoise.me/api/v1/shops/{shop_id}/webhooks' \
  -H 'Authorization: Bearer ivk_...' \
  -H 'Idempotency-Key: <saved-unique-key>' \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com/invoise","filters":["transfer","invoice","payout"]}'
```

Usa tu propia URL HTTPS pública. Las direcciones locales y privadas se rechazan. Guarda el `secret` que se devuelve al crear el endpoint: lo necesitas para verificar solicitudes.

Una tienda puede tener hasta 10 endpoints. La URL de un endpoint se puede cambiar, así que reutiliza uno en vez de añadir otro. Consulta [Límites](https://docs.invoise.me/es/integration/idempotency-and-errors/#límites).

## 2. Elige los eventos que necesitas

| Evento | Significado |
| --- | --- |
| `transfer.observed` | Se detectó una transferencia; aún no está confirmada. |
| `transfer.confirmed` | Una transferencia entrante tiene confirmaciones suficientes. Puede ser solo un pago parcial de la factura. |
| `transfer.reverted` | Se revirtió un evento de transferencia anterior. |
| `invoice.closed` | La factura se cerró en la cadena. |
| `invoice.cancelled` | La página de pago se canceló o la factura caducó; esto no es un reembolso. |
| `payout.sent` | El pago se transmitió o se detectó su transferencia saliente. |
| `payout.confirmed` | La contabilidad completa de la liquidación está disponible. |
| `payout.reverted` | Se revirtió una confirmación de pago anterior. |
| `payout.failed` | Un fallo terminal del pago necesita intervención. |

Para `invoice.cancelled`, `data.reason` indica el motivo: `expired` cuando se agotó la [vigencia de la factura](https://docs.invoise.me/es/payments/deposits-and-invoices/#vigencia-de-la-factura), `merchant_blocked` cuando el personal de Invoise bloqueó el comercio. Una cancelación por parte del comercio no tiene `reason`.

Suscríbete por grupo: `transfer`, `invoice`, `payout`. Las transferencias entrantes y los pagos salientes son eventos distintos. Un retraso temporal de RPC o de gas no es `payout.failed`.

`gas.wait` y `sweep.failed` se aceptan como filtros, pero Invoise no los envía a tu endpoint. Son señales internas: `gas.wait` significa que un pago está esperando fondos para la comisión de red y continúa por su cuenta; `sweep.failed` significa que falló un intento de mover los fondos a tu destinatario. No necesitas actuar en ninguno de los dos casos. Un pago que no puede completarse te llega como `payout.failed`.

En una tienda sandbox puedes simular ambos mediante `POST /shops/{shop_id}/sandbox/simulate` para comprobar que un pago retrasado no rompe tu flujo. Solo cambian el estado del pago y no envían ningún webhook.

## 3. Verificar antes de procesar

`Invoise-Signature` tiene la forma `t=<timestamp>,v1=<hex>`. La firma es HMAC-SHA256 de `timestamp + "." + raw_body`, usando tu secreto de webhook.

El siguiente ejemplo en Node.js también rechaza las marcas de tiempo que difieren más de cinco minutos del reloj del servidor. Mantén ese reloj sincronizado y elige tu tolerancia de forma deliberada.

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(rawBody, header, secret) {
  const match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(header ?? '');
  if (!match) return false;
  const [, timestamp, signature] = match;
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > 300) return false;
  const expected = createHmac('sha256', secret)
    .update(timestamp + '.')
    .update(rawBody)
    .digest();
  return timingSafeEqual(Buffer.from(signature, 'hex'), expected);
}
```

Pasa los bytes originales de la solicitud como `rawBody`. No analices el JSON ni lo conviertas de nuevo en cadena antes de comprobar la firma. Rechaza las firmas no válidas antes de guardar o actuar sobre su contenido.

## 4. Procesa una sola vez

Un payload abreviado tiene este aspecto:

```json
{
  "id": "<event-id>",
  "type": "transfer.confirmed",
  "created_at": "2026-09-19T10:00:00Z",
  "data": {
    "issuance_id": "<issuance-id>",
    "shop_id": "<shop-id>",
    "external_id": "order-123",
    "type": "invoice",
    "invoice_cancelled": false
  }
}
```

| Caso | Manejo |
| --- | --- |
| Duplicados | Evita duplicados usando `Invoise-Event-ID`, que coincide con el `id` del sobre. Guarda el ID y la actualización de negocio de forma atómica. |
| Aceptar un evento | Guarda los eventos aceptados de forma duradera, devuelve 2xx con prontitud y procesa el trabajo en cola después. |
| Reintentos y repeticiones | Los reintentos y las repeticiones manuales repiten el ID del evento. `Invoise-Attempt-ID` cambia y no es una clave de deduplicación. |
| Orden de los eventos | Los eventos pueden llegar desordenados. Usa su contenido y concilia con el [estado actual](https://docs.invoise.me/es/payments/status/) cuando sea necesario. |

Para las reversiones, `chain_event.reference_event_id` apunta al evento de cadena anterior. Los payloads de pago incluyen un `payout_id` estable, el hash de la transacción, los importes y los detalles de la comisión; los importes desconocidos son `null`. Su lista de ID de transferencias entrantes tiene un tope de 100 y marca el truncamiento.

Los eventos guardados más antiguos pueden usar `project_id`; lee primero `shop_id` y recurre a él si es necesario. Las repeticiones conservan los bytes originales.

## Inspecciona una entrega fallida

Lista con `GET /api/v1/shops/{shop_id}/deliveries`. Repite una entrega con `POST /api/v1/shops/{shop_id}/deliveries/{id}/replay` y una clave de idempotencia guardada. Repetir no crea un nuevo evento de negocio.
