Skip to main content

Checkout Implementation

This guide explains how to implement a safe, production-ready checkout flow using Unchained Engine.

Unchained checkout is not a single mutation.
It is a state machine with locking, payment signing, stock validation and async confirmation.


Checkout State Machine​

Step 1: User Authentication​

Before adding items to a cart, the user must be authenticated (as guest or registered).

Guest Checkout​

mutation LoginAsGuest {
loginAsGuest {
_id
tokenExpires
user {
_id
isGuest
}
}
}

The session token is set as an HTTP-only cookie automatically. For subsequent requests, ensure cookies are sent with your requests.

Registered User Login​

mutation Login {
loginWithPassword(email: "user@example.com", password: "password") {
_id
tokenExpires
user {
_id
username
}
}
}

Step 2: Add Products to Cart​

Add products to the cart. The cart is created automatically on first mutation.

Add Simple Product​

mutation AddToCart {
addCartProduct(productId: "product-123", quantity: 2) {
_id
quantity
product {
_id
texts {
title
}
}
unitPrice {
amount
currencyCode
}
total {
amount
currencyCode
}
order {
_id
total {
amount
currencyCode
}
}
}
}

Add Configurable Product​

For products with variations or configurations:

mutation AddConfiguredProduct {
addCartProduct(
productId: "configurable-product-123"
quantity: 1
configuration: [
{ key: "size", value: "L" }
{ key: "color", value: "blue" }
]
) {
_id
configuration {
key
value
}
}
}

Update Quantity​

mutation UpdateQuantity {
updateCartItem(itemId: "cart-item-123", quantity: 3) {
_id
quantity
}
}

This recalculates tax, shipping and discounts. Always refetch the cart after this.

Remove Item​

mutation RemoveItem {
removeCartItem(itemId: "cart-item-123") {
_id
}
}

Step 3: Set Delivery & Payment​

Get Available Providers​

Fetch both delivery and payment providers in a single query:

query GetProviders {
me {
cart {
_id
supportedDeliveryProviders {
_id
type
interface { _id label }
simulatedPrice { amount currencyCode }
}
supportedPaymentProviders {
_id
type
interface { _id label }
}
}
}
}

Set Both Providers​

Set delivery and payment providers in one mutation:

mutation SetProviders {
updateCart(
deliveryProviderId: "delivery-provider-123"
paymentProviderId: "payment-provider-123"
) {
_id
delivery {
_id
provider {
_id
type
interface {
label
}
}
fee {
amount
currencyCode
}
}
}
}

Set Delivery Address​

mutation SetDeliveryAddress {
updateCartDeliveryShipping(
deliveryProviderId: "delivery-provider-123"
address: {
firstName: "John"
lastName: "Doe"
company: "ACME Inc"
addressLine: "123 Main St"
addressLine2: "Apt 4"
postalCode: "12345"
city: "Zurich"
countryCode: "CH"
}
) {
_id
delivery {
... on OrderDeliveryShipping {
_id
address {
firstName
lastName
city
countryCode
}
}
}
}
}

Set Pickup Location (for PICKUP delivery)​

mutation SetPickupLocation {
updateCartDeliveryPickUp(
deliveryProviderId: "delivery-provider-123"
orderPickUpLocationId: "pickup-location-123"
) {
_id
delivery {
... on OrderDeliveryPickUp {
_id
activePickUpLocation {
_id
name
address {
addressLine
city
}
}
}
}
}
}

Initialize Payment (Sign)​

For client-side payment SDKs (Stripe, PayPal), get the client token before checkout:

mutation SignPayment {
signPaymentProviderForCheckout(
orderPaymentId: "order-payment-123"
)
}

The returned value is used to initialize the payment SDK on the client.

Step 4: Review Cart​

Get the complete cart with all pricing:

query ReviewCart {
me {
cart {
_id
items {
_id
quantity
product {
texts {
title
}
}
total {
amount
currencyCode
}
}
delivery {
... on OrderDeliveryShipping {
address {
firstName
lastName
addressLine
city
countryCode
}
}
fee {
amount
currencyCode
}
}
payment {
provider {
interface {
label
}
}
fee {
amount
currencyCode
}
}
discounts {
total {
amount
currencyCode
}
code
}
total {
amount
currencyCode
}
}
}
}

Apply Discount Code​

mutation ApplyDiscount {
addCartDiscount(code: "SAVE10") {
_id
code
total {
amount
currencyCode
}
order {
_id
total {
amount
currencyCode
}
}
}
}

Remove Discount Code​

mutation RemoveDiscount {
removeCartDiscount(discountId: "discount-123") {
_id
order {
_id
discounts {
_id
}
}
}
}

Step 5: Checkout​

Standard Checkout​

mutation Checkout {
checkoutCart {
_id
status
orderNumber
ordered
payment {
status
}
delivery {
status
}
total {
amount
currencyCode
}
}
}

Checkout with Payment Context​

For payment providers that need additional data:

