Core concepts

Collecting the payment

How a created payment is actually collected: the paymentData block, the paymentFormUrl redirect for direct charge, and the hosted-checkout alternative.

POST /api/v1/payments creates a pending transaction and returns a paymentFormUrl— the provider's hosted page where the buyer completes the payment (the CLABE for SPEI, the QR for PIX, the voucher for cash, the card form, etc. are rendered there). The API does not return those per-method instruction fields inline.

The response envelope

json
{
  "transactionId": "TXN-...",
  "status": "pending",
  "paymentMethodId": "sbx_spei",
  "amount": 50.00,
  "amountLocal": 882.17,
  "currencyLocal": "MXN",
  "paymentFormUrl": "https://secure-int.key2pay.io/checkout?token=...",
  "paymentData": { "method": "spei" },
  "expiresAt": "..."
}
What paymentData actually contains: only { "method": "<slug>" } — the resolved method slug. It does NOT carry a CLABE, QR code, voucher code, bank account, or per-method expiresAt. Those live on the provider's hosted page behind paymentFormUrl. (The top-level expiresAt in the envelope is a short placeholder, not a per-method deadline.)

Smart missing-data recovery

Some rails need an extra buyer field — Brazil PIX needs a CPF, Mexico OXXO needs an RFC/CURP + phone, India UPI needs a VPA. If a required field is missing or malformed, the charge does NOT fail opaquely: you get a 422 missing_required_fields whose details.missingFields (or malformedFields) names exactly what to collect — each with a key, type, label and validation rule. Collect it, retry the same charge, and it goes through. If the data is already valid, the charge proceeds directly — no extra round-trip. (Our hosted checkout turns this into an elegant modal automatically.)

json
// POST /payments for Brazil PIX without the CPF →
{
  "error": {
    "code": "missing_required_fields",
    "type": "invalid_request_error",
    "message": "This payment method requires: Documento de identidad.",
    "details": {
      "reason": "missing",
      "missingFields": [
        { "key": "documentId", "type": "document",
          "label": "Documento de identidad",
          "help": "Verifica el documento (RFC 12-13, CURP 18, CPF 11 dígitos)." }
      ]
    }
  }
}
Retry safely with the same Idempotency-Key. A missing_required_fieldserror is never cached, so resending the corrected body (the field added) under the same key is NOT a 409 — it's a fresh attempt that charges. No tx is created on the error path, so nothing leaks.

Direct charge → redirect the buyer

For the direct-charge flow, send the buyer to paymentFormUrl (redirect, open in a new tab, or embed in an iframe). They complete the payment on the provider's page; you learn the outcome from the webhook (payment.completed / payment.failed) or by polling GET /payments/{id}.

javascript
const res = await api.post("/payments", {
  amount: 50, paymentMethodId: "sbx_spei", country: "MEX", userEmail: "test@test.com",
});
// Direct charge: redirect the buyer to the provider's hosted page.
window.location.href = res.paymentFormUrl;
// Then react to the payment.completed webhook (or poll GET /payments/{id}).

Method slugs

paymentData.methodis one of the canonical method slugs. Switch on it if you want method-aware copy, but you don't need to render per-method UI — the provider's page does that.

carddebitbank_transferwirespeipixoxxoboletovoucheronline_paymentapplepaygooglepaycrypto

Prefer not to redirect to the provider? Use hosted checkout

POST /api/v1/checkout/sessions returns a single checkoutUrl on OUR domain — we render the method picker + the per-method UI (CLABE/QR/voucher) for you, and the buyer never sees the upstream provider. See the hosted-checkout doc for the full flow.