Webhooks
Recibe notificaciones HTTP cuando ocurren eventos en tu cuenta — útil para sincronizar tu sistema interno, alertar al equipo o reaccionar automáticamente. Disponible desde el plan Starter; cap por plan en el header del response.
Eventos disponibles
Sección titulada «Eventos disponibles»| Evento | Cuándo se emite |
|---|---|
endpoint.test | Disparado manualmente desde POST /v1/webhooks/{id}/test o desde el dashboard |
subscription.updated | Cambio de plan, renovación, downgrade |
subscription.canceled | Cancelación efectiva (fin del período pagado) |
usage.threshold_reached | Tu uso mensual cruza el umbral configurado (80% por defecto) |
key.created | Se generó una API key nueva |
key.revoked | Se revocó una API key |
key.rotated | Se rotó una API key (la previa entra en periodo de gracia) |
etl.run.started | Empezó una corrida de ETL (admin) |
etl.run.succeeded | Una corrida de ETL terminó OK |
etl.run.failed | Una corrida de ETL falló |
etl.run.canceled | Una corrida de ETL fue cancelada |
Endpoints
Sección titulada «Endpoints»| Método | Ruta | Propósito |
|---|---|---|
GET | /v1/webhooks | Lista tus endpoints |
POST | /v1/webhooks | Crea un endpoint (devuelve secret una sola vez) |
GET | /v1/webhooks/{id} | Detalle de un endpoint |
PATCH | /v1/webhooks/{id} | Actualiza url, events, active |
DELETE | /v1/webhooks/{id} | Elimina el endpoint y detiene entregas |
POST | /v1/webhooks/{id}/rotate | Rota el secreto (el viejo queda válido 24 h) |
POST | /v1/webhooks/{id}/test | Dispara endpoint.test contra el endpoint |
GET | /v1/webhooks/{id}/deliveries | Historial de entregas (status, intentos, response) |
Todas las rutas requieren Authorization: Bearer ak_live_... con scope webhooks:manage.
Crear un endpoint
Sección titulada «Crear un endpoint»curl -X POST https://api.chequea.pe/api/v1/webhooks \ -H "Authorization: Bearer ak_live_TU_CLAVE" \ -H "Content-Type: application/json" \ -d '{ "url": "https://tu-app.com/hooks/chequea", "events": ["subscription.updated", "key.revoked", "usage.threshold_reached"], "active": true }'Respuesta 201 Created:
{ "id": "wh_3b1a2f7c-2a48-4f2e-9d83-1bd5c2c9e0f1", "url": "https://tu-app.com/hooks/chequea", "events": ["subscription.updated", "key.revoked", "usage.threshold_reached"], "active": true, "secret": "whsec_5d8e3a...REVELADO_UNA_SOLA_VEZ", "createdAt": "2026-05-20T17:00:00.000Z"}Estructura del request entrante
Sección titulada «Estructura del request entrante»Cuando se dispara un evento, Chequea hace POST a tu url con:
Headers:
| Header | Ejemplo | Descripción |
|---|---|---|
content-type | application/json | Siempre |
user-agent | Chequea-Webhooks/1.0 | Para que puedas allowlistear |
x-apiperu-event | subscription.updated | Nombre del evento |
x-apiperu-delivery | dlv_a1b2c3d4 | ID único de esta entrega (idempotencia) |
x-apiperu-timestamp | 1747772400 | Unix timestamp del envío |
x-apiperu-signature | t=1747772400,v1=abcd... | HMAC-SHA256 (ver abajo) |
Body (ejemplo subscription.updated):
{ "id": "evt_5f9a7b3c", "type": "subscription.updated", "createdAt": "2026-05-20T17:00:00.000Z", "data": { "subscriptionId": "sub_d4e5f6a7", "plan": "pro", "previousPlan": "starter", "renewsAt": "2026-06-20T00:00:00.000Z" }}Verificar la firma HMAC
Sección titulada «Verificar la firma HMAC»El header x-apiperu-signature sigue el formato Stripe-like:
t=<timestamp>,v1=<signature>Durante una rotación del secret, ambas firmas viajan por 24 horas:
t=<timestamp>,v1=<new_signature>,v0=<old_signature>El signature es HMAC-SHA256(secret, "<timestamp>.<raw_body>") en hexadecimal lowercase. Importante: el body se firma como bytes crudos antes de cualquier parseo JSON — si tu framework re-serializa, la verificación fallará.
import { createHmac, timingSafeEqual } from "node:crypto"
function verify(req, secret, toleranceSeconds = 300) { const sig = req.headers["x-apiperu-signature"] const ts = Number(req.headers["x-apiperu-timestamp"]) if (!sig || !ts) return false if (Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) return false
const expected = createHmac("sha256", secret) .update(`${ts}.${req.rawBody}`) .digest("hex")
// Parse: "t=...,v1=...,v0=..." const parts = Object.fromEntries( sig.split(",").map((p) => p.split("=", 2)) ) const candidates = [parts.v1, parts.v0].filter(Boolean) return candidates.some((c) => { const a = Buffer.from(c, "hex") const b = Buffer.from(expected, "hex") return a.length === b.length && timingSafeEqual(a, b) })}import hmac, hashlib, time
def verify(headers, raw_body: bytes, secret: str, tolerance_seconds=300) -> bool: sig = headers.get("x-apiperu-signature", "") ts = int(headers.get("x-apiperu-timestamp", "0")) if abs(time.time() - ts) > tolerance_seconds: return False expected = hmac.new( secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256 ).hexdigest() parts = dict(p.split("=", 1) for p in sig.split(",")) for v in (parts.get("v1"), parts.get("v0")): if v and hmac.compare_digest(v, expected): return True return Falsefunction verify(array $headers, string $rawBody, string $secret, int $toleranceSeconds = 300): bool { $sig = $headers['x-apiperu-signature'] ?? ''; $ts = (int)($headers['x-apiperu-timestamp'] ?? 0); if (abs(time() - $ts) > $toleranceSeconds) return false; $expected = hash_hmac('sha256', $ts . '.' . $rawBody, $secret); $parts = []; foreach (explode(',', $sig) as $p) { [$k, $v] = explode('=', $p, 2); $parts[$k] = $v; } foreach (['v1', 'v0'] as $k) { if (!empty($parts[$k]) && hash_equals($parts[$k], $expected)) return true; } return false;}Tolerancia de tiempo y replay
Sección titulada «Tolerancia de tiempo y replay»Recomendamos rechazar entregas con x-apiperu-timestamp más viejas que 5 minutos. Esto evita ataques de replay donde un atacante captura una entrega válida y la re-envía después.
El x-apiperu-delivery es único por intento de entrega. Persistilo en tu lado y trata duplicados como idempotentes (responder 2xx sin re-procesar).
Respuesta esperada y reintentos
Sección titulada «Respuesta esperada y reintentos»Tu endpoint debe responder con código 2xx (preferiblemente 200, 202 o 204) en menos de 10 segundos. Cualquier otro código (o timeout, o redirect, o body > 1 MB) marca el intento como fallido y dispara reintento.
Reintentos con backoff exponencial: 0 s, 1 min, 5 min, 30 min, 2 h, 6 h, 24 h, 48 h. Después de 8 intentos fallidos la entrega se marca como failed_permanent y queda en el historial.
Casos especiales:
- Redirects (
3xx): rechazados — los webhooks deben responder directo, no apuntar a otra URL. - Body > 1 MB: rechazamos para no llenar tu pipe con respuestas largas; los webhooks deberían responder corto.
- TLS inválido / DNS no resuelve: se cuenta como fallo, sigue reintento.
- Connection refused: fallo, sigue reintento.
Protección anti-SSRF
Sección titulada «Protección anti-SSRF»Las URLs de webhook se validan al crear/editar:
- Sólo
https://en producción (http://permitido sólo en desarrollo) - Bloqueamos rangos privados:
127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,::1,fc00::/7,fe80::/10 - Bloqueamos metadatos cloud:
169.254.169.254(AWS/GCP/Azure) - DNS-pin: la IP resuelta al crear el endpoint es la que se usa al entregar (evita rebinding después)
- Sin credenciales en URL (
https://user:pass@...se rechaza)
Historial de entregas
Sección titulada «Historial de entregas»curl https://api.chequea.pe/api/v1/webhooks/{id}/deliveries \ -H "Authorization: Bearer ak_live_TU_CLAVE"Devuelve hasta 100 entregas más recientes con estado, intentos, latencia y código de respuesta — útil para debugging.
Plan de uso
Sección titulada «Plan de uso»| Plan | Webhooks máx | Eventos | Reintentos | DNS-pin |
|---|---|---|---|---|
| Starter | 1 | todos | sí | sí |
| Pro | 5 | todos | sí | sí |
| Empresa | configurable | todos + custom | sí | sí |