Headless checkout
Quote, place an order, take payment, confirm it and read it back, from your own site.
This recipe takes a basket from your own site to a paid order, using a store key. Prices are always worked out on the server, so your site never has to be trusted with money.
You need a store key with checkout:write (every key has it) and a store whose payments.canCharge is true. Read Storefront keys first if you have not issued one.
Quote -> Place order -> Pay -> Confirm -> Read back1. Check the store can take a card
curl https://api.shopeazy.co/v1/store \
-H "Authorization: Bearer sez_pub_..."Look at data.payments:
{ "provider": "yoco", "name": "Yoco", "canCharge": true }Show a card checkout only when canCharge is true. provider is yoco, ikhokha, peach or null. No payment credentials are ever returned. If ShopEazy cannot look the provider up, the store still loads, with canCharge: false.
If the store sells to members only (membership.enabled), the shopper must be signed in as an active member before an order. See Members-only store.
2. Quote the basket
A quote prices the basket from the live catalogue and creates nothing.
curl https://api.shopeazy.co/v1/store/checkout/quote \
-H "Authorization: Bearer sez_pub_..." \
-H "Content-Type: application/json" \
-d '{
"lines": [{ "itemId": "item_456", "quantity": 2 }],
"fulfilment": "delivery",
"shippingAddress": {
"addressLine": "12 Loop Street",
"localArea": "Gardens",
"city": "Cape Town",
"province": "Western Cape",
"postalCode": "8001"
}
}'const quote = await shopeazy.checkout.quote({
lines: [{ itemId: 'item_456', quantity: 2 }],
fulfilment: 'delivery',
shippingAddress,
});What to know about quotes:
fulfilmentisdeliveryorcollect. It resolves todeliveryonly when the basket has a shippable line, the store offers delivery and you did not ask forcollect. Readfulfilmentin the answer.- A line for an item that is unknown or inactive is dropped without a word. If none are left, the call is
422 validation_error. Comparelinesin the answer with your basket. - A delivered basket needs a deliverable address: street (
addressLine), suburb (localArea),cityandpostalCode. Without one it is422 validation_error. - A service that takes online bookings needs
booking.aton its line. See Take bookings.
Delivery options
With an address, the quote lists the delivery services in delivery:
{
"delivery": {
"mode": "live",
"flatFee": { "amount": 8500, "currency": "ZAR" },
"lockerEligible": false,
"unavailable": null,
"options": [
{
"provider": "<courier>",
"serviceCode": "<service code>",
"serviceName": "<service name>",
"price": { "amount": 9900, "currency": "ZAR" },
"estimate": "<estimate>",
"toLocker": false
}
]
}
}The store's delivery fee is its flat fee (free over its threshold), or live courier rates and the merchant's own delivery zones. If no courier can quote at all, the flat fee is charged.
Let the shopper pick an option, then send it back as deliveryChoice on the quote and on the order:
{
"deliveryChoice": {
"provider": "<courier>",
"serviceCode": "<service code>",
"expectedPriceMinor": 9900
}
}The price in deliveryChoice is never charged. The server asks the courier again and charges its price now. If the service has gone, or now costs more than expectedPriceMinor, the call is 422 delivery_changed. Quote again and let the shopper choose again.
3. Place the order
Send an Idempotency-Key, so a retry after a dropped connection cannot place the order twice.
curl https://api.shopeazy.co/v1/store/orders \
-H "Authorization: Bearer sez_pub_..." \
-H "Idempotency-Key: 5f0c1d7e-3a52-4c0b-9a1e-7d9c2b6e41aa" \
-H "Content-Type: application/json" \
-d '{
"lines": [{ "itemId": "item_456", "quantity": 2 }],
"fulfilment": "delivery",
"shippingAddress": { "addressLine": "12 Loop Street", "localArea": "Gardens", "city": "Cape Town", "province": "Western Cape", "postalCode": "8001" },
"deliveryChoice": { "provider": "<courier>", "serviceCode": "<service code>", "expectedPriceMinor": 9900 },
"customer": { "name": "Sipho Dlamini", "email": "sipho@example.co.za", "phone": "+27821234567" }
}'The answer is 201 with the order and its accessToken. Keep the token. It is returned only here and is the only way a guest reads this order back. Store it in an httpOnly cookie, or on your server. It is valid for 30 days, for this one order. If you send a shopper credential, the order is linked to that shopper.
const order = await shopeazy.orders.create(input, { idempotencyKey });
// order.id, order.accessToken4. Take payment
Open a checkout with the store's payment provider and send the shopper to the url it returns. You supply the return URLs.
curl https://api.shopeazy.co/v1/store/orders/order_789/pay \
-H "Authorization: Bearer sez_pub_..." \
-H "Idempotency-Key: 9b2f6a41-6d0e-4f8b-8d1a-2c4e7a90b3d5" \
-H "Content-Type: application/json" \
-d '{
"successUrl": "https://yoursite.co.za/thanks",
"cancelUrl": "https://yoursite.co.za/cart"
}'{
"data": {
"provider": "yoco",
"url": "https://...",
"checkoutId": "<checkout id>"
}
}const { url } = await shopeazy.orders.pay(order.id, {
successUrl: 'https://yoursite.co.za/thanks',
cancelUrl: 'https://yoursite.co.za/cart',
});
// Redirect the shopper to url- Return URLs must be absolute
https.http://localhostis allowed for development.failureUrlis optional and defaults tocancelUrl. - Peach renders its form inside a page you own, so also send
checkoutPageUrl, the page that mounts the widget. The checkout is opened with your site's origin, taken fromcheckoutPageUrlor elsesuccessUrl, and that domain must be on the merchant's Peach allowlist. - Payment errors:
409 already_paidif the order is already paid, and409 payment_unavailableif no provider can take a card or the provider refused to open a checkout.
5. Confirm on the success page
Settlement is the platform's job: the provider tells ShopEazy when the shopper has paid. confirm is a second, independent path, so a slow callback never strands a shopper on your success page. It asks the provider, and settles the order if it is paid. It takes no body, and it is safe to poll.
curl -X POST https://api.shopeazy.co/v1/store/orders/order_789/confirm \
-H "Authorization: Bearer sez_pub_..." \
-H "X-Order-Token: <order access token>"{
"data": {
"paid": true,
"order": { "id": "order_789", "number": "1042", "paymentStatus": "paid" }
}
}The order is the whole order, shortened here. The status always comes from the provider. Nothing your site sends can mark an order paid, and there is no endpoint that does.
const { paid, order } = await shopeazy.orders.confirm(orderId, { orderToken });If no payment was ever started for the order, the call is 422 no_payment.
6. Read the order back
curl https://api.shopeazy.co/v1/store/orders/order_789 \
-H "Authorization: Bearer sez_pub_..." \
-H "X-Order-Token: <order access token>"The order id alone is not enough. Send the X-Order-Token, or the shopper who placed the order. With neither, the call is 401 order_access_required. A token for someone else's order is 404 not_found.
Handling failures
| Code | What to do |
|---|---|
422 validation_error | Show the message. It is safe to show |
422 delivery_changed | Quote again and let the shopper pick a delivery option again |
401 shopper_required, 403 not_a_member | A members-only store. Sign the shopper in, or show how to apply |
409 already_paid | The order is paid. Go to the success page |
409 payment_unavailable | Do not offer a card checkout |
409 in_flight | The same Idempotency-Key is still running. Retry the same call in a moment |
403 store_suspended, store_closed, store_inactive | Show "shop closed" |
See Errors for every code.
Coming soon
These are in the contract, and their routes are not served yet:
- Discount codes. A
discountCodeon the quote and the order, and a preview call, so a shopper can see what a code is worth. - Bank transfer. A shopper choosing to pay by EFT instead of card, with the bank details and a reference on the order.
Until they ship, a checkout takes card payment only and cannot take a discount code.