Ir al contenido

Webhooks

KUTI avisa a tu servidor cuando pasa algo: un cobro pagado, anulado o vencido. Tú registras una URL HTTPS y recibes el evento firmado.

Flujo: registras un endpoint → ocurre un evento → KUTI hace POST a tu URL → respondes 2xx. Si falla, reintenta; puedes forzar un reenvío desde la API o el dashboard.

El endpoint queda ligado al modo de la API key con la que lo creas: kuti_test_… solo recibe eventos de test; kuti_live_… solo de live. signing_secret se muestra una sola vez al crear (whsec_… / whsec_test_…).

Headers de cada entrega

HeaderQué es
X-Kuti-IdId del evento (evt_…)
X-Kuti-TimestampEpoch en segundos
X-Kuti-Signaturev1=<hex HMAC-SHA256>

Firma: HMAC-SHA256(signing_secret, "<timestamp>.<body>"). Rechaza si |now − timestamp| > 5 minutos.

Verificar firma (Node.js)javascript
const crypto = require("crypto");

function verifyKutiWebhook(rawBody, headers, signingSecret) {
  const id = headers["x-kuti-id"];
  const ts = headers["x-kuti-timestamp"];
  const sig = headers["x-kuti-signature"]; // §§SLOT_4§§
  if (!id || !ts || !sig) return false;

  const age = Math.abs(Date.now() / 1000 - Number(ts));
  if (age > 300) return false; // 5 min

  const expected =
    "v1=" +
    crypto
      .createHmac("sha256", signingSecret)
      .update(`${ts}.${rawBody}`)
      .digest("hex");

  try {
    return crypto.timingSafeEqual(
      Buffer.from(sig),
      Buffer.from(expected)
    );
  } catch {
    return false;
  }
}

Envelope

Todas las entregas usan el mismo envelope. data.<recurso> es el objeto del GET de ese recurso (p. ej. data.payment_intent == GET /payment-intents/{id}, sin fee_preview ni settlement). GET /events/{id} devuelve este mismo body.

Forma del bodyjson
{
  "id": "evt_…",
  "type": "payment.succeeded",
  "merchant_id": "mer_…",
  "created_at": "2026-09-04T10:55:12.000Z",
  "data": {
    "<recurso>": { }
  }
}
CampoQué es
idId estable del evento (evt_…). Úsalo para ser idempotente.
typeTipo canónico (dot-case). No cambia una vez publicado.
merchant_idNegocio dueño del evento.
created_atUTC ISO-8601 con Z. Momento en que ocurrió.
data.<recurso>payment_intent | customer | checkout_session | balance_transaction | payout
El JSON real incluye campos en null (para que un mapper no se rompa si espera la clave). En los ejemplos de abajo omitimos los null. payment.* no trae fee_preview ni settlement: esos solo salen en GET /payment-intents/{id}.

Tipos de evento (v1)

payment.* es el ciclo de vida del cobro. Ya no existen payment_intent.* ni payment.pending. Para marcar un pedido como pagado: payment.succeeded.
EventoCuándodata
payment.createdCreaste el cobro (QR / código / link).payment_intent
payment.succeededEl cliente pagó. Marca el pedido.payment_intent
payment.failedEl pago fue rechazado.payment_intent
payment.expiredPasó expires_at sin pagar.payment_intent
payment.cancelledAnulaste el cobro.payment_intent
checkout.session.completedEl pagador terminó el checkout embebido.checkout_session
customer.createdSe creó un cliente (API o al crear el cobro).customer
customer.updatedEditaste el cliente.customer
customer.deletedArchivaste el cliente.customer
balance.transaction_createdEl cobro pagado entró al ledger (PENDING).balance_transaction
balance.availableEse dinero ya se puede pagar a tu CCI.balance_transaction
payout.createdArmamos un depósito a tu cuenta.payout
payout.paidEl depósito llegó a tu CCI.payout
payout.failedEl depósito falló.payout

