@comergent/checkout-js
v0.0.6
Published
Loads the Comergent checkout wrapper, so a Buy now button opens the source merchant's checkout without leaving your page.
Readme
@comergent/checkout-js
Opens the source merchant's checkout from your Buy now button, without the buyer leaving your page.
npm install @comergent/checkout-jsimport { loadComergent } from "@comergent/checkout-js";
const comergent = await loadComergent({ publishableKey: "cpk_…" });
buyNowButton.addEventListener("click", () => {
comergent.buyNow({
store: "storeedt",
brand: "brand-storeedt",
sourceVariantId: "43750038667363",
});
});All three are already in the product payload you fetch for the PDP, and each is sent exactly as it is written:
| Option | Read from |
| ----------------- | ------------------------------------ |
| store | custom.source_shop (product) |
| brand | custom.brand_handle (product) |
| sourceVariantId | custom.source_variant_id (variant) |
store names the merchant who fulfils; brand names what you displayed. On a
marketplace they differ — one merchant carries many brands — and neither can be
derived from the other, because brand-<shop>-<vendor> cannot be split back
into a shop when both halves may contain hyphens.
brand is optional: omit it rather than guess. store also accepts the older
brand-<slug> spelling.
Two steps, if you want the click to be instant
buyNow above does two things: it mints a checkout link on the merchant's
store, then opens it. The first is a network round trip the buyer spends
watching a holding page. Split it and that wait disappears:
// On page load, or on hover — anywhere but inside the click.
const checkout = await comergent.createCheckout({
store: "storeedt",
brand: "brand-storeedt",
sourceVariantId: "43750038667363",
quantity: 1,
});
buyNowButton.addEventListener("click", () => {
comergent.buyNow({ checkout });
});Pass the whole checkout object, not checkout.checkoutUrl. It carries the
session id that completion events are keyed by, and its URL must reach Shopify
byte-for-byte — a rebuilt one is served a clone of the cart.
Both shapes work forever. Use the one-step form when a click that waits is fine, and the two-step form when it is not.
Requirements
The popup is Shopify's Checkout Kit, which the wrapper loads at runtime — not something you install. Its constraints are yours:
- Browsers: evergreen Chromium and Firefox, Safari 16.4+. It needs
<dialog>,customElementsandAbortController. - Anything older: use
display: "redirect", which needs none of them. - While a checkout is open your page is covered by Checkout Kit's scrim and nothing on it is clickable. Released on both completion and abandonment.
Checkout Kit is an alpha on a next dist-tag and Shopify say breaking changes
may land in any release. We pin an exact version and verify a bump against a
real purchase — which is the other reason the wrapper is not in this package.
Popup size
Defaults to 480 x 780. Set Shopify's documented custom properties on the element, or pass dimensions explicitly — the explicit ones win.
shopify-checkout {
--shopify-checkout-dialog-width: 700;
--shopify-checkout-dialog-height: 500;
}await loadComergent({ publishableKey: "cpk_…", popup: { width: 640, height: 900 } });The one rule
Do not await anything between the click and buyNow.
// WRONG — the popup will be blocked
button.addEventListener("click", async () => {
await trackAnalytics();
comergent.buyNow({ store, sourceVariantId });
});A browser only allows a popup to open during a user gesture, and an await
spends it. Do the loading up front, as above, and keep the handler synchronous.
buyNow returns nothing precisely so there is nothing to await.
createCheckout does return a promise, and the same rule covers it — await
it before the click, never inside:
// WRONG — same blocked popup, one step further back
button.addEventListener("click", async () => {
const checkout = await comergent.createCheckout({ store, sourceVariantId });
comergent.buyNow({ checkout });
});React
const [comergent, setComergent] = useState(null);
useEffect(() => {
loadComergent({ publishableKey: "cpk_…" }).then(setComergent);
}, []);
// Disabled until it is ready, so the handler never has to await.
<button
disabled={!comergent}
onClick={() => comergent.buyNow({ store, sourceVariantId, quantity })}
>
Buy now
</button>;Calling loadComergent repeatedly is free: one script tag, one shared promise.
StrictMode double-invoking your effect changes nothing.
createCheckout(options)
Mints the checkout link. Resolves with
{ sessionId, provider, checkoutUrl, clientReference? }.
| Option | Default | What it does |
| ----------------- | ------- | ------------------------------------------------------ |
| store | — | Required. The product's custom.source_shop. |
| brand | none | The product's custom.brand_handle. |
| sourceVariantId | — | Required. The variant's custom.source_variant_id. |
| quantity | 1 | 1 to 100. |
| clientReference | none | Your own id for the cart, echoed back to you. |
| discount | none | A coupon code, applied at the checkout. See below. |
| buyer | none | Prefills the merchant's checkout. See below. |
A link stays valid, so there is no need to mint one per click. One the buyer never uses costs nothing but an abandoned cart on the merchant's store.
buyNow(options)
Takes a checkout from above, or everything createCheckout takes and mints
one itself.
| Option | Default | What it does |
| ------------ | --------- | ------------------------------------------------------------- |
| checkout | none | What createCheckout resolved with. Pass it whole. |
| display | "popup" | "redirect" sends the buyer to the checkout in the same tab. |
| onComplete | none | Fired after the buyer pays. Cosmetic — see below. |
| onClose | none | The checkout window is no longer in front of the buyer. |
Plus every createCheckout option, when you are not passing a checkout.
Prefilling the buyer
comergent.createCheckout({
store,
sourceVariantId,
buyer: {
email: "[email protected]",
phone: "+91 98765 43210",
address: { countryCode: "IN", firstName: "Priya", city: "New Delhi", zip: "110016" },
},
});Everything is optional and only what you send is filled in — except
countryCode, which is required whenever you send an address and must be the
two-letter ISO code in capitals ("IN", never "India" or "in"). It selects
the market and so the prices, which is why we never guess one: without it the
address is dropped and the buyer types it.
Anything Shopify would refuse — a blank string, an unparseable email — is dropped rather than sent, so bad details cost you the prefill and never the sale. These are never stored, logged, or put in a URL.
Coupons
comergent.buyNow({ store, sourceVariantId, discount: "HT500" });The code rides on Shopify's own cart permalink, so the source merchant's store decides whether it is honoured — exactly as if the buyer had typed it. One code, no comma: Shopify separates multiple codes with one, and whether two combine is a setting in that merchant's admin.
A code that store does not recognise is ignored, not refused. The checkout opens and the buyer pays full price, and nothing tells you it happened — so a coupon you show must also exist in that merchant's Shopify admin, not only in whatever you read it from.
onClose is not "the buyer gave up"
Checkout Kit fires it when the popup is dismissed by the buyer, by our code, or on focus loss. It means the checkout window is no longer in front of them, which is not the same as abandonment.
onComplete is not proof of an order
It arrives over the browser, which means anyone with a developer console can fire it. Never fulfil, count, bill or credit anything from it. Use it to refresh your own UI and nothing else. Order truth lives in the source merchant's Shopify admin.
loadComergent(options)
Returns a promise for the wrapper, or null when there is no window — so it
is safe to call from code that also runs during server-side rendering.
| Option | Default | What it does |
| ---------------- | -------------------- | -------------------------------------- |
| publishableKey | — | Required. Identifies you to Comergent. |
| scriptSrc | Comergent production | Point at a staging deployment. |
What this package is not
It does not contain the checkout wrapper. It inserts the script tag that
fetches it, and resolves once that wrapper has installed itself — the same shape
as @stripe/stripe-js, which does not contain Stripe.js either.
That means you do not reinstall this package to get our updates. Fixes and improvements to the wrapper reach your users on their next page load. You would only upgrade this package if the loader's own API changed, which should be close to never.
It also means you must not vendor, bundle or self-host the wrapper. Doing so freezes it into your build, so a fix on our side would need a release on yours — and the checkout protocol underneath is Shopify's, which moves on their schedule.
Content Security Policy
Two origins, if you send one — ours for the wrapper, and jsDelivr, which is where the wrapper pulls Shopify's Checkout Kit at runtime:
script-src 'self' https://ht-tech-shop-now.comergent.ai https://cdn.jsdelivr.net;The checkout runs in a popup window rather than in your document, so no
frame-src is needed.
