Saltar al contenido principal

Integrar webhooks

Esta guía está dirigida a los equipos de desarrollo y arquitectura que van a recibir y procesar los webhooks de la plataforma en sus propios sistemas o servidores backend.

Si solo necesitas dar de alta o consultar una suscripción desde la interfaz gráfica del panel de administración, consulta Administrar webhooks desde el panel.


1. Cómo funciona el servicio de webhooks

  1. Alta de suscripción: Defines una URL HTTPS donde escucharás eventos, el alcance deseado (Tenant completo o Comercio específico) y una lista explícita de tipos de eventos públicos (event_types).
  2. Despacho en tiempo real: Cuando ocurre una operación (venta, actualización de estado, alta de terminal o comercio), el despachador de webhooks consume el evento en Kafka, lo transforma a un DTO público limpio (sin datos sensibles ni de telemetría interna) y ejecuta un POST HTTP firmado hacia tu endpoint.
  3. Confirmación y reintentos: Tu endpoint debe responder con un código HTTP 2xx en pocos segundos. Si responde con error, timeout o el servidor no está disponible, la plataforma aplicará reintentos automáticos con retroceso exponencial.

2. Alcance de la suscripción (scope_kind)

El servicio soporta dos niveles de alcance:

Alcance (scope_kind)merchant_idDescripción
tenant (Global)Omitido o nullRecibe todos los eventos generados en cualquier comercio o terminal de todo el tenant (tenant_wuzi). Es el modo recomendado para socios integradores que administran una red completa de comercios y TPVs.
merchant (Comercio)ObligatorioRecibe exclusivamente los eventos originados en el comercio indicado. Útil para comercios independientes o integraciones específicas de una sola tienda.
Regla de integridad en base de datos

Por diseño estricto, una suscripción con scope_kind: "tenant" rechaza peticiones que incluyan merchant_id. Igualmente, una suscripción scope_kind: "merchant" exige obligatoriamente un merchant_id válido.


3. Catálogo de eventos disponibles

La suscripción debe incluir explícitamente en el arreglo event_types los eventos que desea recibir. Si event_types está vacío, por seguridad la suscripción falla cerrada y no recibe nada.

A. Ventas y Transacciones (sale.*)

EventoCuándo se disparaEstado de la venta
sale.createdSe inicia y procesa una venta en la plataforma (moneda MXN).pending / inicial
sale.approvedLa venta fue aprobada y autorizada satisfactoriamente por el procesador adquirente.approved
sale.declinedLa venta fue declinada o rechazada (fondos insuficientes, sospecha de fraude, error de banco emisor, etc.).declined
sale.cancelledLa venta fue cancelada mediante una operación directa de cancelación o reverso el mismo día.cancelled
sale.refundedSe procesó una devolución o reembolso parcial o total de la venta.refunded

B. Terminales (terminal.*)

EventoCuándo se dispara
terminal.createdSe registra y aprovisiona una nueva terminal punto de venta lógica asociada a un comercio.
terminal.updatedCambia el estado público de una terminal (ej. activa, inactiva, bloqueada).

C. Comercios (merchant.*)

EventoCuándo se dispara
merchant.createdSe da de alta un nuevo comercio en la plataforma.
merchant.updatedCambia el estado público de un comercio (ej. activo, suspendido).

4. Crear suscripciones vía API

Puedes gestionar suscripciones programáticamente mediante la API de Webhooks (POST /subscriptions).

Suscripción a nivel Tenant (Todos los comercios de Wuzi)

POST /subscriptions HTTP/1.1
Host: dev-partner-api-wuzi.paymentnexus.com.mx
Content-Type: application/json
Authorization: Bearer <TU_TOKEN>

{
"tenant_id": "tenant_wuzi",
"scope_kind": "tenant",
"endpoint_url": "https://api.tu-servidor.com/webhooks/nexus",
"event_types": [
"sale.created",
"sale.approved",
"sale.declined",
"sale.cancelled",
"sale.refunded",
"terminal.created",
"terminal.updated",
"merchant.created",
"merchant.updated"
]
}

Suscripción a nivel Comercio específico

POST /subscriptions HTTP/1.1
Host: dev-partner-api-wuzi.paymentnexus.com.mx
Content-Type: application/json
Authorization: Bearer <TU_TOKEN>

{
"tenant_id": "tenant_wuzi",
"scope_kind": "merchant",
"merchant_id": "TECMERT000001",
"endpoint_url": "https://api.tu-servidor.com/webhooks/nexus",
"event_types": [
"sale.created",
"sale.approved",
"sale.declined"
]
}

Respuesta de creación (incluye el secreto de firma):

