Host-to-Host

The complete flow

Six steps from credentials to a settled charge, with the real request and response of each.

Six steps. Steps 1 and 2 happen once per session; 3 through 6 happen per charge.

1. Authenticate

You can send your sk_ key as the bearer on every call and skip this step entirely. Exchanging it for a token is optional and worth it at volume: the token is verified with an HMAC instead of a credential lookup.

bash
curl -X POST https://sandbox.key2pay.ai/api/v1/auth/token \
  -H "Content-Type: application/json" \
  -d '{"apiKey":"pk_test_…","secretKey":"sk_test_…"}'

Returns an accessToken valid for 1 hour and a refreshToken valid for 30 days, plus the shop the credentials belong to.

Rotating the token does not break idempotency. The idempotency scope is anchored to your merchant, not to the token, so a retry that happens after a refresh still deduplicates against the original request. Same for your rate limit: it is per merchant and reflects your tier.

2. Ask what you can charge

Never hardcode a method list. Coverage changes per shop, per country and per provider health, and this endpoint returns only what this shop can actually charge right now — a method that would fail routing is not in the response.

bash
curl https://sandbox.key2pay.ai/api/v1/payment-methods?country=MX \
  -H "Authorization: Bearer sk_test_…"

Each item carries a code (stable, ours), the limits in USD, and iconUrl so you can render the method with its real logo.

3. Create the charge

The amount is always in USD. We convert to the buyer's local currency and return both figures.

curl -X POST https://sandbox.key2pay.ai/api/v1/payments \
  -H "Authorization: Bearer sk_test_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-88213-attempt-1" \
  -d '{
    "amount": 100,
    "paymentMethodId": "1221",
    "country": "MEX",
    "merchantOrderId": "order-88213",
    "userEmail": "buyer@example.com",
    "userName": "Ana Torres",
    "documentId": "TOAA900101HDF"
  }'
The 422 you must handle: missing_required_fields. Different rails need different buyer data — Mexican SPEI and OXXO need an RFC/CURP, Brazilian PIX needs a CPF. When the winning provider needs a field you did not send, we refuse before creating anything and tell you exactly which field, with its type and a human label:
json
{ "error": {
    "code": "missing_required_fields",
    "details": { "missingFields": [
      { "key": "documentId", "type": "document", "label": "RFC o CURP" }
    ] } } }
No transaction is created, so retrying with the field — same Idempotency-Key — is safe.

4. Render the payment data

Direct rails return the instrument in paymentData; redirect rails return a paymentFormUrl. The two are mutually exclusive — branch on whichever is present.

json
{
  "id": "TXN-MT8E00EB-V5OK",
  "status": "pending",
  "amount": 100,           // USD — what you charged
  "currency": "USD",
  "amountLocal": 1820.50,  // MXN — what the buyer transfers
  "currencyLocal": "MXN",
  "paymentMethod": "spei",
  "paymentData": {
    "method": "spei",
    "clabe": "706180000000000018",
    "reference": "TXN-MT8E00EB-V5OK",
    "bankName": "STP",
    "dueDate": "2026-08-27T14:00:00Z"
  },
  "paymentFormUrl": null
}
Show the buyer amountLocal / currencyLocal, never the USD figure. A SPEI transfer for the wrong amount does not reconcile, and the buyer is the one who pays for that mistake.

5. Receive the webhook

We POST payment.completed (or payment.failed) to your registered endpoint. Verify the signature before you trust the body — details in Host-to-Host › Reliability.

6. Reconcile

A webhook can be lost — your endpoint is down, a deploy is mid-flight, a proxy drops it. Treat GET /payments/{id} as the source of truth and poll any charge that stayed pending past its due date. Cash rails settle in hours or days; that is not a failure, it is how the rail works.