Nova
API v1 · Estable

API Nova para Desarrolladores

Recibe PIX, genera enlaces de pago y escucha webhooks desde tu propia app. La API es predecible: clave Bearer en el encabezado, JSON de entrada, JSON de respuesta.

En esta página
  1. Contrato de la API
  2. Inicio rápido
  3. Paquete SDK
  4. Autenticación
  5. Llamadas desde navegador (CORS)
  6. Límites de tasa
  7. Idempotencia
  8. Errores
  9. Endpoints
    1. Sistema
    2. GET Sonda de disponibilidad (liveness)
    3. GET Round-trip autenticado
    4. Cobros PIX
    5. POST Crear un cobro PIX (código QR)
    6. GET Consultar un cobro PIX
    7. Enlaces de pago
    8. POST Crear un enlace de pago reutilizable
    9. GET Consultar un enlace de pago
    10. DELETE Pausar un enlace de pago
    11. Webhooks
    12. GET Listar webhooks
    13. POST Suscribirse a eventos
    14. DELETE Cancelar la suscripción
    15. POST Disparar una entrega de prueba
  10. Webhooks
    1. Webhooks: eventos
    2. Webhooks: verificar la firma
    3. Webhooks: política de retry
  11. Versionado
  12. Bug bounty
  13. Soporte
  14. llms.md

Contrato de producción de la API #

La v1 de Nova es pequeña a propósito. Integra siguiendo estas reglas:

Mínima ruptura: v1 mantiene el formato actual de respuestas. Agregamos campos, encabezados y endpoints sin cambiar nombres o significados existentes.

Acceso a la API #

Necesitas tres cosas:

  1. Una cuenta Nova. Crea una si todavía no tienes.
  2. Aprobación para usar la API. Abre la app Nova, ve a Cuenta → Desarrollador, llena la solicitud y espera la aprobación (normalmente menos de un día hábil).
  3. Una clave de API. Tras la aprobación, la misma pantalla te deja crear una. Copia el secreto al instante — solo guardamos el hash y nunca lo volvemos a mostrar.

Comprueba que todo está conectado:

curl -H "Authorization: Bearer $NOVA_API_KEY" https://novadao.app/api/v1/ping

Deberías recibir un JSON con el id de tu clave, el tier y la hora del servidor. Si te llega 401, revisa la clave. Si te llega 403 hablando de un dominio, ve a Llamadas desde navegador (CORS).

Genera tu primer cobro PIX de R$ 1 (usa una dirección Liquid real que controles):

curl -X POST https://novadao.app/api/v1/pix/charges \
  -H "Authorization: Bearer $NOVA_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "amount_in_cents": 100,
    "depix_address": "lq1..."
  }'

Vas a recibir un id, una cadena qr_copy_paste (pégala en cualquier app bancaria brasileña) y una qr_image_url con la marca de Nova. Paga el QR y verás el cobro pasar a paid.

Paquete SDK #

El paquete TypeScript @novadao/sdk es delgado: un transporte HTTP, grupos tipados por recurso, errores tipados, idempotencia como opción explícita y verificación de webhooks incluida. No oculta reintentos de POST si quien llama no mandó una clave de idempotencia.

Instala desde npm:

npm install @novadao/sdk

Página del paquete: @novadao/sdk en npm.

type NovaApiError = {
  status: number;
  code: string;
  message: string;
  requestId: string;
};

type PixCharge = {
  id: string;
  status: 'pending' | 'paid' | 'expired' | 'failed';
  amountInCents: number | null;
  qrCopyPaste: string | null;
  qrImageUrl: string | null;
  blockchainTxId: string | null;
  createdAt: string;
};

type PaymentLink = {
  id: string;
  name: string;
  mode: 'fixed' | 'range' | 'open';
  status: 'active' | 'paused';
  url: string;
  handle: string;
  slug: string;
  amountBrl: number | null;
  minBrl: number | null;
  maxBrl: number | null;
  depixAddress: string;
  createdAt: string;
};

