Implementing Checkout API
The Checkout API is the heart of ACP. It manages the entire purchase flow from cart creation to order completion. This tutorial covers implementing a production-ready checkout system.
API Overview
The Checkout API consists of four main endpoints:
| Endpoint | Method | Purpose |
|---|---|---|
/checkout_sessions | POST | Create new session |
/checkout_sessions/:id | GET | Retrieve session state |
/checkout_sessions/:id | PATCH | Update session |
/checkout_sessions/:id/complete | POST | Complete purchase |
Session Lifecycle
┌─────────────────────┐
│ Session Created │
│ (not_ready_for_ │
│ payment) │
└──────────┬──────────┘
│
│ Add shipping address
▼
┌─────────────────────┐
│ ready_for_payment │
└──────────┬──────────┘
│
│ Submit payment token
▼
┌─────────────────────┐
│ completed │
└─────────────────────┘
Alternative endings:
• expired (30 min timeout)
• cancelled (explicit cancel)Implementation
Project Structure
checkout/
├── routes/
│ └── checkout.js
├── services/
│ ├── sessionService.js
│ ├── cartService.js
│ └── paymentService.js
├── models/
│ └── session.js
└── middleware/
└── validation.jsSession Model
// models/session.js
const mongoose = require('mongoose');
const lineItemSchema = new mongoose.Schema({
product_id: { type: String, required: true },
variant_id: String,
title: { type: String, required: true },
quantity: { type: Number, required: true, min: 1 },
unit_price: { type: Number, required: true },
line_total: { type: Number, required: true }
});
const addressSchema = new mongoose.Schema({
line1: { type: String, required: true },
line2: String,
city: { type: String, required: true },
state: String,
postal_code: { type: String, required: true },
country: { type: String, required: true, default: 'US' }
});
const sessionSchema = new mongoose.Schema({
checkout_session_id: {
type: String,
required: true,
unique: true,
index: true
},
state: {
type: String,
enum: ['not_ready_for_payment', 'ready_for_payment', 'completed', 'cancelled', 'expired'],
default: 'not_ready_for_payment'
},
cart: {
items: [lineItemSchema]
},
buyer_context: {
email: String,
phone: String,
shipping_address: addressSchema,
billing_address: addressSchema
},
totals: {
subtotal: { type: Number, required: true },
shipping: { type: Number, default: 0 },
tax: { type: Number, default: 0 },
discount: { type: Number, default: 0 },
total: { type: Number, required: true },
currency: { type: String, default: 'USD' }
},
shipping_options: [{
id: String,
name: String,
price: Number,
estimated_days: String
}],
selected_shipping: String,
order: {
order_id: String,
payment_intent_id: String,
created_at: Date
},
metadata: mongoose.Schema.Types.Mixed,
expires_at: {
type: Date,
required: true,
index: true
}
}, {
timestamps: true
});
// Index for cleanup
sessionSchema.index({ expires_at: 1 }, { expireAfterSeconds: 0 });
module.exports = mongoose.model('CheckoutSession', sessionSchema);Cart Service
// services/cartService.js
const Product = require('../models/product');
class CartService {
constructor() {
this.taxRates = {
US: {
CA: 0.0725,
NY: 0.08,
TX: 0.0625,
default: 0.06
}
};
}
async validateAndCalculate(cartItems, shippingAddress) {
const validatedItems = [];
let subtotal = 0;
for (const item of cartItems) {
// Fetch product
const product = await Product.findOne({ id: item.product_id });
if (!product) {
throw new Error(`Product not found: ${item.product_id}`);
}
// Check availability
if (product.availability === 'out_of_stock') {
throw new Error(`Product out of stock: ${product.title}`);
}
// Handle variants
let price = product.price;
let variantTitle = '';
if (item.variant_id && product.variants) {
const variant = product.variants.find(v => v.id === item.variant_id);
if (!variant) {
throw new Error(`Variant not found: ${item.variant_id}`);
}
if (variant.availability === 'out_of_stock') {
throw new Error(`Variant out of stock: ${variant.title}`);
}
price = variant.price || price;
variantTitle = ` - ${variant.title}`;
}
// Calculate line total
const lineTotal = price * item.quantity;
subtotal += lineTotal;
validatedItems.push({
product_id: product.id,
variant_id: item.variant_id,
title: product.title + variantTitle,
quantity: item.quantity,
unit_price: price,
line_total: lineTotal
});
}
// Calculate shipping
const shipping = this.calculateShipping(subtotal, shippingAddress);
// Calculate tax
const tax = this.calculateTax(subtotal, shippingAddress);
// Calculate total
const total = subtotal + shipping + tax;
return {
items: validatedItems,
totals: {
subtotal,
shipping,
tax,
discount: 0,
total,
currency: 'USD'
}
};
}
calculateShipping(subtotal, address) {
// Free shipping over $50
if (subtotal >= 5000) {
return 0;
}
// International shipping
if (address?.country && address.country !== 'US') {
return 2500; // $25
}
// Domestic standard
return 500; // $5
}
calculateTax(subtotal, address) {
if (!address?.country || !address?.state) {
return 0;
}
const countryRates = this.taxRates[address.country];
if (!countryRates) {
return 0;
}
const rate = countryRates[address.state] || countryRates.default || 0;
return Math.round(subtotal * rate);
}
getShippingOptions(address) {
const options = [
{
id: 'standard',
name: 'Standard Shipping',
price: 500,
estimated_days: '5-7 business days'
},
{
id: 'express',
name: 'Express Shipping',
price: 1500,
estimated_days: '2-3 business days'
}
];
// Add international option
if (address?.country && address.country !== 'US') {
options.push({
id: 'international',
name: 'International Shipping',
price: 2500,
estimated_days: '10-14 business days'
});
}
return options;
}
}
module.exports = new CartService();Session Service
// services/sessionService.js
const { v4: uuidv4 } = require('uuid');
const CheckoutSession = require('../models/session');
const cartService = require('./cartService');
class SessionService {
generateSessionId() {
return `cs_${uuidv4().replace(/-/g, '')}`;
}
async createSession(cartItems, buyerContext = {}) {
// Validate cart and calculate totals
const { items, totals } = await cartService.validateAndCalculate(
cartItems,
buyerContext.shipping_address
);
// Determine initial state
const state = buyerContext.shipping_address
? 'ready_for_payment'
: 'not_ready_for_payment';
// Get shipping options
const shippingOptions = cartService.getShippingOptions(
buyerContext.shipping_address
);
// Create session
const session = new CheckoutSession({
checkout_session_id: this.generateSessionId(),
state,
cart: { items },
buyer_context: buyerContext,
totals,
shipping_options: shippingOptions,
expires_at: new Date(Date.now() + 30 * 60 * 1000) // 30 minutes
});
await session.save();
return session;
}
async getSession(sessionId) {
const session = await CheckoutSession.findOne({
checkout_session_id: sessionId
});
if (!session) {
return null;
}
// Check expiration
if (session.expires_at < new Date() && session.state !== 'completed') {
session.state = 'expired';
await session.save();
}
return session;
}
async updateSession(sessionId, updates) {
const session = await this.getSession(sessionId);
if (!session) {
throw new Error('Session not found');
}
if (session.state === 'completed' || session.state === 'expired') {
throw new Error('Cannot update completed or expired session');
}
// Update buyer context
if (updates.buyer_context) {
session.buyer_context = {
...session.buyer_context,
...updates.buyer_context
};
// Recalculate if shipping address changed
if (updates.buyer_context.shipping_address) {
const { totals } = await cartService.validateAndCalculate(
session.cart.items,
session.buyer_context.shipping_address
);
session.totals = totals;
session.shipping_options = cartService.getShippingOptions(
session.buyer_context.shipping_address
);
}
}
// Update selected shipping option
if (updates.selected_shipping) {
const option = session.shipping_options.find(
o => o.id === updates.selected_shipping
);
if (option) {
session.selected_shipping = option.id;
session.totals.shipping = option.price;
session.totals.total = session.totals.subtotal +
session.totals.shipping +
session.totals.tax -
session.totals.discount;
}
}
// Update state based on completeness
if (session.buyer_context.shipping_address &&
session.buyer_context.email) {
session.state = 'ready_for_payment';
}
await session.save();
return session;
}
async cancelSession(sessionId) {
const session = await this.getSession(sessionId);
if (!session) {
throw new Error('Session not found');
}
if (session.state === 'completed') {
throw new Error('Cannot cancel completed session');
}
session.state = 'cancelled';
await session.save();
return session;
}
}
module.exports = new SessionService();Routes
// routes/checkout.js
const express = require('express');
const router = express.Router();
const sessionService = require('../services/sessionService');
const paymentService = require('../services/paymentService');
// Validation middleware
const validateCreateSession = (req, res, next) => {
const { cart } = req.body;
if (!cart || !cart.items || !Array.isArray(cart.items)) {
return res.status(400).json({
error: 'Invalid request',
message: 'Cart with items array is required'
});
}
if (cart.items.length === 0) {
return res.status(400).json({
error: 'Invalid request',
message: 'Cart cannot be empty'
});
}
for (const item of cart.items) {
if (!item.product_id || !item.quantity || item.quantity < 1) {
return res.status(400).json({
error: 'Invalid request',
message: 'Each item must have product_id and quantity >= 1'
});
}
}
next();
};
// Create checkout session
router.post('/checkout_sessions', validateCreateSession, async (req, res) => {
try {
const { cart, buyer_context, metadata } = req.body;
const session = await sessionService.createSession(
cart.items,
buyer_context
);
res.status(201).json(formatSession(session));
} catch (error) {
console.error('Create session error:', error);
if (error.message.includes('not found') ||
error.message.includes('out of stock')) {
return res.status(400).json({
error: 'Cart validation failed',
message: error.message
});
}
res.status(500).json({
error: 'Internal server error',
message: 'Failed to create checkout session'
});
}
});
// Get checkout session
router.get('/checkout_sessions/:id', async (req, res) => {
try {
const session = await sessionService.getSession(req.params.id);
if (!session) {
return res.status(404).json({
error: 'Not found',
message: 'Checkout session not found'
});
}
res.json(formatSession(session));
} catch (error) {
console.error('Get session error:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Failed to retrieve checkout session'
});
}
});
// Update checkout session
router.patch('/checkout_sessions/:id', async (req, res) => {
try {
const session = await sessionService.updateSession(
req.params.id,
req.body
);
res.json(formatSession(session));
} catch (error) {
console.error('Update session error:', error);
if (error.message === 'Session not found') {
return res.status(404).json({
error: 'Not found',
message: 'Checkout session not found'
});
}
if (error.message.includes('Cannot update')) {
return res.status(400).json({
error: 'Invalid operation',
message: error.message
});
}
res.status(500).json({
error: 'Internal server error',
message: 'Failed to update checkout session'
});
}
});
// Complete checkout session
router.post('/checkout_sessions/:id/complete', async (req, res) => {
try {
const { payment_token, idempotency_key } = req.body;
if (!payment_token) {
return res.status(400).json({
error: 'Invalid request',
message: 'Payment token is required'
});
}
const session = await sessionService.getSession(req.params.id);
if (!session) {
return res.status(404).json({
error: 'Not found',
message: 'Checkout session not found'
});
}
if (session.state !== 'ready_for_payment') {
return res.status(400).json({
error: 'Invalid state',
message: `Session is ${session.state}, must be ready_for_payment`,
current_state: session.state
});
}
// Process payment
const result = await paymentService.processPayment(
session,
payment_token,
idempotency_key
);
res.json({
success: true,
order_id: result.order_id,
session: formatSession(result.session)
});
} catch (error) {
console.error('Complete session error:', error);
if (error.type === 'payment_failed') {
return res.status(400).json({
error: 'Payment failed',
message: error.message,
decline_code: error.decline_code
});
}
res.status(500).json({
error: 'Internal server error',
message: 'Failed to complete checkout'
});
}
});
// Cancel checkout session
router.delete('/checkout_sessions/:id', async (req, res) => {
try {
await sessionService.cancelSession(req.params.id);
res.status(204).send();
} catch (error) {
console.error('Cancel session error:', error);
if (error.message === 'Session not found') {
return res.status(404).json({
error: 'Not found',
message: 'Checkout session not found'
});
}
res.status(500).json({
error: 'Internal server error',
message: 'Failed to cancel checkout session'
});
}
});
// Format session for API response
function formatSession(session) {
return {
checkout_session_id: session.checkout_session_id,
state: session.state,
cart: session.cart,
buyer_context: session.buyer_context,
totals: session.totals,
shipping_options: session.shipping_options,
selected_shipping: session.selected_shipping,
order: session.order,
created_at: session.createdAt,
expires_at: session.expires_at
};
}
module.exports = router;Error Handling
Implement consistent error responses:
// middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
console.error(err);
// Known errors
if (err.name === 'ValidationError') {
return res.status(400).json({
error: 'Validation error',
message: err.message,
details: err.errors
});
}
if (err.name === 'CastError') {
return res.status(400).json({
error: 'Invalid ID format',
message: 'The provided ID is not valid'
});
}
// Default error
res.status(500).json({
error: 'Internal server error',
message: 'An unexpected error occurred'
});
};
module.exports = errorHandler;Testing the API
Create a session
curl -X POST http://localhost:3000/acp/v1/checkout_sessions \
-H "Content-Type: application/json" \
-d '{
"cart": {
"items": [
{ "product_id": "prod_001", "quantity": 2 }
]
}
}'Add shipping address
curl -X PATCH http://localhost:3000/acp/v1/checkout_sessions/cs_xxx \
-H "Content-Type: application/json" \
-d '{
"buyer_context": {
"email": "customer@example.com",
"shipping_address": {
"line1": "123 Main St",
"city": "San Francisco",
"state": "CA",
"postal_code": "94102",
"country": "US"
}
}
}'Select shipping option
curl -X PATCH http://localhost:3000/acp/v1/checkout_sessions/cs_xxx \
-H "Content-Type: application/json" \
-d '{
"selected_shipping": "express"
}'Complete checkout
curl -X POST http://localhost:3000/acp/v1/checkout_sessions/cs_xxx/complete \
-H "Content-Type: application/json" \
-d '{
"payment_token": "spt_xxxxx"
}'Best Practices
✅
- Always validate cart items against current inventory
- Recalculate totals on every update
- Implement idempotency for payment completion
- Store sessions in a persistent database
- Set appropriate session expiration
- Log all state transitions for debugging
Next Steps
- Handling Payment Tokens - Implement SPT processing
- Testing Your Integration - Verify your implementation