Who this is for. Developers building a checkout page, and anyone asked why a shopper's pay button is disabled. After this page you can say what state a checkout is in, what can still change, and exactly what is missing before it can be paid.

A checkout is a record

A checkout is created from the cart when the shopper starts paying, and becomes an order once they have paid in full. The cart page's checkout button sends POST /checkout, which creates the checkout from the cart's available items, copies the cart's currency and discount codes, and works out the discounts and totals again. Items that are not available stay behind in the cart; a request that names an unavailable item explicitly is refused with a 400 (error.item.notAvailable).

In between, the checkout holds what the cart does not: the person and their email address, billing and delivery addresses, fulfilments with their shipping rates, order metadata, and the totals with discounts, shipping and tax. Three facts shape the rest of this page:

  • It has no status field. "Ready for payment" and "fully paid" are worked out from its contents every time they are asked for, never stored.
  • A client writes four fields: person, billingAddress, deliveryAddress and metadataFieldValues. Items, fulfilments and totals are managed by the platform, through the calls that create shipping, add codes and take payment.
  • It is found through a cookie called checkout, which GET /checkout reads to find the visitor's checkout.

Its currency, and the language of its lines

A checkout is in one currency from start to finish: the cart's currency at the moment the checkout was created, copied across and never chosen on the checkout itself. The cart's currency is set when its first item is added (the currencyCode sent with that add, or else the default currency) and stays until the cart is emptied; see Prices and currencies. Every amount on the checkout, from item prices to shipping rates, discounts and tax, is in that currency. A template reads its code with {$checkout->getCurrencyCode()} (Twig {{ checkout.getCurrencyCode() }}), and the page's script receives it the same way (Seed the state from the server).

A line's name is stored on the line when the item is added to the cart. It is the product's name in the client's default language, and it stays as stored for the life of the cart and the checkout; the line carries no language of its own. On a multilingual site, print each line in the shopper's language from its product: take the line's product (getProduct()) and read its description in the visitor's language, with the default as the fallback, as Reading the right description shows. Build the line's link from the same description, so its name and its URL are in the same language.

From cart to order

A checkout passes through up to three stages. What brings it to each, and what can still change there:

StageHow it gets thereWhat can change
Attached to its cartPOST /checkout from the cart. It lasts 30 minutes without activity (Checkouts.lifetime, 1,800 seconds).The four writable fields, shipping and discount codes; every change works the checkout out again. A change to the cart ends the checkout, and the shopper starts a new one (Changing the cart).
Split from its cart, payment in progressA purchase that needs authentication, redirects to the provider, is pending, or pays only part of the total.The checkout no longer depends on the cart, so the cart can change without touching it. It is kept for up to a year (Checkouts.standaloneLifetime, 31,536,000 seconds), long enough for a slow off-site payment to complete by webhook. Edits are accepted until the payment resolves.
Converted to an orderA payment that settles the total.It is no longer a checkout. The order has its own rules; see From checkout to order.

A shopper can also leave: DELETE /checkout abandons the checkout and returns them to the cart.

What "ready for payment" requires

A checkout is ready for payment when all four of these hold:

  1. It has at least one fulfilment.
  2. Every fulfilment has a selected shipping rate that has not expired. A rate lasts an hour from when it was created.
  3. It has a person with an email address.
  4. It has a billing address.

A delivery address is not one of them. A shipping rate is, so a store that sells only digital goods still creates shipping and selects a rate: give it a shipping method that returns a free rate for every address (Shipping methods and their templates). Creating shipping, and what to do when a rate expires, are on Addresses and shipping at checkout.

Why the pay button is disabled

The checkout says which condition is missing. After every create and every edit, the API response carries a warning message for each check that does not pass, so the page can show the reason next to a disabled button before the shopper tries to pay:

What is missingWarning
A person with an email address"Checkout has no person"
The signed-in member's own email (only when signed in)"Checkout email does not match logged in user"
A billing address"Billing address has not been set"
A fulfilment"Checkout has no fulfillments"
A selected rate on every fulfilment"A fulfillment has no shipping rate selected"
A rate that has not expired"A fulfillment's selected shipping rate has expired"

