Google cart transfer (UCP)
How Brainerce accepts a shopper's cart from Google's Universal Cart. What a sales channel exposes, the profile your storefront must serve at /.well-known/ucp, the keys, the request and response shapes, the limits, and which behaviours are still unconfirmed with Google.
Google's Universal Cart (in Search and Gemini) can hand a shopper's cart to a store. The protocol is UCP, the Universal Commerce Protocol, and the part Brainerce supports is cart transfer: Google sends the items and quantities in one server-to-server call, Brainerce builds a guest cart and answers with a continue_url, and Google sends the shopper to that address. The shopper lands on your storefront's /cart page with the items already in it and pays through your normal checkout.
What this is not. Payment never happens inside Google or Gemini. No order is created by Google. Brainerce does not implement UCP's native checkout, identity linking or loyalty capabilities, and it does not implement reading, updating or cancelling a cart: only creating one. Turning this on does not make your products appear in Google: that depends on Google approving the store and on the product feed Merchant Center already has.
This page is the reference for developers. Merchants read Google cart transfer in the help centre.
What is still unconfirmed with Google. The four behaviours below are Brainerce's own reading of the UCP specification, built as single constants so they can be changed in one line. Each was written down before Google's Merchant Center sandbox had been run against it. This page describes what Brainerce does today. It does not say Google accepts it.
Behaviour today What is unconfirmed The one-time recovery code travels in the URL fragment of continue_url(#brainerce_recover=...)The specification never says whether a fragment is allowed in continue_url.A line Brainerce drops from an otherwise good cart is reported as a message of type: "warning"The specification's warning object has no severity, while Google's own error-code page lists the same codes as type: "error"with a severity.The profile declares payment_handlers: {}(present, empty)The specification requires the key even for a merchant with none, and its own cart example leaves it out. Brainerce does not fetch the profile named in the UCP-AgentheaderThe specification says a business must fetch and validate it. Brainerce records only the host. The caller's identity comes from its key. This page will be updated when the sandbox result is in.
How it fits together
- The merchant turns cart transfer on for a sales channel and creates a key (sandbox first, then production).
- The merchant adds the integration in Merchant Center and gives Google the key by email.
- Google reads the profile from the storefront's own domain:
https://{domain}/.well-known/ucp. The profile names Brainerce's cart endpoint. - For each shopper, Google calls the endpoint with
POST. Brainerce answers with a cart and acontinue_url. - The shopper's browser opens
continue_urlon the storefront, the SDK adopts the cart, and checkout continues as for any guest cart.
Serve the profile at /.well-known/ucp
This is a storefront route. It is a template file, not part of the SDK. Upgrading the brainerce package does not add it to an existing store: a store created before the route existed needs the file copied in, and a store built by hand needs to write it. npx create-brainerce-store includes it in new stores once the template that carries it is released.
The SDK part is only the type: StoreInfo.ucp and UcpProfile (from brainerce 3.13.0). They are optional in the types, so older SDK versions keep compiling.
// app/.well-known/ucp/route.ts
import { getServerClient } from '@/core/lib/brainerce.server';
export const revalidate = 300;
export async function GET() {
let info: unknown;
try {
const client = await getServerClient();
info = await client.getStoreInfo();
} catch {
// "Could not ask" must not look like "UCP is off".
return new Response(null, { status: 503, headers: { 'Retry-After': '30' } });
}
// Read through a local shape so a store pinned to an older SDK still compiles.
const profile = (info as { ucp?: { profile?: unknown } } | null)?.ucp?.profile;
if (!profile || typeof profile !== 'object') return new Response(null, { status: 404 });
return new Response(JSON.stringify(profile), {
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'public, max-age=300',
},
});
}Rules for this route:
- Serve the profile verbatim. Do not edit, merge or add to it. Brainerce builds it, because a storefront is a copy that never updates itself: the endpoint host, the protocol version and the declared capabilities must stay changeable on the server. Google requires the declared capabilities to match exactly what the merchant selected in Merchant Center.
- 404 while it is absent.
getStoreInfo().ucpis present only when cart transfer is switched on for the channel, the channel has a domain, and the server's own public address is configured. Absent means 404, never an empty profile. - 503, not 404, when you cannot ask. A backend that cannot be reached is a different answer from "UCP is off".
application/json, HTTPS, andCache-Control: publicwithmax-ageof at least 60 seconds.- No redirect. Google refuses a 3xx on this URL. A www to apex redirect in front of the store breaks it, so both hostnames must answer directly.
- Keep it out of any page's HTML. Do not put it through the projection that is serialised into every page (
pickPublicStoreInfoin the template). Only this route needs it. - Multi-locale stores: a dotted path like this one must live at the app root, never inside
[locale]/, as withindexnow-key.txtandllms.txt. See SEO & Discoverability.
What the profile looks like (for reading, not for building):
{
"ucp": {
"version": "2026-04-08",
"services": {
"dev.ucp.shopping": [
{
"version": "2026-04-08",
"spec": "https://ucp.dev/2026-04-08/specification/overview",
"transport": "rest",
"endpoint": "https://api.brainerce.com/api/ucp/vc_xxxxxxxxxxxxxxxx",
"schema": "https://ucp.dev/2026-04-08/services/shopping/rest.openapi.json"
}
]
},
"capabilities": {
"dev.ucp.shopping.cart": [
{
"version": "2026-04-08",
"spec": "https://ucp.dev/2026-04-08/specification/cart",
"schema": "https://ucp.dev/2026-04-08/schemas/shopping/cart.json"
}
]
},
"payment_handlers": {}
}
}It declares one capability, the cart. The host comes from the server's configuration, never from the request.
The cart endpoint
POST https://api.brainerce.com/api/ucp/{connectionId}/carts
X-API-Key: ucp_...connectionId is the channel's public vc_* id. Only POST exists on this path: GET, PUT, PATCH and DELETE on /carts, and every method on /carts/{id}, answer 405. There is no way to read a cart back through this surface.
This endpoint is not part of the public API contract and is deliberately left out of the generated API reference. It is documented here.
Keys
- The header is
X-API-Key, and the key starts withucp_. It is not abrainerce_store API key and never works as one. - A key belongs to one channel and one environment,
sandboxorproduction. A sandbox key works on any channel where cart transfer is on. A production key works only while the channel is in LIVE mode (403 channel_not_liveotherwise). - Keys are created and replaced by the merchant in the dashboard and shown once. Only a keyed hash is stored. A replaced key keeps working for 24 hours, because Google swaps keys by hand over email.
- There is no API, MCP tool or assistant action that creates, replaces or returns a key. A secret does not cross a conversation.
- A missing or wrong key, an unknown channel, a channel with cart transfer off and a disabled channel all answer the same
401, so nothing tells a caller which channels exist.
Request
{
"line_items": [{ "item": { "id": "var_cm0abc123" }, "quantity": 2 }],
"context": { "address_country": "US" }
}The body is read leniently: unknown fields are ignored (the specification requires that), and buyer, signals and attribution are not read at all, so no personal data is stored or logged. Headers read when present: Idempotency-Key, Request-Id (echoed back) and UCP-Agent (reduced to a host for the log, never fetched).
item.id is resolved in the two forms Brainerce publishes to Google:
| Form | Where Google got it |
|---|---|
prod_<productId> and var_<variantId> | The Google connector (Content API) |
sku, falling back to the id (for a variant, its own sku or id) | The XML product feed |
An id resolves only if it names exactly one purchasable unit published to this channel. An id that matches nothing, belongs to another store, is not published to the channel (or is switched off, "draft", for this channel in the dashboard), or is ambiguous (two units share it) is rejected with the same message, so the answer cannot be used to probe the catalog. A variable product's parent id is not purchasable: its variants are.
Response
A cart, as a 201:
{
"ucp": {
"version": "2026-04-08",
"status": "success",
"capabilities": { "dev.ucp.shopping.cart": [{ "version": "2026-04-08" }] }
},
"id": "cm0cart...",
"line_items": [
/* item, quantity, totals per line */
],
"currency": "USD",
"totals": [
{ "type": "subtotal", "amount": 3998 },
{
"type": "total",
"amount": 3998,
"display_text": "Estimated total (taxes and shipping calculated at checkout)"
}
],
"continue_url": "https://shop.example/cart?brainerce_cart=cm0cart...&utm_source=google_ucp&utm_medium=universal_cart#brainerce_recover=...",
"expires_at": "2026-10-20T10:00:00.000Z"
}- Amounts are integers in ISO 4217 minor units (cents for USD, whole yen for JPY, thousandths for KWD).
currencyis the store's currency. A cart is always quoted in the store's base currency; showing a regional currency is not built. - Totals are estimates. Tax and shipping are decided at checkout, and the total says so.
- Partial success. Lines that can be sold go into the cart. A line that cannot is left out and reported in
messages, with apathsuch as$.line_items[2], and the response stays a201. Today these are sent astype: "warning"(see the unconfirmed behaviours above). A line is never silently reduced: if fewer units are in stock than asked for, the line is rejected withquantity_invalid_limit_exceededand the available number in the text. - Nothing can be sold. HTTP
200,ucp.status: "error", noid, and acontinue_urlthat is the store's home page. Google requires acontinue_urleven then. - Codes used (all codes Google publishes):
item_unavailable,item_ineligible(a product that needs options chosen, or a kit, or a variable product with no variant named),out_of_stock,quantity_invalid_limit_exceeded,quantity_invalid_minimum_not_met.
Protocol errors use the specification's { "code": "...", "content": "..." } shape and a real HTTP status:
| Status | code | When |
|---|---|---|
| 400 | invalid_request | Body is not an object, or line_items is empty, missing or over 100 entries |
| 401 | unauthorized | Key missing or wrong, channel unknown, switched off or disabled |
| 403 | channel_not_live, store_suspended | A production key on a channel not in LIVE mode, or a suspended store |
| 409 | idempotency_key_reused, idempotency_in_flight | Same Idempotency-Key with a different body, or a duplicate still being processed |
| 429 | rate_limited, cart_cap_reached | Too many calls, or the channel's hourly cart limit |
| 503 | service_unavailable | The cart could not be built right now, or the channel has no storefront domain |
| 405 | method_not_allowed | Any method other than POST on /carts |
Idempotency
Send an Idempotency-Key. The same key with the same body returns the same cart for 24 hours (header Idempotent-Replayed: true). The same key with a different body is a 409. A replay gets a fresh one-time recovery code only while the cart is still an untouched guest cart; once a shopper has taken it over, the replayed continue_url carries no code, so a replay never opens a new way into a cart someone has put an address in. The header is optional: without it, each call makes a new cart.
Limits
| Limit | Value |
|---|---|
| Distinct lines in a cart | 50 (more are reported as item_unavailable) |
| Lines read from one request | 100 |
| Quantity of one item | 100 |
| Calls per key | 300 per minute |
| Carts per channel | 2000 per hour |
| Cart lifetime | 24 hours (1 hour for a sandbox key) |
Brainerce never reserves stock for these carts, whatever the channel's reservation strategy, so holding a key cannot lock inventory. Carts nobody picked up are deleted hourly (only those created through Google, only expired ones, and never one that has a customer or a checkout).
The continue_url
https://{domain}/cart?brainerce_cart=<cartId>&utm_source=google_ucp&utm_medium=universal_cart#brainerce_recover=<one-time code>- The shopper lands on
/cart./checkoutis not supported as a landing page. - The SDK adopts the cart in the browser: it reads
brainerce_cartfrom the query and the one-time code from the fragment, and removes both from the address bar (theutm_*parameters stay and are forwarded to the order). A fragment is never sent to a server, so the code stays out of logs andReferer. The code is single-use, tied to this store and this channel, and lives as long as the cart. - This is the same recovery mechanism as an abandoned-checkout link, so it needs a storefront on an SDK version that supports recovery links, in sales-channel mode (a storefront connected by
storeIdhas no recover route). - If the code is gone (it was used, or expired), the shopper lands with
brainerce_cart=<id>only. The SDK then tries to load that cart by id, which can leave the shopper on an empty cart. Treat the code as the reliable path. - Only the fragment form is read by the SDK today. If Google turns out to drop a fragment from
continue_url, the code moves to the query string and the SDK has to read it from there. That is the unconfirmed behaviour at the top of this page. utm_source=google_ucp&utm_medium=universal_cartrides on the URL, and the SDK already forwardsutm_*parameters to the order. Brainerce does not yet classify these orders as AI-assisted traffic in its analytics.
Readiness checks
The dashboard (Apps > Google, on the Readiness tab and in the cart transfer card) runs these against a real channel. They look at the real storefront and a real test cart rather than trusting settings.
| Check | Blocking | What it looks at |
|---|---|---|
ucp_domain_set | yes | The channel has a storefront domain |
ucp_keys_issued | yes | A sandbox key exists |
ucp_channel_live | warning | A production key needs LIVE mode |
ucp_api_address | yes | The server address in the profile is https and public (not localhost or a private network) |
ucp_profile_reachable | yes | https://{domain}/.well-known/ucp answers 200, with no redirect. A 404 reports whether the server is withholding the profile (switched off, no domain) or the storefront simply lacks the route |
ucp_profile_valid | yes | JSON, application/json, public Cache-Control of 60 seconds or more, payment_handlers present, and an endpoint on the server's own host |
ucp_capabilities_match | yes | The served profile is the one Brainerce builds. What the merchant ticked in Merchant Center cannot be seen from here |
ucp_self_test_cart | yes | Runs the real pipeline on up to three published products, then deletes the cart |
ucp_currency_matches_feed | yes | The currency Google sees (feed or connector) equals the store currency |
ucp_id_parity | warning | Both id forms resolve to the same product |
ucp_price_parity | warning | The cart price equals the price in the feed (a channel price override is the usual difference) |
ucp_quote_mode | info | Shows the quote mode in force (base) |
A passing run proves the Brainerce side. It never proves that Google accepts the answers.
Using the Brainerce MCP servers
The admin MCP exposes the status read-only (get_google_cart_transfer_status): whether it is on, the endpoint, the profile address, the key prefixes and when each was last used. It cannot create, replace or return a key, and it does not run the readiness checks above (those fetch the live storefront and build a test cart, so they stay behind the dashboard's Run checks button).
Storefront MCP
A public, per-channel MCP endpoint that lets AI shopping agents search and read a store's live catalog with structured JSON-RPC calls instead of crawling HTML.
Merchant Integration
Receive real-time POSTs from your Brainerce store when orders, customers, inventory, or payments change, resend events that were missed, and shape the request for a third-party API.