Webhooks & signing
Subscribe to events with HMAC-SHA256 signed deliveries. Pick managed inbox (we host) or your own backend URL.
Context and key considerations
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:
┌──────────────┐ 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 leakedproviderTxIdcan'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 anypendingtx 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/failedwe POSTpayment.completed/payment.failedto every subscribed endpoint, with the HMAC headers documented below.
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
merchant.key2pay.ai/api/webhooks/inbox/<your-shop>). Events land there and you see them in /dashboard/webhooks without standing up any infrastructure. It's the default option when you create a webhook without a URL in the dashboard.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.X-Key2Pay-Signature: t=1714672890,v1=2c8a8…b7
X-Key2Pay-Event: payment.completed
X-Key2Pay-Delivery: dlv_1zP9e…Verifying a delivery
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");
}
}Event types
| Event | Fires when… |
|---|---|
payment.completed | Capture succeeded — funds landed in the merchant balance. |
payment.failed | Charge attempt failed or the customer abandoned a pending voucher/PIX. |
payment.refunded | A previously-completed transaction was refunded in full or in part. |
chargeback.created | A dispute was opened against a completed transaction (fires for chargeback-type claims). |
claim.opened | ANY claim was opened against a transaction — refund, chargeback or dispute. (chargeback.created also fires for the chargeback subset.) |
claim.resolved | Internal claim (refund or chargeback) reached a final resolution. |
settlement.closed | A settlement batch was closed and the crypto payout was initiated. |
settlement.pdf_ready | The PDF receipt for a closed settlement is ready to download. |
withdrawal.completed | A merchant withdrawal finished — funds left the platform. |
withdrawal.failed | A merchant withdrawal failed and the funds were returned to balance. |
payout.completed | A payout (disbursement) was confirmed by the provider — the beneficiary was paid. Payload: id, status, amount, currency, method, timestamps. |
payout.failed | A payout failed and the debit was reversed to the merchant balance. Payload includes failureReason. In sandbox, driven by POST /me/payout/send/{id}/simulate. |
payment.captured | Back-compat alias of payment.completed. Only the sandbox simulator emits it; production fires payment.completed. Subscribe to payment.completed. |
Payment notification data
The payment.completed and payment.failed envelope contains id, event, merchantId, shopId, createdAt, data. The machine-readable contract is published in OpenAPI under webhooks.paymentStatusChanged and the PaymentWebhookEnvelope / PaymentWebhookData schemas.
{
"id": "wh_example",
"event": "payment.completed",
"merchantId": "MCH-EXAMPLE",
"shopId": "SHP-EXAMPLE",
"createdAt": "2026-09-23T12:00:00.000Z",
"data": {
"id": "TXN-EXAMPLE",
"amount": 26.0001,
"currency": "USD",
"amountLocal": 452.56,
"currencyLocal": "MXN",
"paidAmount": 452.56,
"paidCurrency": "MXN",
"paymentAmountStatus": "matched",
"paymentAmountDifference": 0,
"inputAmount": 100,
"inputCurrency": "PLN",
"rounding": null,
"status": "completed",
"paymentMethod": "spei",
"country": "MEX",
"userEmail": "buyer@example.com",
"merchantOrderId": "order_example",
"endUserRef": null,
"timestamps": {
"created": "2026-09-23T11:55:00.000Z",
"paymentReceived": "2026-09-23T12:00:00.000Z"
}
}
}inputAmount / inputCurrency: the original non-USD presentment snapshot, matching GET /payments/{id}. Both are null for USD-input or legacy rows without that snapshot. Do not reconstruct them from current FX rates.rounding: new fractional requests round upward before FX (100.01 becomes 101). The snapshot containsrequestedAmount,chargeAmount,currencyandpolicy: ceil_whole_unit, including for USD input. Whole-unit and historical requests without a snapshot return null. Provider-calculated final local amounts and actual receipts keep their exact precision.amount / currency: the recorded transaction amount in USD for API-created charges.amountLocal / currencyLocal: the recorded local charge amount. It is not a normalized measure of an actual incoming payment. Deposit-origin transactions are recorded from incoming transfers.paidAmount / paidCurrency: verified provider receipts, independent of the request.paymentAmountDifferenceis paid minus requested local amount only when their currencies match.merchantOrderId / endUserRef: recorded merchant references or null. Missing timestamps are omitted fromtimestamps.
Test partial and excess payments
Verified receipt adapters currently cover Fintoc SPEI and Cobre SPEI. Other status-only integrations and historical payments return null/unknown until receipt evidence is available. GET exposes held payments; underpayments and overpayments do not emit a completed event.
On a sandbox payment requesting 100 MXN, submit this to POST /payments/{id}/simulate. Then GET the payment: it remains pending, paidAmount is 90 and paymentAmountStatus is underpaid. Resubmit a complete list including a second receipt of 10 MXN to complete it. A total of 110 MXN requires review. Stable receipt IDs prevent replay double-counting.
{
"action": "paid",
"receipts": [
{
"id": "credit-1",
"amount": "90.00",
"currency": "MXN"
}
]
}Sandbox and production
Simulated payment events contain the same base fields and additionally sandbox: true, sandboxAction and simulatedAt. These diagnostic fields are absent on live provider events. The sandbox expired recipe emits payment.failed with status: expired; normal failed events use failed. Subscribe to payment.completed: the legacy sandbox payment.captured alias is not emitted by live completion paths.
Depending on the emitter, an event may also contain source, providerStatus or timestamps.reconciledAt. These are not sandbox-only fields. Receivers should tolerate additive fields. Historical stored deliveries, including their retries and replays, retain their original payload and can lack fields added later. Retrieve the payment when an older event lacks its presentment snapshot. An event being queued does not mean delivery succeeded; inspect the delivery log for the actual result.
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 API — POST /api/v1/webhooks:
# 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_…", … }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).