> ## 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 and signatures

> Receive rates and events, verify HMAC and process deliveries without losing notifications.

<span id="que-son" />

## Configure a webhook

Under [Webhooks in the dashboard](https://app.cotizave.com), confirm account availability, register your server’s HTTPS URL, select rates and events, and store the `whsec_…` secret in private configuration. It differs from your API key. Deliveries do not consume API requests.

The schema described here uses the simplified rate notification: `delivery_type` identifies tests and `previous_mid` is the previous value. Site language does not change the payload or signature.

<span id="planes" />

## Plans and limits

| Limit | Esencial | Profesional | Escala |
| - | - | - | - |
| Webhooks | 1 | 2 | 3 |
| Additional test webhook | 1 | 1 | 1 |
| Binance: minimum interval | 1 h | 30 min | 15 min |
| Notifications per month per account | 1,000 | 2,000 | 4,000 |
| Concurrent deliveries per webhook | 2 | 2 | 2 |
| Successful/cancelled delivery detail | 24 h | 24 h | 24 h |
| Failed/exhausted delivery detail | 1 day | 2 days | 3 days |
| Daily counters | 30 days | 30 days | 30 days |
| Custom headers | 3 | 3 | 3 |

Gratis and Inicio do not include webhooks. Paused endpoints count towards the limit; the test webhook does not. The notification cap resets on the first day of each UTC month, separately from the API-request cycle. Rate changes and account events count; retries, tests, `sync`, `recovery` and `probe` do not. Exhaustion blocks Binance, including its synchronisation and recovery; BCV, parallel and account events continue.

The dashboard warns at 80%, 95% and 100% of the notification cap. Moving to a plan without webhooks pauses them without deleting them. If the new limit is lower, older endpoints take priority. Returning to a compatible plan can reactivate endpoints paused by plan changes; new subscriptions are not selected automatically.

| Dashboard action | Limit |
| - | - |
| Create or edit | 10/h per account |
| Tests | 10/h and 30/day per account; one per 10 s per webhook |
| Resends | 20/day per account |
| Reactivation | 5/day per webhook |
| Rotate secret | 5/day per webhook |
| Reveal secret | 10/day per account |

<span id="tasas" />

## Markets and frequency

The four markets are `reference` (USD), `eur_reference` (EUR), `parallel` (USD) and `binance` (USD). Other markets are queried through the API. BCV and parallel notify on detected changes; Binance honours the plan interval and sends the most recent changed value. Not every intermediate movement is sent, and an exact delivery time is not guaranteed.

<span id="sincronizacion" />

## Synchronisation

Creating, reactivating or changing the URL, rates or headers sends the current value, at most once per market and webhook every 15 minutes. Editing only the description does not trigger synchronisation. An exhausted cap still blocks Binance.

<span id="eventos" />

## Envelope and data

The envelope contains `id`, `type`, `timestamp`, `delivery_type` and `data`. `timestamp` identifies the event and stays unchanged on retries. The current schema does not include the `test` boolean: identify tests with `delivery_type === "test"`.

Illustrative response: figures and dates are not current quotes.

```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` requires `market`, `mid` and `updated_at`. `ask`, `bid`, `previous_mid` and `change_pct` are omitted when unavailable. `effective_date` is present for BCV. `change_pct` compares with the previous **market** value, not necessarily the last value you received. `type`, `base`, `best`, `vs_reference_pct`, `source_changed` and the `previous` object are no longer part of `data`; the base of `eur_reference` is EUR, and the other three use USD.

| `type` | `data` fields and trigger |
| - | - |
| `quota.threshold` | `threshold`, `used`, `limit`, `plan`, `cycle_start`, `resets_at`; API-request usage thresholds 80/95/100. |
| `plan.expiring` | `plan`, `expires_at`, `days_left`; notices at 7 and 1 days. |
| `plan.expired` | `plan`, `now_on`, `new_limit`; plan expiry. |
| `source.status_changed` | `previous_status`, `status`, `markets`, `since`; `down` after 15 min without source data and `ok` on recovery after a reported outage. |
| `ping` | `message`; test requested from the dashboard. |

Plans in JSON use codes such as `free`, `lite`, `hobby`, `indie` and `pro`. Do not translate values or fields when switching languages.

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

## Delivery type

| `delivery_type` | Meaning |
| - | - |
| `change` | Rate change. |
| `sync` | Current value on creation, edit or reactivation. |
| `recovery` | Current value when your server recovers. |
| `probe` | Current-value probe during a failure. |
| `test` | Test; must not update production prices. |
| `event` | Account or source event. |

Process known types according to your application. Trigger movement alerts only for `change`; `sync`, `probe` and `recovery` may repeat the same value. Record an unknown or missing type for review; do not automatically treat it as a change.

<span id="formato" />

## Headers

| Header | Use |
| - | - |
| `webhook-id` | Event ID; preserved on retries. |
| `webhook-timestamp` | Send time in Unix seconds; 5-minute tolerance. |
| `webhook-signature` | One or more space-separated `v1,<base64>` signatures. |
| `cotizave-webhook-id` | Configured webhook ID; used to look up its secret. |
| `cotizave-event` | Informational copy of the event type. |
| `cotizave-delivery-type` | Informational copy of the delivery type. |
| `content-type` | `application/json`. |
| `user-agent` | `Cotizave-Webhooks/1`. |

HTTP header names are case-insensitive. `cotizave-*` headers are not signed: select the secret from trusted configuration, then verify. Make decisions using `type` and `delivery_type` from the **verified body**.

<span id="responder" />

## Verify, persist and respond

Return `2xx` within 10 seconds **after** verifying and durably storing the event. If persistence fails, return `503` to allow a retry. Do not return `200` before storage or use an in-memory-only queue.

This Node.js adapter uses the verifier in the next section. Implement `secretFor` with your private secrets and `persistOnce` with a transaction and a unique constraint per webhook and event. An already-persisted duplicate must resolve successfully; an actual storage failure must reject the promise. Configure `express.raw` for this route before any JSON parser and limit body size.

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

A worker processes pending records and marks their state transactionally. Do not mark an event processed before completing its effect. For rates, avoid overwriting a newer `updated_at` by comparing timestamps and coordinating concurrent updates. Signature verification does not replace deduplication.

<span id="firma" />

## HMAC-SHA256 signature

The signed bytes are `webhook-id + "." + webhook-timestamp + "." + original body`. The key is the secret without `whsec_`, decoded from base64. Do not reserialise JSON or translate content before verification. Reject a clock difference greater than 300 seconds and compare signatures in constant time. Accept any valid signature during rotation. The format follows [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>

Store the secret on the server, with a separate secret per webhook. The dashboard allows reveal or rotation after a recent login (less than one hour). Previous-secret overlap can be `0`, `1h`, `24h` or `7d`; with `0`, it stops signing. During overlap, two signatures are sent. Each receiver can use its valid secret within that window.

<span id="vector" />

## Local test vector

This secret is public and for tests only. Use the indicated timestamp as `now` in tests. Body bytes must match exactly; changing content, whitespace or accents invalidates the signature.

```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" />

## Retries and pausing

`2xx` confirms delivery. Network errors, `408`, `429` and `5xx` may be retried up to four attempts: immediately, then after 10 s, 30 s and 2 min, with up to ±20% variation. For `429`/`5xx`, `Retry-After` can extend the wait up to 2 min; a longer value stops retries for that notification. Other `4xx` and `3xx` redirects fail without retry. `410` immediately pauses a live webhook.

The ID and body stay unchanged between attempts; the send timestamp and signature are refreshed. A newer rate notification may replace a pending one, which can no longer be resent. After a failure, current-value probes run every 5 min; on recovery, missing rates are synchronised. Account events do not generate probes.

At most one delivery per rate and webhook is in flight; there is no ordering across rates. Six consecutive failures and at least 10 min failing pause the webhook; a `2xx` resets the count. Reactivate it in the dashboard after fixing your receiver, subject to [synchronisation](#sincronizacion) limits. Tests do not trigger automatic pausing.

<span id="url" />

## URL and custom headers

The URL must use HTTPS and port 443, with a domain resolving to a public IP, no credentials or fragment, and at most 2,048 characters. Literal IPs, localhost, internal/local domains and Cotizave domains are not allowed. Register the final URL: redirects are not followed.

You can configure up to three headers, with valid HTTP names up to 64 characters and visible ASCII values up to 1 KB. Transport and security headers are reserved, including `host`, `content-type`, `content-length`, `user-agent`, cookies and the prefixes `webhook-`, `cotizave-`, `proxy-` and `cf-`. The dashboard validates names. A custom token does not replace HMAC.

<span id="prueba" />

## Test webhook

The test webhook only receives requested tests or deliveries forwarded to the test environment, with `delivery_type: test`. It has its own secret and history; it does not consume the notification cap or automatically receive live changes. A test resend gets a new ID. A rate test uses a current quote; if unavailable, it returns `409 quote_unavailable` without sending.

<span id="probar" />

## Validate the integration

First run local tests with the vector: valid signature, altered body, expired timestamp, multiple signatures and duplicates. For a real test, use your public HTTPS test endpoint and the dashboard button. Check delivery history within its retention window and retain your own business records. Do not log secrets or the full signature header.

Receiving a webhook allows you to process it within your integration. Reselling its data or forwarding notifications as a third-party data service requires prior written authorisation on every plan. See [data use](/en/data-use).


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