> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cotizave.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks y firmas

> Recibe tasas y eventos, verifica HMAC y procesa entregas sin perder avisos.

<span id="que-son" />

## Configurar un webhook

En [Webhooks del dashboard](https://app.cotizave.com), confirma la habilitación de tu cuenta, registra la URL HTTPS de tu servidor, selecciona tasas y eventos, y guarda el secreto `whsec_…` en la configuración privada. Es distinto de tu API Key. Los avisos no consumen consultas de la API.

El esquema descrito aquí usa el aviso de tasa simplificado: `delivery_type` identifica pruebas y `previous_mid` es el valor anterior. El idioma del sitio no cambia el payload ni la firma.

<span id="planes" />

## Planes y límites

| Límite | Esencial | Profesional | Escala |
| - | - | - | - |
| Webhooks | 1 | 2 | 3 |
| Webhook de prueba adicional | 1 | 1 | 1 |
| Binance: intervalo mínimo | 1 h | 30 min | 15 min |
| Avisos por mes y cuenta | 1.000 | 2.000 | 4.000 |
| Envíos simultáneos por webhook | 2 | 2 | 2 |
| Detalle de éxitos y cancelaciones | 24 h | 24 h | 24 h |
| Detalle de fallos y agotados | 1 día | 2 días | 3 días |
| Contadores diarios | 30 días | 30 días | 30 días |
| Cabeceras propias | 3 | 3 | 3 |

Gratis e Inicio no incluyen webhooks. Los pausados cuentan para el límite; el webhook de prueba no. El tope mensual de avisos se reinicia el día 1 UTC, separado del ciclo de consultas de la API. Cuentan cambios de tasa y eventos de cuenta; no reintentos, pruebas, `sync`, `recovery` ni `probe`. Al agotarlo se bloquea Binance, incluidas sus sincronizaciones y recuperaciones; BCV, paralelo y avisos de cuenta continúan.

El panel avisa al 80 %, 95 % y 100 % del cupo de avisos. Un cambio a un plan sin webhooks los pausa sin borrarlos. Si el nuevo límite es menor, se priorizan los más antiguos. Al recuperar un plan compatible se pueden reactivar los pausados por el plan; las nuevas suscripciones no se seleccionan automáticamente.

| Acción del panel | Límite |
| - | - |
| Crear o editar | 10/h por cuenta |
| Pruebas | 10/h y 30/día por cuenta; una cada 10 s por webhook |
| Reenvíos | 20/día por cuenta |
| Reactivación | 5/día por webhook |
| Rotar secreto | 5/día por webhook |
| Mostrar secreto | 10/día por cuenta |

<span id="tasas" />

## Mercados y frecuencia

Los cuatro mercados son `reference` (USD), `eur_reference` (EUR), `parallel` (USD) y `binance` (USD). Los demás mercados se consultan por API. BCV y paralelo avisan al detectar cambios; Binance respeta el intervalo del plan y envía el valor más reciente que haya cambiado. No se envía cada movimiento intermedio ni se garantiza una hora exacta.

<span id="sincronizacion" />

## Sincronización

Al crear, reactivar o cambiar URL, tasas o cabeceras se envía el valor actual, como máximo una vez por mercado y webhook cada 15 minutos. Editar solo la descripción no dispara esta sincronización. El cupo agotado sigue bloqueando Binance.

<span id="eventos" />

## Sobre y datos

El sobre contiene `id`, `type`, `timestamp`, `delivery_type` y `data`. `timestamp` identifica el evento y permanece igual en los reintentos. El esquema actual no incluye el booleano `test`: identifica pruebas con `delivery_type === "test"`.

Respuesta ilustrativa: las cifras y fechas no son cotizaciones actuales.

```json theme={null}
{
  "id": "msg_rate_example",
  "type": "rate.updated",
  "timestamp": "2026-10-19T17:29:15Z",
  "delivery_type": "change",
  "data": {
    "market": "binance",
    "mid": 638.5,
    "ask": 639,
    "bid": 638,
    "previous_mid": 637.87,
    "change_pct": 0.1,
    "updated_at": "2026-10-19T17:29:00Z"
  }
}
```

`rate.updated` requiere `market`, `mid` y `updated_at`. `ask`, `bid`, `previous_mid` y `change_pct` se omiten si no hay dato. `effective_date` aparece para BCV. `change_pct` compara con el valor anterior **del mercado**, no necesariamente con el último que recibiste. `type`, `base`, `best`, `vs_reference_pct`, `source_changed` y el objeto `previous` ya no forman parte de `data`; la base de `eur_reference` es EUR, la de los otros tres es USD.

| `type` | Campos de `data` y disparador |
| - | - |
| `quota.threshold` | `threshold`, `used`, `limit`, `plan`, `cycle_start`, `resets_at`; umbrales 80/95/100 del consumo de consultas API. |
| `plan.expiring` | `plan`, `expires_at`, `days_left`; avisos a 7 y 1 días. |
| `plan.expired` | `plan`, `now_on`, `new_limit`; vencimiento del plan. |
| `source.status_changed` | `previous_status`, `status`, `markets`, `since`; `down` tras 15 min sin datos de fuente y `ok` al recuperarse después de avisar la caída. |
| `ping` | `message`; prueba solicitada desde el panel. |

Los planes en JSON usan códigos como `free`, `lite`, `hobby`, `indie` y `pro`. No traduzcas valores ni campos al cambiar el idioma.

<span id="tipo-de-envio" />

## Tipo de envío

| `delivery_type` | Significado |
| - | - |
| `change` | Cambio de tasa. |
| `sync` | Valor actual al crear, editar o reactivar. |
| `recovery` | Valor actual al recuperarse tu servidor. |
| `probe` | Comprobación con valor actual durante un fallo. |
| `test` | Prueba; no debe actualizar precios de producción. |
| `event` | Evento de cuenta o fuente. |

Procesa tipos conocidos de acuerdo con tu aplicación. Las alertas de movimiento se disparan solo con `change`; `sync`, `probe` y `recovery` pueden repetir el mismo valor. Un tipo desconocido o ausente se registra para revisión y no se interpreta automáticamente como un cambio.

<span id="formato" />

## Cabeceras

| Cabecera | Uso |
| - | - |
| `webhook-id` | ID del evento; se conserva en reintentos. |
| `webhook-timestamp` | Segundos Unix del envío; tolerancia de 5 min. |
| `webhook-signature` | Una o más firmas `v1,<base64>`, separadas por espacios. |
| `cotizave-webhook-id` | ID del webhook configurado; permite buscar su secreto. |
| `cotizave-event` | Copia informativa del tipo de evento. |
| `cotizave-delivery-type` | Copia informativa del tipo de envío. |
| `content-type` | `application/json`. |
| `user-agent` | `Cotizave-Webhooks/1`. |

HTTP no distingue mayúsculas en cabeceras. Las cabeceras `cotizave-*` no están firmadas: selecciona el secreto mediante una configuración de confianza y luego verifica. Toma decisiones sobre `type` y `delivery_type` del **cuerpo verificado**.

<span id="responder" />

## Verificar, persistir y responder

Responde `2xx` antes de 10 segundos **después** de verificar y guardar el evento de forma duradera. Si falla la persistencia, devuelve `503` para permitir reintento. No respondas `200` antes de guardarlo ni uses una cola solo en memoria.

Este adaptador Node.js usa el verificador de la sección siguiente. Debes implementar `secretFor` con tus secretos privados y `persistOnce` con una transacción y una restricción única por webhook y evento. Un duplicado ya persistido debe resolverse sin error; un fallo real de almacenamiento debe rechazar la promesa. Configura `express.raw` para esta ruta antes de cualquier parser JSON y limita el tamaño del body.

```javascript webhook-handler.mjs theme={null}
import { verifyWebhook } from './verify-webhook.mjs';

// Express-compatible handler. Register after express.raw({ type: 'application/json' }).
// secretFor must look up a trusted configuration, never derive a secret from input.
// persistOnce must durably store the event with a UNIQUE(endpointId, event.id) key.
export function createWebhookHandler({ secretFor, persistOnce }) {
  return async function webhookHandler(req, res) {
    const endpointId = req.headers['cotizave-webhook-id'];
    let event;
    try {
      const secret = await secretFor(endpointId);
      event = verifyWebhook(secret, req.headers, req.body);
    } catch {
      return res.sendStatus(401);
    }
    try {
      await persistOnce(endpointId, event);
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  };
}
```

Un worker procesa los registros pendientes y marca su estado de forma transaccional. No marques un evento como procesado antes de completar su efecto. Para tasas, evita sobrescribir un `updated_at` más reciente, comparando timestamps y coordinando actualizaciones concurrentes. La firma no sustituye la deduplicación.

<span id="firma" />

## Firma HMAC-SHA256

Se firma la secuencia de bytes `webhook-id + "." + webhook-timestamp + "." + cuerpo original`. La clave es el secreto sin `whsec_`, decodificado de base64. No reserialices JSON ni traduzcas contenido antes de verificar. Rechaza una diferencia de reloj mayor que 300 segundos y compara firmas en tiempo constante. Acepta cualquiera de las firmas válidas durante una rotación. El formato sigue [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md).

<CodeGroup>
  ```javascript verify-webhook.mjs theme={null}
  import { createHmac, timingSafeEqual } from 'node:crypto';

  export function verifyWebhook(secret, headers, rawBody, now = Date.now() / 1000) {
    const id = headers['webhook-id'];
    const timestamp = headers['webhook-timestamp'];
    const signatures = headers['webhook-signature'];
    if (!Buffer.isBuffer(rawBody) || typeof id !== 'string' || !id ||
        typeof timestamp !== 'string' || !/^\d+$/.test(timestamp) ||
        typeof signatures !== 'string' || !Number.isFinite(now)) {
      throw new Error('Invalid webhook input');
    }
    const seconds = Number(timestamp);
    if (!Number.isSafeInteger(seconds) || Math.abs(now - seconds) > 300) {
      throw new Error('Webhook timestamp outside tolerance');
    }
    if (typeof secret !== 'string' || !secret.startsWith('whsec_')) {
      throw new Error('Invalid signing secret');
    }
    const encoded = secret.slice(6);
    const key = Buffer.from(encoded, 'base64');
    if (!key.length || key.toString('base64') !== encoded) {
      throw new Error('Invalid signing secret');
    }
    const expected = createHmac('sha256', key)
      .update(`${id}.${timestamp}.`, 'utf8')
      .update(rawBody)
      .digest();
    const valid = signatures.split(/\s+/).some(part => {
      if (!/^v1,[A-Za-z0-9+/]+={0,2}$/.test(part)) return false;
      const supplied = Buffer.from(part.slice(3), 'base64');
      return supplied.length === expected.length && timingSafeEqual(supplied, expected);
    });
    if (!valid) throw new Error('Invalid webhook signature');
    const event = JSON.parse(rawBody.toString('utf8'));
    if (!event || Array.isArray(event) || event.id !== id) {
      throw new Error('Webhook id mismatch');
    }
    return event;
  }
  ```

  ```python verify_webhook.py theme={null}
  import base64
  import hashlib
  import hmac
  import json
  import math
  import re
  import time

  def verify_webhook(secret, headers, raw_body, now=None):
      headers = {name.lower(): value for name, value in headers.items()}
      event_id = headers.get("webhook-id", "")
      timestamp = headers.get("webhook-timestamp", "")
      signatures = headers.get("webhook-signature", "")
      now = time.time() if now is None else now
      if (not isinstance(raw_body, bytes) or not isinstance(event_id, str)
              or not event_id or not isinstance(timestamp, str)
              or not re.fullmatch(r"[0-9]+", timestamp)
              or not isinstance(signatures, str) or not math.isfinite(now)):
          raise ValueError("Invalid webhook input")
      if abs(now - int(timestamp)) > 300:
          raise ValueError("Webhook timestamp outside tolerance")
      if not isinstance(secret, str) or not secret.startswith("whsec_"):
          raise ValueError("Invalid signing secret")
      key = base64.b64decode(secret[6:], validate=True)
      if not key:
          raise ValueError("Invalid signing secret")
      signed = f"{event_id}.{timestamp}.".encode("utf-8") + raw_body
      expected = hmac.new(key, signed, hashlib.sha256).digest()
      valid = False
      for part in signatures.split():
          if not part.startswith("v1,"):
              continue
          try:
              supplied = base64.b64decode(part[3:], validate=True)
          except ValueError:
              continue
          if hmac.compare_digest(supplied, expected):
              valid = True
      if not valid:
          raise ValueError("Invalid webhook signature")
      event = json.loads(raw_body)
      if not isinstance(event, dict) or event.get("id") != event_id:
          raise ValueError("Webhook id mismatch")
      return event
  ```
</CodeGroup>

Guarda el secreto en el servidor, distinto para cada webhook. En el panel puedes mostrarlo o rotarlo tras una sesión reciente (menos de una hora). La convivencia del secreto anterior puede ser `0`, `1h`, `24h` o `7d`; con `0` deja de firmar. Durante la convivencia llegan dos firmas. Cada receptor puede usar su secreto vigente dentro de esa ventana.

<span id="vector" />

## Vector de prueba local

Este secreto es público y sirve únicamente para tests. Usa el timestamp indicado como `now` al probar. Los bytes del body deben coincidir exactamente; cambiar contenido, espacios o acentos invalida la firma.

```json signature-vector.json theme={null}
{
  "secret": "whsec_Y290aXphdmUtd2ViaG9va3MtdGVzdC12ZWN0b3ItMDE=",
  "headers": {
    "webhook-id": "msg_test_vector",
    "webhook-timestamp": "1760470500",
    "webhook-signature": "v1,2yK/6wFDrrY5VvfcxAkfwhWoL2ZnPuNWb4FgFpglUhQ="
  },
  "body": "{\"id\":\"msg_test_vector\",\"type\":\"ping\",\"timestamp\":\"2025-10-14T19:35:00Z\",\"delivery_type\":\"test\",\"data\":{\"message\":\"Webhook de prueba de Cotizave\"}}"
}
```

<span id="reintentos" />

## Reintentos y pausa

`2xx` confirma entrega. Errores de red, `408`, `429` y `5xx` pueden reintentarse hasta cuatro intentos: inmediato, después de 10 s, 30 s y 2 min, con variación de hasta ±20 %. Para `429`/`5xx`, un `Retry-After` puede ampliar la espera hasta 2 min; si pide más, se detienen los reintentos de ese aviso. Otros `4xx` y redirecciones `3xx` fallan sin reintento. `410` pausa inmediatamente un webhook real.

El ID y el body permanecen iguales entre intentos; el timestamp de envío y la firma se renuevan. Un aviso de tasa más reciente puede reemplazar uno pendiente, que no podrá reenviarse. Tras un fallo se realizan comprobaciones con el valor actual cada 5 min; al recuperarse se sincronizan las tasas pendientes. Los eventos de cuenta no generan comprobaciones.

Se permite como máximo un envío en curso por tasa y webhook; no hay orden entre tasas. Con 6 fallos consecutivos y al menos 10 min fallando se pausa el webhook; un `2xx` reinicia la cuenta. Reactívalo desde el panel tras corregir tu receptor, sujeto a los límites de [sincronización](#sincronizacion). Los tests no activan pausa automática.

<span id="url" />

## URL y cabeceras propias

La URL debe usar HTTPS y puerto 443, un dominio que resuelva a una IP pública, sin credenciales ni fragmento, hasta 2.048 caracteres. No se permiten IP literales, localhost, dominios internos/locales ni dominios de Cotizave. Registra la URL final: no se siguen redirecciones.

Puedes configurar hasta tres cabeceras, con nombres HTTP válidos de hasta 64 caracteres y valores ASCII visibles de hasta 1 KB. Se reservan cabeceras de transporte y seguridad, incluidas `host`, `content-type`, `content-length`, `user-agent`, cookies y los prefijos `webhook-`, `cotizave-`, `proxy-` y `cf-`. El panel valida los nombres. Un token propio no sustituye HMAC.

<span id="prueba" />

## Webhook de prueba

El webhook de prueba solo recibe envíos solicitados o reenviados al entorno de prueba, con `delivery_type: test`. Tiene su propio secreto e historial; no consume el cupo de avisos ni recibe cambios reales automáticamente. Un reenvío de prueba recibe un ID nuevo. La prueba de tasa usa una cotización actual; si no existe devuelve `409 quote_unavailable` sin enviar.

<span id="probar" />

## Validar la integración

Primero ejecuta tests locales con el vector: firma correcta, body alterado, timestamp vencido, firmas múltiples y duplicados. Para una prueba real usa tu endpoint de pruebas público HTTPS y el botón del panel. Comprueba el historial dentro de su retención y conserva tus propios registros de negocio. No registres secretos ni el header completo de firma.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.