Checkout and payment
Send the buyer to pay, bring them back, and confirm the payment with the Storefront API.
Andrea confirms her cart and chooses to pay with PowerPay. The checkout creates the order and starts the payment; your store sends her to pay, brings her back, and checks whether she paid.
POST / v1/ carts/ {cartToken}/ checkoutpayment with action.urlaction.url/ orders/ {statusToken}/ payment-returnGET / v1/ orders/ status/ {statusToken}step and balanceDueHow payment works
The payment method decides what your store does:
- Redirect. The buyer pays on the provider's page and comes back to your store.
- Form. You mount the provider's form in your checkout.
- Manual. Bank transfer, Yape, or cash on delivery. You show the instructions and the merchant confirms the payment.
In all three cases, the provider or the merchant confirms the payment. Your store checks the status.
Requirements
- A cart ready for checkout. See Headless store.
- Your frontend on HTTPS at the store's primary domain.
GET /marks it withv1/ stores/ domains isPrimary: true. - The
/andorders/ {statusToken} /routes on that domain.orders/ {statusToken}/ payment-return - For Confirm from your system, an Admin token with the
payment:createpermission inECOMIQ_TOKEN. See Quickstart. - Node.js or Python with
requests, for the examples.
Read the payment
POST / creates the order and starts the payment.
The response returns the payment in payment. This example pays with PowerPay,
which charges outside your store.
curl -X POST https://storefront.ecomiq.pe/v1/carts/<cart-token>/checkout \
-H "x-Store-Domain: mi-tienda" \
-H "Content-Type: application/json" \
-d '{ "paymentMethodCode": "powerpay" }'{
"orderId": "7204558912004325350",
"orderNumber": "ORD-2026-0001",
"orderGroup": "7204558912004325350",
"orderIds": ["7204558912004325350"],
"payment": {
"transactionId": "7204558912004325362",
"paymentMethodCode": "powerpay",
"providerId": "powerpay",
"status": "pending",
"amount": 170.0,
"reference": "b6f1c2d4-3e5a-4f7b-8c9d-0e1f2a3b4c5d",
"actionKind": "redirect",
"receiptUrl": null,
"action": {
"publicConfiguration": {},
"url": "https://pasarela.example.com/pagar/b6f1c2d4",
"html": null,
"clientSecret": null,
"qrCode": null,
"expiresAt": "2026-10-05T16:00:00Z"
}
}
}The response also returns cart, already completed, and orderStatusUrl, the
order status page, which ends in /. Store orderId,
orderNumber, the cartToken, and the statusToken: you use them to check the
status. The next steps reuse API and headers.
| Field | Contains |
|---|---|
transactionId | Payment ID. Use it to confirm the payment from your system. |
paymentMethodCode | Method chosen at checkout. |
providerId | Provider that charges. manual when there is no provider. |
status | Payment status. |
amount | Amount to charge: the total of every order in the cart. |
reference | The provider's reference, your externalRef, or the transactionId, in that order. |
actionKind | What the buyer does to pay. See Continue the payment. |
receiptUrl | Attached receipt, if you sent one. |
action | Data to continue the payment. null when nothing else is needed. |
status | Meaning |
|---|---|
pending | The buyer has not paid, or the merchant has not confirmed the payment. |
authorized | The provider reserved the amount. It still needs a capture. |
captured | Payment collected. The order is confirmed. |
failed | The provider rejected the payment. |
cancelled | The payment was cancelled. |
refunded | The payment was refunded. |
If the total is 0, payment is null and the order is confirmed. While the
order stays active, repeating the checkout for the same cart returns the same
order and payment.
Continue the payment
actionKind tells you what to do with the buyer. action carries the data.
actionKind | What to do |
|---|---|
redirect | Redirect the buyer to action.url. If action.expiresAt is set, the link expires then. |
embedded | Mount the provider's form on your page with action.clientSecret or action.html, plus the public keys in action.publicConfiguration. |
qrCode | Show the QR in action.qrCode. With providerId: "manual", it is an image URL. |
voucher | Show the code in action.voucherCode and the QR image in action.qrImageUrl. The buyer pays outside the checkout. |
threeDS | Authenticate the card. See Tokenized card and 3DS authentication. |
none | If status is captured, show the confirmation. Otherwise show the method's instructions: the merchant confirms the payment. |
function continuePayment(payment) {
if (payment === null || payment.status === "captured") {
return showConfirmation();
}
if (payment.actionKind === "redirect") {
return window.location.assign(payment.action.url);
}
if (payment.actionKind === "embedded") {
return mountProviderForm(payment.action);
}
if (payment.actionKind === "qrCode") {
return showQr(payment.action.qrCode);
}
if (payment.actionKind === "voucher") {
return showVoucher(payment.action.voucherCode, payment.action.qrImageUrl);
}
if (payment.actionKind === "threeDS") {
return authenticateCard(payment);
}
return showInstructions(payment.paymentMethodCode, payment.action);
}
continuePayment(checkout.payment);showConfirmation, mountProviderForm, showQr, showVoucher,
authenticateCard, and showInstructions are your store's functions. In Python, / shows the form, the QR, or
the instructions.
Show each method's name and description as they arrive: they are already in
the store's language. If description is null, the method has no
description.
With manual methods, nobody charges for you. They are the ones with
isManual: true in paymentMethods.methods, such as bank_transfer,
cash_on_delivery, or manual_yape. The order stays pendingToConfirm until
the merchant confirms the payment.
For a manual method, action.publicConfiguration carries the data the store
set up: phoneNumber, accountHolder, and qrImageUrl. If there is a
qrImageUrl, actionKind is qrCode.
{
"paymentMethodCode": "manual_yape",
"providerId": "manual",
"status": "pending",
"actionKind": "qrCode",
"action": {
"publicConfiguration": {
"phoneNumber": "+51987654321",
"accountHolder": "Mi tienda",
"qrImageUrl": "https://…/yape-qr.png"
},
"url": null,
"html": null,
"clientSecret": null,
"qrCode": "https://…/yape-qr.png",
"expiresAt": null
}
}If the store did not set up that data, action is null and actionKind is
none. Show the method's name and description.
Tokenized card and 3DS authentication
With checkoutMode: "embedded", create the token in the browser using the
public key in publicConfiguration from GET /.
For Culqi card, also load https:/, configure its public key,
and call Culqi3DS.generateDevice() before paying. Send that identifier in
deviceSessionId: without it, the payment is rejected. Culqi yape uses its
own token and skips 3DS.
{
"paymentMethodCode": "card",
"token": "tkn_live_7Kq2",
"deviceSessionId": "8019959c-fab1-49eb-bbbe-b846d308d8df"
}If Culqi asks to authenticate the card, the payment arrives pending with
actionKind: "threeDS". Configure Culqi3DS.settings with the amount in minor
units, the currency, the email, and the current URL, then call
Culqi3DS.initAuthentication(token). Accept parameters3DS only from a message
whose origin matches your page. Send the result with the same token and
deviceSessionId, the payment's transactionId, and the statusToken:
{
"token": "tkn_live_7Kq2",
"deviceSessionId": "8019959c-fab1-49eb-bbbe-b846d308d8df",
"authentication3DS": {
"eci": "05",
"cavv": "value-returned-by-culqi",
"protocolVersion": "2.2.0",
"directoryServerTransactionId": "id-returned-by-culqi"
}
}xid and directoryServerTransactionId are optional: include them when Culqi
returns them. An expired challenge doesn't start another charge, and repeating a
completed authentication returns the same result.
For Mercado Pago card, also send the card's payment_method_id in
providerPaymentMethodId; for yape, create the token with
mp.yape({ phoneNumber, otp }). A token from another method is rejected with
providerCode: "source_mismatch".
Card installments
When the store offers installments, each card method of a provider that
accepts them (Culqi and Mercado Pago today) lists in installments the counts
the store accepts for that total, for example [2, 3, 4, 5, 6]. It is empty
when the store doesn't offer them or the total is below the minimum amount. Send the chosen count in installments;
without it, the payment goes in a single installment.
{
"paymentMethodCode": "card",
"token": "tkn_live_7Kq2",
"deviceSessionId": "8019959c-fab1-49eb-bbbe-b846d308d8df",
"installments": 3
}With Culqi, each card accepts its own counts and debit cards accept none. If
the card doesn't accept the chosen count, checkout answers
Payments.InstallmentsNotAllowed without charging; ask for another count or a
single payment. The buyer's bank sets the interest.
With Mercado Pago, pass the largest count in installments to the Card Payment
Brick as maxInstallments. Send the installments and the issuer_id its
onSubmit returns in installments and issuerId.
A count the store doesn't offer is rejected with
Payments.InstallmentsUnavailable.
Payment with a code or QR
Culqi plin and pagoefectivo, and Mercado Pago pagoefectivo, generate the
code during checkout. Send the method without a token.
{
"paymentMethodCode": "pagoefectivo"
}Show voucherCode, qrImageUrl, or url from payment.action, with
expiresAt. Culqi Plin expires in 30 minutes and PagoEfectivo in 24 hours.
The payment stays pending with actionKind: "voucher" until the provider
confirms it. To show the code again, read pendingPayment from
GET /. If the checkout times out, check the
order before repeating it.
Attach the receipt
With a manual payment, the buyer can upload a photo of their payment. Upload it
before checkout with POST /. Then send the
url in receiptUrl.
# 1. Upload the receipt
curl -X POST https://storefront.ecomiq.pe/v1/carts/<cart-token>/payment-receipt \
-H "x-Store-Domain: mi-tienda" \
-F "file=@comprobante-yape.jpg;type=image/jpeg"
# 2. Complete the checkout with its url
curl -X POST https://storefront.ecomiq.pe/v1/carts/<cart-token>/checkout \
-H "x-Store-Domain: mi-tienda" \
-H "Content-Type: application/json" \
-d '{ "paymentMethodCode": "manual_yape", "receiptUrl": "<receipt-url>" }'{
"id": "7204558912004325355",
"url": "https://…/comprobante-yape.jpg"
}The checkout payment returns that same url in receiptUrl. The merchant sees
it with the order.
| Rule | Value |
|---|---|
| Formats | JPEG, PNG, or WebP. |
| Maximum size | 5 MB. |
| Cart | Active. Upload the file before checkout. |
If the file does not comply, the API responds 400 with Carts.ReceiptEmpty,
Carts.ReceiptTooLarge, Carts.ReceiptInvalidType, or
Carts.ReceiptInvalidContent.
After checkout, use
POST /.
Send file and the paymentMethodCode of a manual method.
Receive the buyer
When they finish with the provider, the buyer comes back to this URL:
https://<primary-domain>/orders/<status-token>/payment-returnThe URL always uses the store's primary domain. statusToken identifies the
order.
The provider may return with GET or POST. Ignore any parameters or body it
sends. Respond 303 to /.
const PAYMENT_RETURN = /^\/orders\/([A-Za-z0-9_-]+)\/payment-return$/;
export function handlePaymentReturn(request) {
const match = PAYMENT_RETURN.exec(new URL(request.url).pathname);
if (match === null) return null;
return new Response(null, {
status: 303,
headers: {
Location: `/orders/${match[1]}`,
"Cache-Control": "private, no-store",
},
});
}If your server rejects cross-origin POST requests, let this route through: it
only redirects. Order emails also link to /.
The return does not confirm the payment
The provider may send the buyer back before it reports the result. Always read the status as in Check the status.
Check the status
On /, call GET /. It
needs no session: the token is enough.
curl https://storefront.ecomiq.pe/v1/orders/status/<status-token> \
-H "x-Store-Domain: mi-tienda"{
"order": {
"id": "7204558912004325350",
"orderNumber": "ORD-2026-0001",
"orderGroup": "7204558912004325350",
"status": "confirmed",
"total": 170.0,
"totalPaid": 170.0,
"currencyCode": "PEN",
"paymentAt": "2026-10-05T15:34:10Z",
"email": "andrea.ramos@example.com",
"firstName": "Andrea",
"lastName": "Ramos",
"payments": [
{
"id": "7204558912004325364",
"paymentMethod": "powerpay",
"paymentCode": "powerpay",
"amount": 170.0,
"paymentStatus": "captured",
"receiptUrl": null,
"createdOn": "2026-10-05T15:30:02Z"
}
]
},
"step": "confirmed",
"balanceDue": 0.0,
"paymentMethods": []
}step | What to show |
|---|---|
pending | Order received. The payment is not confirmed yet. |
confirmed | Payment confirmed. |
shipped | Order shipped. |
outForDelivery | Out for delivery. |
delivered | Delivered. |
cancelled | Order cancelled. |
If step is pending, check again every 10 seconds. The provider may report
after the buyer returns. If balanceDue is greater than 0, paymentMethods
lists the methods to pay it.
The API responds 404 Orders.StatusLinkNotFound when the token is invalid or
expired, or the order is invalid. The token lasts one year. Right after the
provider return, treat a 404 as a failed payment: see Handle a failed payment.
Without a statusToken
With a manual payment, the buyer never goes through a provider. Check the order with the cart token:
curl https://storefront.ecomiq.pe/v1/carts/<cart-token>/order \
-H "x-Store-Domain: mi-tienda"{
"orderId": "7204558912004325350",
"orderNumber": "ORD-2026-0001",
"orderGroup": "7204558912004325350",
"orderIds": ["7204558912004325350"],
"status": "pendingToConfirm",
"total": 170.0,
"currencyCode": "PEN",
"createdOn": "2026-10-05T15:30:00Z",
"updatedOn": "2026-10-05T15:30:02Z"
}status belongs to the first order in orderIds. total adds them all.
status | Meaning |
|---|---|
new | The payment did not finish starting. Repeat the checkout. |
pendingToConfirm | Waiting for payment confirmation. |
confirmed | Payment confirmed, or the order had nothing to pay. |
invalid | The payment failed or was cancelled. See Handle a failed payment. |
cancelled | The merchant cancelled the order. |
If the customer is signed in, you can also use
GET /.
See Customer accounts.
Handle a failed payment
If the provider reports that the payment failed or was cancelled, the order
becomes invalid. If it reports nothing, the order stays pendingToConfirm
with a balance due.
| What happened | What to do |
|---|---|
| The checkout returned an error. | The cart is still available. Repeat POST /, with the same method or another one. |
| The payment failed after leaving for the provider. | Repeat the checkout with the same cartToken: it reopens the cart, releases its coupons, and creates a new order. |
| The buyer did not finish paying. | Repeat the checkout: it returns the same payment and its action.url. Check action.expiresAt. |
A provider payment that reports action.expiresAt, such as PowerPay or
PagoEfectivo, is cancelled one hour after it expires if nobody paid. The order
becomes invalid and the checkout can be repeated.
When you repeat the checkout, use code to decide:
code | Status | What to do |
|---|---|---|
Checkout.InProgress | 409 | Another checkout for the cart is still running. Wait and check the order. |
Payments.ProviderOperationInProgress | 409 | A payment has already started. Repeat with the same paymentMethodCode. |
Payments.ProviderOutcomePending | 409 | The provider has not confirmed the outcome. Check the order before retrying. |
Checkout.PaymentResolutionPending | 409 | The failed payment is still being cancelled or refunded. Retry in a few minutes. |
Payments.Rejected | 400 | The provider declined the payment. params.providerCode carries its reason. Offer another payment method. |
Checkout.NotInProgress | 409 | The cart is no longer active. Create a new one. |
Promotions.CodeNoLongerApplies | 409 | A code no longer applies and the cart dropped it. Show the new total and retry. |
Payments.MethodNotAvailable | 400 | The method is not active. Fetch the methods again with GET /. |
Pay the balance
An order with balanceDue greater than 0 is paid with
POST /. For example, a bank_transfer
order that the buyer would rather pay with PowerPay. Send a code from
paymentMethods.
curl -X POST https://storefront.ecomiq.pe/v1/orders/status/<status-token>/payment \
-H "x-Store-Domain: mi-tienda" \
-H "Content-Type: application/json" \
-d '{ "paymentMethodCode": "powerpay" }'{
"actionUrl": "https://pasarela.example.com/pagar/9a7e3f10",
"isCaptured": false,
"transactionId": "7204558912004325370",
"actionKind": "redirect",
"providerId": "powerpay",
"publicConfiguration": {},
"clientSecret": null,
"qrCode": null,
"receiptUrl": null,
"expiresAt": "2026-10-06T15:30:00Z"
}actionKind takes the values from Continue the payment. The data comes at the top level:
actionUrl, clientSecret, qrCode, voucherCode, and qrImageUrl. The
buyer returns to the same route as in Receive the buyer.
| Response | When |
|---|---|
409 Orders.NoBalanceDue | The order has no balance due. |
409 Payments.ProviderOutcomePending | An earlier provider payment is still open. |
404 Orders.StatusLinkNotFound | The token is invalid, or the order is invalid or cancelled. |
Confirm from your system
The buyer may close the browser before coming back. To hear about the payment in your backend, subscribe to these order webhooks:
| Event | When it arrives |
|---|---|
order.created | When the order is created, before the payment is confirmed. Hold the shipment for now. |
order.confirmed | The payment was confirmed, or the merchant confirmed the order. |
order.invalidated | The payment failed or was cancelled. |
Register the endpoint with the Admin API. See Webhooks.
With a manual payment, the merchant confirms it in the admin. From your system,
capture the payment with POST / and
the transactionId from Read the payment.
curl -X POST https://api.ecomiq.pe/api/v1/payments/7204558912004325362/capture \
-H "Authorization: Bearer $ECOMIQ_TOKEN"{
"transactionId": "7204558912004325362",
"status": "captured",
"isCaptured": true,
"paymentMethodCode": "manual_yape",
"code": "manual_yape",
"cardType": null,
"amount": 170.0,
"reference": "7204558912004325362",
"providerId": "manual",
"publicConfiguration": {
"phoneNumber": "+51987654321",
"accountHolder": "Mi tienda",
"qrImageUrl": "https://…/yape-qr.png"
},
"clientSecret": null,
"checkoutUrl": null,
"checkoutHtml": null,
"qrCode": "https://…/yape-qr.png",
"receiptUrl": "https://…/comprobante-yape.jpg",
"expiresAt": null,
"actionKind": "qrCode"
}The order then becomes confirmed and order.confirmed arrives. Capturing a
payment that is already captured returns the same payment. A failed,
cancelled, or refunded payment returns 400 Payments.InvalidStatus.
Next steps
- Customer accounts: orders for signed-in customers.
- Tracking and reviews: shipping and reviews after the purchase.
- Webhooks: signing, delivery, and retries.
- Orders: status, balance, and receipts in the reference.
Last updated: