Endpoints

Create checkout session

POST/api/v1/checkout/sessions

Hosted-checkout flow — two modes: SELECTOR (customer picks the method) and ONE-SHOT (you pick the method).

Dedicated endpoint for the hosted checkout flow. It has two modes depending on whether you send paymentMethodId in the body or not — both end on a hosted page where the customer pays.

The amount is ALWAYS in USD.Key2Pay's operation currency is USD; the currency field is optional and always "USD". We convert to the buyer's local currency automatically.
SELECTOR mode (recommended) — without paymentMethodId. We mint a cs_xxx session and return a /checkout/<token>. The customer sees the premium grid with ALL of the shop's methods (logo, flag, limits, fee), picks one, and the payment starts automatically against the provider. Zero lines of UI on your side.
ONE-SHOT mode (back-compat) — with paymentMethodId. You skip the selector. We create the tx directly and return /c/<txId>which redirects to the provider's hosted form. Useful when you already know which method the customer wants and don't want to show the grid.

SELECTOR mode (recommended)

The integrator sends only amount + currency + (optional) country + (optional) pre-filled customer data. Do NOT send paymentMethodId. The response carries checkoutUrl = /checkout/<token> — you redirect the customer there and we show the premium grid.

bash
curl https://sandbox.key2pay.ai/api/v1/checkout/sessions \
  -H "Authorization: Bearer sk_test_51N8mP...exampleK3Y" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 100.00,
    "currency": "USD",
    "country": "MX",
    "customer": {
      "firstName": "Carlos",
      "lastName": "Pérez",
      "email": "carlos@example.com",
      "phone": "+52 55 1234 5678"
    },
    "merchantOrderId": "ORD-12345",
    "returnUrl": "https://your-store.com/orders/1234"
  }'

Response (selector mode)

json
{
  "sessionId": "cs_91c5474e524b4108b9a29cec7443",
  "checkoutUrl": "https://sandbox.key2pay.ai/checkout/cs_91c5474e524b4108b9a29cec7443",
  "paymentMethodId": null,
  "status": "awaiting_method",
  "amount": 100,
  "currency": "USD",
  "country": "MX",
  "expiresAt": "2026-05-27T17:03:37.702Z"
}

Accepted fields (selector mode)

  • amount (required) — number.
  • currency (optional, default USD) — ISO-4217 code (USD, MXN, BRL, etc.).
  • country (optional) — ISO-2 or ISO-3. Without it, the grid shows ALL of the shop's methods. With it, it filters to that country.
  • customer (required) — buyer data. Validated server-side; if any required field is missing we return 400 with code: invalid_request.
    • customer.firstName (required, 1-60 chars) — first name.
    • customer.lastName (required, 1-60 chars) — last name.
    • customer.email (required, valid email format, max 254).
    • customer.phone (required, 1-40 chars) — include the country code, e.g. +52 55 1234 5678.
    • customer.documentId (optional, max 40) — RFC / CPF / CURP / DNI. Some methods (OXXO, voucher) require it; the provider prompts for it on its hosted form when applicable.
  • merchantOrderId (optional) — your own order id. Echoed back in webhooks.
  • returnUrl (optional) — where we send the customer when they finish or cancel.
Customer IP — captured automatically. When the customer opens /checkout/<token> (or clicks a method), we read their IP from the headers (x-forwarded-for first hop, then x-real-ip). We persist it on the session and forward it to the provider as userIpwhen creating the tx. The integrator's IP (server-to-server of the initial POST) is NOT used — it would be your backend's IP, useless for fraud scoring.
Automatic trims. firstName / lastName / email / phone / documentId are whitespace-trimmed before validation. Paste values with leading/trailing spaces without worry.

End-to-end flow

text
1. Integrator        POST /api/v1/checkout/sessions  (no paymentMethodId)
                       → { sessionId, checkoutUrl }

2. Integrator        redirect customer to checkoutUrl

3. Customer          sees premium grid with the shop's methods
                       (logo + flag + channel + limits + fee)
                       picks one → click

4. Selector page     POST /api/v1/checkout/sessions/<token>/select
                       { paymentMethodId: "1001" }
                       → /select creates the tx via payments-create
                       → tx has the provider's paymentFormUrl

5. Browser           redirect to /c/<txId>

