Webhooks

Guia de webhooks de pago

Los webhooks notifican a tu backend cuando un cupon cambia a pagado. La entrega es asincronica: 360Pay confirma el pago y luego intenta enviar el evento a tu URL.

Flujo de entrega

Crear hook y guardar secreto

No envies signing_secret al crear un hook. 360Pay lo genera, lo cifra en el servidor y lo devuelve una sola vez para que tu backend pueda verificar X-360Pay-Signature.

POST /app/v1/hooks

{
  "type": "PAYMENT_PAID",
  "url": "https://api.mibusiness.com/webhook",
  "active": true
}

Reintentos e idempotencia

360Pay hace un intento inicial y hasta 3 reintentos. La entrega tiene semantica at-least-once: el mismo evento puede llegar mas de una vez si hubo timeout, error de red o respuesta no 2xx.

Headers enviados

HeaderUso
X-360Pay-Event-IdID estable del evento. Usalo para idempotencia.
X-360Pay-Delivery-IdID de una entrega especifica. Cambia entre reintentos manuales o nuevas entregas.
X-360Pay-Hook-IdID del hook que origino el envio. Sirve para buscar el secreto correcto.
X-360Pay-TimestampFecha ISO usada para firmar el payload.
X-360Pay-SignatureFirma HMAC-SHA256 en formato sha256=<hex>.
X-360Pay-AttemptNumero de intento de entrega.

Verificar la firma

La firma se calcula sobre timestamp + "." + rawBody. Debes usar el body crudo recibido, no un objeto JSON vuelto a serializar.

import crypto from 'crypto';

function verify360PayWebhook(rawBody, headers, signingSecret) {
  const timestamp = headers['x-360pay-timestamp'];
  const signature = headers['x-360pay-signature'];

  if (!timestamp || !signature) return false;

  const ageMs = Math.abs(Date.now() - new Date(timestamp).getTime());
  if (ageMs > 5 * 60 * 1000) return false;

  const expected = 'sha256=' + crypto
    .createHmac('sha256', signingSecret)
    .update(timestamp + '.' + rawBody, 'utf8')
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

Payload ejemplo

El cuerpo siempre es un array, incluso cuando el banco paga un solo cupon. Una transaccion con varios documentos produce una sola entrega con todos sus cupones completos en el mismo array.

[
  {
    "event": "ticket.paid",
    "_id": "64b1f...",
    "external_ref": "ORD-9988",
    "payment_reference": "A1B2C3D4E5F6G7",
    "amount": 50,
    "status": "paid",
    "paid_at": "2026-08-26T10:00:00.000Z",
    "operation_number": "OPERACION-BANCO-123",
    "bank_tx_id": "BCP"
  },
  {
    "event": "ticket.paid",
    "_id": "64b20...",
    "external_ref": "ORD-9989",
    "payment_reference": "CUPON-002",
    "amount": 75,
    "status": "paid",
    "paid_at": "2026-08-26T10:00:00.000Z",
    "operation_number": "OPERACION-BANCO-123",
    "bank_tx_id": "BCP"
  }
]

Cada elemento puede variar si el hook usa payload_mapping, include_unmapped_fields o additional_fields.

Si el cupón ya estaba pagado y luego se completa/corrige el codigo bancario (por ejemplo operation_number o bank_tx_id), 360Pay puede emitir un nuevo evento PAYMENT_PAID con un X-360Pay-Event-Id distinto para reflejar la actualizacion.

Usa un unico hook PAYMENT_PAID para pagos de uno o varios cupones. El identificador del evento permanece estable durante los reintentos de la misma operacion.

Donde se configuran

Consultar entregas y obtener deliveryId

Desde App API puedes listar el historial de entregas de un hook. El campo _id de cada item es el deliveryId que se usa para consultar detalle o reintentar una entrega fallida.

GET /app/v1/hooks/:hookId/deliveries?page=1&limit=10
{
  "success": true,
  "data": [
    {
      "_id": "665ab1200f8a4f0012c3d459",
      "hook_id": "665aa0100f8a4f0012c3d458",
      "event_id": "evt_0f8c7f3a9d6e4b1a2c3d4e5f6a7b8c9d",
      "event_type": "PAYMENT_PAID",
      "resource_type": "coupon",
      "resource_id": "665ab0f00f8a4f0012c3d450",
      "status": "dead",
      "attempt_count": 4,
      "max_attempts": 4,
      "last_http_status": 500,
      "last_error": "Webhook responded with HTTP 500",
      "created_at": "2026-05-30T12:30:00.000Z",
      "updated_at": "2026-05-30T12:40:00.000Z"
    }
  ],
  "meta": {
    "page": 1,
    "limit": 10,
    "total": 1,
    "total_pages": 1
  }
}

Con ese deliveryId puedes consultar o reintentar:

GET /app/v1/hooks/:hookId/deliveries/665ab1200f8a4f0012c3d459
POST /app/v1/hooks/:hookId/deliveries/665ab1200f8a4f0012c3d459/retry

Respuesta al crear hook desde App API

El secreto completo solo aparece en la respuesta de creacion. Las consultas posteriores muestran solo una vista parcial.

{
  "success": true,
  "data": {
    "_id": "665aa0100f8a4f0012c3d458",
    "business_id": "665a9f9f0f8a4f0012c3d456",
    "type": "PAYMENT_PAID",
    "url": "https://api.mibusiness.com/webhook",
    "active": true,
    "signing_secret_preview": "whsec_VsLSN...y1A",
    "signing_secret": "whsec_VsLSN43xjcHqk4UoW6Fv5P8v3v8YGxQ2pLkV9fR8y1A"
  }
}

Respuesta al crear negocio con hooks desde Partner API

Cuando el partner crea un negocio y envia hooks, cada hook recibe su propio secreto. Guarda cada secreto asociado a su hook_id.

{
  "success": true,
  "data": {
    "success": true,
    "business_id": "665a9f9f0f8a4f0012c3d456",
    "payment_prefix": "EMP",
    "config_id": "665aa0040f8a4f0012c3d457",
    "hook_ids": ["665aa0100f8a4f0012c3d458"],
    "hook_signing_secrets": [
      {
        "hook_id": "665aa0100f8a4f0012c3d458",
        "signing_secret": "whsec_VsLSN43xjcHqk4UoW6Fv5P8v3v8YGxQ2pLkV9fR8y1A"
      }
    ]
  },
  "message": "Business created and linked to partner successfully"
}

Respuesta al rotar secreto

La rotacion invalida el secreto anterior para futuros envios y muestra el nuevo valor una sola vez. En App API usa POST /app/v1/hooks/:hookId/rotate-secret; en Partner API usa POST /partners/v1/businesses/:businessId/hooks/:hookId/rotate-secret.

{
  "success": true,
  "data": {
    "_id": "665aa0100f8a4f0012c3d458",
    "type": "PAYMENT_PAID",
    "url": "https://api.mibusiness.com/webhook",
    "active": true,
    "signing_secret_preview": "whsec_dF7a...Q9p",
    "signing_secret": "whsec_dF7abpQ1Lk2N9s4xRxu6Qv6M0h3J6zK5uGfQ9p"
  }
}