{
"id": "sub_d7cc813e-4d9f-4ec0-93c9-f5c27c99fd6f",
"tenant_id": "tenant_wuzi",
"scope_kind": "tenant",
"endpoint_url": "https://api.tu-servidor.com/webhooks/nexus",
"secret": "sec_wuzi_d7a8f3b20c914e6e",
"event_types": [
"sale.created",
"sale.approved",
"sale.declined",
"sale.cancelled",
"sale.refunded"
],
"active": true,
"created_at": "2026-09-18T21:40:00Z",
"updated_at": "2026-09-18T21:40:00Z"
}
Guarda tu Secreto de Firma

El campo secret se retorna únicamente al momento de la creación (o tras rotarlo explícitamente mediante POST /subscriptions/{id}/rotate-secret). No podrá ser consultado posteriormente en texto plano.


5. Forma de la solicitud y encabezados

Cada entrega es un POST HTTP con Content-Type: application/json.

Encabezados HTTP (Headers)

HeaderDescripción
X-PNX-Signature-V1Firma HMAC-SHA256 estándar calculada sobre los metadatos de la entrega y el cuerpo exacto.
X-PNX-TimestampMarca de tiempo ISO 8601 / RFC 3339 en UTC cuando se firmó la entrega.
X-PNX-Delivery-IdIdentificador único de este intento de entrega.
X-PNX-Event-IdIdentificador único del evento origen (usar para deduplicar).
X-PNX-Event-TypeTipo de evento público (ej. sale.approved).
X-PNX-Event-VersionVersión del contrato de evento (v1).
X-PNX-Tenant-IDTenant origen (tenant_wuzi).
X-PNX-Signature(Legacy) Firma HMAC del cuerpo para retrocompatibilidad.

6. Estructura de los Payloads

Ejemplo de evento de creación de venta (sale.created):

{
"id": "evt_txn_1785427916_created",
"type": "sale.created",
"event_version": "v1",
"occurred_at": "2026-09-18T21:45:00Z",
"tenant_id": "tenant_wuzi",
"merchant_id": "TECMERT000001",
"data": {
"sale_id": "txn_1785427916",
"display_transaction_id": "TXN-001048",
"display_reference": "REF-992104",
"merchant_id": "TECMERT000001",
"branch_id": "SUC001",
"terminal_id": "TERM_001",
"operation": "sale",
"channel": "tpv",
"status": "pending",
"amount_minor": 25000,
"currency": "MXN",
"created_at": "2026-09-18T21:45:00Z"
}
}

Ejemplo de evento de ciclo de vida (sale.approved, sale.declined, sale.cancelled, sale.refunded):

{
"id": "evt_txn_1785427916_lifecycle_approved",
"type": "sale.approved",
"event_version": "v1",
"occurred_at": "2026-09-18T21:45:08Z",
"tenant_id": "tenant_wuzi",
"merchant_id": "TECMERT000001",
"data": {
"sale_id": "txn_1785427916",
"display_transaction_id": "TXN-001048",
"display_reference": "REF-992104",
"merchant_id": "TECMERT000001",
"branch_id": "SUC001",
"terminal_id": "TERM_001",
"operation": "sale",
"channel": "tpv",
"status": "approved",
"lifecycle_type": "approved",
"amount_minor": 25000,
"currency": "MXN"
}
}

Ejemplo de evento de terminal (terminal.created / terminal.updated):

{
"id": "evt_term_001_created",
"type": "terminal.created",
"event_version": "v1",
"occurred_at": "2026-09-18T20:10:00Z",
"tenant_id": "tenant_wuzi",
"merchant_id": "TECMERT000001",
"data": {
"terminal_id": "TERM_001",
"merchant_id": "TECMERT000001",
"status": "active",
"created_at": "2026-09-18T20:10:00Z",
"updated_at": "2026-09-18T20:10:00Z"
}
}

Ejemplo de evento de comercio (merchant.created / merchant.updated):

{
"id": "evt_mer_001_updated",
"type": "merchant.updated",
"event_version": "v1",
"occurred_at": "2026-09-18T19:30:00Z",
"tenant_id": "tenant_wuzi",
"merchant_id": "TECMERT000001",
"data": {
"merchant_id": "TECMERT000001",
"status": "active",
"created_at": "2026-09-10T15:00:00Z",
"updated_at": "2026-09-18T19:30:00Z"
}
}
Seguridad de Datos (PCI-DSS)

Por regulación y seguridad estricta, el payload público de los webhooks NUNCA incluye:

  • Datos de tarjeta (PAN, CVV/CVC, Fecha de expiración, Track 2, PIN block).
  • Llaves criptográficas (DUKPT, IPEK, KSN, llaves maestras de tokenización).
  • Identificadores de hardware físico de terminal (IMEI, ICCID o números de serie).
  • Respuestas ISO 8583 o payloads crudos del procesador adquirente.
  • Datos bancarios de dispersión ni secretos de API.