6. /c/<txId> page    server-side 307 redirect to tx.paymentFormUrl
                       (provider's hosted form with real CLABE/QR/form)

7. Customer          pays there, returns to returnUrl

8. Webhook           payment.completed (or failed) reaches your endpoint

GET /api/v1/checkout/sessions/<token>

Public endpoint (token-auth) to check a session's status. Used by the selector page internally; you can also use it from your backend to verify a session is still valid before sending the link to the customer.

json
{
  "token": "cs_91c5474e524b4108b9a29cec7443",
  "amount": 100,
  "currency": "USD",
  "country": "MX",
  "customer": {
    "firstName": "Carlos",
    "lastName": "Pérez",
    "email": "carlos@example.com",
    "phone": "+52 55 1234 5678"
  },
  "customerIp": "189.205.42.18",
  "returnUrl": "https://your-store.com/orders/1234",
  "expiresAt": "2026-05-27T17:03:37.702Z",
  "completedTxId": null
}

completedTxId is nullwhile the customer hasn't picked a method. Once they do, it holds the txId — re-visits to the link redirect straight to /c/<txId>.

POST /api/v1/checkout/sessions/<token>/select

Endpoint the selector page calls when the customer picks a method. The session is marked completed and the tx is created via payments-create internally. Returns the /c/<txId> URL.

json
{
  "paymentMethodId": "1001",
  "customer": {
    "email": "carlos@example.com",
    "fullName": "Carlos Pérez"
  }
}

Successful response — the browser redirects to checkoutUrl:

json
{
  "sessionId": "cs_91c5474e524b4108b9a29cec7443",
  "transactionId": "TXN-MPMUKN9G-8CA2",
  "checkoutUrl": "https://sandbox.key2pay.ai/c/TXN-MPMUKN9G-8CA2?returnUrl=https%3A%2F%2Fyour-store.com%2Forders%2F1234",
  "status": "pending"
}

ONE-SHOT mode (back-compat)

If you already know which method the customer wants (you picked it in your own UI), send paymentMethodId in the initial POST. We skip the selector and return /c/<txId> directly.

bash
curl https://sandbox.key2pay.ai/api/v1/checkout/sessions \
  -H "Authorization: Bearer sk_test_51N8mP...exampleK3Y" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 50,
    "paymentMethodId": "1001",
    "country": "MEX",
    "userEmail": "carlos@example.com",
    "merchantOrderId": "ORD-12345",
    "returnUrl": "https://your-store.com/orders/1234"
  }'
json
{
  "sessionId": "TXN-MP25SDIS-ZQ68",
  "checkoutUrl": "https://sandbox.key2pay.ai/c/TXN-MP25SDIS-ZQ68?returnUrl=https%3A%2F%2Fyour-store.com%2Forders%2F1234",
  "paymentMethodId": "1001",
  "status": "pending",
  "amount": 50,
  "amountLocal": 882.17,
  "currencyLocal": "MXN",
  "expiresAt": "2026-05-12T16:00:00.000Z"
}

Errors

  • invalid_request (400) — a required field is missing or a field has an invalid format (e.g. malformed email, empty phone). The response carries per-field guidance in details.issues[], a flat list in details.missingFields, and a details.helpUrl to this doc.
  • checkout_session_not_found (404) — the token expired (24h TTL) or never existed. Create a new session.
  • checkout_session_already_completed (409) — the session already started a payment. The response includes details.existingTxId — redirect the customer there instead of treating it as an error.
  • amount_out_of_limits (422) — the amount is outside the chosen method's range. Show the customer another option.
  • cascade_exhausted (422) — no provider could process the payment. Check details.reason and the method listing.

Example response when fields are missing

If you send a POST with an incomplete customer (e.g. the user has no phone saved in your DB), you get 400 invalid_request with this shape — read message first and details.issues next to identify which field to fix:

json
{
  "error": {
    "code": "invalid_request",
    "type": "invalid_request_error",
    "message": "Missing or invalid customer fields: customer.phone. See `details.issues` below for per-field guidance, or hit the helpUrl for the full spec.",
    "requestId": "req_mpmxkpwb_okxsrotc",
    "details": {
      "issues": [
        {
          "path": "customer.phone",
          "message": "customer.phone is required (e.g. '+52 55 1234 5678'). Pass a non-empty string."
        }
      ],
      "missingFields": ["customer.phone"],
      "helpUrl": "https://sandbox.key2pay.ai/docs/endpoints/hosted-checkout#selector-fields"
    }
  }
}
Recommended pattern in your integration: validate $user->firstName / lastName / email / phoneBEFORE making the call. If any is missing, show the user "complete your profile" and don't make the POST. That avoids the roundtrip and saves you noisy logs. The 400 validation we return is still the safety net if something slips through.
Notes:
  • Single-use sessions with a 24h TTL. When the customer picks a method, the session is locked to that tx.
  • The checkoutUrl always uses sandbox.key2pay.ai (canonical APP_URL) — we never return hosts derived from headers (PR #102 closed that bug).
  • The /c/<txId> page does a server-side redirect to the provider's paymentFormUrl (PR #103). Real provider data: real CLABE, real QR, real card form. Never demo data.
  • Legacy POST /api/v1/payments with hostedCheckout: true still works identically to one-shot mode — use whichever endpoint you prefer.