UCP Payments
Accept card payments from Universal Commerce Protocol platforms on your own domain, without card data reaching your systems.AI agents and other platforms that speak the Universal Commerce Protocol (UCP) can check out on your store, but they need somewhere to send the buyer's card. UCP's Processor-Tokenizer payment handler answers that: the platform sends the card to a tokenize endpoint you advertise, receives a token, and completes the checkout with the token. When you charge, the token resolves back to the card inside the processor's secure environment, so no detokenize endpoint is needed.
Basis Theory can be that handler. A Pre-Configured Proxy on your own hostname receives the platform's tokenize request and creates a short-lived card token. When the platform completes the checkout, you verify the token belongs to that checkout and charge it through the Basis Theory Proxy, which replaces the token with the card on its way to your processor. The card never reaches your servers.
This guide targets UCP release 2026-08-25.
Getting Started
To get started, you will need to create a Basis Theory Account and a TEST Tenant.
Provisioning Resources
You create two applications. Neither is shared with platforms: each platform authenticates to your tokenize endpoint with a credential you issue in Create the Tokenize Proxy.
A key with proxy:invoke can use any token in your tenant through the Proxy and send the card data to any HTTPS destination. A platform that holds one could read every card your tenant stores, not only the ones it tokenized.
Management Application
Your team uses a Management Application to create and inspect the tokenize proxy.
You will need a Management Application to provision resources. Click here to create one using the Basis Theory Customer Portal.
This will create an application with the following Access Controls:
- Permissions:
proxy:create,proxy:read,proxy:update
key from the created Management Application as it will be used later in this guide.Private Application
Your backend uses a Private Application to verify each token's binding, charge it, and delete it after the charge.
You will need a Private Application to allow your backend to call Basis Theory APIs. Click here to create one using the Basis Theory Customer Portal.
This will create an application with the following Access Controls:
- Permissions:
token:read,token:delete,proxy:invoke
key from the created Private Application as it will be used later in this guide.Create the Tokenize Proxy
The tokenize proxy is the UCP /tokenize endpoint. Its request transform runs on the Node.js runtime, creates a card token from the platform's request, and responds directly with the UCP tokenize response, so the proxy never forwards the request to its destination_url.
Save the transform as tokenize.js:
const crypto = require("crypto");
const { BasisTheoryClient } = require("@basis-theory/node-sdk");
const TOKEN_TTL_MS = 15 * 60 * 1000;
const reply = (statusCode, body) => ({
res: { statusCode, headers: { "Content-Type": "application/json" }, body },
});
const error = (statusCode, code, message) =>
reply(statusCode, { error: { code, message } });
const digest = (value) => crypto.createHash("sha256").update(value).digest();
// Returns the platform ID whose key matches the bearer credential, or null.
function authenticate(authorization, platformKeys) {
const match = /^Bearer (.+)$/.exec(authorization || "");
if (!match) return null;
const presented = digest(match[1]);
for (const [platformId, key] of Object.entries(platformKeys)) {
if (crypto.timingSafeEqual(presented, digest(key))) return platformId;
}
return null;
}
// Header names keep the casing the caller sent.
const getHeader = (headers, name) =>
Object.entries(headers || {}).find(([key]) => key.toLowerCase() === name)?.[1];
const isNonEmptyString = (value) => typeof value === "string" && value.length > 0;
module.exports = async function (event) {
const { req, configuration, applicationOptions } = event;
if (req.method !== "POST" || req.path !== "/tokenize") {
return error(404, "not_found", "Use POST /tokenize.");
}
const platformId = authenticate(
getHeader(req.headers, "authorization"),
JSON.parse(configuration.PLATFORM_KEYS)
);
if (!platformId) {
return error(401, "unauthorized", "Missing or invalid platform credential.");
}
const { credential, binding, identity } = req.body || {};
if (!credential || credential.type !== "pan" || !isNonEmptyString(credential.number)) {
return error(400, "invalid_credential", "credential must be a pan credential with a number.");
}
if (!binding || !isNonEmptyString(binding.type) || !isNonEmptyString(binding.id)) {
return error(400, "invalid_binding", "binding.type and binding.id must be non-empty strings.");
}
// The participant the token is issued to: identity when present, otherwise the caller.
const participant = isNonEmptyString(identity?.access_token)
? identity.access_token
: platformId;
// Token metadata values are limited to 200 characters.
if ([binding.type, binding.id, participant].some((value) => value.length > 200)) {
return error(400, "invalid_binding", "binding and identity values must be at most 200 characters.");
}
const client = new BasisTheoryClient({
apiKey: applicationOptions.apiKey,
baseUrl: applicationOptions.baseUrl,
});
try {
const token = await client.tokens.create({
id: crypto.randomBytes(32).toString("hex"),
type: "card",
data: {
number: credential.number,
expiration_month: credential.expiry_month,
expiration_year: credential.expiry_year,
cvc: credential.cvc,
},
expiresAt: new Date(Date.now() + TOKEN_TTL_MS).toISOString(),
deduplicateToken: false,
metadata: {
ucp_binding_type: binding.type,
ucp_binding_id: binding.id,
ucp_participant: participant,
},
});
return reply(200, { token: token.id });
} catch (err) {
if (err.statusCode === 400) {
return error(400, "invalid_credential", "The card credential failed validation.");
}
throw err;
}
};
The transform does the following for each request:
- Authenticates the platform. It compares the
Authorization: Bearercredential with the keys in the proxy'sPLATFORM_KEYSconfiguration and rejects unknown callers with401. UCP leaves authentication to the handler, andidentityis never accepted as a credential. - Accepts
pancredentials only. The deprecatedcardcredential type andnetwork_tokencredentials are rejected with400. - Validates the binding the way UCP requires.
binding.typeandbinding.idmust be non-empty strings. Theidis opaque, binding types it does not recognize are accepted, and extra members are ignored. - Records the participant. The token is issued to
identity.access_tokenwhen the platform sends one, and to the authenticated platform otherwise. - Creates an unguessable, short-lived token. The token ID is 256 random bits, above UCP's 128-bit minimum. The token expires after 15 minutes, and
deduplicateToken: falsegives every checkout its own token even if your tenant deduplicates tokens. - Stores the binding with the token. The binding type and ID and the participant go in the token's
metadata, where you check them before charging. - Uses the current card field names. UCP's
expiry_monthandexpiry_yearbecome the card token'sexpiration_monthandexpiration_year. The SDK sendsdataas written, so these keys must be snake case.
Generate a random key for each platform you onboard, for example with openssl rand -hex 32, and give it to that platform over a secure channel. Then create the proxy with the Create Proxy API. This example uses jq to embed tokenize.js in the request:
curl 'https://api.test.basistheory.com/proxies' \
-X POST \
-H 'BT-API-KEY: <MANAGEMENT_API_KEY>' \
-H 'Content-Type: application/json' \
--data "$(jq -n \
--rawfile code tokenize.js \
--arg platform_keys '{"platform_a": "<PLATFORM_A_KEY>"}' \
'{
name: "UCP Tokenize",
destination_url: "https://echo.basistheory.com/anything",
require_auth: false,
disable_detokenization: true,
configuration: { PLATFORM_KEYS: $platform_keys },
request_transforms: [{
type: "code",
code: $code,
options: {
runtime: {
image: "node24",
dependencies: { "@basis-theory/node-sdk": "6.3.0" },
permissions: ["token:create"]
}
}
}]
}')"
{
"id": "47afce63-6a16-43c6-aae4-dfa8bce40069",
"key": "e29a50980ca5",
"tenant_id": "77cb0024-123c-4c7f-8f7d-0c1b3f3f6f2a",
"name": "UCP Tokenize",
"destination_url": "https://echo.basistheory.com/anything",
"require_auth": false,
"disable_detokenization": true,
"state": "creating",
"created_at": "2026-10-01T18:22:33.029707+00:00"
}
Save the proxy id and key. A few settings in this request matter for security:
require_auth: falselets platforms call the proxy without a Basis Theory API key. The transform authenticates them instead.disable_detokenization: truestops Basis Theory from resolving token expressions in requests to this proxy, so a caller cannot use it to read existing tokens.runtime.permissionsgrants the transformtoken:createand nothing else.destination_urlis required, but the transform always responds directly, so it is never called.
Basis Theory installs the transform's dependencies before the proxy can be invoked. Poll the Get a Proxy API until state is active, which takes a few minutes:
curl 'https://api.test.basistheory.com/proxies/<PROXY_ID>' \
-H 'BT-API-KEY: <MANAGEMENT_API_KEY>'
{
"id": "47afce63-6a16-43c6-aae4-dfa8bce40069",
"name": "UCP Tokenize",
"state": "active"
}
To add or rotate a platform key later, update configuration with the Patch Proxy API.
Test the Tokenize Endpoint
Send the request a platform sends, with a test card. Until your hostname is attached, call the proxy through https://api.test.basistheory.com/proxy/tokenize and select it with the BT-PROXY-KEY header:
curl 'https://api.test.basistheory.com/proxy/tokenize' \
-X POST \
-H 'BT-PROXY-KEY: <PROXY_KEY>' \
-H 'Authorization: Bearer <PLATFORM_A_KEY>' \
-H 'Content-Type: application/json' \
--data '{
"credential": {
"type": "pan",
"number": "4242424242424242",
"expiry_month": 12,
"expiry_year": 2030,
"cvc": "123"
},
"binding": {
"type": "dev.ucp.shopping.checkout",
"id": "chk_1234567890"
}
}'
{
"token": "d2a70490ddca5a045731f5c92476b52e02ba0742fcbe078166b74896c095c41f"
}
The transform returns these errors. UCP's tokenization API does not define error responses, so publish them in your handler specification:
| Status | error.code | Cause |
|---|---|---|
401 | unauthorized | The Authorization header is missing or does not match a platform key. |
400 | invalid_credential | The credential is not a pan credential with a number, or the card data failed validation. |
400 | invalid_binding | binding.type or binding.id is missing or empty, or a binding or identity value is longer than 200 characters. |
404 | not_found | The request is a POST to a path other than /tokenize. |
Give Platforms Your Own Hostname
Platforms should call a hostname on your domain, such as https://pay.example.com/tokenize, rather than a Basis Theory URL with a proxy key. To attach one to the tokenize proxy:
- Create a
CNAMErecord that points your hostname toapi.btproxy.cloud, and confirm that it resolves, for example withdig +short pay.example.com CNAME. - In the Portal, open the tokenize proxy and select Request Custom Hostname.
- Basis Theory provisions the TLS certificate and tells you when the hostname is attached. Custom hostnames explains the process in more detail.
Requests to the hostname go to the proxy with no BT-PROXY-KEY, and a request to /tokenize reaches the transform as req.path /tokenize. While provisioning is in progress, a Proxy not found for host response means the hostname is not attached to the proxy yet, and a TLS handshake failure means the certificate is not active yet.
Each hostname is attached to one proxy in one tenant, so use a separate hostname, proxy, and set of platform keys for each environment. Requests through a custom hostname are rate limited per hostname and client IP address (see Rate Limits); contact us if a platform needs a higher limit.
Register the Handler
Advertise the handler under ucp.payment_handlers in your business profile at /.well-known/ucp:
{
"ucp": {
"version": "2026-08-25",
"services": {},
"payment_handlers": {
"com.example.processor_tokenizer": [
{
"id": "processor_tokenizer",
"version": "2026-08-25",
"spec": "https://example.com/ucp/processor-tokenizer",
"schema": "https://example.com/ucp/processor-tokenizer/schema.json",
"available_instruments": [
{
"type": "card",
"constraints": {
"properties": { "brand": { "enum": ["visa", "mastercard", "amex"] } }
}
}
],
"config": {
"environment": "production",
"business_id": "<YOUR_BUSINESS_ID>"
}
}
]
}
}
}
Your services registry lists your other UCP services; it is empty here only to keep the example short. Two UCP rules shape the handler entry:
- The
schemaURL must match the handler name. UCP's authority binding reverses theschemaURL's host and requires the handler name to equal it or extend it.com.example.processor_tokenizerwith a schema onexample.compasses. The same name with a schema onpay.example.comfails, and platforms must ignore the handler. Serve the schema without redirects, because platforms do not follow them. - The
specdocument is where platforms find your endpoint. It can live on any HTTPS host. UCP's tokenization guide lists what it must cover: the handler name, the production and sandbox endpoint URLs (your custom hostnames), how platforms authenticate and are onboarded, the accepted credentials (pan), the token lifecycle (15-minute expiration, deleted after a charge), and the error responses in Test the Tokenize Endpoint.
Charge the Token
When a platform completes a checkout, its request carries the token in payment.instruments[].credential.token. UCP requires the processor to verify that the token was issued for the checkout being completed before it charges, so your backend checks the binding in code and sends the charge only when it matches.
What the Token Records
With token:read, the Get a Token API returns the card masked and the binding the transform stored in metadata:
curl 'https://api.test.basistheory.com/tokens/<TOKEN_ID>' \
-H 'BT-API-KEY: <PRIVATE_API_KEY>'
{
"id": "d2a70490ddca5a045731f5c92476b52e02ba0742fcbe078166b74896c095c41f",
"type": "card",
"tenant_id": "77cb0024-123c-4c7f-8f7d-0c1b3f3f6f2a",
"data": {
"number": "XXXXXXXXXXXX4242",
"expiration_month": 12,
"expiration_year": 2030
},
"metadata": {
"ucp_binding_type": "dev.ucp.shopping.checkout",
"ucp_binding_id": "chk_1234567890",
"ucp_participant": "platform_a"
},
"card": {
"bin": "42424242",
"last4": "4242",
"brand": "visa",
"funding": "credit"
},
"expires_at": "2026-10-01T18:40:16.676+00:00",
"created_at": "2026-10-01T18:25:17.2140234+00:00"
}
A 404 response means the token expired or does not exist.
Charge Only a Matching Token
The function below charges a token for one checkout. It:
- Retrieves the token with the Get a Token API and stops with
binding_mismatchunlessmetadata.ucp_binding_typeis exactlydev.ucp.shopping.checkoutandmetadata.ucp_binding_idis exactly the ID of the checkout being completed. - Creates a Stripe PaymentMethod through the Proxy with your publishable key, the way Stripe's client libraries do, using token expressions for the card fields. If the token has expired or was deleted, the Proxy returns
400withTokens were not foundand does not call Stripe. - Charges the PaymentMethod by creating and confirming a PaymentIntent with your secret key. This request carries no card data. Its idempotency key is derived from the checkout ID, so a replayed or concurrent completion of the same checkout cannot charge the card twice.
- Deletes the token with the Delete Token API after a successful charge, so it cannot be charged again, and reports whether the deletion worked in
token_deleted.
Creating PaymentMethods with a publishable key outside Stripe's prebuilt UI requires the Stripe setting that enables card data collection with a publishable key without Stripe's prebuilt UI elements. See Stripe's publishable key restrictions.
Set BT_PRIVATE_API_KEY, STRIPE_PUBLISHABLE_KEY, STRIPE_SECRET_KEY, TOKEN_ID, and CHECKOUT_ID in the environment:
const BT_API = "https://api.test.basistheory.com";
const bt = (path, init = {}) =>
fetch(`${BT_API}${path}`, {
...init,
headers: { "BT-API-KEY": process.env.BT_PRIVATE_API_KEY, ...init.headers },
});
// Charges a UCP token for the checkout being completed. Stripe is never called unless the
// token was issued for exactly this checkout.
export async function chargeUcpToken({ tokenId, checkoutId, amount, currency }) {
// 1. Verify the binding the tokenize transform stored with the token.
const tokenResponse = await bt(`/tokens/${encodeURIComponent(tokenId)}`);
if (tokenResponse.status === 404) return { status: "failed", reason: "token_not_found" };
if (!tokenResponse.ok) throw new Error(`Token lookup failed with ${tokenResponse.status}`);
const { metadata = {} } = await tokenResponse.json();
if (
metadata.ucp_binding_type !== "dev.ucp.shopping.checkout" ||
metadata.ucp_binding_id !== checkoutId
) {
return { status: "failed", reason: "binding_mismatch" };
}
// 2. Create a Stripe PaymentMethod through the Proxy, with token expressions for the card.
const paymentMethod = await bt("/proxy", {
method: "POST",
headers: {
"BT-PROXY-URL": "https://api.stripe.com/v1/payment_methods",
Authorization: `Bearer ${process.env.STRIPE_PUBLISHABLE_KEY}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
type: "card",
"card[number]": `{{ token: ${tokenId} | json: "$.data.number" }}`,
"card[exp_month]": `{{ token: ${tokenId} | json: "$.data" | card_exp: "MM" }}`,
"card[exp_year]": `{{ token: ${tokenId} | json: "$.data" | card_exp: "YYYY" }}`,
"card[cvc]": `{{ token: ${tokenId} | json: "$.data.cvc" }}`,
}),
}).then((response) => response.json());
if (!paymentMethod.id) return { status: "failed", reason: "payment_method_not_created" };
// 3. Charge the PaymentMethod with the secret key. This request carries no card data.
// The idempotency key allows one PaymentIntent per checkout, so a replayed or
// concurrent completion of the same checkout cannot charge the card twice.
const paymentIntent = await fetch("https://api.stripe.com/v1/payment_intents", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
"Content-Type": "application/x-www-form-urlencoded",
"Idempotency-Key": `ucp-checkout-${checkoutId}`,
},
body: new URLSearchParams({
amount: String(amount),
currency,
confirm: "true",
"payment_method_types[]": "card",
payment_method: paymentMethod.id,
}),
}).then((response) => response.json());
// 4. Delete the token after a successful charge so it cannot be charged again.
// Report whether that worked, so you can retry a failed deletion.
let tokenDeleted = false;
if (paymentIntent.status === "succeeded") {
const deletion = await bt(`/tokens/${encodeURIComponent(tokenId)}`, { method: "DELETE" });
tokenDeleted = deletion.ok;
}
return {
status: paymentIntent.status ?? "failed",
payment_intent: paymentIntent.id,
token_deleted: tokenDeleted,
reason: paymentIntent.error?.code ?? paymentIntent.error?.type,
};
}
console.log(
await chargeUcpToken({
tokenId: process.env.TOKEN_ID,
checkoutId: process.env.CHECKOUT_ID,
amount: 4200,
currency: "usd",
})
);
{
status: 'succeeded',
payment_intent: 'pi_3ULq1BEn61mZL9TM0wK6SZHI',
token_deleted: true,
reason: undefined
}
If token_deleted is false, retry the deletion. Until it succeeds, the idempotency key still prevents a second charge for the checkout, and the token expires 15 minutes after it was created.
A second completion of the same checkout does not charge again: after the first succeeds, the token is gone and the function returns token_not_found; while the first is still running, Stripe rejects the second with idempotency_key_in_use. With a token issued for a different checkout, the function stops before Stripe is called:
{ status: 'failed', reason: 'binding_mismatch' }
Map the result to the checkout's outcome. A processor that accepts card data on server-side requests takes the token expressions in its charge request directly, so steps 2 and 3 become one request through the Proxy; give that request an idempotency key the same way.
Security Considerations
- Platforms never hold a Basis Theory credential. Each platform has its own key, checked by the transform, so you can revoke one platform by removing its key from
PLATFORM_KEYS. - Your charging key is powerful. The Private Application's
proxy:invokepermission lets it use any token in the tenant. Keep it in your backend only. If no integration in the tenant needs the Proxy with arbitrary destinations, setdisable_ephemeral_proxyand charge through a Pre-Configured Proxy whosedestination_urlis your processor. - Card security codes expire. Basis Theory deletes a card token's
cvcone hour after it is collected by default, longer than the 15-minute token lifetime, so it is available for the charge.
FAQ
Do platforms need a Basis Theory account?
No. Platforms call your hostname with the key you gave them. They never call Basis Theory APIs or hold a Basis Theory API key.
Do I need a /detokenize endpoint?
No. In the Processor-Tokenizer pattern, the token resolves to the card inside the processor's environment. The Basis Theory Proxy does that when you charge, so platforms never detokenize.
Which UCP credential types does this handler accept?
pan only. The transform rejects the deprecated card credential type and network_token credentials with invalid_credential. Publish that in your handler specification, since UCP requires each handler to state which credentials it accepts.
Why does the transform generate its own token ID?
UCP requires at least 128 bits of entropy in a token. Basis Theory's default token IDs are random UUIDs, which carry 122 random bits. The transform generates a 256-bit random ID instead.
How do I test before my hostname is attached?
Call https://api.test.basistheory.com/proxy/tokenize with the BT-PROXY-KEY header, as in Test the Tokenize Endpoint. Your test and production tenants each need their own proxy, platform keys, and hostname, because configuration does not copy between tenants.