Skip to Content
🚀Agentic Commerce Protocol is now live! Instant Checkout is available in ChatGPT. Learn more →
DocumentationStep-by-Step TutorialsImplementing Checkout API

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:

EndpointMethodPurpose
/checkout_sessionsPOSTCreate new session
/checkout_sessions/:idGETRetrieve session state
/checkout_sessions/:idPATCHUpdate session
/checkout_sessions/:id/completePOSTComplete 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.js

Session 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

Specification maintained by OpenAI and Stripe

AboutPrivacyTermsRSS

Apache 2.0 · Open Source