Tab Commerce

Storefront promotion API

Understand the additive App Proxy contract behind Tab Promo storefront facts, assignments, gifts, and top-up products.

The Storefront promotion API is the signed Shopify App Proxy surface behind Tab Promo’s visible storefront facts. It describes promotion opportunities and current eligibility; it does not change checkout pricing. Shopify Discount Functions remain authoritative for discounts and final totals.

For custom Shopify Online Store themes, the Storefront SDK is the recommended integration path. It projects Cart AJAX data, manages assignment convergence, applies timeout and failure behavior, and provides confirmed commerce helpers. Use the raw contract below only when you are maintaining a reviewed low-level integration.

Transport and identity

Requests use the merchant-configured same-origin App Proxy base:

POST /apps/{proxy-subpath}/promos
GET  /apps/{proxy-subpath}/topup-pools/{pool_id}/products

Do not assume the proxy subpath is always tab-promo, and do not call the Tab Promo backend host directly. Shopify injects and signs the shop and logged-in customer context. Theme code must never send an Admin token, API key, shop, or customer identity in the body. A request without a valid App Proxy signature is rejected with 401.

The official Theme Extension uses a separate rendered-HTML endpoint. That endpoint is private to the extension and is not part of this contract.

Plan requirements

The server checks the shop’s effective plan on every request.

EndpointPlansDenied response
POST /promosPro and Growth403 subscription_required on Free and Starter
GET /topup-pools/{pool_id}/productsEvery plan with live promotions; a paid plan must be active or trialing403 subscription_required on a Free store whose monthly orders are used up, or on an inactive paid plan

context.experiment_assignment is honored only on Growth. On other plans the server ignores it and omits campaigns that belong to an A/B test.

Promotion request

{
  "v": 1,
  "context": {
    "surface": "cart",
    "country": "US",
    "experiment_assignment": {
      "experiment_id": "11111111-1111-1111-1111-111111111111",
      "variant_key": "b",
      "source": "rollout"
    }
  },
  "cart": {
    "currency": "USD",
    "presentment_currency_rate": 1,
    "total_price": 7200,
    "item_count": 2,
    "attributes": {
      "tab_promo_rollout_assignment": "{\"experiment_id\":\"11111111-1111-1111-1111-111111111111\",\"variant_key\":\"b\",\"source\":\"rollout\"}"
    },
    "items": [
      {
        "key": "46301971546166:9f2c1a7e4b",
        "product_id": 8674529443894,
        "variant_id": 46301971546166,
        "quantity": 2,
        "line_price": 7200
      },
      {
        "key": "46301971579001:3d8e0b6a12",
        "product_id": 8674529509430,
        "variant_id": 46301971579001,
        "quantity": 1,
        "line_price": 2500,
        "gift": true
      }
    ]
  }
}

Request fields

FieldMeaning
vContract version. The only supported value is 1.
context.surfacecart, product, collection, or global.
context.product_idRequired for a product surface. Use the numeric Shopify ID as a JSON integer, not a GID.
context.variant_idOptional selected variant. It is accepted only together with product_id.
context.countryOptional uppercase ISO 3166-1 alpha-2 country code.
context.experiment_assignmentOptional experiment_id (UUID), variant_key, and source (rollout or session). A session assignment must also echo the server-issued signature unchanged. The SDK manages this for normal integrations.
cartOptional Cart AJAX projection. Product requests can resolve relevant facts before the product is in the cart.
cart.currencyUppercase ISO 4217 presentment currency.
cart.presentment_currency_rateOptional finite, positive shop-to-presentment rate. Cross-currency money facts are suppressed when it is required but unavailable.
cart.total_price / line_priceNon-negative integer minor units in cart currency.
cart.itemsRequired when cart is sent; may be empty. Each item needs integer product_id and variant_id of at least 1, a quantity of at least 1, and a non-negative integer line_price. Gift lines are validated the same way.
cart.items[].keyOptional string of at most 256 characters: the Cart AJAX line key. Complete-group offers use it to order lines with the same price without merging repeated variants. Without it, each array row stays a separate line.
cart.items[].giftOptional JSON boolean. Send true for a claimed Tab Promo gift line: a Cart AJAX line whose _tab_promo_gift property, trimmed, is non-empty and is not false in any letter case, the same rule the checkout Discount Functions use. Omitted or false means a paid line.
cart.attributesOptional. The only accepted key is tab_promo_rollout_assignment; any other key fails validation. The SDK owns this projection.

