Skip to content

Powered by Grav CMS + Helios Theme

UCP checkout

KahunaCart speaks the Universal Commerce Protocol (UCP) version 2026-04-08. An agent platform such as Google AI Mode or Gemini can build a checkout on your store, show the buyer your own totals, and pay with Google Pay without the buyer leaving the conversation. This page is for switching it on, and for developers integrating a platform or debugging one.

A buyer an agent cannot pay for natively is never stuck. Every open checkout carries a continue_url that drops the cart into your own checkout, where every payment provider you run works as usual.

The same checkout code serves two transports: REST under {base}/ucp, and the storefront MCP endpoint at {base}/mcp. Everything on this page about statuses, messages, totals and idempotency holds for both.

Before you begin

  • agentic.enabled on. See Agentic commerce.
  • The site served over HTTPS at the root of its host. A Grav installed in a subdirectory cannot answer /.well-known/ucp.
  • policies.terms_url and policies.privacy_url set, because Google requires both links on every checkout.
  • The cart switched on, because the continue_url handoff and every payment handler need your checkout.
  • The job worker running, for signing keys, expiry and order webhooks.
  • For native payment, the Stripe provider 1.2.4 or later. Without it, every checkout hands off through the continue_url.

Switch it on

  1. Set ucp.enabled: true.
  2. Make a signing key with bin/plugin kahunacart agentic:keys, or let the worker make one on its next tick.
  3. Open the Agentic commerce screen in the store admin, or run bin/plugin kahunacart check, and fix whatever is listed.

/.well-known/ucp answers 404 until every readiness problem is fixed: HTTPS at the host root, the terms and privacy links, a signing key, the cart, and, with ucp.accept_unknown_platforms off, at least one host in ucp.platforms. Once it answers, the store is ready for a platform.