Disable the pay button while any of these warnings is present, and print beside it what the shopper should do ("Choose a delivery option", "Add your billing address"). A purchase sent anyway is refused with a 400, "Checkout is not ready for payment" (error.checkout_not_ready_for_payment), and the response carries the same warnings again.

What the purchase call does

POST /cdna-api/checkout/<id>/purchase takes payment, in this order:

  1. It checks the captcha token, unless the captcha is set to disabled (The captcha at purchase).
  2. It checks that the checkout is ready for payment.
  3. If the total is zero, it completes a free checkout: a zero invoice is still created and paid, and the checkout still becomes an order.
  4. Otherwise it takes the payment method from _paymentMethodId, creates an invoice (or reuses the one it already has), and calls the payment provider.
  5. It answers according to the provider's result.
ResultWhat the page is toldThe checkout
The provider reports an errorThe provider's error, as givenStays attached to its cart, so the shopper can correct the details and try again
Authentication requiredPAYMENT_AUTHENTICATION_REQUIRED, with an authenticate actionSplit from its cart
RedirectPAYMENT_REDIRECT, to the provider's URLSplit from its cart
PendingPAYMENT_PENDING, to /checkout/payment-processingSplit from its cart
Paid in partPAYMENT_SUCCESSFUL_BUT_NOT_FULLY_PAIDSplit from its cart; stays a checkout
Paid in fullPAYMENT_SUCCESSFUL, to /checkout/complete/<order uuid>Converted to an order

"Paid in full" means the settled amount reaches the checkout's total: paid invoices count in full, open invoices by the amount authorised, draft and void invoices not at all. The completion URL carries the order's uuid, not the checkout's. Every result that leaves the payment unresolved splits the checkout from its cart before answering, so a payment that completes later is not affected by what the shopper does to the cart in the meantime. Handling each result on the page, and the providers themselves, are on Payment methods and how a payment runs.

The pages and the API

The platform supplies the routes and the records; the template, checkouts/single-page.html, and all of the page's script are yours.

Page routeWhat it does
POST /checkoutCreates the checkout from the cart
GET /checkoutRenders the checkout page
DELETE /checkoutAbandons the checkout and returns to the cart
GET /checkout/payment-processingThe page a pending payment waits on
GET /checkout/complete/<order uuid>The page after a completed payment

GET /checkout is never cached. It finds the visitor's checkout by the checkout cookie and, without one, sends them to the cart. When a signed-in visitor opens a checkout that belongs to another person (a guest who has since signed in, say), it links the checkout to the signed-in person. It renders checkouts/single-page.html with three variables: checkout, metadataFields (every order metadata field defined for the site) and _recaptcha.

API callWhat it does
POST /cdna-api/checkout/<cart uuid>Creates a checkout from a cart
GET /cdna-api/checkout/<id>Reads it
PUT /cdna-api/checkout/<id>Writes the person, addresses and metadata
DELETE /cdna-api/checkout/<id>Deletes it
POST /cdna-api/checkout/<id>/discount-code
DELETE /cdna-api/checkout/<id>/discount-code/<code>
Adds and removes a discount code
POST /cdna-api/checkout/<id>/shipping/<algorithm>Creates shipping (simple-delivery or simple-pickup)
PUT /cdna-api/checkout/<id>/shipping/<rate id>Selects a rate
POST /cdna-api/checkout/<id>/purchaseTakes payment
GET /cdna-api/checkout/<id>/payment-processingChecks a payment in progress

<id> is the checkout's uuid. Every call needs a signed-in visitor or guest checkout switched on (The guest checkout setting), and every call except the read is CSRF-checked.

The rest of the checkout

How to structure the page's script, and how to keep what a shopper has typed through a reload, are on The single-page checkout. Collecting the email and addresses and choosing shipping are on Addresses and shipping at checkout.

Discount codes and automatic discounts on the checkout, with what the checkout answers for each kind of code, have their own page: Discounts at checkout.

Order metadata fields, gift options and donations are on Donations, metadata and gift options.

Guest and account checkout, what each creates and how a guest gets an account later, have their own page, together with the captcha: Guest checkout and the captcha.