Create checkout session
/api/v1/checkout/sessionsHosted-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.
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.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.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.
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)
{
"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 withcode: 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.
/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.End-to-end flow
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 endpointGET /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.
{
"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.
{
"paymentMethodId": "1001",
"customer": {
"email": "carlos@example.com",
"fullName": "Carlos Pérez"
}
}Successful response — the browser redirects to checkoutUrl:
{
"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.
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"
}'{
"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 indetails.issues[], a flat list indetails.missingFields, and adetails.helpUrlto 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 includesdetails.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. Checkdetails.reasonand 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:
{
"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"
}
}
}$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.- Single-use sessions with a 24h TTL. When the customer picks a method, the session is locked to that tx.
- The
checkoutUrlalways usessandbox.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'spaymentFormUrl(PR #103). Real provider data: real CLABE, real QR, real card form. Never demo data. - Legacy
POST /api/v1/paymentswithhostedCheckout: truestill works identically to one-shot mode — use whichever endpoint you prefer.