type WebhookEndpoint = {
  id: number;
  url: string;
  events: string[];
  status: 'active' | 'disabled';
  createdAt: string;
};

type CreatedWebhookEndpoint = WebhookEndpoint & {
  signingSecret: string;
};

type NovaClient = {
  pix: {
    createCharge(
      input: { amountInCents: number; depixAddress: string },
      options: { idempotencyKey: string },
    ): Promise<PixCharge>;
    getCharge(id: string): Promise<PixCharge>;
  };
  paymentLinks: {
    create(input: CreatePaymentLink, options?: { idempotencyKey?: string }): Promise<PaymentLink>;
    get(id: string): Promise<PaymentLink>;
    pause(id: string): Promise<PaymentLink>;
  };
  webhooks: {
    list(): Promise<WebhookEndpoint[]>;
    create(
      input: CreateWebhookEndpoint,
      options: { idempotencyKey: string },
    ): Promise<CreatedWebhookEndpoint>;
    remove(id: number): Promise<{ id: number; deleted: true }>;
    test(id: number): Promise<{ deliveryId: string; queuedAt: string }>;
    verifySignature(
      rawBody: string | Uint8Array,
      header: string,
      secret: string,
      options?: { toleranceSeconds?: number },
    ): Promise<boolean>;
  };
};

Uso recomendado:

import { NovaClient, createIdempotencyKey } from '@novadao/sdk';

const nova = new NovaClient({
  apiKey: process.env.NOVA_API_KEY!,
});

const charge = await nova.pix.createCharge(
  { amountInCents: 100, depixAddress: 'lq1...' },
  { idempotencyKey: createIdempotencyKey('pix') },
);

Reglas de implementación:

Autenticación #

Una clave de API es una cadena secreta que demuestra que la solicitud viene de ti. Trátala como una contraseña: nunca la pongas en código que corre en el navegador, nunca le hagas commit en GitHub, y cámbiala si sospechas que se filtró.

Envía tu clave en el encabezado Bearer estándar:

Authorization: Bearer nv_live_8aXk3rT9...
Un solo encabezado. Nova no acepta X-Api-Key. Enviarlo devuelve 401 auth_use_bearer apuntando aquí.

¿Qué significa “aprobación”?

La API hace cosas reales — genera cobros PIX, crea enlaces de pago — así que aprobamos a mano cada cuenta antes de que pueda crear claves. El formulario en la app pregunta qué estás construyendo y cuánto volumen esperas. La mayoría se revisa en un día hábil.

Lista de IPs permitidos

Si configuras una lista de IPs en tu cuenta de desarrollador (también en Cuenta → Desarrollador), la API rechaza cualquier solicitud cuyo IP de origen no esté en la lista. Es opcional, pero muy recomendado para servidores en producción.

Llamadas desde navegador (CORS) #

