Checkout Handoff
Hand a shopper from your headless storefront to Blaze-hosted checkout, carrying the cart, the signed-in customer, and the delivery context across in a single redirect.
What you'll learn
- When to hand checkout to Blaze instead of building it yourself
- How to mint a short-lived handoff token for an authenticated shopper
- How to discover the storefront checkout URL for a store
- How to build the handoff URL and encode delivery context
- What happens on the Blaze side when the shopper lands
- The constraints that matter: token TTL, authentication requirement, and cart ownership
Prerequisites
- A Store UUID (staging:
e87437f2-3e35-4738-af5e-6307e368255c) - A signed-in shopper — you hold their JWT from
POST /api/v1/auth/login. See the Authentication guide - A cart built against that shopper. See the Cart & Checkout guide
- The store must have a published storefront site (a Blaze-hosted site with a resolvable domain)
When to use this
You have two ways to finish an order.
| Full headless checkout | Checkout handoff | |
|---|---|---|
| Who renders checkout | You | Blaze |
| Payment provider integration | You integrate each provider | Handled by Blaze |
| ID / age verification, compliance gates | You implement | Handled by Blaze |
| Order creation | You call POST /api/v4/orders |
Blaze calls it |
| Branding at checkout | Yours | The store's Blaze storefront theme |
| Effort | High | One redirect |
Hand off when the storefront experience is the value you're adding and you'd rather not carry payment integrations, identity verification, and per-market compliance rules. Keep it headless when checkout itself is the thing you're differentiating on.
The handoff is a redirect, not an embed. The shopper leaves your domain and finishes on the store's Blaze storefront. Plan your analytics and post-order experience around that.
How it works
Your storefront Blaze API Blaze storefront
────────────── ───────── ────────────────
1. Shopper signs in ──────────▶ POST /api/v1/auth/login
◀────────── session JWT
2. Build the cart ──────────▶ POST /api/v5/carts
◀────────── cart_uuid
3. Get the site URL ──────────▶ GET /api/v1/store/site
◀────────── https://shop.example.com/
4. Mint handoff token ──────────▶ POST /api/v1/users/me/access_token
◀────────── access_token (5 min TTL)
5. Redirect ─────────────────────────────────────────────────▶ /checkout/{cart_uuid}/
?access_token=…
6. Exchanges the token
for a session,
loads the cart,
strips the params
7. Shopper pays and
the order is created
Step 1: Authenticate the shopper
The handoff carries an identity, so the shopper must be signed in on your storefront first. Use the standard login flow and keep the returned JWT — every subsequent step uses it.
const STORE_UUID = "e87437f2-3e35-4738-af5e-6307e368255c";
const BASE_URL = "https://ecom-api.staging.blaze.me";
const headers = {
"Content-Type": "application/vnd.api+json",
Accept: "application/vnd.api+json",
"X-Store": STORE_UUID,
};
const loginRes = await fetch(`${BASE_URL}/api/v1/auth/login`, {
method: "POST",
headers,
body: JSON.stringify({
data: {
type: "users",
attributes: { email: "john@example.com", password: "securepassword123" },
},
}),
});
const { data: session } = await loginRes.json();
const sessionJwt: string = session.attributes.token;
Guest carts cannot be handed off. The Blaze checkout page requires a signed-in customer when a cart UUID is present in the path — an anonymous shopper is redirected to the login screen instead of the checkout. See Constraints below.
Step 2: Build the cart
Create the cart with the shopper's JWT so it is bound to their account. Anything you set here — items, delivery specification, promo codes, rewards — travels with the cart and is what the shopper sees at checkout.
const authHeaders = { ...headers, Authorization: `Bearer ${sessionJwt}` };
const cartRes = await fetch(`${BASE_URL}/api/v5/carts`, {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
data: {
type: "carts",
attributes: {
delivery_specification: "pickup",
inventory_type: "recreational",
item: { product_id: "PRODUCT_UUID", quantity: 1 },
},
},
}),
});
const { data: cart } = await cartRes.json();
const cartUuid: string = cart.id;
See the Cart & Checkout guide for adding items, delivery specifications, promo codes, and rewards.
Step 3: Discover the checkout base URL
Each store has its own storefront domain. Read it from the store's site resource rather than hardcoding it — domains differ per store and change without notice.
GET /api/v1/store/site
cURL
curl https://ecom-api.staging.blaze.me/api/v1/store/site \
-H "Accept: application/vnd.api+json" \
-H "X-Store: e87437f2-3e35-4738-af5e-6307e368255c"
TypeScript
const siteRes = await fetch(`${BASE_URL}/api/v1/store/site`, { headers });
const { data: site } = await siteRes.json();
// Always returned with a trailing slash, e.g. "https://shop.example.com/"
const siteUrl: string = site.attributes.url;
Response
{
"data": {
"id": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"type": "store_sites",
"attributes": {
"url": "https://shop.example.com/",
"basepath": "",
"pathname": "",
"logo_url": "https://blaze.imgix.net/...",
"favicon_url": "https://blaze.imgix.net/...",
"html_title": "Example Dispensary",
"meta_description": "Order cannabis online for pickup or delivery."
}
}
}
The url attribute always ends in a trailing slash. Strip it before appending the checkout path, or you will produce a double slash.
If the store has no published site, url is null — there is nowhere to hand off to, and you must complete checkout headlessly.
Step 4: Mint a handoff token
The handoff token is a short-lived credential the shopper's browser presents to the Blaze storefront to resume their session there. You mint it with the shopper's own JWT, so no partner-level privilege is involved — it is the shopper delegating their session to the next page.
POST /api/v1/users/me/access_token
cURL
curl -X POST https://ecom-api.staging.blaze.me/api/v1/users/me/access_token \
-H "Content-Type: application/vnd.api+json" \
-H "Accept: application/vnd.api+json" \
-H "X-Store: e87437f2-3e35-4738-af5e-6307e368255c" \
-H "Authorization: Bearer YOUR_SESSION_JWT" \
-d '{"data": {"type": "user_access_tokens", "attributes": {}}}'
TypeScript
const tokenRes = await fetch(`${BASE_URL}/api/v1/users/me/access_token`, {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
data: { type: "user_access_tokens", attributes: {} },
}),
});
const { data: token } = await tokenRes.json();
const handoffToken: string = token.attributes.access_token;
Response
{
"data": {
"id": "c3a1f5d2-8b4e-4f6a-9c2d-1a3b5c7d9e0f",
"type": "user_access_tokens",
"attributes": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
}
}
The token expires 5 minutes after it is issued. Mint it at the moment the shopper clicks "Checkout" — immediately before you build the redirect. Do not mint it when the cart is created, cache it, or reuse it across sessions.
Step 5: Build the handoff URL
The checkout path is /checkout/{cart_uuid}/, appended to the site URL. Everything else travels as query parameters.
Query parameters
| Parameter | Required | Values | Purpose |
|---|---|---|---|
access_token |
Yes | Handoff token from Step 4 | Signs the shopper in on the Blaze storefront |
delivery_type |
Recommended | pickup, delivery |
Pre-selects the fulfilment type |
delivery_mode |
Delivery only | asap, scheduled, express |
Pre-selects the delivery mode |
delivery_address |
Delivery only | Base64-encoded JSON | Pre-fills the delivery address |
utm_source |
Optional | Any string | Attribution — identify your storefront |
Any additional query parameters you add are preserved on the page, so you can carry your own attribution or session correlation values across.
Encoding the delivery address
delivery_address is a base64-encoded JSON object using snake_case keys:
interface HandoffAddress {
address: string; // "1234 Market St"
address_line2?: string; // "Apt 5"
city: string; // "San Francisco"
state: string; // "CA"
zip_code: string; // "94103"
country?: string; // "US"
building_number?: string;
lat?: number; // 37.7749
lng?: number; // -122.4194
}
const encodeAddress = (address: HandoffAddress): string =>
Buffer.from(JSON.stringify(address), "utf-8").toString("base64");
In the browser, use btoa(JSON.stringify(address)) instead. An address that fails to decode is ignored rather than raising an error — the shopper is simply asked to enter it again, so validate the shape on your side.
Building the URL
function buildHandoffUrl(opts: {
siteUrl: string;
cartUuid: string;
handoffToken: string;
deliveryType: "pickup" | "delivery";
deliveryMode?: "asap" | "scheduled" | "express";
deliveryAddress?: HandoffAddress;
utmSource?: string;
}): string {
const base = opts.siteUrl.replace(/\/$/, "");
const params = new URLSearchParams({
access_token: opts.handoffToken,
delivery_type: opts.deliveryType,
});
if (opts.utmSource) params.set("utm_source", opts.utmSource);
if (opts.deliveryType === "delivery") {
if (opts.deliveryMode) params.set("delivery_mode", opts.deliveryMode);
if (opts.deliveryAddress) {
params.set("delivery_address", encodeAddress(opts.deliveryAddress));
}
}
return `${base}/checkout/${opts.cartUuid}/?${params.toString()}`;
}
Result
https://shop.example.com/checkout/8f14e45f-ceea-467a-9f0e-8b7c1d2a3f4b/
?access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
&delivery_type=delivery
&delivery_mode=scheduled
&delivery_address=eyJhZGRyZXNzIjoiMTIzNCBNYXJrZXQgU3QiLCJjaXR5IjoiU2FuIEZyYW5jaXNjbyJ9
&utm_source=partner-storefront
Redirect the shopper's browser to it — a top-level navigation, not a fetch. Because the token is in the URL, use a 303 See Other server-side redirect or window.location.assign(); do not render it as a visible link the shopper might copy or share.
Step 6: What happens on the Blaze side
You do not implement any of this — it's what the storefront does with what you sent, and it's useful to know when debugging a handoff.
-
Token exchange. The storefront reads
access_tokenfrom the query string and posts it toPOST /api/v1/auth/loginas{"data": {"type": "users", "attributes": {"access_token": "..."}}}. The API validates the short-lived token and returns a full session JWT. This is why the 5-minute TTL is not a limit on the shopper's checkout time — only on the gap between minting the token and landing on the page. -
Parameter stripping. Once consumed,
access_token,delivery_address,delivery_type, anddelivery_modeare removed from the URL via a history replace, so the token does not survive in browser history or in a shared link. -
Delivery context applied. The decoded address, delivery type, and delivery mode are set as the shopper's active selections and persisted to local storage.
-
Cart load. The storefront reads
{cart_uuid}from the path, stores it as the active cart, and fetches it viaGET /api/v5/carts/{uuid}. -
Checkout. The shopper proceeds through payment, any identity or age verification the store requires, and order creation — all on the Blaze side.
If the token has expired or is malformed, the exchange fails and the shopper is sent to the storefront's login page with a redirect_uri back to the checkout. They can sign in manually and their cart is still there, so an expired token degrades to an extra login rather than a lost cart.
Complete example
const STORE_UUID = "e87437f2-3e35-4738-af5e-6307e368255c";
const BASE_URL = "https://ecom-api.staging.blaze.me";
const jsonApi = {
"Content-Type": "application/vnd.api+json",
Accept: "application/vnd.api+json",
"X-Store": STORE_UUID,
};
/**
* Builds a checkout handoff URL for an already-authenticated shopper.
* Call this at the moment the shopper clicks "Checkout" — the token is
* only valid for 5 minutes.
*/
async function createCheckoutHandoff(opts: {
sessionJwt: string;
cartUuid: string;
deliveryType: "pickup" | "delivery";
deliveryMode?: "asap" | "scheduled" | "express";
deliveryAddress?: HandoffAddress;
}): Promise<string> {
const authed = { ...jsonApi, Authorization: `Bearer ${opts.sessionJwt}` };
const [siteRes, tokenRes] = await Promise.all([
fetch(`${BASE_URL}/api/v1/store/site`, { headers: jsonApi }),
fetch(`${BASE_URL}/api/v1/users/me/access_token`, {
method: "POST",
headers: authed,
body: JSON.stringify({
data: { type: "user_access_tokens", attributes: {} },
}),
}),
]);
if (!siteRes.ok) throw new Error(`Site lookup failed: ${siteRes.status}`);
if (!tokenRes.ok) throw new Error(`Token mint failed: ${tokenRes.status}`);
const { data: site } = await siteRes.json();
const { data: token } = await tokenRes.json();
const siteUrl: string | null = site.attributes.url;
if (!siteUrl) {
throw new Error("Store has no published storefront — cannot hand off.");
}
return buildHandoffUrl({
siteUrl,
cartUuid: opts.cartUuid,
handoffToken: token.attributes.access_token,
deliveryType: opts.deliveryType,
deliveryMode: opts.deliveryMode,
deliveryAddress: opts.deliveryAddress,
utmSource: "partner-storefront",
});
}
Wire it to a server-side redirect so the token never lands in client-side application state:
// Express example
app.post("/checkout", async (req, res) => {
const url = await createCheckoutHandoff({
sessionJwt: req.session.blazeJwt,
cartUuid: req.session.cartUuid,
deliveryType: "pickup",
});
res.redirect(303, url);
});
After the handoff
The shopper completes the order on the Blaze storefront and stays there — there is no automatic redirect back to your domain.
You can still follow the order from your side. The cart UUID you handed over links to the resulting order:
// Poll or check on return — uses the shopper's session JWT
const orderRes = await fetch(
`${BASE_URL}/api/v1/carts/${cartUuid}/order`,
{ headers: authed }
);
For push-based updates instead of polling, see the Webhooks guide — order.created and order status events fire regardless of which surface created the order.
Constraints
Read these before committing to the handoff as your checkout strategy.
The shopper must be authenticated. A cart UUID in the checkout path requires a signed-in customer. If the token is missing or expired, the storefront redirects to login rather than offering guest checkout. Guest checkout exists on the Blaze storefront, but not on the cart-UUID entry path — so an anonymous cart built by your storefront cannot be handed off. Authenticate the shopper before you build the cart, not after.
The handoff token is a full user credential. For its 5-minute life it authenticates as that shopper, and it is not restricted to the cart you are handing over or to a single use. Treat it like a password:
- Mint it immediately before the redirect, never in advance
- Never log it, store it, or put it in an analytics payload
- Only ever transmit it over TLS
- Never render it in a link the shopper can copy or share
Cart UUIDs are bearer capabilities. A cart is fetched by UUID scoped to the store, with no ownership check. Anyone holding a cart UUID can read and modify that cart. Do not expose cart UUIDs in shareable URLs, referrer-visible contexts, or third-party analytics.
Delivery stores may use a different site. Stores configured with a separate delivery storefront resolve to a different domain for delivery orders. If a store uses one, use that domain when delivery_type=delivery — GET /api/v1/store/site returns the store's primary site.
Checkout branding is the store's, not yours. The shopper sees the store's Blaze storefront theme. If a seamless brand transition matters more than the integration savings, build checkout headlessly instead.
Endpoint summary
| Step | Endpoint | Auth |
|---|---|---|
| Sign the shopper in | POST /api/v1/auth/login |
None |
| Build the cart | POST /api/v5/carts |
Shopper JWT |
| Find the storefront URL | GET /api/v1/store/site |
None |
| Mint the handoff token | POST /api/v1/users/me/access_token |
Shopper JWT |
| Follow the resulting order | GET /api/v1/carts/{cart_uuid}/order |
Shopper JWT |
What's Next?
- Build checkout yourself instead: the Cart & Checkout guide covers validation, order creation, and status tracking
- Payments: if you keep checkout headless, the Payments guide covers all supported providers
- Order updates: the Webhooks guide covers order and delivery events
- Architecture: Building a Headless Storefront puts the handoff in context of a full storefront build
For request/response format details, see the General Concepts guide. For authentication setup, see the Authentication guide.