Core

Webhooks & signing

Subscribe to events with HMAC-SHA256 signed deliveries. Pick managed inbox (we host) or your own backend URL.

Subscribe to event types from your dashboard or via the API. Each delivery includes a signed header so you can verify the payload was issued by Key2Pay. Hay dos formas de recibir los eventos — usá la que mejor encaje con tu stack.

End-to-end flow: provider → Key2Pay → you

For async payment methods (SPEI, OXXO, voucher, PIX, bank transfer) Key2Pay never relies on the customer to tell us they paid. The flow is always:

text
  ┌──────────────┐    1. customer pays      ┌─────────────────┐
  │  Customer    │ ───────────────────────▶ │ Upstream         │
  │ (browser /   │                          │ provider          │
  │  bank app)   │                          │ (PagSmile / etc) │
  └──────────────┘                          └────────┬────────┘
                                                     │ 2. signed webhook
                                                     ▼
                                            ┌─────────────────┐
                                            │   Key2Pay       │
                                            │   (verifies +   │
                                            │    updates tx)  │
                                            └────────┬────────┘
                                                     │ 3. signed webhook
                                                     ▼
                                            ┌─────────────────┐
                                            │  YOUR backend   │
                                            │  (this guide)   │
                                            └─────────────────┘
  • Step 2 — provider → Key2Pay. Every driver implements parseWebhook() with HMAC-SHA256 signature verification. We reject unsigned or invalid deliveries (HTTP 401) so a leaked providerTxIdcan't be used to mark a tx paid. Once the payload verifies, we update the transaction status, post the ledger entries, and enqueue settlement.
  • Step 2.5 — defense-in-depth. A 5-minute poll cron (pending-tx-poll-cron) ALSO reconciles any pendingtx against the provider's status API, so even if a webhook delivery is lost we still detect the transition. We then fire our outbound webhook to you with the same signed envelope as a normal delivery — there is no separate "reconciled" event name.
  • Step 3 — Key2Pay → you. The moment a real-money transaction lands in completed / failed we POST payment.completed / payment.failed to every subscribed endpoint, with the HMAC headers documented below.
Implication for your handler: you should rely on payment.completed rather than polling GET /payments/{id}. The webhook is the canonical signal and is fired by BOTH the live upstream webhook path AND the safety-net reconcile cron, so coverage is symmetric. Polling is a fallback, not a primary signal.

Two delivery modes

Managed inbox
Recomendado para empezar
Te asignamos una URL en NUESTRO dominio (merchant.ionea.io/api/webhooks/inbox/<tu-shop>). Los eventos llegan ahí y los ves en /dashboard/webhooks sin levantar infraestructura. Es la opción default cuando creás un webhook sin URL en el dashboard.
Tu propia URL
Vos hosteás el endpoint (ej. https://acme.com/webhooks/key2pay) y nosotros le hacemos POST con el evento firmado. Más control, ideal para producción cuando tu backend ya está listo para procesar eventos automáticamente.
Path de adopción típico: sandbox arranca con managed inbox para inspeccionar las cargas y ajustar el handler; cuando todo se ve bien, agregás una segunda subscripción con tu URL real y deshabilitás la managed. Las dos pueden coexistir, así que también podés tener producción usando ambas (la managed como audit log de backup, la externa como el handler real).
text
X-Key2Pay-Signature: t=1714672890,v1=2c8a8…b7
X-Key2Pay-Event: payment.completed
X-Key2Pay-Delivery: dlv_1zP9e…

Verifying a delivery

javascript
import crypto from "node:crypto";

export function verify(payload, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=")),
  );
  const t = Number(parts.t);
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${payload}`)
    .digest("hex");
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))) {
    throw new Error("invalid_signature");
  }
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) {
    throw new Error("stale_timestamp");
  }
}
Always verify with a constant-time comparison. Reject any delivery whose timestamp is more than 5 minutes old to defend against replay.

Event types

EventFires when…
payment.completedCapture succeeded — funds landed in the merchant balance.
payment.failedCharge attempt failed or the customer abandoned a pending voucher/PIX.
payment.refundedA previously-completed transaction was refunded in full or in part.
chargeback.createdA dispute was opened against a completed transaction. Funds are frozen.
claim.openedLegacy alias for chargeback.created — kept for back-compat. Prefer chargeback.created on new code.
claim.resolvedInternal claim (refund or chargeback) reached a final resolution.
settlement.closedA settlement batch was closed and the crypto payout was initiated.
settlement.pdf_readyThe PDF receipt for a closed settlement is ready to download.
withdrawal.completedA merchant withdrawal finished — funds left the platform.
withdrawal.failedA merchant withdrawal failed and the funds were returned to balance.
payment.capturedDeprecated alias for payment.completed. Subscribe to payment.completed instead.

Register an endpoint

Vía dashboard/dashboard/webhooks tiene un wizard con dos opciones (Managed inbox / URL propia). El secret HMAC se muestra una sola vez después de crear; guardalo.

Vía APIPOST /api/v1/webhooks:

bash
# Tu propia URL (modo externo):
curl https://api.ionea.io/api/v1/webhooks \
  -H "Authorization: Bearer sk_test_51N8mP...exampleK3Y" \
  -H "Content-Type: application/json" \
  -d '{
        "url": "https://example.com/webhooks/key2pay",
        "events": ["payment.completed", "payment.failed", "payment.refunded"]
      }'

# Managed inbox (modo gestionado — omití el campo url):
curl https://api.ionea.io/api/v1/webhooks \
  -H "Authorization: Bearer sk_test_51N8mP...exampleK3Y" \
  -H "Content-Type: application/json" \
  -d '{
        "events": ["*"]
      }'
# Respuesta: { "url": "https://api.ionea.io/api/webhooks/inbox/<shopSlug>",
#              "managed": true, "secret": "whsec_…", … }
La respuesta incluye managed: true cuando se autogeneró la URL. El secret se devuelve EXACTAMENTE UNA VEZ — guardalo en tu vault aunque uses managed inbox (el día que migres a tu propia URL podés reutilizarlo).