Storefront SDK
Render Tab Promo facts and run confirmed cart actions from a custom Shopify Online Store theme.
@tabcommerceio/promo-sdk is the supported browser integration for a custom
Shopify Online Store theme. It reads promotion facts through the shop’s signed,
same-origin App Proxy and provides explicit Cart AJAX helpers. Your theme owns
the markup, copy, accessibility, localization, and styling; Tab Promo owns
promotion evaluation, and Shopify checkout remains authoritative for discounts
and final totals.
Current release:
@tabcommerceio/promo-sdk@1.1.0, the stable release on npm’slatesttag. Releases follow SemVer, so a^1.1.0range receives compatible fixes and additions without breaking changes. Version 1.1.0 adds gift pools (pool_choice), batch gift claims, and thecheckGiftClaimandabandonGiftClaimrecovery methods.
Choose the SDK when
Use the SDK when an implementation team needs to own one or more promotion placements in custom theme code. Keep the official Theme Extension when the merchant wants Tab Promo to own the visible blocks and their normal theme lifecycle.
Version 1 is deliberately narrow:
- browser-only and ESM-only;
- Shopify Online Store themes using a same-origin App Proxy;
- promotion facts rather than rendered HTML or UI components;
- no Admin API client, API key, customer identity input, analytics tracker, or local promotion evaluator;
- no cross-origin Hydrogen or other externally hosted headless storefront.
Share links are a separate reporting workflow. A share-link token never changes
promotion eligibility. A custom theme saves the token to the cart with the
captureAttributionSource client method; see
Capture share-link tokens.
Plan requirements
Installing the npm package does not grant service access. The App Proxy checks the shop’s effective plan on every request.
| SDK call | Tab Promo endpoint | Plans |
|---|---|---|
resolve, mount, refresh | POST /promos | Pro and Growth |
getTopupProducts | GET /topup-pools/{pool_id}/products | Every plan with live promotions: Free within its monthly orders, or an active or trialing paid plan |
addTopupItem, claimGift, removeGift, checkGiftClaim, abandonGiftClaim, reconcileGiftClaims, applyDiscountCode, captureAttributionSource | Shopify Cart AJAX and Shopify discount links only | No Tab Promo endpoint; checkout decides the discount |
On Free or Starter, a facts read returns null and reports a diagnostic with
status: 403 and code subscription_required. Free and Starter merchants use
the official Theme Extension for rendered promotion blocks. Session A/B test
assignments are honored only on Growth.
Install the current release
Install the stable release:
pnpm add @tabcommerceio/promo-sdk@^1.1.0
Create one client for the current page or storefront shell. proxyBase must be
the merchant-configured, same-origin relative /apps/... path. The example
below is common, but an integration must not assume every installation uses it.
import { init, type PromoDiagnostic } from '@tabcommerceio/promo-sdk'
const promo = init({
proxyBase: '/apps/tab-promo',
timeoutMs: 2_500,
onDiagnostic(diagnostic: PromoDiagnostic) {
reportStorefrontDiagnostic(diagnostic)
},
})
| Option | Behavior |
|---|---|
proxyBase | Required. A relative /apps/... path. Anything else, including an absolute URL or query string, makes init throw PromoSdkError with code invalid_proxy_base. |
timeoutMs | One deadline for each facts read, covering the cart read, the App Proxy request, and any assignment retry. Defaults to 10000; 0 disables it. |
onDiagnostic | Optional observer for read failures and invalid input. It never receives shopper-facing copy, and an exception inside it is ignored. |
fetch, cartProvider, eventTarget, getRolloutAssignment | Optional overrides for tests and controlled integrations. Normal themes omit them. A custom cartProvider(signal?) can use the optional AbortSignal to cancel its own work; the SDK still settles at the deadline if the provider ignores it. |
Do not pass an Admin token, API key, shop domain, customer identity, or absolute URL. Shopify signs the App Proxy request, and Tab Promo derives the shop and logged-in customer context on the server.
Public API
The package has one supported import path. Its runtime exports are init,
PromoSdkError, and inspectGiftClaims:
import {
init,
PromoSdkError,
inspectGiftClaims,
type PromoClient,
} from '@tabcommerceio/promo-sdk'
init(options) returns a PromoClient with these methods:
| Method | Result |
|---|---|
resolve(request) | Current promotion response, or null when a facts read cannot be used. |
mount(options) | Resolves facts for a renderer and returns a disposer. |
refresh() | Refreshes every active mount, or the last resolved request when nothing is mounted. |
notifyCartChanged() | Schedules refresh after a successful cart mutation owned by the theme. |
getTopupProducts(input) | Live top-up products, or null when unavailable. |
addTopupItem(input) | A Shopify-confirmed fresh cart after an explicit add action. |
claimGift(input) / removeGift(input) | A Shopify-confirmed fresh cart after an explicit gift action. |
checkGiftClaim({ ruleId }) | Reads the outcome of an unresolved gift claim for that rule, or the current cart, without writing to the cart. |
abandonGiftClaim({ ruleId }) | Forgets an unresolved gift claim for that rule. It never changes the cart. |
reconcileGiftClaims({ facts, ...options }) | Reports claim state for one response’s opaque gift_facts snapshot; removal requires an explicit { remove: true }. |
applyDiscountCode(input) | Whether the SDK started navigation to Shopify’s discount link for the code. |
captureAttributionSource({ token }) | A Shopify-confirmed cart carrying a share-link token for last-touch source reporting. |
destroy() | Removes listeners, timers, and active lifecycle work. |
All supported types are exported from the same root. Do not import generated models, theme adapters, storage keys, cart markers, or package subpaths. Those are implementation details rather than SemVer commitments.
Render promotion facts
Use resolve for a one-off read. A product surface requires the current product
ID; pass the selected variant when it is known. IDs may be numbers or numeric
strings, and country is a two-letter code.
const response = await promo.resolve({
surface: 'product',
productId: currentProductId,
variantId: selectedVariantId,
country: currentCountryCode,
})
if (response) renderPromotionFacts(response.promos)
else clearPromotionFacts()
Use mount for a section that should refresh while the shopper remains on the
page. Dispose the mount when Shopify unloads that section, and destroy the
client when the final page or app shell is torn down.
const stop = await promo.mount({
request: { surface: 'global' },
render(response) {
renderPromotionFacts(response.promos)
},
onEmpty() {
clearPromotionFacts()
},
})
document.addEventListener('shopify:section:unload', stop, { once: true })
// At final page-shell teardown:
promo.destroy()
mount calls render for a response with promotions and onEmpty for an
empty response or a failed read, so facts from an earlier render never stay on
screen as current. Disposing a mount, or starting its next refresh, cancels the
read still in flight for it.
Render only primitives and reward kinds your UI understands. The current
primitives are progress, status, and ladder, but the v:1 wire contract
is additive. Unknown fields or future values must fall back to no promotional
UI, not to a locally calculated offer. The SDK drops unknown primitives before
your renderer sees them. A status fact for a group offer can also carry
current, remaining_to_next, and discounted_quantity.
Money values are integer minor units in the reported presentment currency. Use
the currency’s fraction digits rather than assuming every currency has two.
Gift, gift display, and top-up IDs are short numeric strings such as "123", never
GraphQL GIDs. scope_ref product and variant IDs are JSON integers.
Show the merchant’s own wording
Any promo can carry an optional display object, typed PromoDisplay, with
text the merchant already shows shoppers at Shopify checkout. It lets a theme
reuse the merchant’s wording instead of inventing it.
interface PromoDisplay {
title?: string
message?: string
}
| Field | Present only when |
|---|---|
title | Checkout shows the campaign name as a rule’s label. That happens for an automatic-discount campaign with a rule that has no checkout label of its own (for a tiered rule, only while at least one tier has no name). Code-discount campaigns, and campaigns whose rules all have their own labels, have no title. |
message | Checkout shows the rule’s own discount label: always for an untiered rule, and for a tiered rule only while at least one tier has no name. It is omitted when blank or when it repeats title. |
display itself is omitted when both fields are empty, so keep copy of your
own for that case. Both values are plain text, passed through verbatim with
surrounding whitespace trimmed. They are never markup: insert them with
textContent or your template engine’s escaping, never as HTML. Tab Promo does
not translate, format, or shorten them, and they are in the language the
merchant wrote them in. Localization stays with the theme: prefer your own
localized copy keyed by campaign_id and use display as the fallback.
function promoHeading(promo: Promo): string | undefined {
return localizedCopyFor(promo.campaign_id) ?? promo.display?.title ?? promo.display?.message
}
heading.textContent = promoHeading(promo) ?? defaultHeading
The text comes from the published campaign version, never from unpublished
edits, internal descriptions, rule names, or notes. A renamed campaign shows
its new title after the next publish, and a rollback serves the restored
version’s name. A version published before display existed has no title
until it is published again; its rules’ checkout labels still appear as
message.
For tier ladders, checkout shows each tier’s name when every tier is named, so
message is omitted and each rung’s name is the label to use. A gift ladder,
several gift rules grouped on the product page, keeps each display field only
when every grouped rule has the same value.
display is part of the facts read, so it follows the same Pro and Growth plan
gate as the rest of the response.
Explicit commerce actions
Commerce changes must follow a shopper action. The SDK validates public facts, performs Cart AJAX work, and resolves only after Shopify returns a confirming fresh cart.
async function claim(gift, selectedVariantId) {
try {
const cart = await promo.claimGift({
gift,
...(selectedVariantId ? { variantId: selectedVariantId } : {}),
})
renderCart(cart)
} catch (error) {
if (error instanceof PromoSdkError) {
showCartActionError(error.code)
return
}
throw error
}
}
claimGift rejects with invalid_input when the gift is not eligible, is
already claimed in the cart, or is unavailable. A gift’s selection decides
what you pass:
pool_choice— the shopper picks gifts fromcandidates, each a product with its eligiblevariant_idsanddisplay. In one call, passselections, an array of{ variantId, quantity }for variants markedavailable_for_salein the candidate’sdisplay.variants. The total, plus pool gifts already in the cart, can’t exceed the gift’squantity(up to 20). Withrepeat_policy: 'distinct_products'each product can be picked once;allow_repeatsallows several of one product. Shoppers can claim part of the quantity now and the rest on a later visit.shopper_choice— pass onevariantIdfrom the gift’s availabledisplay.variants.- pinned (
selectionomitted or'pinned') — the gift uses its ownvariant_id; callclaimGift({ gift }).
For pinned and shopper-choice gifts, any quantity you pass must equal the
gift’s quantity. removeGift takes the Cart AJAX line key of the gift line.
const cart = await promo.claimGift({
gift, // selection: 'pool_choice'
selections: [
{ variantId: '44120001', quantity: 1 },
{ variantId: '44120007', quantity: 1 },
],
})
A pool claim adds every selection in one Cart AJAX request. If the result isn’t
fully confirmed, claimGift rejects with a PromoSdkError whose giftClaim
describes what happened, with the cart read afterward and each selection’s
requestedQuantity and confirmedQuantity:
| Code | giftClaim.state | What to do |
|---|---|---|
gift_claim_partial | partial | Shopify kept only some gifts. Show the confirmed ones and let the shopper choose whether to request the rest. |
gift_claim_unknown | unknown | The request timed out before its outcome was confirmed. Call checkGiftClaim({ ruleId }) before retrying, or abandonGiftClaim({ ruleId }) to stop waiting. Retrying the same selection first checks the cart instead of adding twice. |
gift_claim_conflict | conflict | The cart changed during the claim and now holds more pool gifts than allowed. Review it with reconcileGiftClaims. |
For a top-up recommendation, fetch choices only after the shopper opens the
chooser, then add an available variant after the shopper selects it.
getTopupProducts reads the cart currency when you omit currency.
addTopupItem adds a quantity of 1 by default.
const products = await promo.getTopupProducts({ poolId })
if (products !== null) renderTopupChoices(products)
const cart = await promo.addTopupItem({ variantId, quantity: 1 })
renderCart(cart)
Cart helpers also accept sections and sectionsUrl, which are passed to
Shopify’s Section Rendering API with the cart mutation.
For a discount code, require an explicit shopper click. applyDiscountCode
navigates the page to Shopify’s /discount/{code} link under the current locale
root. A true result means only that navigation started; Shopify checkout still
decides whether the code is accepted. It returns false for a code containing
anything other than letters, digits, _, or -, or after destroy().
Gift reconciliation
inspectGiftClaims(cart, gifts) reports claim state without changing the cart.
Each accepted PromosResponse also carries an opaque, client-local
gift_facts snapshot for that exact response. Pass that snapshot when reviewing
claims, then reuse the same snapshot for a shopper-approved removal pass:
const facts = await promo.resolve({ surface: 'global' })
if (facts) {
const report = await promo.reconcileGiftClaims({ facts: facts.gift_facts })
showGiftClaimReview(report.claims)
if (shopperConfirmedCleanup) {
const removed = await promo.reconcileGiftClaims({
facts: facts.gift_facts,
remove: true,
})
if (removed.removedCart) renderCart(removed.removedCart)
}
}
Each claim has a state of valid, stale, ineligible, duplicate, or
unknown (the SDK can’t classify the claim, so it leaves it alone). For pool
gifts, validQuantity and excessQuantity split a line that holds more than
the pool allows. { remove: true } changes only claims that are not valid or
unknown, reducing each line to its validQuantity; removedCart is present
only when a line changed.
Do not serialize a snapshot, substitute a different surface’s response, or rely on a client-global “latest” Gift list. Resolve fresh facts for each review.
Never recreate the SDK’s private cart metadata. The Discount Function independently revalidates every gift at checkout, including forged, stale, duplicated, unavailable, and no-longer-eligible claims.
How the SDK reports the cart
Before each facts read, the SDK projects the current Cart AJAX cart for Tab
Promo. A line is a claimed Tab Promo gift by the same rule the checkout
Discount Functions use: its private gift marker, trimmed, is non-empty and is
not false in any letter case. Claimed gift lines, including the components of
a gift bundle line, are sent with gift: true. They still count for conditions
that check whether a product is in the cart, but they are left out of the cart
total and item count, so a gift never helps reach its own threshold.
claimGift, inspectGiftClaims, and reconcileGiftClaims use the same rule
and match each claim to its exact rule ID.
Each projected line also carries its Cart AJAX line key, which lets
complete-group offers order equal-price lines without merging repeated
variants.
Failure behavior
| Operation | Integration behavior |
|---|---|
init | Throws PromoSdkError (invalid_proxy_base) for an unsupported proxyBase. |
resolve and getTopupProducts | Fail soft: invalid input, timeout, network failure, malformed data, plan denial, and non-success responses return null. Clear or preserve non-promotional UI and never block cart or checkout. |
mount | Throws PromoSdkError (invalid_input) for an invalid request or missing render. Otherwise treat an empty or unusable response as no promotion UI, and do not show stale facts as current. |
| Cart helpers | Fail closed with PromoSdkError unless Shopify confirms a fresh cart containing the requested result. Codes include invalid_input, network_error, cart_mutation_failed, cart_confirmation_failed, destroyed, and, for pool gifts, gift_claim_partial, gift_claim_unknown, and gift_claim_conflict. |
applyDiscountCode | Reports navigation start only, not code acceptance. |
The SDK does not log to the shopper console by default. Diagnostics are optional and intended for the implementation team’s monitoring rather than shopper UI.
Cart changes and assignments
Call promo.notifyCartChanged() after a successful cart mutation performed by
your theme or another integration that does not emit a recognized cart event.
SDK cart helpers already return a confirmed cart and schedule their own facts
refresh.
The client listens on document for tab-promo:cart:changed, cart:updated,
cart:refresh, cart:change, cart-updated, and shopify:section:load.
Notifications within 100 ms are coalesced into one refresh.
Most integrations need no experiment configuration. The SDK reads the current cart, converges accepted session or rollout assignments, and confirms the checkout-relevant assignment through Cart AJAX. Do not create visitor IDs, write the private assignment attribute yourself, or treat local storage as checkout or attribution truth.
For a session A/B test, the SDK keeps the server-issued assignment signature
and sends it back unchanged, so the shopper stays in the same variant and the
order can be attributed to it.
Capture share-link tokens
A share link carries a tp_source query parameter. When the official Theme
Extension’s app embed is active it saves the token for you. A custom theme
saves it with captureAttributionSource:
const url = new URL(location.href)
const token = url.searchParams.get('tp_source')
if (token) {
try {
await promo.captureAttributionSource({ token })
url.searchParams.delete('tp_source')
history.replaceState(history.state, '', url)
} catch (error) {
// Keep tp_source in the address so a later page can retry.
if (!(error instanceof PromoSdkError)) throw error
}
}
The token must be 43 base64url characters after trimming; anything else
rejects with invalid_input. The SDK writes the token to the cart through Cart
AJAX and resolves only when Shopify’s returned cart confirms it, otherwise it
rejects with cart_confirmation_failed. Identical concurrent calls share one
request, and a later confirmed token replaces an earlier one. The token never
changes promotion eligibility, A/B test assignment, or checkout pricing.