In this tutorial you will build a real, working UCP-compatible store endpoint from scratch using Node.js and Express. By the end, AI agents will be able to discover your store, search your catalog, build a cart, and initiate checkout. No prior UCP knowledge needed.
An Express.js server that serves a UCP discovery profile, catalog search, product detail, cart, and checkout endpoints. You will test every endpoint with curl and verify the full flow works end to end.
Node.js 18+, curl, jq
15 minutes
2026-04-08
REST (OpenAPI 3.x)
mkdir ucp-store && cd ucp-store
npm init -y
npm install express cors
We need express for the HTTP server and cors because UCP agents will fetch your endpoints from different origins. Without CORS headers, agents will be blocked by the browser.
Create server.js and add the boilerplate:
const express = require('express');
const cors = require('cors');
const app = express();
// CORS is mandatory for UCP - agents come from different origins
app.use(cors({
origin: '*',
allowedHeaders: ['Content-Type', 'UCP-Agent'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
}));
app.use(express.json());
const PORT = 3000;
const UCP_VERSION = '2026-04-08';
const SPEC_BASE = 'https://ucp.dev/2026-04-08';
// Helper: wrap every response with UCP metadata
function ucpResponse(res, data) {
const payload = {
ucp: { version: UCP_VERSION, status: 'success' },
...data
};
res.json(payload);
}
// Helper: UCP error response
function ucpError(res, code, content, severity = 'unrecoverable', continueUrl = null) {
const payload = {
ucp: { version: UCP_VERSION, status: 'error' },
messages: [{
type: 'error',
code: code,
content: content,
severity: severity
}]
};
if (continueUrl) payload.continue_url = continueUrl;
res.json(payload);
}
app.listen(PORT, () => {
console.log(`UCP store running on http://localhost:${PORT}`);
});
UCP agents run on different domains (Google Gemini, ChatGPT, custom agents). When they fetch your /.well-known/ucp profile or call your API endpoints, the browser enforces CORS. Without Access-Control-Allow-Origin, these requests fail silently. The UCP-Agent header must be in allowedHeaders or preflight requests will fail.
The /.well-known/ucp endpoint is the heart of UCP discovery. It is a JSON file that tells agents what your store supports: which protocol version, which services, which transports, which capabilities, and which payment handlers. Think of it as robots.txt for AI commerce agents.
// ============================================
// DISCOVERY: /.well-known/ucp
// ============================================
// This is the entry point. Agents fetch this
// first to learn what your store supports.
// ============================================
app.get('/.well-known/ucp', (req, res) => {
res.json({
ucp: {
version: UCP_VERSION,
// Services map: which verticals you support
// Each service lists its transport bindings
services: {
'dev.ucp.shopping': [
{
version: UCP_VERSION,
spec: `${SPEC_BASE}/specification/overview`,
transport: 'rest',
endpoint: `http://localhost:${PORT}/api/ucp`,
schema: `${SPEC_BASE}/services/shopping/rest.openapi.json`
}
]
},
// Capabilities: what features you support within the service
// Each capability MUST have spec and schema URLs
// The spec URL origin MUST match the namespace authority
// dev.ucp.* -> https://ucp.dev/...
// com.example.* -> https://example.com/...
capabilities: {
'dev.ucp.shopping.checkout': [
{
version: UCP_VERSION,
spec: `${SPEC_BASE}/specification/checkout`,
schema: `${SPEC_BASE}/schemas/shopping/checkout.json`
}
],
'dev.ucp.shopping.fulfillment': [
{
version: UCP_VERSION,
spec: `${SPEC_BASE}/specification/fulfillment`,
schema: `${SPEC_BASE}/schemas/shopping/fulfillment.json`,
extends: 'dev.ucp.shopping.checkout'
}
],
'dev.ucp.common.identity_linking': [
{
version: UCP_VERSION,
spec: `${SPEC_BASE}/specification/identity-linking`,
schema: `${SPEC_BASE}/schemas/common/identity_linking.json`,
config: {
scopes: {
'dev.ucp.shopping.order:read': {},
'dev.ucp.shopping.order:manage': {}
}
}
}
]
},
// Payment handlers: which processors you support
// UCP is payment-agnostic via AP2
payment_handlers: {
'com.example.stripe': [
{
id: 'stripe',
version: UCP_VERSION,
spec: 'https://example.com/specs/payments/stripe',
schema: 'https://example.com/specs/payments/stripe.json',
available_instruments: [
{ type: 'card', display_name: 'Credit/Debit Card' }
]
}
]
}
}
});
});
# Start the server
node server.js
# In another terminal, fetch the profile
curl -s http://localhost:3000/.well-known/ucp | jq .ucp.version
# Expected: "2026-04-08"
# List the capabilities you declared
curl -s http://localhost:3000/.well-known/ucp | jq '.ucp.capabilities | keys'
# Expected: [
# "dev.ucp.common.identity_linking",
# "dev.ucp.shopping.checkout",
# "dev.ucp.shopping.fulfillment"
# ]
# Check available transports
curl -s http://localhost:3000/.well-known/ucp | \
jq '.ucp.services["dev.ucp.shopping"][] | .transport'
# Expected: ["rest"]
# Verify spec URLs match namespace authority
curl -s http://localhost:3000/.well-known/ucp | \
jq '.ucp.capabilities["dev.ucp.shopping.checkout"][0].spec'
# Expected: "https://ucp.dev/2026-04-08/specification/checkout"
# The origin (ucp.dev) matches the namespace (dev.ucp.*) ✓
Your store is now discoverable. Any UCP agent that knows your domain can fetch this profile and learn exactly what you support. No central registry, no API keys, no onboarding call. Just a file at a well-known path.
The spec and schema URLs MUST match the namespace authority. dev.ucp.* capabilities must point to https://ucp.dev/.... If you create a custom capability like com.yourstore.loyalty, its spec URL must be on https://yourstore.com/.... Platforms will reject capabilities where the spec origin does not match the namespace.
Now agents know your store exists, but they cannot see your products. The catalog endpoint lets agents search and retrieve product information. There are two scopes: the global catalog (search across all UCP merchants) and your storefront catalog (search within your store only). Here we implement the storefront catalog.
// ============================================
// CATALOG: Product search and detail
// ============================================
// Sample product catalog (in production, this comes from your DB)
const PRODUCTS = [
{
id: 'item_001',
title: 'Aurora Wireless Headphones',
description: 'Active noise cancellation, 30h battery, USB-C charging.',
price: 19999, // Minor units: $199.99
currency: 'USD',
seller: { domain: 'localhost:3000' },
variants: [
{ id: 'var_001_b', title: 'Black', price: 19999, in_stock: true },
{ id: 'var_001_w', title: 'White', price: 19999, in_stock: true },
{ id: 'var_001_g', title: 'Gold', price: 21999, in_stock: false }
]
},
{
id: 'item_002',
title: 'Nimbus Bluetooth Speaker',
description: 'Waterproof, 360-degree sound, 12h battery life.',
price: 7999, // $79.99
currency: 'USD',
seller: { domain: 'localhost:3000' },
variants: [
{ id: 'var_002_b', title: 'Black', price: 7999, in_stock: true }
]
},
{
id: 'item_003',
title: 'Pulse Smart Watch',
description: 'Heart rate, GPS, sleep tracking, 7-day battery.',
price: 24999, // $249.99
currency: 'USD',
seller: { domain: 'localhost:3000' },
variants: [
{ id: 'var_003_b', title: 'Black', price: 24999, in_stock: true },
{ id: 'var_003_s', title: 'Silver', price: 24999, in_stock: true }
]
}
];
// Catalog search endpoint
// Agent calls: GET /api/ucp/catalog?q=headphones&limit=10
app.get('/api/ucp/catalog', (req, res) => {
const q = (req.query.q || '').toLowerCase();
const limit = parseInt(req.query.limit) || 10;
let results = PRODUCTS;
// Simple search (in production, use your DB's search)
if (q) {
results = results.filter(p =>
p.title.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q)
);
}
results = results.slice(0, limit);
ucpResponse(res, {
results: results.map(p => ({
id: p.id,
title: p.title,
price: p.price,
currency: p.currency,
description: p.description,
seller: p.seller
})),
total: results.length,
has_more: false
});
});
// Product detail endpoint
// Agent calls: GET /api/ucp/products/item_001
app.get('/api/ucp/products/:id', (req, res) => {
const product = PRODUCTS.find(p => p.id === req.params.id);
if (!product) {
return ucpError(res, 'item_unavailable',
`Product ${req.params.id} not found`, 'unrecoverable');
}
ucpResponse(res, product);
});
All monetary amounts in UCP are in minor units (cents). 19999 means $199.99, not $19,999. This avoids floating-point issues in JSON. Never send dollar amounts as floats.
# Search for headphones
curl -s http://localhost:3000/api/ucp/catalog?q=headphones | jq .
# Expected:
{
"ucp": { "version": "2026-04-08", "status": "success" },
"results": [
{
"id": "item_001",
"title": "Aurora Wireless Headphones",
"price": 19999,
"currency": "USD",
"description": "Active noise cancellation, 30h battery...",
"seller": { "domain": "localhost:3000" }
}
],
"total": 1,
"has_more": false
}
# Get product detail with variants
curl -s http://localhost:3000/api/ucp/products/item_001 | jq .variants
# Expected: array of 3 variants (Black, White, Gold)
# Test error case: product not found
curl -s http://localhost:3000/api/ucp/products/item_999 | jq .ucp.status
# Expected: "error"
Agents build carts across multiple conversation turns. A buyer might say "add the headphones", then "also add the speaker", then "change the headphones to white". Your cart API needs to handle add, update, and remove operations while tracking line item IDs.
// ============================================
// CART: Create, update, retrieve
// ============================================
// In-memory cart store (use Redis or DB in production)
const carts = new Map();
function generateId(prefix) {
return prefix + '_' + Math.random().toString(36).slice(2, 12);
}
function calculateTotals(lineItems) {
const subtotal = lineItems.reduce((sum, li) => {
const product = PRODUCTS.find(p => p.id === li.item.id);
const variant = product?.variants.find(v => v.id === li.variant_id);
const unitPrice = variant?.price || product?.price || 0;
return sum + (unitPrice * li.quantity);
}, 0);
const tax = Math.round(subtotal * 0.08); // 8% tax
const total = subtotal + tax;
return [
{ type: 'subtotal', display_text: 'Subtotal', amount: subtotal },
{ type: 'tax', display_text: 'Estimated Tax', amount: tax },
{ type: 'total', display_text: 'Total', amount: total }
];
}
// Create cart
// Agent calls: POST /api/ucp/carts
app.post('/api/ucp/carts', (req, res) => {
const { line_items } = req.body;
if (!line_items || !Array.isArray(line_items) || line_items.length === 0) {
return ucpError(res, 'invalid_request',
'line_items array is required', 'recoverable');
}
const cartId = generateId('cart');
const items = line_items.map(li => {
const product = PRODUCTS.find(p => p.id === li.product_id);
if (!product) {
return null; // handle error below
}
return {
id: generateId('li'),
item: {
id: product.id,
title: product.title,
price: product.price
},
variant_id: li.variant_id || product.variants[0].id,
quantity: li.quantity || 1
};
}).filter(Boolean);
if (items.length === 0) {
return ucpError(res, 'item_unavailable',
'None of the requested products were found', 'recoverable');
}
const cart = {
id: cartId,
line_items: items,
totals: calculateTotals(items)
};
carts.set(cartId, cart);
ucpResponse(res, cart);
});
// Get cart
app.get('/api/ucp/carts/:id', (req, res) => {
const cart = carts.get(req.params.id);
if (!cart) {
return ucpError(res, 'not_found',
'Cart not found or expired', 'unrecoverable');
}
ucpResponse(res, cart);
});
// Update cart (full state replacement per UCP spec)
// Agent calls: PUT /api/ucp/carts/cart_abc123
app.put('/api/ucp/carts/:id', (req, res) => {
const cart = carts.get(req.params.id);
if (!cart) {
return ucpError(res, 'not_found',
'Cart not found or expired', 'unrecoverable');
}
// UCP uses full state replacement for PUT
// The agent sends the complete desired state
if (req.body.line_items) {
cart.line_items = req.body.line_items.map(li => ({
id: li.id || generateId('li'),
item: li.item,
variant_id: li.variant_id,
quantity: li.quantity
}));
cart.totals = calculateTotals(cart.line_items);
}
carts.set(req.params.id, cart);
ucpResponse(res, cart);
});
UCP uses PUT for full state replacement, not partial updates. The agent sends the complete desired cart state, not just the fields it wants to change. This simplifies the protocol: no PATCH semantics, no merge logic. Your server replaces the entire cart with what the agent sent.
# Create a cart with one item
curl -s -X POST http://localhost:3000/api/ucp/carts \
-H "Content-Type: application/json" \
-d '{
"line_items": [
{ "product_id": "item_001", "variant_id": "var_001_b", "quantity": 1 }
]
}' | jq .
# Expected:
{
"ucp": { "version": "2026-04-08", "status": "success" },
"id": "cart_a1b2c3d4e5f6",
"line_items": [
{
"id": "li_x1y2z3",
"item": { "id": "item_001", "title": "Aurora Wireless Headphones", "price": 19999 },
"variant_id": "var_001_b",
"quantity": 1
}
],
"totals": [
{ "type": "subtotal", "display_text": "Subtotal", "amount": 19999 },
{ "type": "tax", "display_text": "Estimated Tax", "amount": 1600 },
{ "type": "total", "display_text": "Total", "amount": 21599 }
]
}
# Update the cart: change quantity to 2
# Save the cart ID from above first
CART_ID="cart_a1b2c3d4e5f6" # replace with your actual ID
curl -s -X PUT http://localhost:3000/api/ucp/carts/$CART_ID \
-H "Content-Type: application/json" \
-d '{
"line_items": [
{ "id": "li_x1y2z3", "item": { "id": "item_001" }, "variant_id": "var_001_b", "quantity": 2 }
]
}' | jq .totals
# Expected totals now reflect quantity 2:
# subtotal: 39998, tax: 3200, total: 43198
The checkout is where UCP gets interesting. A cart becomes a checkout session, which moves through a defined status lifecycle: incomplete -> ready_for_complete -> completed. The agent fills in buyer information, fulfillment details, and payment instruments across multiple turns.
// ============================================
// CHECKOUT: Create, update, complete, cancel
// ============================================
const checkouts = new Map();
// Middleware: extract agent profile from UCP-Agent header
// Required for all checkout operations per UCP spec
function requireAgentProfile(req, res, next) {
const header = req.headers['ucp-agent'];
if (!header) {
return ucpError(res, 'missing_agent_profile',
'UCP-Agent header is required for checkout operations',
'recoverable');
}
// Parse: profile="https://agent.example/profile"
const match = header.match(/profile="([^"]+)"/);
req.agentProfile = match ? match[1] : null;
next();
}
// Create checkout session
// Agent calls: POST /api/ucp/checkout-sessions
app.post('/api/ucp/checkout-sessions',
requireAgentProfile, (req, res) => {
const { line_items, cart_id } = req.body;
// If cart_id provided, pull items from cart
let items = line_items;
if (cart_id) {
const cart = carts.get(cart_id);
if (!cart) {
return ucpError(res, 'not_found',
'Cart not found', 'unrecoverable');
}
items = cart.line_items;
}
if (!items || items.length === 0) {
return ucpError(res, 'invalid_request',
'line_items or cart_id is required', 'recoverable');
}
const checkoutId = generateId('chk');
const totals = calculateTotals(items);
const checkout = {
id: checkoutId,
status: 'incomplete',
currency: 'USD',
line_items: items,
totals: totals,
payment: null,
fulfillment: null,
buyer: null,
links: {
self: `/api/ucp/checkout-sessions/${checkoutId}`,
complete: `/api/ucp/checkout-sessions/${checkoutId}/complete`
}
};
checkouts.set(checkoutId, checkout);
ucpResponse(res, checkout);
});
// Get checkout status
app.get('/api/ucp/checkout-sessions/:id',
requireAgentProfile, (req, res) => {
const checkout = checkouts.get(req.params.id);
if (!checkout) {
return ucpError(res, 'not_found',
'Checkout session not found or expired', 'unrecoverable');
}
ucpResponse(res, checkout);
});
// Update checkout (full state replacement)
// Agent adds buyer info, fulfillment, payment instruments
app.put('/api/ucp/checkout-sessions/:id',
requireAgentProfile, (req, res) => {
const checkout = checkouts.get(req.params.id);
if (!checkout) {
return ucpError(res, 'not_found',
'Checkout session not found or expired', 'unrecoverable');
}
// Update buyer info
if (req.body.buyer) {
checkout.buyer = req.body.buyer;
}
// Update fulfillment (shipping, pickup, digital)
if (req.body.fulfillment) {
checkout.fulfillment = req.body.fulfillment;
}
// Update payment instrument (collected but not charged yet)
if (req.body.payment) {
checkout.payment = req.body.payment;
}
// Recalculate totals if fulfillment changed
if (req.body.fulfillment) {
const shippingCost = req.body.fulfillment.methods?.[0]?.groups?.[0]
?.options?.find(o => o.id === 'standard-shipping')?.totals?.[0]?.amount || 0;
const subtotal = checkout.totals.find(t => t.type === 'subtotal').amount;
const tax = checkout.totals.find(t => t.type === 'tax').amount;
const total = subtotal + tax + shippingCost;
checkout.totals = [
{ type: 'subtotal', display_text: 'Subtotal', amount: subtotal },
{ type: 'tax', display_text: 'Tax', amount: tax },
{ type: 'fulfillment', display_text: 'Shipping', amount: shippingCost },
{ type: 'total', display_text: 'Total', amount: total }
];
}
// Determine if checkout is ready to complete
// Required: buyer.email, fulfillment, payment instrument
const hasBuyer = checkout.buyer?.email;
const hasFulfillment = checkout.fulfillment?.methods?.length > 0;
const hasPayment = checkout.payment?.instruments?.length > 0;
if (hasBuyer && hasFulfillment && hasPayment) {
checkout.status = 'ready_for_complete';
}
checkouts.set(req.params.id, checkout);
ucpResponse(res, checkout);
});
// Complete checkout (submit payment, finalize order)
// Agent calls: POST /api/ucp/checkout-sessions/chk_xxx/complete
app.post('/api/ucp/checkout-sessions/:id/complete',
requireAgentProfile, (req, res) => {
const checkout = checkouts.get(req.params.id);
if (!checkout) {
return ucpError(res, 'not_found',
'Checkout session not found or expired', 'unrecoverable');
}
if (checkout.status !== 'ready_for_complete') {
return ucpError(res, 'incomplete_checkout',
'Checkout is not ready for completion. Add buyer info, fulfillment, and payment.',
'recoverable');
}
// In production: submit payment to your payment handler
// (Stripe, Adyen, etc.) via AP2
// Here we simulate two outcomes:
const requires3DS = Math.random() > 0.5; // simulate 3DS challenge
if (requires3DS) {
// Escalation: buyer needs to verify payment in browser
checkout.status = 'requires_escalation';
ucpResponse(res, {
...checkout,
messages: [{
type: 'info',
code: '3ds_required',
content: 'Payment verification required. Please complete in your browser.',
severity: 'requires_buyer_input'
}],
continue_url: `http://localhost:3000/checkout/${checkout.id}/verify`
});
} else {
// Success: order created
checkout.status = 'completed';
const orderId = generateId('order');
ucpResponse(res, {
...checkout,
order: {
id: orderId,
label: `Order ${orderId.slice(-6).toUpperCase()}`,
permalink_url: `http://localhost:3000/orders/${orderId}`
}
});
}
});
// Cancel checkout
app.delete('/api/ucp/checkout-sessions/:id',
requireAgentProfile, (req, res) => {
const checkout = checkouts.get(req.params.id);
if (!checkout) {
return ucpError(res, 'not_found',
'Checkout session not found', 'unrecoverable');
}
checkout.status = 'canceled';
checkouts.set(req.params.id, checkout);
ucpResponse(res, { id: checkout.id, status: 'canceled' });
});
# 1. Create a checkout from cart items
# (requires UCP-Agent header!)
curl -s -X POST http://localhost:3000/api/ucp/checkout-sessions \
-H "Content-Type: application/json" \
-H 'UCP-Agent: profile="https://my-agent.example/profile"' \
-d '{
"line_items": [
{ "item": { "id": "item_001", "title": "Aurora Wireless Headphones", "price": 19999 },
"variant_id": "var_001_b", "quantity": 1 }
]
}' | jq .status
# Expected: "incomplete"
# Save the checkout ID
CHK_ID="chk_xxx" # replace with your actual ID
# 2. Add buyer info and fulfillment (updates to ready_for_complete)
curl -s -X PUT http://localhost:3000/api/ucp/checkout-sessions/$CHK_ID \
-H "Content-Type: application/json" \
-H 'UCP-Agent: profile="https://my-agent.example/profile"' \
-d '{
"buyer": {
"email": "jane@example.com",
"first_name": "Jane",
"last_name": "Doe"
},
"fulfillment": {
"methods": [{
"id": "method_1",
"type": "shipping",
"line_item_ids": ["li_1"],
"selected_destination_id": "dest_1",
"destinations": [{
"id": "dest_1",
"first_name": "Jane",
"last_name": "Doe",
"street_address": "123 Main St",
"address_locality": "Austin",
"address_region": "TX",
"postal_code": "78701",
"address_country": "US"
}],
"groups": [{
"id": "group_1",
"line_item_ids": ["li_1"],
"selected_option_id": "standard-shipping",
"options": [{
"id": "standard-shipping",
"title": "Standard Shipping",
"totals": [{ "type": "total", "amount": 599 }]
}]
}]
}]
},
"payment": {
"instruments": [
{ "type": "card", "display_name": "Visa ending 4242" }
]
}
}' | jq .status
# Expected: "ready_for_complete"
# 3. Complete the checkout
curl -s -X POST http://localhost:3000/api/ucp/checkout-sessions/$CHK_ID/complete \
-H "Content-Type: application/json" \
-H 'UCP-Agent: profile="https://my-agent.example/profile"' \
-d '{}' | jq .status
# Expected: "completed" or "requires_escalation"
# 4. If requires_escalation, the response includes continue_url
# The agent presents this URL to the buyer for browser verification
# Then polls the checkout status to resume
# 5. Test missing agent profile (should fail)
curl -s -X POST http://localhost:3000/api/ucp/checkout-sessions \
-H "Content-Type: application/json" \
-d '{"line_items": [{"item": {"id": "item_001"}}]}' | jq .ucp.status
# Expected: "error"
You now have a working UCP implementation. An AI agent can discover your store, search your catalog, build a cart, create a checkout, add buyer and fulfillment info, and complete the purchase. This is the core of what UCP does.
The checkout status field is how your store communicates with the agent about what happens next. The agent reads the status and decides what action to take. Here is the full lifecycle:
| Status | Meaning | Agent Action |
|---|---|---|
incomplete |
Missing required info (buyer, fulfillment, payment) | Inspect messages, fix via Update Checkout, retry |
requires_escalation |
Human input needed (3DS, address selection, final-sale confirmation) | Present continue_url to buyer, poll for completion |
ready_for_complete |
All required info present. Ready to finalize. | Call Complete Checkout endpoint |
complete_in_progress |
Payment processor is handling the transaction | Wait and poll checkout status |
completed |
Order placed successfully. Payment processed. | Confirm to buyer, provide order tracking info |
canceled |
Session expired or was cancelled | Start a new checkout if buyer still wants to purchase |
The messages array in responses uses severity levels to guide agent behavior:
recoverable - Agent can fix this by modifying inputs via API (e.g. reformat phone number) and calling Update Checkoutrequires_buyer_input - Agent must hand off to buyer via continue_url (e.g. 3DS verification)requires_buyer_review - Buyer must review and authorize (e.g. high-value order confirmation)unrecoverable - No resource exists to act on. Start fresh or hand off.Running on localhost is fine for testing, but real UCP agents need to reach your store over HTTPS. Here is how to deploy:
In your discovery profile, change the endpoint from localhost to your real domain:
// In the /.well-known/ucp handler:
endpoint: `https://your-store.com/api/ucp`, // was localhost:3000
// Also update seller domain in product data:
seller: { domain: 'your-store.com' }
# nginx config for your-store.com
server {
listen 443 ssl;
server_name your-store.com;
ssl_certificate /etc/letsencrypt/live/your-store.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-store.com/privkey.pem;
# Serve the .well-known/ucp file directly (fastest)
location = /.well-known/ucp {
proxy_pass http://127.0.0.1:3000/.well-known/ucp;
proxy_set_header Host $host;
}
# Proxy all UCP API calls to the Express server
location /api/ucp/ {
proxy_pass http://127.0.0.1:3000/api/ucp/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# HTTP -> HTTPS redirect
server {
listen 80;
server_name your-store.com;
return 301 https://$host$request_uri;
}
npm install -g pm2
pm2 start server.js --name ucp-store
pm2 save
pm2 startup # follow the instructions to auto-start on boot
# From outside your server, test the real URL
curl -s https://your-store.com/.well-known/ucp | jq .ucp.version
# Expected: "2026-04-08"
# Test catalog search
curl -s https://your-store.com/api/ucp/catalog?q=headphones | jq .results[0].title
# Expected: "Aurora Wireless Headphones"
Before you ship, verify every item on this list:
| # | Check | How to verify |
|---|---|---|
| 1 | Discovery profile returns 200 with valid JSON | curl -s https://your-store.com/.well-known/ucp | jq .ucp.version |
| 2 | CORS headers present on all endpoints | curl -I -X OPTIONS https://your-store.com/.well-known/ucp |
| 3 | UCP-Agent in allowed headers | Check Access-Control-Allow-Headers includes it |
| 4 | Spec URLs match namespace authority | dev.ucp.* capabilities point to ucp.dev |
| 5 | Catalog search returns results | curl -s https://your-store.com/api/ucp/catalog?q=test | jq .total |
| 6 | Product detail returns variants | curl -s https://your-store.com/api/ucp/products/item_001 | jq .variants |
| 7 | Cart creation works | POST to /api/ucp/carts with line items |
| 8 | Checkout requires agent profile | POST without UCP-Agent header returns error |
| 9 | Checkout lifecycle transitions correctly | incomplete → ready_for_complete → completed |
| 10 | Escalation returns continue_url | When 3DS or buyer input needed, response has continue_url |
| 11 | All amounts in minor units (cents) | No decimal prices in any response |
| 12 | HTTPS enforced, HTTP redirects | curl -I http://your-store.com returns 301 |
You now have a working UCP implementation. Here is where to go from here:
Replace the simulated payment with Stripe, Adyen, or your processor of choice via AP2. Handle real card tokens, 3DS flows, and payment mandates.
Read checkout spec →Implement the order endpoint for post-purchase: fulfillment events, tracking, returns, and refunds. Set up webhooks so agents can monitor order lifecycle.
Read capabilities →Now that your store is UCP-enabled, build an AI agent that shops. Use the UCP CLI or build against the REST API directly. Point the agent at gateway.fast for fast inference.
Agent guide →Expose the same endpoints as an MCP server so Claude, Cursor, and other MCP-compatible agents can use your store. Same business logic, different transport.
MCP guide →The #1 issue we see. Agents run on different origins. Without Access-Control-Allow-Origin and UCP-Agent in allowed headers, every request fails silently. Test with curl -I -X OPTIONS before deploying.
UCP uses minor units everywhere. 199.99 is wrong. 19999 is correct. If you send floats, agents may misinterpret your prices by 100x.
All checkout operations require the agent to identify itself. If you do not check for the UCP-Agent header, you are not compliant with the spec. Agents will get errors from compliant merchants.
A dev.ucp.* capability with a spec URL not on ucp.dev will be rejected by platforms. The namespace authority and spec origin must match.
Escalation is a normal part of UCP, not an error. If your checkout returns requires_escalation with a continue_url, the agent will hand off to the buyer. Make sure your continue_url actually works.