Core concepts

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. There are two ways to receive events — use whichever best fits your 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
Recommended to start
We assign you a URL on OUR domain (merchant.key2pay.ai/api/webhooks/inbox/<your-shop>). Events land there and you see them in /dashboard/webhookswithout standing up any infrastructure. It's the default option when you create a webhook without a URL in the dashboard.
Your own URL
You host the endpoint (e.g. https://acme.com/webhooks/key2pay) and we POST the signed event to it. More control, ideal for production once your backend is ready to process events automatically.
Typical adoption path: sandbox starts with the managed inbox to inspect the payloads and tune your handler; once everything looks good, you add a second subscription with your real URL and disable the managed one. Both can coexist, so you can also run production using both (the managed one as a backup audit log, the external one as the real handler).
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 (fires for chargeback-type claims).
claim.openedANY claim was opened against a transaction — refund, chargeback or dispute. (chargeback.created also fires for the chargeback subset.)
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.capturedBack-compat alias of payment.completed. Only the sandbox simulator emits it; production fires payment.completed. Subscribe to payment.completed.

Register an endpoint

Via dashboard/dashboard/webhooks has a wizard with two options (Managed inbox / Your own URL). The HMAC secret is shown only once after creating it; save it.

Via APIPOST /api/v1/webhooks:

bash
# Your own URL (external mode):
curl https://sandbox.key2pay.ai/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 (managed mode — omit the url field):
curl https://sandbox.key2pay.ai/api/v1/webhooks \
  -H "Authorization: Bearer sk_test_51N8mP...exampleK3Y" \
  -H "Content-Type: application/json" \
  -d '{
        "events": ["*"]
      }'
# Response: { "url": "https://sandbox.key2pay.ai/api/webhooks/inbox/<shopSlug>",
#              "managed": true, "secret": "whsec_…", … }
The response includes managed: true when the URL was auto-generated. The secret is returned EXACTLY ONCE — store it in your vault even if you use the managed inbox (the day you migrate to your own URL you can reuse it).