Headless Storefront SDK (Developer Guide)

Embed loyalty widgets on custom storefronts. The SDK runs in the shopper's browser and uses Merly public storefront APIs — never embed server-side API keys in client code.

The Merly Storefront SDK brings the same loyalty experience as Shopify and WooCommerce storefronts to your own site: balance displays, earn estimates, nudges, a rewards hub, and cart credit redemption.

You need a Merly-connected store and your storeKey from onboarding. Complete setup in the Merchant Portal (Getting Started, Store Settings, Integrations) or follow the steps your agency provides.

What the SDK does

The SDK loads in the shopper’s browser and talks to Merly’s public storefront APIs (/storefront/*). It provides the same loyalty UX as Shopify/Woo storefronts:

WidgetPurposeTypical page
**Rewards block**Balance, tier badge, “earn X credits” on product price, ready-to-use discount codesProduct PDP
**Nudges**Balance reminders, points-to-reward, expiring credits (merchant-configured)Any page
**Loyalty hub**Full balance, earn methods, recent activity, wallet / pass CTAsAccount / rewards page
**Launcher**Floating “Rewards” button opening the hub panelSite-wide
**Cart redeem**Quote + apply Merly credits → returns a **store coupon code** for your cartCart / checkout

Earn rules, tier multipliers, and campaign bonuses are platform-controlled (Merly operator). Merchants configure nudges only — not earn rates.

Prerequisites

Before integrating, confirm with the merchant / Merly onboarding:

1. Store is connected in Merly (catalog ingest, platform connection). The storeKey you pass must match the merchant’s normalized shop host (e.g. boutique.example.com, not a full URL with path).

2. Shoppers have a Merly wallet — usually created when they:

- Sign up / log in on the merchant site and are linked to Merly Identity, or

- Arrive via a Merly attribution link (mr_id — see §4).

3. Orders earn credits through Merly’s normal ingest pipeline (webhooks / integrations). The SDK does not post orders; it only displays loyalty state and redeems credits.

4. For cart redeem on custom stacks: set platform to woocommerce unless the store is a native Shopify storefront (see §7.4). Headless custom sites use the WooCommerce redemption adapter path when the catalog store is registered as WooCommerce, or ensure your store platform matches Merly’s catalog record.

**Server-side Merchant API (pk_live_* keys) is a separate product surface for ERP/agency backends. Do not embed API keys in this SDK.** Use the storefront endpoints below (no secret in the browser).

Asset URLs

Production (replace host with your Merly API gateway):

https://{gateway-host}/api/v1/integration/storefront/sdk/merly.v1.js
https://{gateway-host}/api/v1/integration/storefront/sdk/merly.v1.css

Local development (default stack):

http://127.0.0.1:8080/api/v1/integration/storefront/sdk/merly.v1.js
http://127.0.0.1:8080/api/v1/integration/storefront/sdk/merly.v1.css

The JavaScript bundle exposes a global: window.Merly.

Versioning: File name includes v1. Merly will publish new major versions as merly.v2.js when breaking changes occur.

Customer identification (critical)

Every storefront API call needs who the shopper is. The SDK resolves session in this priority order:

PriorityMechanismWhen to use
1`externalCustomerId` + `storeKey` in configLogged-in customer on **your** site
2`mr_id` query param on page loadGuest arrived from Merly affiliate / network link
3`mr_id` in `sessionStorage`Persisted from a prior `mr_id` visit
4`mr_id` cookieSet by Merly attribution redirect on your domain

4.1 Logged-in customers

Pass your ecommerce customer ID (string) — the same ID Merly Identity uses when the customer was provisioned/linked:

Merly.init({
  apiBase: 'https://api.example.com/api/v1/integration',
  storeKey: 'boutique.example.com',
  externalCustomerId: String(yourCustomer.id), // required when logged in
});

Backend requirement: Your server (or Merly’s integration webhooks) must have linked this customer to a Merly user + wallet. If not linked, APIs return shopify_customer_not_linked / empty widgets.

API parameter name: Storefront HTTP APIs use shopify_customer_id as the query/body field name for historical reasons. For headless/Woo/custom sites, pass your platform customer ID there — the SDK does this automatically.

4.2 Guests & affiliate traffic (mr_id)

Merly attribution appends ?mr_id={uuid} to landing URLs and may set a mr_id cookie. The SDK reads:

  • ?mr_id=…
  • ?utm_source=merly&utm_term={uuid} (legacy/alternate)
  • Cookie mr_id=…
  • No externalCustomerId is required when a valid mr_id session exists in Merly Redis.

    4.3 Updating session on login/logout (SPAs)

    After login, re-initialize with externalCustomerId. After logout, clear Merly guest state and re-init without it:

    function onCustomerLogin(customerId) {
      Merly.init({
        apiBase: API_BASE,
        storeKey: STORE_KEY,
        externalCustomerId: String(customerId),
        locale: 'en',
        onCouponApplied: applyMerlyCouponToCart,
      });
      refreshAllWidgets();
    }
    
    function onCustomerLogout() {
      try { sessionStorage.removeItem('merly_mr_id'); } catch (_) {}
      document.cookie = 'mr_id=; Max-Age=0; path=/; SameSite=Lax';
      Merly.init({ apiBase: API_BASE, storeKey: STORE_KEY, locale: 'en' });
    }

    Quick start — script tag (auto-mount)

    Fastest path: one script tag + placeholder <div> elements.

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <link rel="stylesheet" href="https://api.example.com/api/v1/integration/storefront/sdk/merly.v1.css" />
    </head>
    <body>
    
      <!-- Product page -->
      <div id="merly-block"></div>
    
      <!-- Site-wide nudges -->
      <div id="merly-nudges"></div>
    
      <!-- Floating launcher -->
      <div id="merly-launcher"></div>
    
      <!-- Cart page: set subtotal/currency on the element -->
      <div
        data-merly="cart-redeem"
        data-cart-subtotal="149.99"
        data-cart-currency="USD"
      ></div>
    
      <script
        src="https://api.example.com/api/v1/integration/storefront/sdk/merly.v1.js"
        data-api-base="https://api.example.com/api/v1/integration"
        data-store-key="boutique.example.com"
        data-platform="headless"
        data-locale="en"
        data-external-customer-id="CUSTOMER_ID_WHEN_LOGGED_IN"
      ></script>
    
    </body>
    </html>

    5.1 Script data-* attributes

    AttributeRequiredDescription
    `data-api-base`Yes*Integration API base, e.g. `https://{gateway}/api/v1/integration`. *Auto-derived from script `src` origin if omitted.
    `data-store-key`YesMerchant store host, lowercase, no `https://` (e.g. `shop.example.com`).
    `data-platform`No`headless` (default), `shopify`, or `woocommerce`. Affects cart **apply** backend adapter.
    `data-locale`No`en` (default), `es`, `de`, `fr`, `pt`, `zh`, `ru`.
    `data-external-customer-id`NoYour logged-in customer ID. Omit for guests with `mr_id`.
    `data-styles-href`NoOverride CSS URL; defaults to same path as JS with `.css` extension.

    5.2 Auto-mount selectors

    On DOMContentLoaded, the SDK mounts widgets when these elements exist:

    SelectorElement
    `#merly-nudges` or `[data-merly="nudges"]`Nudges
    `#merly-block` or `[data-merly="rewards-block"]`Rewards block
    `#merly-launcher` or `[data-merly="launcher"]`Launcher FAB + panel
    `[data-merly="loyalty-hub"]`Inline loyalty hub
    `[data-merly="cart-redeem"]` + `data-cart-subtotal`Cart redeem UI

    API reference (browser SDK)

    All methods are async unless noted. Call Merly.init() first.

    7.1 Global shortcuts (Merly.*)

    After init(), these delegate to the default instance:

    MethodDescription
    `Merly.getInstance()`Returns current SDK instance or `null`
    `Merly.getBalance(mrId?)`Wallet balance (number) or `null`
    `Merly.getNudges(mrId?, pageContext?)`Raw nudges JSON
    `Merly.renderNudges(target, pageContext?)`Render nudges into DOM
    `Merly.renderRewardsBlock(target, options?)`Product / balance block
    `Merly.renderLoyaltyHub(target)`Full hub panel
    `Merly.renderCartRedeem(target, cartOptions)`Cart redeem UI
    `Merly.renderLauncher(target)`FAB + slide-up hub
    `Merly.openLauncher()`Open launcher panel (no args)
    `Merly.closeLauncher()`Close launcher panel

    7.2 Instance methods (sdk.*)

    Same as above, plus:

    MethodDescription
    `sdk.getSession()`Current `{ type: 'mr_id', mrId }` or `{ type: 'customer', shopDomain, externalCustomerId }` or `null`
    `sdk.getRewardsBlock(options?)`Raw rewards-block JSON
    `sdk.getLoyaltyHub()`Raw loyalty-hub JSON
    `sdk.getCartRedemptionQuote(cartOptions)`Quote without rendering
    `sdk.applyCartRedemption({ ...cartOptions, creditsAmount })`Apply credits; returns coupon code
    `sdk.refreshLauncher()`Re-fetch hub HTML into open launcher
    `sdk.setLocale(locale)`Switch UI language
    `sdk.mountAutoWidgets(options?)`Batch mount (used by script auto-boot)

    7.3 pageContext for nudges

    ValueUse when
    `'product'`PDP (default if URL has no cart/checkout/account)
    `'cart'`Cart page
    `'checkout'`Checkout
    `'account'`Customer account / loyalty page

    Widgets in detail

    8.1 Rewards block (renderRewardsBlock)

    Shows: Current credit balance, VIP tier badge (Silver/Gold), estimated earn for a product price, unused Merly coupon codes.

    await sdk.renderRewardsBlock('#merly-block', {
      productPrice: 79.0,   // optional — enables “Earn +X credits”
      currency: 'USD',      // ISO 4217, default USD
    });

    Underlying HTTP:

    GET {apiBase}/storefront/rewards-block?shop_domain={storeKey}&shopify_customer_id={id}
        &product_price=79&currency=USD

    Or with guest session:

    GET {apiBase}/storefront/rewards-block?mr_id={uuid}

    Example response (200):

    {
      "enabled": true,
      "balance": 12500,
      "earn_rate_bps": 100,
      "estimated_earn": 790,
      "estimated_earn_boosted": 1185,
      "plan_multiplier": 1.5,
      "plan_tier": "SILVER",
      "coupons": [
        {
          "code": "MERLY-AB12CD",
          "discount_amount": "10.00",
          "currency_code": "USD"
        }
      ]
    }

    Disabled example:

    { "enabled": false, "reason": "no_customer_context" }

    Reasons: no_customer_context, session_expired, shopify_customer_not_linked.

    8.2 Nudges (renderNudges)

    Shows: Merchant-configured prompts (balance reminder, expiring credits, etc.) + optional active campaign banner (platform-wide earn multiplier).

    await sdk.renderNudges('#merly-nudges', 'cart');

    HTTP:

    GET {apiBase}/storefront/nudges?{customerQuery}&page_context=cart

    Example response:

    {
      "nudges": [
        {
          "id": "balance_reminder",
          "type": "balance_reminder",
          "title": "You have 12,500 Merly credits",
          "body": "Redeem on your next order or explore the network directory.",
          "cta_label": "View rewards",
          "cta_url": "/pages/rewards",
          "dismissible": true,
          "priority": 10
        }
      ],
      "active_campaign": {
        "id": "camp_…",
        "name": "Spring 2× earn",
        "multiplier": 2,
        "ends_at": "2026-07-01T00:00:00.000Z"
      }
    }

    Dismissed nudges are hidden for 60 minutes via sessionStorage (per nudge id).

    ---

    8.3 Loyalty hub (renderLoyaltyHub)

    Shows: Balance, subscription tier, ways to earn (read-only platform rules), recent earn activity, links to Customer PWA wallet / Apple & Google Wallet passes, network directory.

    await sdk.renderLoyaltyHub('#loyalty-page');

    HTTP:

    GET {apiBase}/storefront/loyalty-hub?{customerQuery}

    Example response (abbreviated):

    {
      "balance": "12500.0000",
      "subscription_tier": {
        "plan_tier": "SILVER",
        "label": "Silver",
        "multiplier": 1.5,
        "next_plan_tier": "GOLD",
        "next_tier_label": "Gold",
        "upgrade_cta_url": "https://app.merly.com/subscription"
      },
      "earn_methods": [
        { "rule_type": "purchase", "label": "Earn 1% back in Merly credits on every purchase" },
        { "rule_type": "birthday", "label": "5000 credits — Birthday bonus" }
      ],
      "recent_activity": [
        { "type": "EARN", "points": "250", "label": "Purchase rewards", "at": "2026-06-15T12:00:00.000Z" }
      ],
      "redeem": {
        "pwa_wallet_url": "https://app.merly.com/wallet",
        "pass_apple_url": "https://app.merly.com/wallet?pass=apple",
        "pass_google_url": "https://app.merly.com/wallet?pass=google",
        "cart_redeem_available": true
      },
      "directory_cta_url": "https://app.merly.com/directory"
    }

    ---

    8.4 Launcher (renderLauncher)

    Floating action button (bottom-right) that opens a panel with loyalty hub content. Nudge CTAs call Merly.openLauncher() by default.

    await sdk.renderLauncher('#merly-launcher');
    // Later:
    Merly.openLauncher();
    Merly.closeLauncher();

    ---

    8.5 Cart redeem (renderCartRedeem) — headless integration

    This is the most important custom integration surface.

    Flow:

    sequenceDiagram
      participant Browser as Your storefront
      participant SDK as Merly SDK
      participant API as Merly integration
      participant Ledger as Merly ledger
      participant Cart as Your cart/checkout
    
      Browser->>SDK: renderCartRedeem(subtotal, currency)
      SDK->>API: GET /storefront/cart-redemption-quote
      API-->>SDK: max_credits_applicable, discount_amount_shop
      Browser->>SDK: shopper clicks Apply
      SDK->>API: POST /storefront/cart-redemption-apply
      API->>Ledger: REDEEM credits
      API-->>SDK: coupon_code
      SDK->>Cart: onCouponApplied(coupon_code)
      Cart->>Cart: apply discount in your system

    Render UI:

    await sdk.renderCartRedeem('#merly-redeem', {
      cartSubtotal: '199.50',      // string, decimal shop currency
      currency: 'USD',
      storeId: undefined,            // optional catalog store override
      appliedCouponCodes: 'SAVE10', // optional — Merly adjusts max if Merly codes already applied
    });

    Quote HTTP:

    GET {apiBase}/storefront/cart-redemption-quote?{customerQuery}
        &cart_subtotal=199.50&currency=USD

    Quote response (eligible):

    {
      "eligible": true,
      "wallet_balance": "12500.0000",
      "max_credits_applicable": "9950",
      "discount_amount_shop": "99.50",
      "currency_code": "USD",
      "cap_band_max_shop": "50.00",
      "message": "Apply 9950 credits for $99.50 off"
    }

    Apply HTTP:

    POST {apiBase}/storefront/cart-redemption-apply
    Content-Type: application/json
    x-idempotency-key: headless-cart-{unique}
    
    {
      "shop_domain": "boutique.example.com",
      "shopify_customer_id": "42",
      "cart_subtotal": "199.50",
      "currency": "USD",
      "credits_amount": "9950",
      "platform": "woocommerce"
    }
    Use "platform": "shopify" only for native Shopify Online Store cart flows. Custom/headless sites typically use "woocommerce" when the merchant catalog connection is WooCommerce, or as advised by Merly onboarding.

    Apply response (success):

    {
      "coupon_code": "MERLY-X7K9M2",
      "credits_redeemed": "9950",
      "discount_amount_shop": "99.50",
      "currency_code": "USD",
      "idempotency_replayed": false
    }

    Your responsibility: Implement onCouponApplied to inject coupon_code into your cart API (session discount, promo code field, order draft, etc.). The SDK does not know your cart system.

    Merly.init({
      apiBase: API_BASE,
      storeKey: STORE_KEY,
      externalCustomerId: customer.id,
      onCouponApplied: async (code, credits) => {
        const res = await fetch('/api/cart/apply-coupon', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ code }),
        });
        if (!res.ok) throw new Error('Cart rejected Merly coupon');
        await refreshCartTotals();
      },
    });

    Idempotency: Each apply sends x-idempotency-key (auto-generated). Retries with the same key return the same coupon without double-redeeming (idempotency_replayed: true).

    Common errors:

    HTTP`error`Meaning
    401`session_expired``mr_id` invalid/expired
    401`shopify_customer_not_linked`Customer not in Merly Identity
    404`store_not_connected``storeKey` not in Merly catalog
    400`insufficient_balance`Ledger rejected redeem
    503`fx_rate_missing_for_currency`Unsupported currency for FX table

    ---

    Underlying REST endpoints (reference)

    Base: {apiBase} = https://{gateway}/api/v1/integration

    MethodPathAuthUsed by SDK
    GET`/storefront/rewards-block`Customer query¹Yes
    GET`/storefront/nudges`Customer query¹Yes
    GET`/storefront/loyalty-hub`Customer query¹Yes
    GET`/storefront/cart-redemption-quote`Customer query¹Yes
    POST`/storefront/cart-redemption-apply`Customer query¹ + `x-idempotency-key`Yes
    GET`/storefront/sdk/merly.v1.js`None (public)Loader
    GET`/storefront/sdk/merly.v1.css`None (public)Loader

    ¹ Customer query = either mr_id={uuid} or shop_domain={host}&shopify_customer_id={id}.

    CORS: storefront routes return Access-Control-Allow-Origin: * for browser fetch.

    Merchant Public API (server-side only)

    For backend integrations (balance sync, CRM, custom apps), use the Merchant Public API with pk_live_* keys:

    GET https://{gateway}/api/v1/merchant/customers/{merly_user_uuid}/balance
    Authorization: Bearer pk_live_…

    Never put pk_live_* keys in this browser SDK.

    Localization

    Set locale in init() or data-locale on the script tag.

    CodeLanguage
    `en`English (default)
    `es`Spanish
    `de`German
    `fr`French
    `pt`Portuguese (Brazilian copy in SDK)
    `zh`Chinese (Simplified)
    `ru`Russian

    All widget chrome (buttons, labels, errors) uses the selected locale. Nudge title/body come from merchant configuration (Merly admin) and may remain in the merchant’s language.

    Styling & branding

    1. Include merly.v1.css for default Polaris-inspired styling (.merly-rw-* classes).

    2. Override in your site CSS, e.g.:

    .merly-rw-launcher__fab {
      background: #111827;
      border-radius: 999px;
    }
    .merly-rw-block__value--earn {
      color: #059669;
    }

    3. Widgets use semantic class names prefixed with merly-rw-. No Shadow DOM — easy to theme.

    Framework notes

    React / Next.js (client component)

    'use client';
    import { useEffect, useRef } from 'react';
    
    declare global {
      interface Window {
        Merly: {
          init: (cfg: Record<string, unknown>) => {
            renderRewardsBlock: (sel: string, opts?: object) => Promise<void>;
          };
        };
      }
    }
    
    export function MerlyBlock({ price, currency, customerId }: Props) {
      const ref = useRef<HTMLDivElement>(null);
      useEffect(() => {
        const sdk = window.Merly.init({
          apiBase: process.env.NEXT_PUBLIC_MERLY_API_BASE!,
          storeKey: process.env.NEXT_PUBLIC_MERLY_STORE_KEY!,
          externalCustomerId: customerId,
          locale: 'en',
        });
        if (ref.current) sdk.renderRewardsBlock(ref.current, { productPrice: price, currency });
      }, [price, currency, customerId]);
      return <div ref={ref} />;
    }

    Load the script in layout.tsx or via next/script with strategy="afterInteractive".

    Vue / Nuxt

    Call Merly.init in onMounted and re-render when customerId or cart totals change.

    Testing checklist

  • [ ] Script/CSS load without console errors (200 responses).
  • [ ] Logged-in customer: rewards block shows balance > 0 (or zero for new user).
  • [ ] Guest with ?mr_id=… from Merly link: widgets populate.
  • [ ] Product page: estimated earn updates when productPrice changes.
  • [ ] Cart: quote returns eligible: true when balance and subtotal allow.
  • [ ] Apply: onCouponApplied receives code; your cart shows discount.
  • [ ] Double-click apply: no double charge (idempotency_replayed or same code).
  • [ ] Logout: widgets clear or show sign-in state.
  • [ ] Locale: switch locale → button labels change.
  • Troubleshooting

    SymptomLikely causeFix
    Empty nudges / `enabled: false`No customer sessionPass `externalCustomerId` or valid `mr_id`
    `shopify_customer_not_linked`Customer never provisioned in MerlyLink customer on registration / first order
    `store_not_connected`Wrong `storeKey`Use exact host Merly has in catalog
    Cart apply 422 ShopifyShopify token missingUse `platform: 'woocommerce'` for headless, or fix Shopify connection
    CORS errorsWrong `apiBase` (not gateway integration path)Use `/api/v1/integration`, not bare integration port in production

    Security summary

  • Storefront APIs are designed for browsers — they identify shoppers via mr_id or linked customer ID, not via merchant secrets.
  • Do not embed pk_live_* merchant API keys in frontend code.
  • Always use HTTPS in production.
  • Implement onCouponApplied server-side validation if your cart supports it (verify code format MERLY-*).
  • Support & changelog

    VersionDateNotes
    **v1.0.0**2026-06-16Initial release: nudges, rewards block, loyalty hub, launcher, cart redeem; 7 locales

    For integration support, provide Merly with: storeKey, example mr_id or customer ID, browser network HAR, and x-correlation-id from failing responses (if visible in gateway logs).

    *End of developer guide.*