Por defecto, la API de Nova solo acepta llamadas servidor-a-servidor. Si intentas llamar a /api/v1/* desde un navegador, la respuesta es 403 browser_origin_disallowed.

Es intencional. Una clave de API en el navegador es una clave de API en las devtools de cada visitante. Si necesitas absolutamente llamar desde un navegador (por ejemplo, una integración híbrida donde la misma clave firma llamadas servidor y cliente), puedes registrar dominios permitidos específicos para esa clave. El panel muestra una advertencia clara cuando lo haces.

Recomendado: mantén todo el uso de la clave en tu backend. Usa un proxy fino en tu propia infraestructura si necesitas exponer algo al navegador.

Límites de tasa #

Dos presupuestos, ambos por clave de API:

Algunos endpoints tienen sub-límites propios — revisa las notas de cada uno abajo. Toda respuesta autenticada incluye:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1714000060
X-RateLimit-Daily-Limit: 10000
X-RateLimit-Daily-Remaining: 9842
X-RateLimit-Daily-Reset: 1714086400

En 429 rate_limited también recibes Retry-After (en segundos). Espera y reintenta — no entres en bucle.

Idempotencia #

Los hipos de red existen. Si tu solicitud POST /pix/charges se queda en timeout, no sabes si el cobro se creó o no. Las claves de idempotencia hacen que reintentar sea seguro.

Envía un encabezado Idempotency-Key único (1..255 caracteres ASCII, lo que generes) en cada POST reintentable. Si la misma clave llega dos veces con el mismo cuerpo, devolvemos la respuesta cacheada con X-Idempotent-Replay: true en vez de crear un segundo cobro. Si la misma clave llega con un cuerpo distinto, devolvemos 409 idempotency_key_reused para que sepas que algo está raro.

Las respuestas cacheadas viven 24 horas. Los errores del servidor (5xx) nunca se cachean, así que siempre puedes reintentar una falla transitoria con la misma clave.

Errores #

Todos los errores comparten el mismo envelope:

{
  "error": {
    "code": "rate_limited",
    "message": "You are sending requests too quickly. Slow down and try again.",
    "request_id": "req_8aXk3rT9...",
    "docs_url": "https://novadao.app/devs/errors#rate_limited"
  }
}

El request_id es único por solicitud. Inclúyelo en cualquier ticket de soporte — nos permite encontrar el log del servidor al instante.

HTTP Código Significado
401 auth_missing No se envió el encabezado Authorization.
401 auth_use_bearer Enviaste X-Api-Key. Usa Authorization: Bearer <clave> en su lugar.
401 auth_invalid La clave de API es inválida, revocada o no aprobada.
403 browser_origin_disallowed Solicitud del navegador cuyo Origin no está en allowed_domains de la clave. Las llamadas servidor-a-servidor no deben enviar Origin.
403 ip_not_allowlisted El IP de la solicitud no está en la lista de IPs permitidos de la cuenta.
429 rate_limited Demasiadas solicitudes. Lee X-RateLimit-Reset y Retry-After. El agotamiento del límite diario usa el mismo código.
400 / 409 / 422 invalid_payload El cuerpo, ruta u operación falló la validación. Los SDKs deben guardar el status HTTP y error.code.
400 invalid_json El cuerpo de la solicitud está vacío o no es JSON válido.
413 payload_too_large El cuerpo de la solicitud es mayor de lo que acepta el endpoint.
400 idempotency_key_required POST /pix/charges requiere Idempotency-Key en cada solicitud.
409 idempotency_key_in_progress Una solicitud con esta Idempotency-Key aún se está procesando. Reintenta la misma solicitud en breve.
409 idempotency_key_reused Enviaste la misma Idempotency-Key con un cuerpo distinto. Genera una nueva clave para nuevas solicitudes.
404 not_found El recurso no existe o no es tuyo.
503 payment_service_unavailable El proveedor de pago está temporalmente fuera de servicio. Reintenta la misma solicitud lógica con la misma Idempotency-Key.
500 internal_error Algo salió mal de nuestro lado.

Endpoints #

URL base: https://novadao.app. Toda ruta autenticada está bajo /api/v1/.

GET /api/v1/health Público

Sonda de disponibilidad (liveness).

Sin autenticación, sin límite de tasa, sin base de datos. Devuelve 200 mientras la API esté corriendo. Úsala en páginas de status y monitores de uptime.

Formato de la respuesta

{ "ok": true, "service": "nova-public-api", "server_time": "ISO8601" }

Comprobar que la API está activa

cURL
cURL
curl https://novadao.app/api/v1/health
Node.js
Node.js
const res = await fetch('https://novadao.app/api/v1/health');
const data = await res.json();
console.log(data);
Python
Python
import httpx

res = httpx.get('https://novadao.app/api/v1/health')
print(res.json())

Devuelve

JSON
{
  "ok": true,
  "service": "nova-public-api",
  "server_time": "2026-04-25T12:34:56.000Z"
}
GET /api/v1/ping Auth Bearer

Round-trip autenticado.

Confirma que tu clave de API funciona y muestra lo que ve el servidor: el id de la clave, el tier, los dominios permitidos y la hora del servidor. El botón "probar conexión" del panel llama este endpoint.

Parámetros

  • Authorization obligatorio header · string

    Bearer <tu-clave-de-api>

Formato de la respuesta

{ "ok": true, "key_id": number, "tier": "tier1" | "tier2", "allowed_domains": string[], "server_time": "ISO8601", "request_id": "req_..." }

Validar tu clave

cURL
cURL
curl -H "Authorization: Bearer $NOVA_API_KEY" \
  https://novadao.app/api/v1/ping
Node.js
Node.js
const res = await fetch('https://novadao.app/api/v1/ping', {
  headers: { Authorization: `Bearer ${process.env.NOVA_API_KEY}` },
});
console.log(await res.json());
Python
Python
import os, httpx

res = httpx.get(
    'https://novadao.app/api/v1/ping',
    headers={'Authorization': f'Bearer {os.environ["NOVA_API_KEY"]}'},
)
print(res.json())

Devuelve

JSON
{
  "ok": true,
  "key_id": 42,
  "tier": "tier1",
  "allowed_domains": [],
  "server_time": "2026-04-25T12:34:56.000Z",
  "request_id": "req_8aXk..."
}
POST /api/v1/pix/charges Auth Bearer Idempotency-Key obligatoria

Crear un cobro PIX (código QR).

Genera un código QR PIX que tu cliente puede pagar. Presta atención a dos encabezados: Authorization e Idempotency-Key. La Idempotency-Key es OBLIGATORIA — te deja reintentar tras un error de red sin cobrar dos veces.

Límite de tasa: tier1: 30/min · tier2: 300/min

Parámetros

  • Authorization obligatorio header · string

    Bearer <tu-clave-de-api>

  • Idempotency-Key obligatorio header · string

    Un valor único que generas por solicitud (1..255 caracteres ASCII). También se acepta `X-Idempotency-Key`.

  • amount_in_cents obligatorio body · integer

    Monto del cobro en centavos (BRL). Mínimo 100 (R$ 1,00).

  • depix_address obligatorio body · string

    Dirección confidencial de la red Liquid (`lq1...`) que recibirá los DePix cuando el cobro liquide.

Formato de la respuesta

{ "id": "...", "status": "pending", "amount_in_cents": number, "qr_copy_paste": "...", "qr_image_url": "...", "blockchain_tx_id": null, "created_at": "ISO8601" }

Crear un cobro de R$ 50

cURL
cURL
curl -X POST https://novadao.app/api/v1/pix/charges \
  -H "Authorization: Bearer $NOVA_API_KEY" \
  -H "Idempotency-Key: order-1234" \
  -H "Content-Type: application/json" \
  -d '{
    "amount_in_cents": 5000,
    "depix_address": "lq1..."
  }'
Node.js
Node.js
import { randomUUID } from 'node:crypto';

const res = await fetch('https://novadao.app/api/v1/pix/charges', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NOVA_API_KEY}`,
    'Idempotency-Key': randomUUID(),
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    amount_in_cents: 5000,
    depix_address: 'lq1...',
  }),
});
const charge = await res.json();
console.log(charge.id, charge.qr_copy_paste);
Python
Python
import os, uuid, httpx

res = httpx.post(
    'https://novadao.app/api/v1/pix/charges',
    headers={
        'Authorization': f'Bearer {os.environ["NOVA_API_KEY"]}',
        'Idempotency-Key': str(uuid.uuid4()),
    },
    json={
        'amount_in_cents': 5000,
        'depix_address': 'lq1...',
    },
)
charge = res.json()
print(charge['id'], charge['qr_copy_paste'])

Devuelve

JSON
{
  "id": "ch_a8b3...",
  "status": "pending",
  "amount_in_cents": 5000,
  "qr_copy_paste": "00020126580014BR.GOV.BCB.PIX...",
  "qr_image_url": "https://novadao.app/api/v1/pix/charges/ch_a8b3.../qr.png",
  "blockchain_tx_id": null,
  "created_at": "2026-04-25T12:34:56.000Z"
}
GET /api/v1/pix/charges/:id Auth Bearer

Consultar un cobro PIX.

Devuelve el estado actual de un cobro PIX. Suscríbete a `pix.charge.received` cuando necesites la confirmación bancaria del pago, y a `pix.charge.paid` cuando necesites la transacción liquidada en Liquid.

Parámetros

  • id obligatorio path · string

    El id del cobro devuelto por POST /pix/charges.

Formato de la respuesta

{ "id": "...", "status": "pending" | "paid" | "expired" | "failed", "amount_in_cents": number, "qr_copy_paste": "...", "qr_image_url": "...", "blockchain_tx_id": "..." | null, "created_at": "ISO8601" }

Comprobar si el cobro fue pagado

cURL
cURL
curl -H "Authorization: Bearer $NOVA_API_KEY" \
  https://novadao.app/api/v1/pix/charges/ch_a8b3
Node.js
Node.js
const res = await fetch('https://novadao.app/api/v1/pix/charges/ch_a8b3', {
  headers: { Authorization: `Bearer ${process.env.NOVA_API_KEY}` },
});
const charge = await res.json();
console.log(charge.status);
Python
Python
import os, httpx

res = httpx.get(
    'https://novadao.app/api/v1/pix/charges/ch_a8b3',
    headers={'Authorization': f'Bearer {os.environ["NOVA_API_KEY"]}'},
)
charge = res.json()
print(charge['status'])

Devuelve

JSON
{
  "id": "ch_a8b3...",
  "status": "paid",
  "amount_in_cents": 5000,
  "qr_copy_paste": "00020126580014BR.GOV.BCB.PIX...",
  "qr_image_url": "https://novadao.app/api/v1/pix/charges/ch_a8b3.../qr.png",
  "blockchain_tx_id": "7f4a...",
  "created_at": "2026-04-25T12:34:56.000Z"
}
GET /api/v1/webhooks Auth Bearer

Listar webhooks.

Devuelve todos los webhooks de tu cuenta.

Formato de la respuesta

{ "webhooks": [{ "id": number, "url": "...", "events": string[], "status": "active" | "disabled", "created_at": "ISO8601" }] }

Listar webhooks configurados

cURL
cURL
curl -H "Authorization: Bearer $NOVA_API_KEY" \
  https://novadao.app/api/v1/webhooks
Node.js
Node.js
const res = await fetch('https://novadao.app/api/v1/webhooks', {
  headers: { Authorization: `Bearer ${process.env.NOVA_API_KEY}` },
});
const data = await res.json();
console.log(data.webhooks.length);
Python
Python
import os, httpx

res = httpx.get(
    'https://novadao.app/api/v1/webhooks',
    headers={'Authorization': f'Bearer {os.environ["NOVA_API_KEY"]}'},
)
data = res.json()
print(len(data['webhooks']))

Devuelve

JSON
{
  "webhooks": [
    {
      "id": 7,
      "url": "https://yourshop.com/webhooks/nova",
      "events": ["pix.charge.paid", "payment_link.paid"],
      "status": "active",
      "created_at": "2026-04-25T12:34:56.000Z"
    }
  ]
}
POST /api/v1/webhooks Auth Bearer Idempotency-Key opcional

Suscribirse a eventos.

Crea un suscriptor de webhook. La respuesta incluye un `signing_secret` que se muestra una sola vez — guárdalo; lo vas a necesitar para verificar la firma de cada entrega.

Parámetros

  • url obligatorio body · string (URL)

    Endpoint HTTPS que recibirá las entregas vía POST.

  • events obligatorio body · string[]

    Uno o más de: pix.charge.created, pix.charge.received, pix.charge.paid, pix.charge.expired, pix.charge.failed, payment_link.paid.

Formato de la respuesta

{ "id": number, "url": "...", "events": string[], "status": "active", "signing_secret": "<base64url, shown once>", "created_at": "ISO8601" }

Suscribirse a cobros pagados

cURL
cURL
curl -X POST https://novadao.app/api/v1/webhooks \
  -H "Authorization: Bearer $NOVA_API_KEY" \
  -H "Idempotency-Key: webhook-paid-events-v1" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourshop.com/webhooks/nova",
    "events": ["pix.charge.paid", "payment_link.paid"]
  }'
Node.js
Node.js
const res = await fetch('https://novadao.app/api/v1/webhooks', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.NOVA_API_KEY}`,
    'Idempotency-Key': 'webhook-paid-events-v1',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    url: 'https://yourshop.com/webhooks/nova',
    events: ['pix.charge.paid', 'payment_link.paid'],
  }),
});
const wh = await res.json();
console.log('Save this signing secret:', wh.signing_secret);
Python
Python
import os, httpx

res = httpx.post(
    'https://novadao.app/api/v1/webhooks',
    headers={
        'Authorization': f'Bearer {os.environ["NOVA_API_KEY"]}',
        'Idempotency-Key': 'webhook-paid-events-v1',
    },
    json={
        'url': 'https://yourshop.com/webhooks/nova',
        'events': ['pix.charge.paid', 'payment_link.paid'],
    },
)
wh = res.json()
print('Save this signing secret:', wh['signing_secret'])

Devuelve

JSON
{
  "id": 7,
  "url": "https://yourshop.com/webhooks/nova",
  "events": ["pix.charge.paid", "payment_link.paid"],
  "status": "active",
  "signing_secret": "Rll0hXsdn2hzp1G6...",
  "created_at": "2026-04-25T12:34:56.000Z"
}
DELETE /api/v1/webhooks/:id Auth Bearer

Cancelar la suscripción.

Elimina un suscriptor de webhook. Las entregas ya encoladas igual se intentan.

Parámetros

  • id obligatorio path · integer

    Id del webhook.

Formato de la respuesta

{ "id": number, "deleted": true }

Eliminar un webhook

cURL
cURL
curl -X DELETE https://novadao.app/api/v1/webhooks/7 \
  -H "Authorization: Bearer $NOVA_API_KEY"
Node.js
Node.js
const res = await fetch('https://novadao.app/api/v1/webhooks/7', {
  method: 'DELETE',
  headers: { Authorization: `Bearer ${process.env.NOVA_API_KEY}` },
});
console.log(await res.json());
Python
Python
import os, httpx

res = httpx.delete(
    'https://novadao.app/api/v1/webhooks/7',
    headers={'Authorization': f'Bearer {os.environ["NOVA_API_KEY"]}'},
)
print(res.json())

Devuelve

JSON
{
  "id": 7,
  "deleted": true
}
POST /api/v1/webhooks/:id/test Auth Bearer

Disparar una entrega de prueba.

Encola una entrega `webhook.test.ping` hacia la URL de tu webhook. Úsala para confirmar que tu endpoint recibe y verifica firmas correctamente. La entrega se firma igual que un evento real.

Parámetros

  • id obligatorio path · integer

    Id del webhook.

Formato de la respuesta

{ "delivery_id": "whd_...", "queued_at": "ISO8601" }

Enviar un evento firmado de prueba

cURL
cURL
curl -X POST https://novadao.app/api/v1/webhooks/7/test \
  -H "Authorization: Bearer $NOVA_API_KEY"
Node.js
Node.js
const res = await fetch('https://novadao.app/api/v1/webhooks/7/test', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.NOVA_API_KEY}` },
});
const delivery = await res.json();
console.log(delivery.delivery_id);
Python
Python
import os, httpx

res = httpx.post(
    'https://novadao.app/api/v1/webhooks/7/test',
    headers={'Authorization': f'Bearer {os.environ["NOVA_API_KEY"]}'},
)
delivery = res.json()
print(delivery['delivery_id'])

Devuelve

JSON
{
  "delivery_id": "whd_abc123",
  "queued_at": "2026-04-25T12:34:56.000Z"
}

Webhooks: eventos #

Nova publica un payload JSON a la URL de tu webhook cuando algo le pasa a uno de tus cobros o enlaces de pago. Suscríbete a los eventos que te importan al crear el webhook.

Para cobros PIX D+2, usa pix.charge.received para confirmar que el pagador completó el PIX en el riel bancario. Un pago delayed llega con status: "delayed", settlement_status: "delayed", delay_until, confirmed_at y blockchain_tx_id: null. Usa pix.charge.paid solo para la liquidación final en Liquid con blockchain_tx_id, y sigue escuchando pix.charge.failed para cobros reembolsados, cancelados o con error.

Cada entrega incluye estos encabezados:

Nova-Signature: t=1714000000,v1=8a7c3b...
Nova-Event: pix.charge.paid
Nova-Delivery-Id: whd_abc123
Nova-Api-Version: 2026-04-24

Webhooks: verificar la firma #

Te mandamos un sello; tú confirmas que el sello coincide antes de confiar en el mensaje. Sin verificación, cualquiera que conozca tu URL podría enviarte eventos falsos. El encabezado Nova-Signature tiene dos partes: t (timestamp Unix en segundos) y v1 (HMAC-SHA256 en hex). La cadena firmada es t + "." + raw_body. Calcula el HMAC con tu signing_secret (devuelto una sola vez al crear el webhook) y compara.

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

export function verifyNovaWebhook(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!t || !v1) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  if (expected.length !== v1.length) return false;
  return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
Python
Python
import hmac, hashlib, time

def verify_nova_webhook(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split('=', 1) for p in header.split(','))
    try:
        t = int(parts['t']); v1 = parts['v1']
    except KeyError:
        return False
    if abs(int(time.time()) - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)
PHP
PHP
function verify_nova_webhook(string $body, string $header, string $secret, int $tolerance = 300): bool {
    parse_str(str_replace(',', '&', $header), $parts);
    $t = (int) ($parts['t'] ?? 0); $v1 = $parts['v1'] ?? '';
    if (!$t || !$v1) return false;
    if (abs(time() - $t) > $tolerance) return false;
    $expected = hash_hmac('sha256', $t . '.' . $body, $secret);
    return hash_equals($expected, $v1);
}
Usa el cuerpo crudo. Si tu framework parsea el JSON antes de que puedas hashearlo, la firma no va a coincidir — Express, Fastify y Bun exponen los bytes crudos vía un setting o un middleware. La mayoría de las fallas que vemos en soporte son por esto.

Webhooks: política de retry #

Si tu endpoint responde 2xx en menos de 15 segundos, la entrega se marca como completa. Cualquier otra respuesta es una falla. Las fallas reintentan seis veces con backoff:

Tras el último intento, la entrega se marca como failed. Tu panel la muestra bajo "Entregas de webhook" con un botón de replay manual. La app Nova también deja una notificación en tu bandeja.

1m 5m 30m 2h 6h 24h

Versionado #

La versión actual es /api/v1/. La tratamos como un contrato permanente. Los campos y tipos de evento nuevos son aditivos y no requieren que cambies nada. Si alguna vez sale un cambio rompedor, aterriza en /api/v2/ y v1 sigue viva al menos 12 meses. Durante la ventana de sunset, toda respuesta de v1 carga un encabezado Deprecation: true y una fecha Sunset.

Cada payload de webhook también incluye un campo api_version para que los suscriptores puedan ramificar el código sin clavar fechas.

Bug bounty y divulgación responsable #

Nova recibe reportes de seguridad sobre superficies del producto y de la API. Envía hallazgos en privado a hello@novadao.app con el asunto [security] <descripción corta>. No abras issues públicas ni publiques detalles hasta que Nova confirme que la divulgación está permitida.

Alcance

Reglas

Fuera de alcance

Recompensas

Los reportes validados pueden ser elegibles para recompensas discrecionales. La decisión considera severidad, impacto real, reproducibilidad, explotabilidad, calidad del reporte y si el problema ya era conocido.

Soporte

Dos formas de contactarnos:

  • Bot de soporte en Telegram — la vía más rápida, respondemos en menos de una hora en horario hábil.
  • Incluye el request_id del envelope de error cuando escribas. Con eso podemos rastrear cada llamada al instante.

Para integraciones asistidas por LLM, apunta tu herramienta a /es/devs/llms.md — mismo contenido, formato legible por máquina.