Key Default What it does
ucp.enabled false Serves /.well-known/ucp and {base}/ucp. Off, both are 404.
ucp.session_ttl 21600 Seconds a checkout or cart session stays open (the spec's six hours). At least 300.
ucp.accept_unknown_platforms true Any platform whose profile passes the fetch guards may transact. Off, only the hosts in ucp.platforms are served and every other platform gets 403.
ucp.platforms [] Profile hosts always accepted, such as google.com. A host matches itself and its subdomains.
ucp.order_webhooks true Lists the order capability in the profile, sends order webhooks and serves GET {base}/ucp/orders/{id}.
ucp.google.api_key '' The X-API-Key configured in Merchant Center. Stored encrypted. When set, a request from a google.com profile must carry it.
ucp.google.order_webhook_url '' The order webhook URL Google issues at onboarding. Stored encrypted. Used for a google.com platform whose profile names no webhook URL of its own.
ucp.google_pay.merchant_id '' Your Google Pay and Wallet Console merchant id. Required in PRODUCTION: without it no Google Pay handler is advertised.
ucp.google_pay.environment TEST TEST or PRODUCTION.
ucp.google_pay.card_networks [AMEX, DISCOVER, JCB, MASTERCARD, VISA] The networks the Google Pay sheet offers.
ucp.google_pay.auth_methods [PAN_ONLY, CRYPTOGRAM_3DS] The authentication methods the sheet accepts.

Google Pay with Stripe

Google Pay is the payment handler Google's agent checkout uses. The platform shows the Google Pay sheet, Google encrypts the card for your payment gateway, and your provider charges the token it is handed. Stripe is the first provider that takes it. PayPal's Google Pay support is planned for a later phase.

  1. Install and configure the Stripe provider with its publishable and secret keys. Leave Stripe Managed Payments off: it runs through Checkout Sessions and cannot take a wallet token.
  2. In the Google Pay and Wallet Console, create or find your merchant id and put it in ucp.google_pay.merchant_id.
  3. Leave ucp.google_pay.environment on TEST while you try it. In TEST, Google Pay with the stripe gateway hands back Stripe test-mode tokens, so a store in Stripe test mode can run the whole flow without a real card.
  4. Check /.well-known/ucp. payment_handlers["com.google.pay"] should hold one entry with the id kc_stripe_gpay and a tokenization_specification naming the gateway stripe and your publishable key. Both are public by design.
  5. When Google approves the integration, set ucp.google_pay.environment to PRODUCTION and put Stripe in live mode. The doctor warns while the environment is TEST and Stripe is live.

Every checkout lists only the handlers of providers the cart may use, filtered to the handlers the platform negotiated. The instrument's billing address becomes the order's billing address.

The profile

GET /.well-known/ucp at the site root is the store's business profile: the REST and MCP service endpoints, the capabilities, the payment handlers and the public signing keys. It is built from configuration only, carries Cache-Control: public, max-age=3600, starts no session and sets no cookie. The 404 a store that is not ready answers is no-store, so it is not cached past the moment the store becomes ready.

The REST endpoint is https://your-store.example/shop/ucp: the storefront route with no language prefix, so reorganising languages never moves it. The MCP service is listed only while storefront_mcp.enabled is on, and the order capability only while ucp.order_webhooks is.

Capabilities, all at 2026-04-08: dev.ucp.shopping.checkout, dev.ucp.shopping.fulfillment, dev.ucp.shopping.discount, dev.ucp.shopping.cart, dev.ucp.shopping.order, dev.ucp.shopping.catalog.search and dev.ucp.shopping.catalog.lookup.

How a platform is trusted

Every UCP request names the platform's profile: the REST header UCP-Agent: profile="https://platform.example/.well-known/ucp", or meta["ucp-agent"].profile over MCP. The store fetches that profile, over HTTPS only, with no redirects, public addresses only, short timeouts and a 64 KB cap. A good profile is cached for its Cache-Control max-age, between 60 seconds and a day.

  • Negotiation. The platform's ucp.version must be 2026-04-08. A capability is agreed when both sides list it, and the payment handlers are the store's, filtered to the ones the platform also lists.
  • X-API-Key. With ucp.google.api_key set, a request from google.com or a subdomain must carry it, else 401.
  • Signatures. A request carrying Signature and Signature-Input must verify (RFC 9421, ES256 or ES384) against the signing_keys in the platform's profile. An unsigned request is served, because the spec makes signing a SHOULD.
  • Authenticated means a valid API key or a verified signature. Only an authenticated request may read an order, and it is rate limited per platform rather than per address.
  • No identity linking. OAuth bearer tokens are ignored.

UCP permits open checkout, and so does this store while ucp.accept_unknown_platforms is on. That is no more open than your public checkout form. To transact only with platforms you name, turn it off and list them in ucp.platforms.

REST routes

Method Path Operation
POST {base}/ucp/checkout-sessions Create a checkout (201)
GET, PUT {base}/ucp/checkout-sessions/{id} Get, update
POST {base}/ucp/checkout-sessions/{id}/complete Complete; Idempotency-Key required
POST {base}/ucp/checkout-sessions/{id}/cancel Cancel; Idempotency-Key required
POST {base}/ucp/carts Create a cart (201)
GET, PUT {base}/ucp/carts/{id} Get, update a cart
POST {base}/ucp/carts/{id}/cancel Cancel a cart; Idempotency-Key required
POST {base}/ucp/catalog/search, /catalog/lookup, /catalog/product The catalog, through the same code as the MCP catalog tools
GET {base}/ucp/orders/{id} The order, for an authenticated platform
GET {base}/ucp/continue/{id}?t=… The buyer's handoff to your checkout; not a UCP operation

Writes are application/json, at most 256 KB and 32 levels deep. UCP-Agent is required on everything but /continue. Every answer is Cache-Control: no-store, and no Grav session is started. Protocol errors are {"code": …, "content": …, "continue_url": …} with a 4xx or 5xx status. Business outcomes are 200, or 201 on create, with the UCP envelope. Requests count against the agent_read, agent_write and agent_complete buckets.

While the store is coming soon or paused, creating or updating a checkout or a cart and completing a checkout answer 503 store_closed, with Retry-After: 3600, the store's message as content and the storefront as continue_url. Getting and cancelling still work, and the catalog routes keep answering. A completion for a purchase already made still gets its answer: a retry under the same Idempotency-Key replays the stored one, a checkout already completed says so, and a payment challenge the buyer started before the pause is followed through to the finished checkout. See Configuration → Store status.

A card number anywhere in a request is refused with 400 card_data_refused. The one exception is the Google Pay credential in a completion, which is taken out before the check runs and handed to the provider.

Checkouts and carts

A UCP checkout is an ordinary KahunaCart cart with its own token. Every write goes through the same cart, calculator and checkout code the storefront uses, so an agent cannot reach a state the checkout form could not. An open session creates no customer record: a customer is made only when the checkout completes.

  • Ids. A checkout is chk_ and a cart cart_, each followed by 26 characters from 128 random bits. The id is bound to the platform that created it. Another platform's id, an unknown id and an expired cart all answer the same not_found.
  • Lines. A request line is {item: {id}, quantity}: 1 to 100 lines, quantity 1 to 999, item.id a variant id from the catalog or the feeds. Nothing a request says about a price is read.
  • A set whose parts the shopper picks. A line naming a grouped product sold as a set, or a kit, with a part whose variant the shopper picks, needs parts next to item and quantity. It's an object of kit item id to item id, such as {"item": {"id": "EASEL-KIT"}, "quantity": 1, "parts": {"812": "EASEL-BLUE"}}. The item id can be any form item.id takes, or a variant id. This is KahunaCart's own addition to the line, not part of UCP, so a platform that doesn't send it gets the line left out with an item_unavailable message saying Pick which "Easel" you want. An agent only learns the kit item ids from the products API, because the feeds don't carry them.
  • What a checkout accepts. Only variants an agent may check out. See Agent checkout. A cart takes any purchasable variant, because it hands off to your checkout.
  • Updates replace what they name. line_items, when sent, is the whole basket, and discounts.codes the whole code set. A member left out keeps what the session had.
  • Complete. Exactly one Google Pay instrument from a handler this checkout offered. The session moves to complete_in_progress before the charge, so of two concurrent completions one charges and the other gets 409. Success answers status: "completed" with the order's id, number and signed receipt link, and the confirmation email goes out as for any order. A declined payment returns the session to ready_for_complete with a payment_failed message, and so does a card that would need a 3-D Secure challenge.
  • Cancel releases the stock holds. The cart row stays as an abandoned cart, which cart recovery never mails.
  • Completed and canceled checkouts do not change. A later update or completion answers the stored checkout with a session_closed warning.
  • Expiry. A session lives ucp.session_ttl seconds. After that an open checkout answers as canceled and an open cart as not_found, and the worker releases its stock.
  • A buyer who finishes in the browser. When the order behind a session completes by any road, your own checkout included, the session is marked completed and the platform still gets its order webhooks.

Shipping and collection. fulfillment offers shipping methods for a line that ships. A partial destination (country, region, postcode) is enough for quotes. Pickup is also offered when the store has eligible collection points. A free point is free in every currency; a charged point needs an amount in the checkout’s currency. A single eligible option is chosen for the buyer.

Discounts. discounts.codes takes at most 20 codes. applied lists each coupon and sale on the order. A code that does not apply is a warning with your store's own sentence.

Links. Every checkout carries terms_of_service and privacy_policy, plus refund_policy, shipping_policy and faq when they are set. With checkout.require_terms on, completing through a platform that showed these links counts as accepting the terms, and the order records terms_accepted_via: ucp.

Statuses

Status When
incomplete Something the platform can fix is missing or wrong: the buyer's email, a full shipping address, a shipping option, an unavailable line.
requires_escalation Nothing the platform can fix, but something needs the buyer: a checkout field UCP cannot carry, a plugin's refusal, or no payment handler this platform can use. continue_url is always there.
ready_for_complete The email, every line available, an address and an option when something ships, and a payment handler both sides share.
complete_in_progress Set just before the charge.
completed, canceled Final. Nothing changes them.

A plain MCP client with no UCP profile negotiates no payment handler, so its checkouts stop at requires_escalation with a payment_handoff message and the buyer pays on your checkout.

Messages

Every refusal your store already makes is said as a UCP message, the same way over REST and MCP, in plain text.

Situation Message
No email, or not an email error missing or invalid_email at $.buyer.email
No full shipping address error missing at $.fulfillment.methods[0].destinations[0]
An address the store refuses error invalid_address
No shipping method for the address error address_undeliverable
More than one option and none chosen, or an option not offered error missing or invalid_option
An unknown or ineligible item error item_unavailable
A set with a part the shopper picks and no pick for it error item_unavailable at $.line_items[i], saying Pick which "Easel" you want.
Out of stock error out_of_stock
A quantity or order limit error quantity_limit, or a warning quantity_adjusted when the store lowered it
A members-only product, or pay what you want error item_unavailable
A variant with no price in the checkout's currency (a store that sells in more than one currency) error item_unavailable at $.line_items[i], recoverable, saying it is not available in that currency
An address that moves the checkout to another currency (currencies.lock_to_country) error currency_changed at $.fulfillment.methods[0].destinations[0], requires_buyer_review
A required checkout field or add-on field error checkout_field, needs the buyer
A plugin's refusal on onKahunaCartCheckoutValidate (context agent) error checkout_refused, needs the buyer, with the plugin's sentence
No payment handler shared with the platform error payment_handoff, needs the buyer
A bad instrument or credential error invalid_instrument or missing
The provider declined error payment_failed, with the provider's reason
A code that does not apply, or sent twice warning discount_code_invalid or discount_code_already_applied
A write to a completed or canceled session warning session_closed
A cancel of a completed or canceled session error not_cancelable
An unknown, expired or other platform's id error not_found
No capability in common error capabilities_incompatible
Anything else the store refuses error checkout_error, with the store's sentence

Listeners on onKahunaCartCheckoutValidate see context: agent for these orders, so a plugin can tell an agent from the checkout form.

Totals

totals[] is built from the order row, never from the request: one subtotal, one discount per code or sale (negative), one fulfillment per shipping method, one tax when tax is not inside the prices, a fee for a payment method fee, and one total. Add-on surcharges, price lists and quantity ladders are already in the line prices. Amounts are integers in minor units, so 2800 is $28.00. Each line also carries its own subtotal, items_discount and total.

Idempotency

Idempotency-Key (the REST header, or meta["idempotency-key"] over MCP) is required on complete and cancel for a UCP platform, and honoured on create and update. A key is 16 to 128 letters, digits, _ and -. A repeat with the same key and body replays the stored answer with Idempotent-Replayed: true. The same key with a different body is 409 idempotency_conflict. A store that cannot read its stored answers refuses the request with 503 rather than run it unguarded.

Only the response is stored, never the request, because a completion carries a payment token. Answers are kept 48 hours. The key also goes to the payment provider, so a completion retried after a lost answer cannot charge twice.

Currencies

A store that sells in more than one currency makes each UCP cart and checkout in one of them, and the resource says which in its currency. A store with one currency makes everything in it and reads none of this, and its answers are the bytes they were.

Which currency a new cart or checkout is in, first match wins:

  1. The currency currencies.map gives the buyer's country: context.address_country, else the country of the first destination in fulfillment.
  2. The context.currency the request names, when the store sells in it. The code is three letters in any case.
  3. The store's own currency.

A context.currency the store does not sell in is not an error: the resource is made in the store's own currency and says so in its currency. A cart made from the same request through the storefront MCP is in the same currency, because both read the same rule.

It stays there. A later update does not move a cart or checkout because it sends another context.currency or another country; the resource keeps the currency it was made in, and every amount in it (the line prices, totals, the shipping options, the completed order) is in that currency. A buyer who wants another currency starts a new checkout.

One exception: currencies.lock_to_country. The shipping country decides for delivery; the billing country decides for downloads or collection. An update or completion can supply that country, including the billing address first supplied by the payment instrument. If the map requires another currency, checkout reprices the lines before charging and drops unavailable lines and choices. It returns requires_escalation with currency_changed, so the buyer reviews the new totals through continue_url before paying. A refused move leaves the checkout as it was and returns a checkout_error.

A line with no price in the currency is left out when the cart or checkout is created, with an item_unavailable message at $.line_items[i] saying the item is not available in that currency. When no line is left the answer is the error response and no session is created. A product that has a price in some currencies is bought in those only; there is no conversion and no fallback to the store's own price.

Collection points. In a currency other than the store's own, a pickup location is priced from its charge in that currency, and a location with no charge is free there. A location whose charge is set in the store's currency alone is not offered in the others until it has a charge there.

Amounts. Every amount is an integer in the minor units of the resource's currency, so 2600 is 26.00 EUR and 3900 is 3900 JPY: a yen total has no decimals. totals, a line's totals, an option's total and the price of an item are all in it, and the order and its webhooks carry the same currency.

The continue_url handoff

Every open checkout and cart carries a continue_url: https://your-store.example/{lang}/shop/ucp/continue/{id}?t={token}. The token is signed for that session only and expires with it.

Following it puts the session's cart into the buyer's browser and redirects to your checkout, in the language the session started in, where the buyer finishes with any provider you run. A completed session goes to its receipt and a canceled one to the shop. A forged, lapsed or unknown link is a plain 404. The request counts against the cart_restore bucket.

A cart an agent started and a buyer finished still reports as an agent sale: the order keeps the agent attribution it was created with. See Analytics → Where an order came from.

Signing keys

The store signs its order webhooks with ES256 keys, kept encrypted with the site secret. bin/plugin kahunacart agentic:keys makes one, and agentic:keys --rotate or Rotate signing key on the Agentic commerce screen adds a new key and retires the old one. The worker makes one when UCP is on and none exists. A key id is kc- plus the day it was made, such as kc-2026-09-22. The profile publishes the public keys in signing_keys, and a retired key stays there for seven days so platforms can still verify webhooks signed just before a rotation. See CLI → agentic:keys.

Orders and webhooks

A platform that sold through your store hears about the order afterwards. The store sends it signed order webhooks as the order moves, and the platform can read the order whenever it likes. Both carry the full UCP order entity as it stands, never a delta.

Which orders. Only orders placed through a UCP session with a platform profile, including a cart the buyer took through the continue_url and paid for in the browser. An order from the storefront, or from a plain MCP client, sends nothing. The webhook goes to the URL the platform's profile names, or for a google.com platform that names none, to ucp.google.order_webhook_url.

What triggers one.

Event When
onKahunaCartOrderCompleted The order is placed, by the agent or by the buyer in the browser
onKahunaCartOrderPaid The money lands on an order that was waiting for it
onKahunaCartShipmentCreated A shipment is recorded
onKahunaCartShipmentDelivered A shipment is marked delivered
onKahunaCartRefundApplied A refund is applied
onKahunaCartOrderReversed The order is canceled
onKahunaCartOrderChangeConfirmed An order change is confirmed

Mark delivered. Google's order lifecycle expects a delivered event. Press Mark delivered on the shipment when the parcel arrives, or let a carrier add-on that tracks parcels do it. See Mark a shipment delivered.

The body carries the order's id (the order hash, the same order.id the completed checkout answered), label (#1042), checkout_id, permalink_url (the signed receipt), every line the order ever had with its original, current and fulfilled quantities, fulfillment expectations and shipped and delivered events with tracking, adjustments for refunds and the cancellation, and currency and totals. Every timestamp is RFC 3339 in UTC. The body is the order as it is when the job runs, so a webhook that goes out late still tells the platform the latest state.

Headers. Each webhook is a POST with Content-Type: application/json, the store's own UCP-Agent profile, a Webhook-Id (a UUID made once per event, the same on every retry), a Webhook-Timestamp, and an RFC 9421 signature (Content-Digest, Signature-Input and Signature, ES256 with the current signing key). The platform verifies it against the signing_keys in /.well-known/ucp.

Important

Check the Webhook-Timestamp format during Merchant Center onboarding. KahunaCart sends it as unix seconds, which is what the UCP OpenAPI at 2026-04-08 declares. Google's order-lifecycle guide shows an ISO 8601 string in its prose.

Retries and ordering. The platform has 10 seconds to answer. Anything other than a 2xx is a failed attempt, retried with exponential backoff (a minute, then doubling, at most an hour apart) up to 8 attempts. Google refuses an update older than the last one it recorded, so the events for one order go out in the order they were queued, and an event whose order already had a newer one delivered is dropped.

Reading an order. GET {base}/ucp/orders/{id} answers the same entity a webhook sends. {id} is the order hash, never the numeric id, so orders cannot be walked. The caller must be authenticated, and an order that does not exist, is still a cart, or was placed through another platform's session answers the same not_found.