Complete worked tutorial. Real Node.js code you can copy, run, and test. Verified against UCP spec version 2026-04-08.

Your First UCP Profile in 15 Minutes

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.

What you will build

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.

Prerequisites

Node.js 18+, curl, jq

Time

15 minutes

Spec version

2026-04-08

Transport

REST (OpenAPI 3.x)

Step 1: Project Setup

1

Create the project

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.

2

Create the server file

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}`);
});
Why CORS matters

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.

Step 2: The Discovery Profile

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.

1

Add the discovery endpoint to server.js

// ============================================
// 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' }
            ]
          }
        ]
      }
    }
  });
});
2

Test the discovery profile

# 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.*) ✓
Discovery works

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.

Namespace binding rule

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.

Step 3: Catalog Search (Product Discovery)

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.

1

Add sample product data and the catalog endpoint

// ============================================
// 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);
});
Amounts in minor units

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.

2

Test the catalog

# 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"

Step 4: Cart Management

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.

1

Add cart endpoints

// ============================================
// 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);
});
PUT is full state replacement

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.

2

Test the cart flow

# 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

Step 5: Checkout Sessions

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.

1

Add checkout endpoints

// ============================================
// 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' });
});
2

Test the full checkout flow

# 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"
Full flow works

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.

Understanding the Checkout Status Lifecycle

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:

incomplete
ready_for_complete
complete_in_progress
completed
StatusMeaningAgent 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
Error severity matters

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 Checkout
  • requires_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.

Step 6: Deploy to Production

Running on localhost is fine for testing, but real UCP agents need to reach your store over HTTPS. Here is how to deploy:

1

Update the endpoint URL

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' }
2

Set up HTTPS and nginx reverse proxy

# 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;
}
3

Run the server with PM2

npm install -g pm2
pm2 start server.js --name ucp-store
pm2 save
pm2 startup  # follow the instructions to auto-start on boot
4

Verify production deployment

# 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"

Validation Checklist

Before you ship, verify every item on this list:

#CheckHow to verify
1Discovery profile returns 200 with valid JSONcurl -s https://your-store.com/.well-known/ucp | jq .ucp.version
2CORS headers present on all endpointscurl -I -X OPTIONS https://your-store.com/.well-known/ucp
3UCP-Agent in allowed headersCheck Access-Control-Allow-Headers includes it
4Spec URLs match namespace authoritydev.ucp.* capabilities point to ucp.dev
5Catalog search returns resultscurl -s https://your-store.com/api/ucp/catalog?q=test | jq .total
6Product detail returns variantscurl -s https://your-store.com/api/ucp/products/item_001 | jq .variants
7Cart creation worksPOST to /api/ucp/carts with line items
8Checkout requires agent profilePOST without UCP-Agent header returns error
9Checkout lifecycle transitions correctlyincomplete → ready_for_complete → completed
10Escalation returns continue_urlWhen 3DS or buyer input needed, response has continue_url
11All amounts in minor units (cents)No decimal prices in any response
12HTTPS enforced, HTTP redirectscurl -I http://your-store.com returns 301

What's Next

You now have a working UCP implementation. Here is where to go from here:

🔌

Connect a real payment processor

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 →
📦

Add order management

Implement the order endpoint for post-purchase: fulfillment events, tracking, returns, and refunds. Set up webhooks so agents can monitor order lifecycle.

Read capabilities →
🤖

Build the agent side

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 →
🏗️

Add MCP transport

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 →

Common Mistakes to Avoid

1. Forgetting CORS

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.

2. Using dollar amounts instead of cents

UCP uses minor units everywhere. 199.99 is wrong. 19999 is correct. If you send floats, agents may misinterpret your prices by 100x.

3. Missing UCP-Agent header on checkout

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.

4. Spec URL / namespace mismatch

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.

5. Not handling escalation

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.

Next: Platform Guides → Read the Spec Overview