A Shopify cart permalink is a URL that loads specific variants into a cart and sends the buyer straight to checkout. The legacy format is /cart/{variant_id}:{quantity} on the store’s domain. Since 28 October 2026 Shopify also serves /buy, a Universal Commerce Protocol (UCP) permalink whose endpoint you read from the store’s /.well-known/ucp profile. Use /buy for new links unless you need line item properties, cart notes or channel attribution.
Those three are the features quote tools and sales channel apps depend on, so a fair number of existing /cart links should stay exactly where they are for now.
How does a Shopify cart permalink work?
The legacy cart permalink is a path you build yourself. Variant IDs and quantities are separated by colons, items by commas:
https://{shop}.myshopify.com/cart/70881412:1,70881382:1
Everything else rides in the query string. checkout[email] and the checkout[shipping_address][...] fields (first_name, last_name, address1, address2, city, province, country, zip) prefill checkout. discount takes one or more codes separated by commas, which means a code that itself contains a comma cannot be passed at all. note and any number of attributes[key] parameters land in the Notes section of the order, and ref shows up as a referral code in the Conversion summary.
Sales channel apps get two more: access_token (a Storefront API access token) attributes the order to your channel in the Sales by Channel report, and source_name points at a specific order attribution definition your app owns. source_name only works alongside the token. A locale goes in the path (/fr/cart/...), storefront=true lands the buyer on the online store cart instead of checkout, and payment=shop_pay asks for Shop Pay.
Line item properties are the odd one. You can attach up to 25 of them, but only to the first item, and the value of properties must be base64url-encoded JSON.
What changed with Shopify’s /buy permalink?
On 28 October 2026 Shopify announced on the developer changelog that storefronts now support the UCP dev.ucp.shopping.permalink capability, starting with UCP version 2026-08-25, on a new /buy route. The /cart docs now carry a “Legacy” banner. Existing links keep working and Shopify says it will keep supporting them, but new capabilities only go to /buy.
Three changes go further than the new path.
You discover the endpoint rather than build it. Each store publishes a profile at /.well-known/ucp, and the permalink capability’s config.endpoint is the base URL. It is usually the primary domain plus /buy, but a store without an online store uses its myshopify.com domain, so Shopify’s guide tells you to use the value exactly as listed. If the capability is missing, the store does not accept /buy links and you fall back to /cart.
Field names are JSON Pointer paths from the UCP cart schema with the leading slash dropped: buyer/email, context/address_country, context/language, discounts/codes/0. Lists are indexed from zero with no gaps. Shop Pay becomes context/payment/0/handler=dev.shopify.shop_pay, and the destination is continue_to with a store path such as /cart.
Behaviour is versioned. A link with no version uses Shopify’s current default UCP version, which moves over time. Adding ucp/version=2026-08-25 keeps a link behaving the way it did when you tested it.
How do you build a /buy link in TypeScript?
This helper reads the profile, encodes items and fields the way the UCP permalink specification requires, and refuses to emit a link Shopify would reject for length. It runs on Node 24 with native type stripping and type-checks under strict.
type Item = { variantId: string | number; quantity: number };
type BuyOptions = {
email?: string;
discountCodes?: string[];
country?: string; // ISO 3166-1 alpha-2, e.g. "GB"
language?: string; // BCP 47, e.g. "en-GB"
continueTo?: string; // a store path such as "/cart"
ucpVersion?: string; // pin the version you tested against
};
/** Read the store's UCP profile and return its /buy endpoint, or null. */
export async function findPermalinkEndpoint(storeUrl: string): Promise<string | null> {
const res = await fetch(new URL("/.well-known/ucp", storeUrl));
if (!res.ok) return null;
const profile = await res.json();
const entries = profile?.ucp?.capabilities?.["dev.ucp.shopping.permalink"];
return entries?.[0]?.config?.endpoint ?? null;
}
/** Raw IDs that fit the token grammar go in as-is; anything else is ~base64url. */
function itemToken(id: string | number): string {
const raw = String(id);
if (/^[A-Za-z0-9._-]+$/.test(raw)) return raw;
return "~" + Buffer.from(raw, "utf8").toString("base64url");
}
export function buildBuyLink(endpoint: string, items: Item[], opts: BuyOptions = {}): string {
if (items.length === 0) throw new Error("A permalink needs at least one item");
for (const { quantity } of items) {
if (!Number.isInteger(quantity) || quantity < 1) {
throw new Error(`Quantity must be a whole number above zero, got ${quantity}`);
}
}
const path = items.map((i) => `${itemToken(i.variantId)}:${i.quantity}`).join(",");
// Each field once, list indexes from 0 with no gaps, every value percent-encoded.
const params: [string, string][] = [];
if (opts.ucpVersion) params.push(["ucp/version", opts.ucpVersion]);
if (opts.email) params.push(["buyer/email", opts.email]);
if (opts.country) params.push(["context/address_country", opts.country]);
if (opts.language) params.push(["context/language", opts.language]);
opts.discountCodes?.forEach((code, i) => params.push([`discounts/codes/${i}`, code]));
if (opts.continueTo) params.push(["continue_to", opts.continueTo]);
const query = params.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
const url = `${endpoint}/${path}${query ? `?${query}` : ""}`;
if (new TextEncoder().encode(url).length > 2048) {
throw new Error("Permalink is over 2,048 bytes; Shopify will send the buyer to the home page");
}
return url;
}
Called with one numeric ID, one variant GID, an email and a discount code, it produces:
https://shop.example.com/buy/70881412:1,~Z2lkOi8vc2hvcGlmeS9Qcm9kdWN0VmFyaWFudC83MDg4MTM4Mg:2?ucp/version=2026-08-25&buyer/email=sasha%2Borders%40example.com&context/address_country=GB&context/language=en-GB&discounts/codes/0=WELCOME10
The encoding rules that break links
A GID such as gid://shopify/ProductVariant/70881382 contains slashes and a colon, which would collide with the path syntax. The spec forbids percent-encoding it; instead you prefix ~ and base64url-encode it without padding. Node’s base64url encoding already omits the padding.
Percent-encode every value. Shopify reads an unencoded + as a space, so a phone number like +18005550123 must go out as %2B18005550123, and so must the plus in a sub-addressed email. encodeURIComponent handles both.
Never repeat a field. If a link contains the same field twice, Shopify throws away all the buyer and cart details and builds the cart from the items alone. That is a silent failure: the link still “works”, the discount just never applies.
Watch the length. The hard limits are 500 items and 2,048 bytes for the whole URL. The spec recommends far less for shared links: 512 octets for QR codes and print, 1,024 for SMS and broad campaigns.
When should you still use /cart?
When the link has to carry something /buy cannot. As of October 2026, /buy has no cart notes, no cart attributes, no line item properties, no ref code, and no sales channel attribution, because there is nowhere to put an access_token or source_name. Shopify also ignores ordinary query parameters on /buy, so a bare utm_source or gclid is dropped; you have to resend them as attribution/utm_source, attribution/click_id_tag=gclid and attribution/click_id_value.
A quote tool that sends a customer a personalised engraving order is the textbook /cart case:
/** Legacy /cart link carrying line item properties on the first item. */
export function buildCartLink(
shopDomain: string,
items: { variantId: number; quantity: number }[],
properties: Record<string, string>,
note?: string,
): string {
const path = items.map((i) => `${i.variantId}:${i.quantity}`).join(",");
const props = Buffer.from(JSON.stringify(properties), "utf8").toString("base64url");
let url = `https://${shopDomain}/cart/${path}?properties=${props}`;
if (note) url += `¬e=${encodeURIComponent(note)}`;
return url;
}
Put the personalised product first, since properties only attach there. If the properties drive pricing or fulfilment, the rules from our line item properties guide still apply once the order exists.
Neither format supports selling plans. On /buy, every item goes in as a one-time purchase, and a subscription-only product stops the cart being created at all. For subscriptions, build the cart through the Storefront API instead, where a cart line can carry a selling plan ID.
What happens when a permalink fails?
Mostly, nothing loud. If /buy cannot build the cart, Shopify sends the buyer to a valid continue_to path or the home page. Over 2,048 bytes, or on a store with no online store, headless channel or Shopify Catalog, it ignores continue_to and goes straight to the home page. Bad percent-encoding can return 400 Bad Request, and rate limiting or timeouts can surface as 429 or 504.
Stock is handled per item. A sold-out or short item is added at whatever quantity is available, or left out. If Shopify cannot add an item for any other reason, it creates no cart at all, even for the items that were fine.
Opening a /buy link always creates a fresh cart containing only the link’s items, replacing whatever the buyer already had. Fine for a “buy this bundle” email; a nasty surprise if you put the link on a product page for shoppers who are mid-basket. Browser prefetch and prerender do not create carts, so speculation rules will not fill carts behind your back, and bot protection may show the buyer a verification step first.
Links with an email or address in them are personal. They leak through browser history, analytics, referrers and screenshots. Keep buyer details for one-to-one handoffs, and never put them in a QR code or a campaign link.
Which one should you build?
For a new integration that sends buyers from email, an agent or a partner site into checkout, build /buy: read the endpoint from /.well-known/ucp, pin ucp/version, and keep a /cart fallback for stores whose profile lacks the capability. Stay on /cart if you are a sales channel that needs attribution in the merchant’s reports, or if the link carries properties, notes or attributes. Do not migrate those links until Shopify ships the missing fields, because a /buy link without them quietly loses data the merchant relies on. If you need live totals or editable lines before handoff, a permalink is the wrong tool, and the Storefront API cart flow is the one to reach for.
Whoooop builds Shopify storefronts, apps and the integrations that link into them. If you are moving a set of campaign or quote links onto /buy and want them tested against real stores first, our Shopify development team does that work.