7. Verificar la firma (X-PNX-Signature-V1)

Para garantizar que la petición proviene genuinamente de Payment Nexus y no ha sido alterada, debes validar el encabezado X-PNX-Signature-V1.

Algoritmo de validación:

  1. Obtén el cuerpo crudo en bytes (rawBody) antes de parsearlo como JSON.
  2. Extrae los encabezados X-PNX-Timestamp, X-PNX-Delivery-Id, X-PNX-Event-Id y X-PNX-Signature-V1.
  3. Valida que el Timestamp no tenga más de 5 minutos de diferencia con tu reloj local (previene ataques de repetición / replay attacks).
  4. Calcula el hash SHA-256 en hexadecimal del cuerpo crudo: bodyHash = sha256(rawBody)
  5. Concatena la cadena de firma: signingInput = "v1." + timestamp + "." + deliveryId + "." + eventId + "." + bodyHash
  6. Calcula el HMAC-SHA256 de signingInput usando tu secreto de suscripción (secret).
  7. Compara la firma calculada con X-PNX-Signature-V1 utilizando una comparación de tiempo constante (timing safe).

Implementación en Node.js / TypeScript

import crypto from "crypto";

export function isValidSignatureV1(
secret: string,
rawBody: Buffer | string,
headers: Record<string, string | undefined>,
maxSkewMs = 5 * 60 * 1000
): boolean {
const timestamp = headers["x-pnx-timestamp"];
const deliveryId = headers["x-pnx-delivery-id"];
const eventId = headers["x-pnx-event-id"];
const signature = headers["x-pnx-signature-v1"];

if (!timestamp || !deliveryId || !eventId || !signature) {
return false;
}

// Prevenir replay attacks verificando la ventana de tiempo
const signedAt = Date.parse(timestamp);
if (!Number.isFinite(signedAt) || Math.abs(Date.now() - signedAt) > maxSkewMs) {
return false;
}

// Hash del cuerpo exacto recibido
const bodyHash = crypto.createHash("sha256").update(rawBody).digest("hex");
const signingInput = `v1.${timestamp}.${deliveryId}.${eventId}.${bodyHash}`;
const expected = crypto.createHmac("sha256", secret).update(signingInput).digest("hex");

const expectedBuf = Buffer.from(expected, "hex");
const receivedBuf = Buffer.from(signature, "hex");

if (expectedBuf.length !== receivedBuf.length) {
return false;
}

return crypto.timingSafeEqual(expectedBuf, receivedBuf);
}

Implementación en Python

import hashlib
import hmac
from datetime import datetime, timezone, timedelta

def is_valid_signature_v1(
secret: str,
raw_body: bytes,
headers: dict,
max_skew_seconds: int = 300
) -> bool:
timestamp = headers.get("X-PNX-Timestamp")
delivery_id = headers.get("X-PNX-Delivery-Id")
event_id = headers.get("X-PNX-Event-Id")
signature = headers.get("X-PNX-Signature-V1")

if not timestamp or not delivery_id or not event_id or not signature:
return False

signed_at = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
if abs(datetime.now(timezone.utc) - signed_at) > timedelta(seconds=max_skew_seconds):
return False

body_hash = hashlib.sha256(raw_body).hexdigest()
signing_input = f"v1.{timestamp}.{delivery_id}.{event_id}.{body_hash}".encode("utf-8")
expected = hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).hexdigest()

return hmac.compare_digest(expected, signature)
Validar sobre bytes crudos

Si tu framework parsea el body a un objeto JSON antes de calcular la firma, los cambios en orden de propiedades o espacios en blanco invalidarán la firma. Asegúrate de capturar el raw body (bytes o buffer original).


8. Ciclo de vida de entregas, reintentos e idempotencia

Estados de una entrega (webhook_deliveries):

EstadoSignificado
pendingEvento encolado, listo para su primer intento de entrega.
dispatchingEnvío HTTP en progreso hacia tu endpoint.
deliveredTu servidor respondió con un código HTTP exitoso (2xx).
failedEl intento falló (error 4xx/5xx, timeout o conexión caída). Se programará un reintento automático.

Política de reintentos

Si tu endpoint no devuelve 2xx, el despachador ejecuta reintentos automáticos con retroceso exponencial (1m, 2m, 4m, 8m, 16m... hasta un tope de 2 horas entre intentos) hasta un máximo de 8 intentos. Tras agotarlos, la entrega se marca como fallida terminal.

Idempotencia en tu receptor

Debes diseñar tu manejador de webhooks para ser idempotente:

  • Guarda el X-PNX-Event-Id (o campo id del JSON) en tu base de datos antes de procesar la lógica de negocio.
  • Si recibes un evento con un Event-Id ya procesado previamente, responde de inmediato con 200 OK sin duplicar transacciones, créditos o registros contables.

9. Qué sigue