mutation CheckoutWithPayment {
checkoutCart(
paymentContext: {
paymentIntentId: "pi_xxx" # From Stripe
}
) {
_id
status
orderNumber
}
}

Step 6: Post-Checkout​

Check Order Status​

query OrderStatus {
order(orderId: "order-123") {
_id
status
orderNumber
payment {
status
}
delivery {
status
}
}
}

Order Statuses Explained​

StatusMeaning
OPENOpen order / cart (not checked out)
PENDINGChecked out, awaiting payment
CONFIRMEDPayment confirmed, ready for delivery
FULFILLEDOrder delivered and complete
REJECTEDOrder cancelled

Complete Example: React Implementation​

import { useState } from 'react';
import { useMutation, useQuery } from '@apollo/client/react';

function Checkout() {
const [step, setStep] = useState('cart');

// Get cart data
const { data: cartData, refetch } = useQuery(GET_CART);
const cart = cartData?.me?.cart;

// Mutations
const [addToCart] = useMutation(ADD_TO_CART);
const [updateCart] = useMutation(UPDATE_CART);
const [setDeliveryAddress] = useMutation(SET_DELIVERY_ADDRESS);
const [signPayment] = useMutation(SIGN_PAYMENT);
const [checkout] = useMutation(CHECKOUT);

const handleAddProduct = async (productId: string, quantity: number) => {
await addToCart({ variables: { productId, quantity } });
refetch();
};

const handleSetProviders = async (
deliveryProviderId: string,
paymentProviderId: string,
address: Address
) => {
// Set both providers in one call
await updateCart({
variables: { deliveryProviderId, paymentProviderId },
});
await setDeliveryAddress({
variables: { deliveryProviderId, address },
});
refetch();
setStep('review');
};

const handleCheckout = async () => {
try {
// Get payment client token if needed
const { data: signData } = await signPayment({
variables: { orderPaymentId: cart.payment._id },
});

// Initialize payment SDK (e.g., Stripe)
// await stripe.confirmPayment(signData.signPaymentProviderForCheckout)

// Complete checkout
const { data: orderData } = await checkout();

if (orderData.checkoutCart.status === 'CONFIRMED') {
setStep('confirmation');
} else if (orderData.checkoutCart.status === 'PENDING') {
// Payment pending - show waiting message
setStep('pending');
}
} catch (error) {
console.error('Checkout failed:', error);
}
};

return (
<div>
{step === 'cart' && (
<CartStep cart={cart} onContinue={() => setStep('providers')} />
)}
{step === 'providers' && (
<ProvidersStep
deliveryProviders={cart?.supportedDeliveryProviders}
paymentProviders={cart?.supportedPaymentProviders}
onSelect={handleSetProviders}
/>
)}
{step === 'review' && (
<ReviewStep cart={cart} onCheckout={handleCheckout} />
)}
{step === 'confirmation' && (
<ConfirmationStep orderNumber={cart?.orderNumber} />
)}
</div>
);
}

Error Handling​

Common Checkout Errors​

A failed checkoutCart throws an OrderCheckoutError (extensions.code); the underlying cause is in extensions.detailCode and extensions.detailMessage:

detailCodeCauseSolution
NoItemsErrorEmpty cartEnsure items are added
NoDeliveryProviderErrorDelivery not setSet delivery provider
NoPaymentProviderErrorPayment not setSet payment provider
ContactMissingErrorContact data not setSet contact via updateCart
BillingAddressMissingErrorBilling address not setSet billing address via updateCart
QuotationInvalidErrorQuotation expired or fulfilledRequest a new offer

Payment charge failures surface the same way, with the payment adapter's error name as detailCode.

Handling Payment Errors​

mutation CheckoutWithErrorHandling {
checkoutCart {
_id
status
}
}

If the mutation throws, catch and display the error:

try {
await checkout();
} catch (error) {
const extensions = error.graphQLErrors?.[0]?.extensions;
if (extensions?.code === 'OrderCheckoutError') {
switch (extensions.detailCode) {
case 'NoDeliveryProviderError':
case 'NoPaymentProviderError':
showError('Please select a delivery and payment method.');
break;
case 'NoItemsError':
showError('Your cart is empty.');
refetch(); // Refresh cart
break;
default:
showError(extensions.detailMessage ?? 'Checkout failed. Please try again.');
}
}
}

Webhooks for Async Payments​

Many payment providers confirm payments asynchronously via webhooks. The payment plugins register their webhook routes themselves — you don't write a handler. The Stripe plugin, for example, serves POST /payment/stripe/webhook (path configurable via STRIPE_WEBHOOK_PATH): it verifies the signature with STRIPE_ENDPOINT_SECRET and, on payment_intent.succeeded, checks out the pending order so it transitions to CONFIRMED.

Point the provider's dashboard at your engine, e.g. https://your-shop.example/payment/stripe/webhook, and set STRIPE_ENDPOINT_SECRET — without it, webhooks are not processed.