Al crear el endpoint, events es un array de esos tipos, o ["*"] para todos. Integración mínima: ["payment.succeeded", "payment.failed"].

payment.created

Cobro recién creado, esperando pago. Trae payment_method (QR y/o código) y client_secret / checkout_url.

payment.createdjson
{
  "id": "evt_01J8Z3K4M5N6P7Q8R9S0T1U2V1",
  "type": "payment.created",
  "merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
  "created_at": "2026-09-04T10:48:59.556906Z",
  "data": {
    "payment_intent": {
      "id": "pi_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "livemode": true,
      "customer": {
        "id": "cus_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
        "type": "INDIVIDUAL",
        "first_name": "María",
        "last_name": "López",
        "name": "María López",
        "document_type": "DNI",
        "document_value": "45678912",
        "email": "maria@example.com"
      },
      "amount": { "amount": "50.00", "currency": "PEN" },
      "status": "PENDING",
      "payment_method_types": ["INTEROPERABLE_QR", "BANK_TRANSFER"],
      "payment_method": {
        "qr": {
          "type": "INTEROPERABLE_QR",
          "payload": "00020101021226…",
          "expires_at": "2026-09-05T23:59:59Z"
        },
        "payment_code": {
          "type": "BANK_TRANSFER",
          "code": "41041172",
          "expires_at": "2026-09-05T23:59:59Z"
        }
      },
      "checkout_url": "https://pay.kuti.pe/c/A3F9K2P7QM",
      "client_secret": "pi_01J8…_secret_ab12cd34ef56gh78ij90kl12",
      "description": "Pedido #1042",
      "external_reference": "order-1042",
      "requires_customer_info": false,
      "created_at": "2026-09-04T10:48:59.556906Z"
    }
  }
}

payment.succeeded

El cliente pagó. Este es el evento para marcar el pedido. paid_with dice con qué método; payment_id es el pago (pay_…). Ya no viene client_secret ni payment_method.

payment.succeededjson
{
  "id": "evt_01J8Z3K4M5N6P7Q8R9S0T1U2V2",
  "type": "payment.succeeded",
  "merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
  "created_at": "2026-09-04T10:55:12.000Z",
  "data": {
    "payment_intent": {
      "id": "pi_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "livemode": true,
      "customer": {
        "id": "cus_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
        "type": "INDIVIDUAL",
        "first_name": "María",
        "last_name": "López",
        "name": "María López",
        "document_type": "DNI",
        "document_value": "45678912",
        "email": "maria@example.com"
      },
      "amount": { "amount": "50.00", "currency": "PEN" },
      "status": "SUCCEEDED",
      "payment_method_types": ["INTEROPERABLE_QR", "BANK_TRANSFER"],
      "paid_with": {
        "method_type": "INTEROPERABLE_QR",
        "paid_at": "2026-09-04T10:55:12.000Z"
      },
      "payment_id": "pay_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "checkout_url": "https://pay.kuti.pe/c/A3F9K2P7QM",
      "description": "Pedido #1042",
      "external_reference": "order-1042",
      "requires_customer_info": false,
      "created_at": "2026-09-04T10:48:59.556906Z"
    }
  }
}

payment.failed

El cobro quedó FAILED (el pago fue rechazado). Usa external_reference o id para hallar el pedido. Si el cliente reintenta y paga, te llega payment.succeeded aparte.