Request objects accept only the fields listed above; an unknown field fails validation with 422.

The server resolves collection membership and any permitted customer, product, or variant facts. Customer identity and metafield values never belong in this body.

Send claimed gift lines with gift: true rather than dropping them. The server keeps a gift line only for conditions that check whether a product is in the cart, as checkout does, and leaves it out of subtotals, quantities, collection and product totals, per-line reward values, and every other cart calculation. cart.total_price and cart.item_count must also exclude gift lines, so a gift cannot satisfy its own threshold. In the example above, the 2500 gift line is not part of 7200 or 2. The SDK builds this projection for you.

Promotion response

{
  "v": 1,
  "evaluated_at": "2026-08-06T10:30:00Z",
  "experiment_assignment": {
    "experiment_id": "11111111-1111-1111-1111-111111111111",
    "variant_key": "b",
    "source": "session",
    "signature": "3qJv0x0b9n2pY7yQkT4c1mZ8aR6uW5eDhG2sLk9Pz1A"
  },
  "promos": [
    {
      "campaign_id": "0192f0a3-d3a1-7000-8000-000000000001",
      "primitive": "progress",
      "unit": "money",
      "scope": "cart",
      "currency": "USD",
      "current": 7200,
      "target": 10000,
      "remaining": 2800,
      "progress_percent": 72,
      "achieved": false,
      "reward": {
        "kind": "order_percentage",
        "percentage": 10
      },
      "topup": {
        "pool_id": "01930c1b-e872-7000-8000-000000000001"
      },
      "display": {
        "message": "10% off orders over $100"
      }
    }
  ],
  "diagnostics": {
    "evaluated": 1,
    "skipped": 0
  }
}

An empty promos array is a normal successful result. That includes visitors the merchant’s storefront price-visibility setting excludes.

experiment_assignment is optional and contains the server-accepted session assignment. SDK consumers should let the SDK persist and confirm it; raw consumers must treat the complete experiment_id + variant_key + source tuple and its signature as one value. If an echoed session assignment is missing, unsigned, or no longer valid while a session test is running, the server assigns a variant again.

Gift, gift display, and top-up IDs in a response are short numeric IDs represented as strings, such as "456". scope_ref.product_id and scope_ref.variant_id are JSON integers. Response IDs are never GraphQL GIDs.

Merchant display text

Any promo can carry an optional display object with merchant-authored text that Shopify checkout already shows shoppers for the same promotion.

