What Is UCP and How Do You Implement It in 2026?
UCP gives shopping agents a signed, cacheable description of your catalog, cart and checkout at /.well-known/ucp. This page walks through the profile document, the hosting rules that keep it discoverable, and the RFC 9421 verification an incoming agent request has to pass.
UCP (Universal Commerce Protocol) is an open standard that lets a shopping agent discover what a business sells and transact with it without a prior integration. A business publishes one JSON profile at /.well-known/ucp declaring its protocol version, service endpoints, capabilities (catalog, cart, checkout, order) and public signing keys; an agent fetches that profile, negotiates the overlap with its own, and calls the declared endpoints over REST, MCP, A2A or an embedded channel. It exists for merchants and commerce platforms that want agent traffic to reach a working checkout instead of a scraped product page.
Why UCP matters for AI visibility
An agent asked to buy something has to answer two questions before it can act: can I transact here, and how. Without a profile it has to guess from HTML, which is why most agent shopping journeys stop at a product page. A UCP profile turns that into one cached fetch: the document lists the transports and capabilities on offer, and the same document publishes the public keys used to verify signed requests. The spec calls the result permissionless onboarding, because any platform with a discoverable profile can start transacting with any business without a pre-agreed API key.
The profile is also the piece that vendors actually read. Google's Merchant UCP documentation and Shopify's agent platform docs both treat /.well-known/ucp as the entry point, and Shopify storefronts already serve one automatically. UCP stays deliberately narrow: payment credentials are delegated to payment handlers and AP2 mandates, transport is delegated to MCP, A2A or the embedded protocol, and request authentication is RFC 9421 HTTP Message Signatures. What UCP owns is the vocabulary in between, plus the discovery document that ties it together.
Where the spec lives
UCP ships as dated snapshots, so read the version you actually declare rather than a general landing page. These are the documents worth keeping open while you build.
- UCP specification overview (latest) - the normative document: discovery, profile structure, namespace authority binding, capability negotiation and the identity resolution algorithm. Start here.
- Message Signatures (2026-08-25) - the RFC 9421 binding: supported algorithms, JWK shapes, covered components, signature encoding and key rotation. Read before you write any signing code.
- Schema reference (2026-08-25) - field-by-field tables, including the Business Discovery Profile that defines exactly which top-level members are required.
- Universal-Commerce-Protocol/ucp - the spec repository. Long-lived
release/YYYY-MM-DDbranches show what changed between snapshots and what was backported. - ucp-schema CLI - the reference Rust tool that composes capability schemas, resolves the request or response direction, and validates a payload against them.
- RFC 9421: HTTP Message Signatures - the IETF document behind the signature base construction, the
Signature-Inputgrammar and the raw r||s ECDSA encoding rule.
Three ways to implement UCP
The three pieces below are the ones that actually block agent traffic, in the order you should ship them. The first is the profile document itself, written as a static file, which is enough for a read-only catalog. The second is the hosting layer: the cache headers and version pinning that decide whether a platform will trust and cache your profile at all. The third is the runtime side, verifying signed requests before anything mutates a cart, which you need the moment you declare cart, checkout or order.
Publish a read-only catalog profile as a static file
Start here if you sell through your own storefront and want agents to search and look up products without you building a checkout API. Catalog search and catalog lookup are independent capabilities, so declaring them commits you to nothing else. Note that payment_handlers is required on a business profile even when it is empty, while capabilities is technically optional.
{
"ucp": {
"version": "2026-08-25",
"services": {
"dev.ucp.shopping": [
{
"version": "2026-08-25",
"spec": "https://ucp.dev/2026-08-25/specification/overview/",
"transport": "mcp",
"endpoint": "https://shop.example.com/ucp/mcp",
"schema": "https://ucp.dev/2026-08-25/services/shopping/mcp.openrpc.json"
}
]
},
"capabilities": {
"dev.ucp.shopping.catalog.search": [
{
"version": "2026-08-25",
"spec": "https://ucp.dev/2026-08-25/specification/shopping/catalog/search",
"schema": "https://ucp.dev/2026-08-25/schemas/shopping/catalog_search.json"
}
],
"dev.ucp.shopping.catalog.lookup": [
{
"version": "2026-08-25",
"spec": "https://ucp.dev/2026-08-25/specification/shopping/catalog/lookup",
"schema": "https://ucp.dev/2026-08-25/schemas/shopping/catalog_lookup.json"
}
]
},
"payment_handlers": {}
},
"keys": [
{
"kid": "shop-2026-08",
"kty": "EC",
"crv": "P-256",
"x": "WKn-ZIGevcwGIyyrzFoZNBdaq9_TsqzGl96oc0CWuis",
"y": "y77t-RvAHRKTsSGdIYUfweuOvwrvDD-Q3Hv5J0fSKbE",
"use": "sig",
"alg": "ES256"
}
]
}
What this does: an agent fetches this one document, sees it can call your MCP endpoint for search_catalog, lookup_catalog and get_product, and gets the ES256 public key it needs to verify anything you sign back. Every entry is an array because a business can offer the same capability at more than one version during a migration.
Serve the profile with spec-compliant headers and version pins
This is where most first attempts fail. The spec requires the profile to be served over HTTPS with Cache-Control: public and a max-age of at least 60 seconds, and it forbids redirects on the profile path: a platform must not follow a 3xx when fetching a profile, so a routine apex-to-www or trailing-slash rewrite silently kills discovery. Older snapshots you still support get their own self-contained documents, referenced from supported_versions.
server {
listen 443 ssl;
server_name shop.example.com www.shop.example.com;
# Exact-match locations are evaluated before the prefix location below,
# so a profile fetch never reaches the canonical-host redirect.
location = /.well-known/ucp {
alias /srv/ucp/2026-08-25.json;
default_type application/json;
add_header Cache-Control "public, max-age=300, stale-while-revalidate=60" always;
}
# Self-contained profiles for the versions listed in supported_versions.
location ~ "^/\.well-known/ucp/(2026-04-08|2026-01-23)$" {
alias /srv/ucp/$1.json;
default_type application/json;
add_header Cache-Control "public, max-age=300" always;
}
location / {
if ($host != "shop.example.com") {
return 301 https://shop.example.com$request_uri;
}
proxy_pass http://storefront;
}
}
What this does: it guarantees every platform gets a 200 with a shared-cacheable body and an ETag on the first hop, which is what lets them cache your profile instead of refetching it per request. The version-pinned locations let an agent that has only implemented an older snapshot keep transacting while you move the default forward.
Verify RFC 9421 request signatures before touching a cart
Once you declare cart, checkout or order, unauthenticated writes are a real problem. UCP resolves the caller's key from the profile URL in its UCP-Agent header, matches the keyid from Signature-Input against a kid in that profile's keys[], and rejects any signature that does not cover the request target and body. This is the minimum honest version in Node with no dependencies.
import { webcrypto } from 'node:crypto';
const cache = new Map(); // profile URL -> { keys, expires }
async function keysFor(profileUrl) {
const hit = cache.get(profileUrl);
if (hit && hit.expires > Date.now()) return hit.keys;
const url = new URL(profileUrl);
if (url.protocol !== 'https:') throw new Error('profile must be https');
const res = await fetch(url, { redirect: 'error' }); // spec: never follow a 3xx
if (!res.ok) throw new Error('profile fetch failed: ' + res.status);
const keys = (await res.json()).keys ?? []; // 2026-08-25 JWK Set
cache.set(profileUrl, { keys, expires: Date.now() + 60000 });
return keys;
}
function signatureBase(req, input) {
const params = input.slice(input.indexOf('(')); // ("@method" ...);keyid="..."
const url = new URL(req.url, 'https://' + req.headers.host);
const derived = { '@method': req.method, '@authority': url.host,
'@path': url.pathname, '@query': url.search };
const lines = params.slice(1, params.indexOf(')')).split(' ').map((item) => {
const n = item.slice(1, -1);
return `${item}: ${derived[n] ?? String(req.headers[n] ?? '').trim()}`;
});
return lines.concat(`"@signature-params": ${params}`).join('\n');
}
export async function verifyUcpRequest(req, body) {
const profile = /profile="([^"]+)"/.exec(req.headers['ucp-agent'] ?? '')?.[1];
const input = req.headers['signature-input'] ?? '';
const keyid = /keyid="([^"]+)"/.exec(input)?.[1];
if (!profile || !keyid) throw new Error('missing UCP-Agent or keyid');
const covered = input.slice(input.indexOf('(') + 1, input.indexOf(')'));
const need = ['"@method"', '"@authority"', '"@path"', '"ucp-agent"'];
if (body?.length) need.push('"content-digest"', '"content-type"');
for (const c of need) if (!covered.includes(c)) throw new Error('uncovered: ' + c);
const jwk = (await keysFor(profile)).find((k) => k.kid === keyid && k.use !== 'enc');
if (!jwk) throw new Error('key_not_found');
const key = await webcrypto.subtle.importKey(
'jwk', jwk, { name: 'ECDSA', namedCurve: jwk.crv }, false, ['verify']);
const sig = Buffer.from(/:(.+):/.exec(req.headers.signature)[1], 'base64'); // raw r||s
const ok = await webcrypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' },
key, sig, Buffer.from(signatureBase(req, input)));
if (!ok) throw new Error('signature verification failed');
return profile; // authenticated platform
}
What this does: it turns an anonymous POST into a named platform identity you can rate limit, log and authorise against, and it refuses signatures that leave the method, path, UCP-Agent header or body outside the covered set, which is the attack the spec's covered-component rule exists to stop. Verify the Content-Digest against the raw body bytes separately before you parse the JSON.
Implementation guidelines
These are the things that break in production rather than in the example.
- Pin one exact version, then link the rest.
ucp.versionis a single date inYYYY-MM-DDform, never a range. The current snapshot is2026-08-25;2026-04-08,2026-01-23and2026-01-11remain published, and Google's merchant documentation and most live Shopify storefronts still declare2026-04-08. Map every older date you still support to its own complete profile URI undersupported_versions. - Match every schema URL to its namespace. Authority binding requires the reversed labels of the
schemahost to be an exact match for, or a label-aligned prefix of, the entity name, sodev.ucp.shopping.checkoutcan be served fromucp.devorshopping.ucp.devbut not from a shared CDN hostname. Vendor capabilities belong under your own reverse domain: thedev.ucp.*namespace is reserved. - Treat the profile as a cache entry, not a session. Every platform is expected to cache it with a floor of 60 seconds, so per-agent, per-transaction or per-session values in the profile will be served to the wrong caller. Keep it stable, keep it under a few kilobytes, and give it an
ETagorLast-Modifiedso revalidation is cheap. - Rotate keys by adding before removing. Publish the new JWK in
keys[], start signing with it, keep accepting the old one for at least seven days, then delete the old entry. A compromised key keeps verifying until it is absent from the array, so removal is the revocation, not the announcement. - Encode ECDSA signatures as raw r||s. RFC 9421 section 3.3.1 requires fixed-width concatenation, 64 bytes for P-256 and 96 for P-384. OpenSSL, Java and .NET emit ASN.1 DER by default, and a DER signature fails verification with no useful error, which is the single most common interop bug in this stack.
- Never let an intermediary re-serialise the body.
Content-Digesthashes the raw bytes per RFC 9530, so an API gateway that pretty-prints or reorders JSON invalidates every signature passing through it. Check your WAF, your ingress and any body-rewriting middleware before you blame the client. - Use idempotency keys for replay protection, not timestamps. UCP handles replay at the business layer: state-changing requests carry an
Idempotency-Keywith at least 128 bits of entropy inside the signed component set, stored for 24 hours minimum. A repeat with the same key and the same body digest returns the cached response; a repeat with a different body is a 409.
Do this, not that
Do
- Key
services,capabilitiesandpayment_handlersby reverse-domain name, with an array of entries as each value. - Include
payment_handlerseven when it is an empty object: it is required on a business profile, whereascapabilitiesis optional. - Publish at least one ES256 key (
kty: EC,crv: P-256) inkeys[], since it is the one algorithm every UCP verifier must support. - Point each
supported_versionsentry at a complete, self-contained profile for that date, not at a diff or a partial document.
Do not
- Do not redirect
/.well-known/ucp. An apex-to-www or trailing-slash 3xx makes discovery fail outright, because platforms are forbidden from following it. - Do not serve the profile with
Cache-Control: private,no-storeorno-cache, or with amax-agebelow 60 seconds. - Do not declare a capability you cannot serve. Negotiation intersects both parties' declarations, so an advertised checkout that 404s becomes a live error rather than a quiet no-op.
- Do not leave a retired key in
keys[]after the grace period, and do not point a verifier at a key markeduse: "enc".
How Glippy checks this
Glippy fetches https://yourdomain/.well-known/ucp as a domain-level check and scores the result under Agent Interactivity, category 10 of the agent accessibility checker. A profile counts as valid when it carries both ucp.version and ucp.services, and the capability count is reported alongside it. Glippy then inspects the response headers for Content-Type: application/json and a Cache-Control value that is public, has max-age of at least 60 and avoids private, no-store and no-cache; it checks each declared signing key for kid, kty: EC, crv: P-256, x and y; it looks for the catalog search, lookup and get_product sub-capabilities; it warns when a declared cart has no embedded or mcp transport binding; and it checks a declared dev.ucp.shopping.order capability for an HTTPS config.webhook_url. When the profile advertises an a2a transport, Glippy also probes /.well-known/agent-card.json. One caveat worth knowing: Glippy currently treats 2026-04-08 as its latest known version and reads that release's signing_keys[] field name, so a profile already migrated to the 2026-08-25 top-level keys[] array will show the signing-key check as a warning rather than a pass.
Check your UCP setup
Glippy runs 240+ checks across 16 categories on any page, including Agent Interactivity (category 10). No sign-up required.
Frequently asked questions
UCP uses date-based versions and the current stable snapshot is 2026-08-25, published at ucp.dev/latest. The earlier snapshots 2026-04-08, 2026-01-23 and 2026-01-11 are still available, and adoption lags the spec: Google's merchant documentation and live Shopify storefronts currently declare 2026-04-08. Declare the newest version your endpoints actually implement, and list the older ones under supported_versions with a self-contained profile for each.
It depends on the version the profile declares. In UCP 2026-08-25 the canonical location is a top-level keys[] array, which is a valid RFC 7517 JWK Set and can therefore double as a Web Bot Auth key source. The earlier 2026-04-08 release called the same array signing_keys[]. If you publish version-pinned profiles for both, use the field name that each version defines rather than trying to satisfy both in one document.
No. MCP is one of four transport bindings a UCP service can declare, alongside rest, a2a and embedded. UCP defines the commerce vocabulary, the capability schemas and the discovery document; MCP is one way of carrying those calls, mapped to JSON-RPC tools such as search_catalog and get_product. A business can advertise several transports for the same service in one profile, and the agent picks whichever it supports.
No. Each capability is adopted independently, and ucp.capabilities is an optional member of the profile. A read-only profile that declares only dev.ucp.shopping.catalog.search and dev.ucp.shopping.catalog.lookup is valid and useful, because it lets agents query live prices and availability instead of scraping. Add cart, checkout and order later, and only once the corresponding endpoints and signature verification are actually live.
Reviewed against the primary sources on . These standards move quickly, so check the linked specs before you ship.