payment.failedjson
{
  "id": "evt_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
  "type": "payment.failed",
  "merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
  "created_at": "2026-09-04T10:56:02.000Z",
  "data": {
    "payment_intent": {
      "id": "pi_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "livemode": true,
      "customer": {
        "id": "cus_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
        "type": "INDIVIDUAL",
        "first_name": "María",
        "last_name": "López",
        "name": "María López",
        "document_type": "DNI",
        "document_value": "45678912",
        "email": "maria@example.com"
      },
      "amount": { "amount": "50.00", "currency": "PEN" },
      "status": "FAILED",
      "payment_method_types": ["INTEROPERABLE_QR", "BANK_TRANSFER"],
      "payment_method": {
        "qr": {
          "type": "INTEROPERABLE_QR",
          "payload": "00020101021226…",
          "expires_at": "2026-09-05T23:59:59Z"
        },
        "payment_code": {
          "type": "BANK_TRANSFER",
          "code": "41041172",
          "expires_at": "2026-09-05T23:59:59Z"
        }
      },
      "checkout_url": "https://pay.kuti.pe/c/A3F9K2P7QM",
      "description": "Pedido #1042",
      "external_reference": "order-1042",
      "requires_customer_info": false,
      "created_at": "2026-09-04T10:48:59.556906Z"
    }
  }
}

payment.expired

Pasó expires_at sin pago. Un cobro sin expires_at no expira. Si el dinero llega después, KUTI puede recuperarlo a SUCCEEDED (te llega payment.succeeded).

payment.expiredjson
{
  "id": "evt_01J8Z3K4M5N6P7Q8R9S0T1U2V4",
  "type": "payment.expired",
  "merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
  "created_at": "2026-09-05T23:59:59.000Z",
  "data": {
    "payment_intent": {
      "id": "pi_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "livemode": true,
      "customer": {
        "id": "cus_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
        "type": "INDIVIDUAL",
        "first_name": "María",
        "last_name": "López",
        "name": "María López",
        "document_type": "DNI",
        "document_value": "45678912",
        "email": "maria@example.com"
      },
      "amount": { "amount": "50.00", "currency": "PEN" },
      "status": "EXPIRED",
      "payment_method_types": ["INTEROPERABLE_QR", "BANK_TRANSFER"],
      "checkout_url": "https://pay.kuti.pe/c/A3F9K2P7QM",
      "expires_at": "2026-09-05T23:59:59Z",
      "description": "Pedido #1042",
      "external_reference": "order-1042",
      "requires_customer_info": false,
      "created_at": "2026-09-04T10:48:59.556906Z"
    }
  }
}

payment.cancelled

Anulaste el cobro (POST /payment-intents/{id}/cancel). El QR y el código dejan de cobrar.

payment.cancelledjson
{
  "id": "evt_01J8Z3K4M5N6P7Q8R9S0T1U2V5",
  "type": "payment.cancelled",
  "merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
  "created_at": "2026-09-04T11:10:00.000Z",
  "data": {
    "payment_intent": {
      "id": "pi_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "livemode": true,
      "customer": {
        "id": "cus_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
        "type": "INDIVIDUAL",
        "first_name": "María",
        "last_name": "López",
        "name": "María López",
        "document_type": "DNI",
        "document_value": "45678912",
        "email": "maria@example.com"
      },
      "amount": { "amount": "50.00", "currency": "PEN" },
      "status": "CANCELLED",
      "payment_method_types": ["INTEROPERABLE_QR", "BANK_TRANSFER"],
      "checkout_url": "https://pay.kuti.pe/c/A3F9K2P7QM",
      "description": "Pedido #1042",
      "external_reference": "order-1042",
      "requires_customer_info": false,
      "created_at": "2026-09-04T10:48:59.556906Z"
    }
  }
}

checkout.session.completed

El pagador terminó el checkout embebido (Checkout.js). El cobro pagado llega aparte como payment.succeeded — usa payment_intent_id para cruzarlos.

checkout.session.completedjson
{
  "id": "evt_01J8Z3K4M5N6P7Q8R9S0T1U2V6",
  "type": "checkout.session.completed",
  "merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
  "created_at": "2026-09-04T10:55:12.000Z",
  "data": {
    "checkout_session": {
      "id": "cs_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "customer_id": "cus_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "payment_intent_id": "pi_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "amount": { "amount": "50.00", "currency": "PEN" },
      "status": "COMPLETED",
      "description": "Pedido #1042",
      "success_url": "https://tienda.pe/pago/ok",
      "created_at": "2026-09-04T10:48:59.556906Z"
    }
  }
}