FieldTypePresent only when
display.titlestringCheckout shows the campaign name as a rule’s label: an automatic-discount campaign has a rule without its own checkout label (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.
display.messagestringCheckout shows the matched rule’s own discount label: always for an untiered rule, and for a tiered rule only while at least one tier has no name. Omitted when blank or when it repeats title.

The object is omitted when both fields are empty. Values are plain text passed through verbatim with surrounding whitespace trimmed, never HTML, and stay in the language the merchant wrote them in; the theme owns markup, surrounding copy, and localization. They come from the published campaign version and never include internal descriptions, rule names, notes, or unpublished edits. A version published before display existed has no title until it is published again.

When every tier of a ladder has a name, checkout shows the tier names, so message is omitted and each rung’s name is the label to use. A gift ladder grouped from several gift rules keeps each display field only when every grouped rule has the same value.

Display primitives

PrimitiveMeaningTypical presentation
progressProgress toward one money or quantity threshold. achieved may be false or true.Opportunity messaging while unachieved; confirmation after achievement.
ladderOrdered tiers and progress toward the next rung. Returned on the product surface; a ladder grouped by product scope can also appear on the global surface.Tier table using rungs, current_rung, next_rung, and remaining_to_next.
statusEligibility with no useful progress track. eligible can be false when the merchant chose to advertise to non-qualifying shoppers. Group offers can add current, remaining_to_next, and discounted_quantity.Available or unavailable state.

An unachieved progress or ladder entry is informational and does not promise that a Shopify discount is already applied. Consumers must render only the primitive and reward kinds they understand and ignore future additive values.

Money values use the response currency’s minor units. Never assume two fraction digits. Fixed product rewards are per-item facts rather than cart-total savings, and Buy X Get Y facts describe the mechanic rather than a direct product-page sale price.

Gifts

A promo with a free_gift reward can carry a gift object. Every gift carries a rule_id, quantity, and eligible. Its selection is one of three types:

selectionFieldsMeaning
omitted or "pinned"variant_idOne fixed variant.
"shopper_choice"product_id, display.variantsThe shopper picks one available variant of a product.
"pool_choice"candidates, repeat_policyThe shopper picks up to quantity gifts from a pool. Each candidate has a product_id, its eligible variant_ids, and a display. repeat_policy is distinct_products (one gift per product) or allow_repeats.

New gift rules created in the admin use pool_choice; campaigns with pinned or shopper-choice gifts keep working. A gift display describes the product (title, image, handle, value). When it describes one variant, and on each entry of display.variants, an optional options array lists that variant’s option values as { "name": "Color", "value": "Navy" } in the product’s option order, so a theme can offer one choice per option instead of one list of combined titles. options comes from the App Proxy response; Storefront SDK 1.1.0 does not expose it yet, so SDK users combine variant titles. On the product surface, tiered gifts from one campaign can be grouped into a ladder whose rungs each carry their own gift.

Gift facts are valid input to the public SDK’s claimGift, removeGift, and reconciliation helpers after explicit shopper consent. Do not recreate the private cart marker or write gift lines from raw response fields. The SDK owns cart marking and confirmation, and the Discount Function revalidates the claim at checkout.

Top-up products

A money progress promo may include topup.pool_id when the shopper is within the merchant-configured recommendation gap. Fetch products only after the shopper opens the chooser:

GET /apps/{proxy-subpath}/topup-pools/{pool_id}/products?currency=USD&presentment_currency_rate=1

pool_id is a UUID and currency is a required uppercase ISO 4217 code for the raw endpoint. presentment_currency_rate is optional and must be positive.

The response body is a JSON array. Each product has product_id, title, an optional image_url, and variants; each variant has variant_id, title, available_for_sale, and an optional price in minor units. Never invent a missing price or offer an unavailable variant. An unknown pool, or a visitor the price-visibility setting excludes, returns 404 topup_pool_not_found. SDK users should call getTopupProducts and addTopupItem instead of assembling these requests and cart mutations themselves.

Failures

Request-level failures use an error response and contain no promos. A partial campaign failure still returns HTTP 200: unusable campaigns are omitted and counted in diagnostics.skipped.

Error bodies use one envelope:

{ "error": { "code": "subscription_required", "message": "..." } }
StatuscodeCause
400unsupported_versionv is greater than 1.
401invalid_signatureThe request did not arrive through a valid Shopify App Proxy signature.
403subscription_requiredThe shop’s plan does not include the endpoint.
404shop_not_foundThe shop is unknown or the app is not installed.
404topup_pool_not_foundTop-up endpoint only, as described above.
422unprocessable_entityThe body or query failed validation. A details array can name the field.
500internal_server_errorUnexpected server failure.

Treat every timeout, network failure, malformed response, or non-success status as no current promotion UI. The Storefront API is best-effort enrichment and must never block cart or checkout. SDK facts reads implement this fail-soft behavior; SDK cart mutations fail closed with a typed error unless Shopify confirms a fresh cart.

Versioning

Version v:1 is additive and unknown-tolerant. New fields, reward kinds, and primitive values can appear without a version change. Consumers must ignore what they do not understand. Removing a field or changing its meaning requires a new wire version.

Sandbox before live theme work

Use the Storefront sandbox, opened from Open sandbox on the Storefront page in Tab Promo admin, to exercise synthetic product, collection, cart, and global contexts without modifying a live theme. It uses the same display vocabulary but remains admin-session authenticated.

Complete the final integration check on a development theme and real Shopify cart. That is the only place to verify section lifecycle, Cart AJAX behavior, assignment convergence, gift claims, top-up additions, code redirects, and an actual checkout result together.