Crear una suscripción
curl -X POST https://api.qbank.cl/platform/v1/webhooks/subscriptions \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"event_type": "payout_status_changed",
"callback_url": "https://api.miapp.com/webhooks/cbpay",
"secret": "un-secreto-de-al-menos-16-chars"
}'
event_type: uno de los eventos de la tabla siguiente, o*para todos.callback_url: HTTPS obligatorio; se rechazan localhost e IPs privadas — para desarrollo local usa un túnel HTTPS.secret: mínimo 16 caracteres; se usa para firmar cada entrega. Se almacena cifrado y no puede recuperarse.
curl https://api.qbank.cl/platform/v1/webhooks/subscriptions \
-H "Authorization: Bearer <token>"
{
"page": 1,
"page_size": 50,
"subscriptions": [
{
"id": "5f3a…",
"event_type": "payout_status_changed",
"callback_url": "https://api.miapp.com/webhooks/cbpay",
"status": "active",
"created_at": "2026-07-01T12:00:00Z"
}
]
}
Desactivar y reactivar una suscripción
Cuando un callback deja de usarse, desactívalo en vez de borrarlo (las suscripciones nunca se borran: quedandisabled y puedes reactivarlas
cuando quieras):
curl -X PATCH https://api.qbank.cl/platform/v1/webhooks/subscriptions/5f3a… \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{ "status": "disabled" }'
{
"id": "5f3a…",
"event_type": "payout_status_changed",
"callback_url": "https://api.miapp.com/webhooks/cbpay",
"status": "disabled",
"created_at": "2026-07-01T12:00:00Z"
}
{ "status": "active" }.
- El toggle rige lo futuro: una suscripción
disableddeja de recibir eventos nuevos, pero las entregas ya encoladas siguen saliendo. - Es idempotente: repetir el estado vigente responde
200sin cambiar nada. - Solo puedes tocar las suscripciones de tu cuenta: una suscripción de
otra cuenta responde
404(es indistinguible de una inexistente).
| HTTP | error | Solución |
|---|---|---|
| 400 | invalid_status | El estado debe ser active o disabled |
| 404 | not_found | La suscripción no existe o pertenece a otra cuenta |
Eventos
| Evento | Cuándo se emite |
|---|---|
payin_credited | Un cobro fiat fue recibido y abonado |
payin_expired | Un cobro activo (QR / checkout) venció o falló sin recibir el pago |
payin_refunded | Una devolución de un cobro con tarjeta llegó a estado final (incluye el contracargo impuesto por el emisor) |
payin_settlement_scheduled | Un cobro con tarjeta quedó confirmado (credited, se emitió payin_credited) y su saldo se programó para un settle_at futuro (espera de settlement configurada por la org). Se emite exactamente una vez al confirmarse el pago; el saldo queda disponible al vencimiento |
payout_status_changed | Un payout cambió de estado |
transfer_received | La cuenta recibió una transferencia interna |
crypto_deposit_credited | Un depósito on-chain fue confirmado y abonado |
crypto_deposit_held | Un depósito entrante quedó retenido por riesgo del remitente (screening) |
crypto_deposit_alert | Un depósito se acreditó pero el remitente presenta riesgo alto (informativo) |
crypto_withdrawal_status_changed | Un retiro on-chain cambió de estado |
banking_customer_status_changed | Cambió la verificación de un perfil bancario (propio o de un tercero registrado — customer_kind lo distingue) |
banking_operation_status_changed | Un pago bancario cambió de estado |
card_transaction | Una compra con tarjeta fue autorizada, anulada o ajustada |
card_status_changed | Una tarjeta cambió de estado (incluye congelamiento automático) |
card_stored | La tarjeta de un pagador fue tokenizada y guardada con consentimiento (tarjetas guardadas) |
stored_card_revoked | Una credencial de tarjeta guardada fue revocada (los cobros iniciados por el comercio dejan de funcionar) |
subscription_status_changed | Una suscripción sobre una tarjeta guardada cambió de estado (active / paused / past_due / canceled) |
kyc_verification_status_changed / kyb_verification_status_changed | Una verificación de identidad cambió de estado (incluye tu propio onboarding, con self_onboarding: true) |
kyc_link_completed / kyb_link_completed | Un link de verificación hosteado fue completado |
kyc_document_validated / kyb_document_validated | Terminó el OCR de un documento subido por API |
kyc_liveness_completed | Una prueba de vida fue completada desde un liveness link |
aml_screening_updated | Novedades del screening AML (resultado, casos, riesgo, transacción revisada) |
risk_report_ready | Un informe crediticio Qscore terminó de generarse (lleva el score y la banda) |
risk_score_changed | El score de un sujeto monitoreado se movió (re-evaluación tras nuevos datos del buró) |
risk_monitoring_alert | Un sujeto monitoreado de Qscore gatilló una alerta: el score cayó bajo tu umbral, aparecieron registros nuevos en el buró o se eliminaron registros |
risk_batch_completed | Un lote de informes Qscore terminó de procesarse (exactamente un webhook por lote, con conteos — nunca uno por sujeto) |
risk_consent_granted | El titular autorizó un link de autorización — los hechos bancarios positivos fluyen a la ficha crediticia del sujeto |
risk_consent_revoked | El titular rechazó un link de autorización o tu cuenta lo revocó |
wallet_deposit_received | Llegó un depósito on-chain a una wallet segregada (no toca el ledger) |
wallet_send_status_changed | Un envío desde una wallet segregada cambió de estado |
wallet_key_exported | Se exportó la llave privada de una wallet segregada (alerta de seguridad) |
wallet_external_movement | Movimiento on-chain de una wallet segregada que no pasó por la plataforma (esperable en custodia client) |
wallet_key_compromise_suspected | Alarma crítica: salida externa desde una wallet con custodia cbpay — posible llave comprometida |
wallet_signature_created | Se creó una prueba de firma con una wallet segregada (firma de mensajes EIP-191 / TIP-191) |
wallet_linked | Se vinculó una wallet externa (custody=client) a la cuenta mediante un desafío nonce firmado |
txn_review_status_changed | Una operación retenida por el firewall transaccional cambió de estado de revisión (in_review / info_requested / released / rejected) — payload neutro, sin motivos internos; un rechazo también puede venir del barrido automático por plazo (auto-rechazo) |
corridor_status_changed | Un corredor de pago cambió su disponibilidad (operational / degraded / down) — broadcast, ver la guía de estado del servicio |
balance_adjusted | Un administrador aplicó un abono o cargo manual sobre un saldo |
account_status_changed | El estado administrativo de la cuenta cambió (active / blocked / closed) |
member_security_event | Hecho de seguridad de un usuario de la cuenta (inicio de sesión, cambio de credenciales, factor nuevo, sesión revocada) |
Payload de cada evento
{
"payin_id": "9c2a…",
"account_id": "ae8c…",
"country": "BO",
"currency": "BOB",
"local_amount": "700.00",
"fx_rate": "6.91",
"usdt_credited": "100.302460",
"fee": "1.000000"
}
{
"payin_id": "567d…",
"account_id": "ae8c…",
"status": "expired",
"country": "BO",
"currency": "BOB",
"local_amount": "60.99",
"reference": "CBK7Q2M4XZ9P"
}
{
"refund_id": "3a7d…",
"payin_id": "9f1c…",
"account_id": "c57f…",
"kind": "refund",
"status": "completed",
"currency": "USD",
"local_amount": "40.00",
"usdt_debited": "40.000000",
"receipt_url": "https://api.qbank.cl/platform/v1/payin-refunds/3a7d…/receipt"
}
{
"payin_id": "8b3e…",
"account_id": "ae8c…",
"country": "US",
"currency": "USD",
"local_amount": "498.75",
"fx_rate": "1.010202",
"usdt_gross": "493.811881",
"fee": "27.159654",
"usdt_net": "466.652227",
"status": "credited",
"settle_at": "2026-08-12T14:08:33Z",
"receipt_url": "https://api.qbank.cl/platform/v1/payins/8b3e…/receipt"
}
Con una demora de settlement configurada, ambos webhooks se emiten al
confirmarse el pago: primero
payin_settlement_scheduled (el saldo
quedó programado para settle_at) y después payin_credited (el payin
quedó confirmado, status: credited). Lo único que espera al settle_at
es la disponibilidad del saldo — la respuesta del payin lleva
settlement_pending: true mientras está pendiente y settled_at cuando
cae en el ledger.usdt_net es el monto que se acreditará al vencimiento (bruto − comisión).
Los montos pueden venir vacíos ("") en filas históricas sin bruto/comisión.
Al llegar el settle_at, el worker de settlement acredita el saldo y emite
payin_credited (el flujo de siempre, sin cambios).{
"payout_id": "0d4f…",
"account_id": "ae8c…",
"country": "MX",
"currency": "MXN",
"local_amount": "1500.00",
"usdt_amount": "85.714286",
"total_debit": "86.014286",
"status": "completed",
"status_code": "",
"bank_reference": "00761123456"
}
{
"account_id": "ae8c…",
"review_id": "7a3f…",
"kind": "payout",
"resource_id": "0d4f…",
"status": "info_requested",
"previous_status": "in_review",
"amount": "1500.00",
"asset": "USD"
}
{
"transfer_id": "77b1…",
"from_account_id": "389d…",
"to_account_id": "ae8c…",
"asset": "USDT",
"amount": "25.000000",
"description": "Split de gastos",
"created_at": "2026-07-06T20:10:00Z"
}
{
"account_id": "ae8c…",
"chain": "tron",
"asset": "USDT",
"tx_id": "b1946ac9…",
"amount": "499.000000",
"fee": "1.000000"
}
{
"account_id": "ae8c…",
"hold_id": "c1d2e3f4…",
"chain": "tron",
"asset": "usdt",
"tx_id": "8a5b3c…",
"risk": "Severe",
"status": "held"
}
{
"account_id": "ae8c…",
"chain": "tron",
"asset": "usdt",
"tx_id": "9c6d4e…",
"risk": "High",
"status": "credited"
}
{
"withdrawal_id": "5e8c…",
"account_id": "ae8c…",
"chain": "tron",
"asset": "USDT",
"tx_id": "7d3f01aa…",
"status": "completed",
"amount": "100.000000"
}
{
"account_id": "ae8c…",
"customer_id": "9f2b…",
"customer_kind": "third_party",
"third_party_id": "77aa…",
"kyc_status": "approved"
}
En
banking_customer_status_changed, customer_kind distingue tu perfil
propio (self) de un tercero que registraste (third_party, con su
third_party_id — el mismo id de GET /v1/banking/third-parties/{id}).{
"account_id": "ae8c…",
"customer_id": "9f2b…",
"operation_id": "7e8a…",
"type": "withdraw",
"status": "completed"
}
{
"account_id": "ae8c…",
"card_id": "3c2b…",
"transaction_id": "5e4d…",
"status": "authorized",
"amount_usdt": "16.170000",
"merchant": "AMZN Mktp"
}
{
"account_id": "ae8c…",
"card_id": "3c2b…",
"status": "frozen",
"reason": "monthly_fee_unpaid"
}
{
"stored_card_id": "a9b8…",
"account_id": "ae8c…",
"payer_reference": "pagador@email.com",
"brand": "VISA",
"last4": "1234",
"country": "BO",
"currency": "BOB",
"seed_payin_id": "9c2a…"
}
{
"stored_card_id": "a9b8…",
"account_id": "ae8c…",
"payer_reference": "pagador@email.com",
"brand": "VISA",
"last4": "1234"
}
{
"subscription_id": "4f1e…",
"account_id": "ae8c…",
"stored_card_id": "a9b8…",
"status": "past_due",
"period": 3,
"next_charge_at": "2026-08-01T12:00:00Z",
"payer_reference": "pagador@email.com",
"reason": "dunning_exhausted",
"failed_attempts": 3
}
{
"account_id": "ae8c…",
"kind": "kyc",
"event": "approved",
"submission_id": "c3d4…",
"external_customer_id": "cust_789",
"status": "approved",
"risk_band": "low",
"decision": "approved",
"decision_source": "auto"
}
Cuando el evento lleva una decisión final (
approved, rejected o
changes_requested), el payload incluye decision_source: "auto" si la
resolvió el motor de decisión automática (los expedientes limpios se aprueban
en segundos sin intervención humana), "admin" si la decidió un oficial de
cumplimiento desde la consola de revisión. El campo se omite en submissions
antiguas sin datos del motor.{
"account_id": "ae8c…",
"kind": "kyb",
"event": "link_completed",
"link_id": "b2c3…",
"submission_id": "d4e5…",
"external_customer_id": "cust_456",
"status": "completed"
}
{
"account_id": "ae8c…",
"kind": "kyc",
"submission_id": "c3d4…",
"external_customer_id": "cust_789",
"category": "identity",
"outcome": "MATCH",
"score": 0.97,
"summary": "Document matches the submitted identity"
}
{
"account_id": "ae8c…",
"kind": "kyc",
"submission_id": "c3d4…",
"external_customer_id": "cust_789",
"outcome": "PASS",
"passed": true
}
{
"account_id": "ae8c…",
"screening_event": "compliance_risk_changed",
"customer_id": "cus_8f2e…",
"data": { "risk_level": "high" }
}
{
"wallet_id": "b7e3…",
"account_id": "ae8c…",
"chain": "tron",
"asset": "USDT",
"tx_id": "b1946ac9…",
"amount_raw": "125000000",
"from_address": "TDonor…"
}
{
"send_id": "9c8b…",
"wallet_id": "b7e3…",
"account_id": "ae8c…",
"chain": "tron",
"asset": "USDT",
"tx_id": "b1946ac9…",
"status": "completed",
"amount_raw": "25500000"
}
{
"wallet_id": "b7e3…",
"account_id": "ae8c…",
"chain": "tron",
"asset": "USDT",
"address": "TRmSZRaMAqLEevAdGwo3R43bRBXamWR5bd"
}
{
"wallet_id": "b7e3…",
"account_id": "ae8c…",
"chain": "tron",
"asset": "USDT",
"direction": "out",
"tx_id": "9a3c1e5f…",
"amount_raw": "25000000",
"custody": "client"
}
{
"wallet_id": "b7e3…",
"account_id": "ae8c…",
"chain": "tron",
"asset": "USDT",
"direction": "out",
"tx_id": "9a3c1e5f…",
"amount_raw": "25000000",
"custody": "cbpay"
}
{
"proof_id": "c41d…",
"account_id": "ae8c…",
"wallet_id": "b7e3…",
"chain": "eth",
"address": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F",
"purpose": "wallet_ownership",
"proof_code": "G9f2c…",
"verify_url": "https://api.qbank.cl/platform/v1/public/signature-proofs/G9f2c…"
}
{
"link_id": "d52e…",
"account_id": "ae8c…",
"chain": "eth",
"address": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F",
"proof_id": "c41d…"
}
{
"report_id": "9f1c…",
"subject_id": "6aa2…",
"doc_id": "76.123.456-7",
"country": "CL",
"subject_type": "person",
"score": 712,
"band": "B",
"verify_code": "Q9f1c2e7a5b6d4c8e9a0f1d2b3c4d5e6f0123456789ab"
}
{
"subject_id": "6aa2…",
"report_id": "9f1c…",
"old_score": 688,
"new_score": 712,
"old_band": "C",
"new_band": "B"
}
{
"monitoring_id": "7c9e2f14-5b6a-4c8d-9e1f-3a7b5c2d8e91",
"subject_id": "8f5fb0d2-1d45-4a0e-9d5c-2d39f2b7a112",
"doc_id": "12.345.678-5",
"country": "CL",
"subject_type": "person",
"triggers": ["score_drop_below", "new_records"],
"score": 487,
"previous_score": 512,
"band": "D",
"record_count": 3,
"detected_at": "2026-08-08T18:41:12Z",
"new_records": [
{
"source": "res_chile",
"record_type": "debt_collection",
"reported_at": "2026-08-05T00:00:00Z",
"amount": "450000",
"currency": "CLP",
"status": "open"
}
]
}
{
"batch_id": "b7f2c1a4-3e5d-4f8a-9c2b-1d0e6a8f4c5d",
"status": "completed_with_errors",
"total_items": 4,
"succeeded_items": 3,
"failed_items": 1,
"country": "CL",
"purpose": "credit_evaluation"
}
{
"consent_id": "c3a1e9b2-7f4d-4c8a-9e1b-2a5c6d7e8f9a",
"subject_id": "s8b2c4d6-1e3f-4a5b-9c7d-8e9f0a1b2c3d",
"country": "CL",
"doc_id": "76123456-8",
"subject_type": "person",
"purpose": "credit_evaluation",
"status": "granted",
"previous_status": "pending",
"holder_name": "Maria Jose Contreras Soto",
"openfinance_link_id": "9f1a2b3c-4d5e-4f6a-8b9c-0d1e2f3a4b5c",
"granted_at": "2026-08-09T15:42:10Z"
}
{
"consent_id": "c3a1e9b2-7f4d-4c8a-9e1b-2a5c6d7e8f9a",
"subject_id": "s8b2c4d6-1e3f-4a5b-9c7d-8e9f0a1b2c3d",
"country": "CL",
"doc_id": "76123456-8",
"subject_type": "person",
"purpose": "credit_evaluation",
"status": "revoked",
"previous_status": "granted",
"revoked_at": "2026-08-11T09:05:33Z"
}
{
"flow": "payout",
"country": "VE",
"currency": "VES",
"method": "bank_transfer",
"status": "down",
"previous_status": "operational",
"since": "2026-07-24T22:10:00Z",
"reason": "consecutive infrastructure failures"
}
{
"account_id": "ae8c…",
"asset": "USDT",
"amount": "25.000000",
"direction": "credit",
"reason": "goodwill credit",
"available": "1025.000000",
"held": "0.000000"
}
{
"account_id": "ae8c…",
"status": "blocked",
"previous_status": "active"
}
{
"account_id": "ae8c…",
"member_id": "3f7b…",
"event_type": "password_changed",
"ip": "200.83.14.7",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
}
payout_status_changed y crypto_withdrawal_status_changed, status
puede ser completed o failed (con failed el débito ya fue
reembolsado cuando recibes el evento).
Formato de entrega
Cada entrega es unPOST JSON con estos headers:
| Header | Contenido |
|---|---|
X-Webhook-Event | Tipo de evento |
X-Webhook-Event-ID | ID único del evento |
X-Webhook-Delivery-ID | ID de esta entrega (cambia entre reintentos) |
X-Webhook-Timestamp | Unix timestamp (segundos, UTC) |
X-Webhook-Signature | Firma HMAC (ver abajo) |
Verificar la firma
X-Webhook-Signature = hex( HMAC-SHA256( secret, timestamp + "." + body ) )
const crypto = require("crypto");
function verifyWebhook(req, secret) {
const ts = req.headers["x-webhook-timestamp"];
const sig = req.headers["x-webhook-signature"];
const expected = crypto
.createHmac("sha256", secret)
.update(ts + "." + req.rawBody)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
import hashlib, hmac
def verify_webhook(headers, raw_body: bytes, secret: str) -> bool:
ts = headers["X-Webhook-Timestamp"]
sig = headers["X-Webhook-Signature"]
expected = hmac.new(
secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(sig, expected)
Calcula el HMAC sobre el body crudo (bytes tal como llegan), no sobre el
JSON re-serializado. Rechaza timestamps muy antiguos (> 5 minutos) para
prevenir replay.
Reintentos e idempotencia
- Tu endpoint debe responder 2xx dentro del timeout; cualquier otra respuesta se reintenta.
- Hasta 5 intentos con backoff incremental:
| Intento | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| Espera aprox. | inmediato | ~5s | ~20s | ~45s | ~80s |
- Usa
X-Webhook-Event-IDpara deduplicar: el mismo evento puede llegar más de una vez (entregas at-least-once). - Si los 5 intentos fallan, el evento no se reenvía — recupera el estado
con el
GETdel recurso (por eso ningún flujo debe depender SOLO del webhook).
Buenas prácticas
- Responde
200de inmediato y procesa en background. - Registra el
X-Webhook-Delivery-IDpara trazabilidad. - No dependas solo de webhooks para estados críticos: puedes consultar el
objeto por API en cualquier momento (
GET /v1/payouts/{id}, etc.).