Tab Commerce

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’s latest tag. Releases follow SemVer, so a ^1.1.0 range receives compatible fixes and additions without breaking changes. Version 1.1.0 adds gift pools (pool_choice), batch gift claims, and the checkGiftClaim and abandonGiftClaim recovery 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 callTab Promo endpointPlans
resolve, mount, refreshPOST /promosPro and Growth
getTopupProductsGET /topup-pools/{pool_id}/productsEvery plan with live promotions: Free within its monthly orders, or an active or trialing paid plan
addTopupItem, claimGift, removeGift, checkGiftClaim, abandonGiftClaim, reconcileGiftClaims, applyDiscountCode, captureAttributionSourceShopify Cart AJAX and Shopify discount links onlyNo 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)
  },
})
OptionBehavior
proxyBaseRequired. A relative /apps/... path. Anything else, including an absolute URL or query string, makes init throw PromoSdkError with code invalid_proxy_base.
timeoutMsOne deadline for each facts read, covering the cart read, the App Proxy request, and any assignment retry. Defaults to 10000; 0 disables it.
onDiagnosticOptional observer for read failures and invalid input. It never receives shopper-facing copy, and an exception inside it is ignored.
fetch, cartProvider, eventTarget, getRolloutAssignmentOptional 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:

MethodResult
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
}
FieldPresent only when
titleCheckout 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.
messageCheckout 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 from candidates, each a product with its eligible variant_ids and display. In one call, pass selections, an array of { variantId, quantity } for variants marked available_for_sale in the candidate’s display.variants. The total, plus pool gifts already in the cart, can’t exceed the gift’s quantity (up to 20). With repeat_policy: 'distinct_products' each product can be picked once; allow_repeats allows several of one product. Shoppers can claim part of the quantity now and the rest on a later visit.
  • shopper_choice — pass one variantId from the gift’s available display.variants.
  • pinned (selection omitted or 'pinned') — the gift uses its own variant_id; call claimGift({ 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:

CodegiftClaim.stateWhat to do
gift_claim_partialpartialShopify kept only some gifts. Show the confirmed ones and let the shopper choose whether to request the rest.
gift_claim_unknownunknownThe 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_conflictconflictThe 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

OperationIntegration behavior
initThrows PromoSdkError (invalid_proxy_base) for an unsupported proxyBase.
resolve and getTopupProductsFail 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.
mountThrows 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 helpersFail 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.
applyDiscountCodeReports 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.

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.