@tabcommerceio/promo-sdk
v1.1.0
Published
Browser SDK for Tab Promo storefront facts and commerce helpers.
Readme
@tabcommerceio/promo-sdk
@tabcommerceio/promo-sdk is a browser-only, ESM-only SDK for rendering Tab
Promo facts in a Shopify Online Store theme. It reads a shop's same-origin
App Proxy and provides explicit Cart AJAX actions. Your theme owns the DOM,
copy, accessibility, locale, and styling; Tab Promo owns promotion evaluation
and Shopify checkout remains authoritative for discounts and final totals.
The package does not provide rendered Tab Promo HTML, UI components, an Admin API client, customer identity inputs, or a local promotion evaluator.
Stable since
1.0.0; releases follow SemVer. A repository version does not by itself mean that version has been published — check npm.
The committed manifest version is the release signal. A version change merged
to main is validated and published by GitHub Actions through npm Trusted
Publishing, then tagged as promo-sdk@<version>. Source changes without a
version change are not published, and CI never bumps the version itself. Package source metadata
points to the private tabcommerce/tab-promo repository. Prerelease publishes
target the next dist-tag; stable versions use latest. The repository's
publish:promo-sdk:dry command performs the same package gate without registry
authentication or publication.
Service entitlement
Supported public promotion facts (/promos) require an effective active or
trialing Pro or Growth subscription. Installing this npm package does not
confer service access. Starter includes the official Theme Extension's rendered
promotion experience and shared gift/top-up helpers. Those browser endpoints do
not authenticate a particular JavaScript library; caller headers are not paid
entitlement credentials. Free Preview does not provide production storefront
access. Pending downgrades preserve the current effective plan's rights.
Install and initialize
pnpm add @tabcommerceio/promo-sdk@^1.0.0Create one client for the current page or storefront shell. proxyBase must be
the merchant-configured, same-origin relative /apps/... path. It is commonly
/apps/tab-promo, but integrations must not assume that path is universal.
import { init, type PromoDiagnostic } from '@tabcommerceio/promo-sdk'
const promo = init({
proxyBase: '/apps/tab-promo',
timeoutMs: 2_500,
onDiagnostic(diagnostic: PromoDiagnostic) {
// Optional: send a non-shopper-facing diagnostic to your own monitoring.
reportStorefrontDiagnostic(diagnostic)
},
})Do not pass an Admin token, API key, shop, customer identity, or an absolute URL. Shopify signs the App Proxy request and Tab Promo derives identity on the server.
Public root API
The package has one public import path:
import { init, PromoSdkError, inspectGiftClaims, type PromoClient } from '@tabcommerceio/promo-sdk'Its runtime exports are init, PromoSdkError, and inspectGiftClaims.
init(options) returns a PromoClient with these public methods:
| Method | Result |
| --- | --- |
| resolve(request) | Current PromosResponse, or null when a facts read cannot be used. |
| mount(options) | Resolves facts for a renderer and returns a disposer. |
| refresh() / notifyCartChanged() | Refresh active fact renderers; notify after a theme-owned successful cart mutation. |
| getTopupProducts(input) | Live top-up products, or null when unavailable. |
| addTopupItem(input) | A Shopify-confirmed cart after an explicit top-up action. |
| claimGift(input) / removeGift(input) | A Shopify-confirmed cart after an explicit gift action. |
| reconcileGiftClaims({ facts, ...options }) | A gift-claim report for one response's gift_facts snapshot; removal is opt-in with { remove: true }. |
| applyDiscountCode(input) | Whether a valid Shopify checkout redirect was started. |
| captureAttributionSource({ token }) | A Shopify-confirmed cart carrying a Share-link token for last-touch source reporting. The public method name is retained. |
| destroy() | Removes SDK listeners, timers, and active lifecycle work. |
inspectGiftClaims(cart, gifts) is a pure report helper. It compares a Cart
AJAX payload with current public gift facts and returns claim states without
changing the cart. Each accepted PromosResponse also has an opaque,
client-local gift_facts snapshot. Pass that exact snapshot to
reconcileGiftClaims; it prevents one surface's refresh from changing the
facts used to review another surface's claims.
All public types are exported from this same root, including Promo,
PromosResponse, Gift, TopupPoolProduct, CartJsPayload, and
PromoSdkErrorCode. Do not import package internals or rely on a subpath: the
root API is the supported SemVer boundary.
Render promotion facts
Use resolve for a one-off fact read. A product surface requires a current
product ID; pass the selected variant when it is known.
import type { PromosResponse } from '@tabcommerceio/promo-sdk'
const response: PromosResponse | null = 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 it when the section is unloaded, and call destroy() at final
page-shell teardown.
const stop = await promo.mount({
request: { surface: 'global' },
render(response) {
renderPromotionFacts(response.promos)
},
onEmpty() {
clearPromotionFacts()
},
})
document.addEventListener('shopify:section:unload', stop, { once: true })Render only facts you understand. The supported primitives are progress,
status, and ladder; keep a conservative no-op fallback as the wire contract
is additive. Do not calculate eligibility, discounts, thresholds, or campaign
combinations locally.
import type { Promo } from '@tabcommerceio/promo-sdk'
function renderPromotionFact(promo: Promo) {
switch (promo.primitive) {
case 'progress':
renderProgress(promo.current, promo.target, promo.remaining)
return
case 'status':
renderStatus(promo.eligible)
return
case 'ladder':
renderLadder(promo.rungs, promo.current_rung, promo.next_rung)
return
default:
// A future primitive is not a reason to make up promotional UI.
return
}
}A promo may carry display, merchant-authored text that Shopify checkout
already shows customers: title is the campaign name, present only when
checkout shows it as the label of a rule without its own label, and message
is the rule's checkout discount label (omitted when it repeats title). Either field, or the whole object, may be absent. The text is
passed through verbatim in the merchant's authoring language: insert it as
plain text (never as HTML), and keep localization and surrounding copy in your
theme. It never contains internal campaign notes or unpublished edits.
function promoHeading(promo: Promo): string | undefined {
// Prefer your own localized copy; fall back to the merchant's text.
return localizedCopyFor(promo.campaign_id) ?? promo.display?.title ?? promo.display?.message
}
heading.textContent = promoHeading(promo) ?? ''Money values are integer minor units in the reported presentment currency. Use the currency's own fraction digits rather than assuming two decimal places.
function formatMinor(value: number, currency: string, locale: string): string {
const formatter = new Intl.NumberFormat(locale, { style: 'currency', currency })
const digits = formatter.resolvedOptions().maximumFractionDigits
return formatter.format(value / 10 ** digits)
}The minimal no-framework example is in
examples/vanilla. It imports only public root symbols and
renders the three supported primitive facts without adding a local eligibility
or pricing calculation.
Explicit commerce actions
Commerce changes must result from a shopper action. For example, fetch a top-up pool only when its chooser opens, and add an available variant only after a shopper chooses it:
const products = await promo.getTopupProducts({ poolId })
if (products !== null) renderTopupChoices(products)
await promo.addTopupItem({ variantId: selectedVariantId, quantity: 1 })
promo.notifyCartChanged()The App Proxy requires an uppercase ISO 4217 currency. When currency is
omitted as above, the SDK first derives it (and a valid presentment rate when
available) from the current Cart AJAX payload. Pass currency explicitly only
when a custom cart provider cannot supply it; explicit values must be uppercase
three-letter codes and presentmentCurrencyRate, when supplied, must be
greater than zero.
For a pinned gift, pass its public Gift fact to claimGift({ gift }). For a
shopper-choice gift, show only its public available display variants and pass
the selected variantId. Resolve current facts, then inspect reconciliation
results before asking the shopper to approve a 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) {
await promo.reconcileGiftClaims({ facts: facts.gift_facts, remove: true })
}
}For X-of-Y gifts (selection: 'pool_choice'), render the explicit candidates
and only their available display variants. The quota is shared across products
and variants; distinct_products allows one unit per product, while
allow_repeats allows repeated units up to the shared quota. X=1 and Y=1 use
this same interface. Pass the shopper's selected vector in one request:
if (gift.selection === 'pool_choice' && gift.eligible) {
await promo.claimGift({
gift,
selections: [{ variantId: selectedVariantId, quantity: selectedQuantity }],
})
}The helper reads a fresh cart, serializes gift writes within a client and
confirms every requested quantity. It does not guarantee an atomic transaction
across tabs. After success or failure, refresh promotion facts and the cart UI.
gift_claim_partial reports confirmed and missing selections in
error.giftClaim; keep the confirmed items and ask before requesting missing
items. gift_claim_conflict reports a cart that needs review. An excess marked
quantity stays paid at checkout until the shopper explicitly removes it.
For gift_claim_unknown, disable new submissions for that rule. Use promo.checkGiftClaim({ ruleId }) to observe the pending batch without
writing, including after eligibility is lost. A fully observed batch resolves
without another write. A subsequent claimGift attempt also checks pending
state and never resends it while unresolved. Offer a
separate explicit abandonment action using promo.abandonGiftClaim({ ruleId });
this only clears local pending state. A delayed Shopify response may still add
items, so read the cart and current facts again before asking for a new
selection. Do not automatically retry, replace unavailable gifts or remove
paid items. Reconciliation preserves valid pool quantities and reduces only
explicitly approved excess gift quantities.
Pool support first ships in 1.1.0; the published 1.0.0 does not contain
it. This checkout does not establish npm publication — check npm for 1.1.0.
The official Theme builds shared claim code directly from source;
npm publication is not its prerequisite. Pool authoring is available to every
normally authorized merchant without operator registration. Release compatibility
requires pool-capable official Theme and both Function profiles. There are
currently no external SDK users to migrate; future custom gift integrations
must use pool-capable decoding and claim/cleanup helpers. See the
rollout runbook.
Use the client methods for these actions. Cart-line metadata, transport details, headers, and browser storage are implementation details, not public integration inputs.
Failure model
Facts reads fail soft. resolve() and getTopupProducts() return null for a
timeout, network error, malformed response, or unusable HTTP response. Clear
or preserve non-promotional UI and never block cart or checkout because facts
cannot be read. mount() calls onEmpty for an empty or failed current response.
The read deadline covers cart reads and resolve retries. A custom
cartProvider(signal?) may use the optional signal to cancel its own work; the
SDK still settles on timeout or destruction when a provider ignores it. Disposing
a mount or starting its next refresh cancels the previous read. Already dispatched
cart writes retain their own confirmation semantics and are never replayed by
the read deadline.
Cart helpers fail closed. addTopupItem, gift helpers, and reconciliation
removal resolve only after Shopify returns a confirming fresh cart; otherwise
they throw PromoSdkError. Handle that error as an explicit cart-action
failure, not as a successful promotion. applyDiscountCode() starts the
locale-aware Shopify discount route and reports only whether browser navigation
was initiated; checkout decides whether the code is accepted. Clipboard access
is deliberately outside the SDK contract: it never copies a code or treats a
clipboard write as discount success.
The SDK does not log to the shopper console by default. onDiagnostic is an
optional integration hook for non-blocking read diagnostics.