customer.created

Se creó un cliente: POST /customers o al crear un cobro con datos nuevos. customer.updated y customer.deleted usan el mismo data.customer (updated = datos nuevos; deleted = el que se archivó). No incluye payment_intents_count (eso solo sale en GET /customers/{id}).

customer.createdjson
{
  "id": "evt_01J8Z3K4M5N6P7Q8R9S0T1U2V7",
  "type": "customer.created",
  "merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
  "created_at": "2026-09-04T10:48:59.556906Z",
  "data": {
    "customer": {
      "id": "cus_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "external_id": "cust_erp_4821",
      "type": "INDIVIDUAL",
      "first_name": "María",
      "last_name": "López",
      "document": {
        "country": "PE",
        "type": "DNI",
        "value": "45678912"
      },
      "email": "maria@example.com",
      "phone": "+51987654321",
      "created_at": "2026-09-04T10:48:59.556906Z"
    }
  }
}

balance.transaction_created

El cobro pagado entró al ledger. status = PENDING hasta que el dinero quede disponible; entonces llega balance.available con el mismo objeto y status = AVAILABLE. source_id es el pago (pay_…).

balance.transaction_createdjson
{
  "id": "evt_01J8Z3K4M5N6P7Q8R9S0T1U2V8",
  "type": "balance.transaction_created",
  "merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
  "created_at": "2026-09-04T10:55:12.000Z",
  "data": {
    "balance_transaction": {
      "id": "btxn_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "type": "PAYMENT",
      "status": "PENDING",
      "currency": "PEN",
      "payment_method": "INTEROPERABLE_QR",
      "source_type": "PAYMENT",
      "source_id": "pay_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "gross": { "amount": "50.00", "currency": "PEN" },
      "fee": { "amount": "-1.77", "currency": "PEN" },
      "fee_tax": { "amount": "-0.27", "currency": "PEN" },
      "net": { "amount": "48.23", "currency": "PEN" },
      "available_on": "2026-09-06T00:00:00Z",
      "available_confirmed": false,
      "created_at": "2026-09-04T10:55:12.000Z"
    }
  }
}

payout.paid

El depósito a tu CCI se completó. payout.created y payout.failed usan el mismo data.payout (status PENDING o FAILED; si falló vienen failure_code y failure_message).

payout.paidjson
{
  "id": "evt_01J8Z3K4M5N6P7Q8R9S0T1U2V9",
  "type": "payout.paid",
  "merchant_id": "mer_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
  "created_at": "2026-09-05T10:15:00.000Z",
  "data": {
    "payout": {
      "id": "pyt_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "source": "PROVIDER",
      "amount": { "amount": "1250.50", "currency": "PEN" },
      "gross_amount": { "amount": "1280.00", "currency": "PEN" },
      "fee_amount": { "amount": "-29.50", "currency": "PEN" },
      "destination_bank": "BCP",
      "destination_account_masked": "••••••••9012",
      "status": "PAID",
      "payout_account_id": "pacc_01J8Z3K4M5N6P7Q8R9S0T1U2V3",
      "statement_descriptor": "KUTI",
      "arrival_expected_on": "2026-09-05",
      "initiated_at": "2026-09-04T18:00:00Z",
      "paid_at": "2026-09-05T10:15:00Z",
      "created_at": "2026-09-04T18:00:00Z"
    }
  }
}

Buenas prácticas

  • Responde 2xx rápido; procesa en cola si hace falta.
  • Usa X-Kuti-Id (o el id del evento) para ser idempotente.
  • Verifica siempre la firma con el body crudo (raw body).
  • HTTPS obligatorio en la URL del endpoint.
  • Para saber si pagó: mira type === "payment.succeeded" y data.payment_intent.external_reference (o id).