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

{
  "event": "ticket.paid",
  "_id": "64b1f...",
  "external_ref": "ORD-9988",
  "amount": 150.5,
  "status": "paid",
  "paid_at": "2026-05-30T12:30:00.000Z",
  "bank_tx_id": "BCP-123456"
}

El payload puede variar si el hook usa payload_mapping, include_unmapped_fields o additional_fields.

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"
  }
}