Create checkout session
Hosted-checkout flow — two modes: SELECTOR (customer picks the method) and ONE-SHOT (you pick the method).
/api/v1/checkout/sessionsContext and key considerations
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 in USD by default; ledger credits and settlement follow the shop balance model (USD or local currency). Optionally pass acurrency to price in a presentment currency (e.g. "EUR"): we convert to a USD headline at our rate minus the FX markup, return your presentment values as inputCurrency + inputAmount (the response currency stays "USD"), and 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(optional prefill) — send the data you have. Our checkout asks for missing or invalid fields required by the selected provider before initiating payment. With a preselected method,status: awaiting_detailsand HTTP 201 mean the buyer should open the returned checkoutUrl to complete the modal.customer.firstName(optional prefill, max 60) — first name.customer.lastName(optional prefill, max 60) — last name.customer.email(optional prefill, max 254) — a valid email is collected before payment.customer.phone(optional prefill, max 40) — 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 (the trusted x-forwarded-for hop our edge wrote — not the first, which the browser can set — then x-real-ip). We persist it on the session and forward it to the provider as userIp when 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 null while 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 method selector. With complete buyer data we return /c/<txId> directly. If required details are missing before any provider attempt, the response is HTTP 201 with status: awaiting_details. Redirect the buyer to the returned checkoutUrl; the page collects those details for the selected method.
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"
}userEmail, userName, userPhone, documentId) or the customerblock used by selector mode. If both are present, the individual field wins and the block fills only missing values. An invalid value inside customer is not promoted. If the rail needs it, the error names it in missing_required_fields, where you can correct it.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, anddocs_urlpointing to this section.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.",
"requestId": "req_mpmxkpwb_okxsrotc",
"docs_url": "https://sandbox.key2pay.ai/docs/endpoints/hosted-checkout#selector-fields",
"details": {
"issues": [
{
"path": "customer.phone",
"message": "customer.phone is required (e.g. '+52 55 1234 5678'). Pass a non-empty string."
}
],
"missingFields": ["customer.phone"]
}
}
}$user->firstName / lastName / email / phone BEFORE 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.