ShopEazydevelopers
Recipes

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 back

1. 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:

  • fulfilment is delivery or collect. It resolves to delivery only when the basket has a shippable line, the store offers delivery and you did not ask for collect. Read fulfilment in 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. Compare lines in the answer with your basket.
  • A delivered basket needs a deliverable address: street (addressLine), suburb (localArea), city and postalCode. Without one it is 422 validation_error.
  • A service that takes online bookings needs booking.at on 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.accessToken

4. 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://localhost is allowed for development. failureUrl is optional and defaults to cancelUrl.
  • 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 from checkoutPageUrl or else successUrl, and that domain must be on the merchant's Peach allowlist.
  • Payment errors: 409 already_paid if the order is already paid, and 409 payment_unavailable if 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

CodeWhat to do
422 validation_errorShow the message. It is safe to show
422 delivery_changedQuote again and let the shopper pick a delivery option again
401 shopper_required, 403 not_a_memberA members-only store. Sign the shopper in, or show how to apply
409 already_paidThe order is paid. Go to the success page
409 payment_unavailableDo not offer a card checkout
409 in_flightThe same Idempotency-Key is still running. Retry the same call in a moment
403 store_suspended, store_closed, store_inactiveShow "shop closed"

See Errors for every code.

Coming soon

These are in the contract, and their routes are not served yet:

  • Discount codes. A discountCode on 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.

On this page