# @moonbase.sh/storefront

Embeddable Moonbase storefront widget for browser apps.

This package wraps auth, cart, checkout, voucher redemption, product downloads, activation, and subscription management behind one client-side API.

Learn more about our embedded storefront in our official docs: https://moonbase.sh/docs/storefronts/embedded/

## Install

```bash
pnpm add @moonbase.sh/storefront
```

## Quick start

```ts
import Moonbase, { MoonbaseEvent } from '@moonbase.sh/storefront'

Moonbase.setup('https://demo.moonbase.sh', {
  toolbar: {
    enabled: true,
    location: 'top-right',
  },
})

Moonbase.on(MoonbaseEvent.CheckoutCompleted, ({ order }) => {
  console.log('Order completed', order.id)
})
```

`setup` should be run once, client-side.

When you load the widget via the CDN script tag:

```html
<script src="https://assets.moonbase.sh/storefront/moonbase.js"></script>
<script>
  Moonbase.setup('https://demo.moonbase.sh')
  Moonbase.on('checkout-completed', ({ order }) => console.log(order.id))
</script>
```

…you can call `Moonbase.setup(...)`, `Moonbase.on(...)`, and any intent method from anywhere on the page — including inline `<script>` in `<head>` — without waiting for `DOMContentLoaded`. The CDN script is a tiny loader that queues calls and replays them once the real bundle and the DOM are both ready.

When you import from npm (`import Moonbase from '@moonbase.sh/storefront'`), `setup` still needs the DOM to be available before it runs, since there is no loader in that path.

## Testing pre-release builds with `?mb_storefront_version=`

The CDN loader recognises an `?mb_storefront_version=` query parameter for opting into a non-default storefront build on a per-page-load basis. This is intended for testing pre-release changes on a live merchant site without changing the embed code.

- `?mb_storefront_version=next` — loads the latest `@next` snapshot from `/storefront/next/moonbase.js`
- `?mb_storefront_version=pr-<N>` — loads the snapshot from a labeled PR `<N>` from `/storefront/pr-<N>/moonbase.js` (only present while the PR carries the `release-snapshot` label)
- `?mb_storefront_version=2.1.0` — pins to a specific published version from `/storefront/<version>/moonbase.js`
- `?mb_storefront_version=latest` or omitted — loads the current stable release (default)

Don't use this to pin a production embed to a specific version — pin the `<script src>` URL instead.

## Host page requirements

The widget renders directly into the host page's DOM and inherits its viewport. For the widget to render correctly on mobile, the host page should declare a standard responsive viewport:

```html
<meta name="viewport" content="width=device-width, initial-scale=1" />
```

Hosts that ship a fixed-width legacy mobile viewport (for example Wix's classic mobile sites use `<meta name="viewport" content="width=320, user-scalable=yes">`) cause the browser to upscale every CSS pixel on the page. The widget auto-detects this and counter-scales itself with CSS `zoom` to remain readable; opt out with `disableViewportCompensation: true` if you want the widget to match the host's upscaled density. On Wix specifically, mounting the widget into a contained element via `target` is also an option — it switches the drawer from a viewport overlay to an in-flow component and sidesteps the upscale entirely.

### Captchas

A merchant can ask customers to solve a captcha before they sign up or subscribe to the
newsletter. With that turned on, the widget's sign-up and subscribe forms show one, drawn in a
frame from the store's own `https://<account-id>.moonbase.sh`. A site with a
Content-Security-Policy only has to allow that address in `frame-src`, which it already needs for
the checkout overlay, and nothing for the captcha provider itself. A newsletter form of your own
cannot hold the frame, so the widget draws it in the drawer instead (see
[On a store that asks for a captcha](#on-a-store-that-asks-for-a-captcha)).

Widget versions from before captchas (3.7 and earlier) cannot show one, so while captchas are on,
signing up from a site pinned to one of those fails. The same goes for newsletter sign-ups from a
site pinned to 3.12 or earlier, which predate the captcha on them. Checkout is unaffected by the
widget version: it runs in Moonbase's own checkout, which shows its own captcha.

## Trigger intents

Use intent methods to open specific views or execute actions:

```ts
Moonbase.sign_in({ email: 'jane@example.com' })

Moonbase.add_to_cart({
  product_id: 'my-product',
  quantity: 1,
})

Moonbase.view_cart()
Moonbase.checkout()
Moonbase.view_products()
```

All intents are snake_case methods on the `Moonbase` instance (for example `view_product`, `manage_subscription`, `redeem_voucher`, `join_group`).

## Configure behavior and theme

You can pass options in `setup(...)` and update them later with `configure(...)`:

```ts
Moonbase.configure({
  auth: {
    signUp: {
      enabled: false,
    },
  },
  cart: {
    quantity: 'single',
  },
  checkout: {
    redirect: 'auto',
  },
  pricing: {
    trailingZeros: 'always',
    currencyDisplay: 'code',
  },
  theme: {
    colors: {
      primary: '#E5A000',
      background: 'white',
    },
    fonts: {
      heading: 'Aleo',
      body: 'Inter',
    },
  },
})
```

`checkout.redirect` decides where checkout happens:

| Value | Behavior |
| --- | --- |
| `'auto'` (default) | Overlay on desktop, full-page redirect to the hosted checkout on mobile. |
| `'always'` / `true` | Always redirect to the hosted checkout. |
| `'never'` / `false` | Always use the overlay. |

The overlay keeps buyers on your page, but it cannot show Apple Pay or Google Pay.
Those wallets require the *top-level* page's domain to be a registered payment-method
domain, and for an embedded storefront that is your own site, not Moonbase's — no
amount of configuration on our side changes that. Redirecting makes the hosted
checkout the top-level document, so the wallets appear. `'auto'` takes that trade only
on mobile, where wallet usage concentrates, and keeps the overlay everywhere else.
Phones redirect; tablets and desktops get the overlay.

A redirected buyer is always brought back to your page once they've paid, so
`checkout-completed` fires and the cart clears exactly as it does with the overlay.
The hosted checkout stays anonymous for the trip — it prefills from the order rather
than signing the buyer into a Moonbase account, matching the overlay's behavior.

### Customer groups

If you keep customer lists in Moonbase and have opened one to public sign-ups,
name it by ID and the widget enrolls people who use the matching form:

```ts
Moonbase.configure({
  groups: {
    signUp: ['newsletter-2026'],
    subscribe: ['newsletter-2026'],
    checkout: ['buyers-2026'],
  },
})
```

Enrollment is silent. There is no checkbox and no label, because the API
deliberately tells a storefront nothing about a group: an ID naming a list the
merchant has not opened to public sign-ups is skipped without a word, so a stale
ID here can never fail somebody's registration or somebody's purchase. At most
five groups per request are applied, and IDs are matched lowercase.

`signUp` and `subscribe` can be added to per link with `mb_groups`:

```html
<a href="?mb_intent=sign_up&mb_groups=beta,vip">Join the beta</a>
```

The two are unioned rather than overridden: `configure` names the lists everyone
signing up through your site joins, the link adds whichever the campaign is for.
A [sign-up form on your own page](#newsletter-sign-up-forms) adds to `subscribe` the
same way, with a field named `groups`.
`checkout` is configuration-only, because the cart outlives any one link.

For somebody who already has an account, `join_group` does it as a request of its
own, and unlike the three above it reports whether it worked:

```html
<a href="?mb_intent=join_group&mb_group_id=beta">Join the beta list</a>
```

It signs the visitor in first if they aren't, and emits `joined-group` on
success. Joining a list twice is not an error.

### Fonts

`theme.fonts.heading` and `theme.fonts.body` each take one of:

| Value | What gets loaded |
| --- | --- |
| A built-in name, e.g. `'Montserrat'` | The widget loads that family from `assets.moonbase.sh`. |
| `'inherit'` | Nothing. The widget renders in whatever font your page already uses. |
| `'system'` | Nothing. The widget uses its own system stack. |
| `{ family }` | Nothing. Names any family your page already loads. |
| `{ family, stylesheet }` | The stylesheet you name, from wherever you host it. |

The built-in families are `Inter`, `Roboto`, `EB Garamond`, `Merriweather`, `Poppins`,
`PT Serif`, `Montserrat` and `Aleo`. Either slot accepts any of them.

They are served from Moonbase's own asset host — the same origin that serves the widget
itself, and the same payload the hosted checkout uses, so the two share a cache entry.
Nothing is requested from Google Fonts, which matters if you sell into mainland China
(where `fonts.googleapis.com` is unreachable) or want to keep visitor IPs away from
Google.

The distinction that catches people out: `'Inter'` means "load Inter for me";
`{ family: 'Inter' }` means "the family is Inter, I already load it".

```ts
// Inherit your page's fonts. Loads nothing, requests nothing.
Moonbase.configure({ theme: { fonts: { heading: 'inherit', body: 'inherit' } } })

// Self-hosted faces — the widget injects the stylesheet.
Moonbase.configure({
  theme: {
    fonts: {
      heading: { family: '"Noto Serif SC", serif', stylesheet: '/assets/fonts/noto-serif-sc.css' },
      body: { family: '"Noto Sans SC", sans-serif', stylesheet: '/assets/fonts/noto-sans-sc.css' },
    },
  },
})

// A family your page already loads — the widget just names it.
Moonbase.configure({ theme: { fonts: { body: { family: '"Inter var", sans-serif' } } } })
```

`pricing.trailingZeros` controls how whole prices render. The default `'auto'`
drops the decimals — a $10.00 product shows as `$10`. Set it to `'always'` to keep
the currency's own fraction digits instead (`$10.00`, and still `¥1000` for
zero-decimal currencies like JPY). It applies to the widget's own UI and to
`data-moonbase-render` price elements on your page alike.

`pricing.currencyDisplay` picks which marker identifies the currency. Taking $10 in
USD read from a British locale, the default `'narrowSymbol'` renders the shortest
symbol (`$10`), `'symbol'` the locale's usual one (`US$10`), `'code'` the ISO 4217
code (`USD 10`), and `'name'` spells it out in the visitor's language
(`10 US dollars`). The symbol and its placement still follow the visitor's own
locale — this only chooses between the four forms.

Both `pricing` options cover the widget's UI, your `data-moonbase-render` price
elements, and the checkout itself: the widget passes them to Moonbase's own
checkout page, so the prices a buyer pays are written the same way as the ones
that got them there.

`cart.offers.cartWide` covers offers keyed off the cart as a whole, such as "10% off orders
over $50", rather than off a single product. These apply themselves the moment the
cart qualifies, so there is nothing for the buyer to press. While the cart is short
of the threshold the widget shows a meter and how much further to go; once it
qualifies, the saving appears as its own row above the total.

```ts
Moonbase.configure({
  cart: {
    offers: {
      cartWide: {
        showProgress: true,
        savingLabel: 'You save',
      },
    },
  },
})
```

Set `showProgress` to `false` to let such an offer apply silently: the saving still
shows in the totals, the nudge does not. With no cart-wide offer configured on your
storefront, the meter never renders either way. `savingLabel` names the saving row
in the cart totals.

Offers keyed off a single product work the other way round: the widget surfaces one
as a card in the cart, and the buyer presses it to take the offer. That card is
hidden once the offer's product is already in the cart, so a buyer who adds that
product *before* the one that qualifies them for the offer never gets a chance to
claim it. `cart.offers.autoApply` closes that gap.

```ts
Moonbase.configure({
  cart: {
    offers: {
      autoApply: true,
    },
  },
})
```

With it on, the cart applies an eligible offer to the product it discounts by
itself, once every product that offer names is in the cart. It never adds anything
to the cart on the buyer's behalf, and it never takes an offer the cart is still
selling: an offer naming a product they have not added keeps showing as a card and
still takes a press, since an offer is good for one line only and applying it early
would take that card away. It never replaces an offer already on a line either. Off
by default.

That governs when an offer is taken rather than where it stays: remove one of the
products that qualified an offer and it stays on the line it landed on, just as an
offer claimed from a card does. A buyer can already put a multi-target offer on one
product and never add the other, so nothing here is a cart they couldn't have built
by hand.

`cart.minimumTotal` sets the smallest order the cart will check out. Below it, the
checkout button gives way to a meter of the same size that fills as the cart grows
and tells the buyer how much more to spend; it turns back into the button once the
cart reaches the minimum.

```ts
Moonbase.configure({
  cart: {
    minimumTotal: { USD: 25, EUR: 25, GBP: 20 },
  },
})
```

Amounts are keyed by currency code, in major units, because Moonbase picks each
visitor's currency and the widget never converts one into another. A currency you
leave out has no minimum. The minimum is measured on the cart's Total, after product
discounts and offers but before coupons and tax, and a cart holding nothing but free
items is exempt. The `checkout` intent respects it too: below the minimum it opens
the cart instead of checking out. The widget enforces this, not the API, so
`purchase()` and checkouts you build outside the widget are not held to it. A value
you pass replaces the previous one; pass `{}` to remove it. No minimum by default.

You can also control where the widget mounts by providing `target`.

## Promotions

When you run a sale across your catalogue, the widget surfaces it on its own — as a
banner, a popup, or both, depending on what you asked for when you set the promotion
up in Moonbase. Nothing to wire in: the sale appears while it is live and to the
shoppers it applies to, and disappears when it ends.

Once a shopper dismisses a promotion it stays dismissed in that browser, for as long
as the promotion runs. Editing the campaign doesn't bring it back — a shopper who has
turned a sale away isn't asked again. Start a new promotion to reach them again.

The two surfaces are tracked separately, so closing the popup leaves the banner up as
a reminder that the sale is still on. A popup also gets a single interaction: clicking
through to the campaign retires it for good, the same as closing it would, since it
interrupted the shopper to ask. A banner interrupted nobody, so clicking through leaves
it standing — only an explicit dismissal takes it down.

By default the banner pins itself to the top of the viewport. Give the widget a
container and it renders inline in your own layout instead — anywhere on the page:

```html
<div data-moonbase-promotion></div>
```

```ts
Moonbase.configure({
  promotions: {
    // Set to false to render sales yourself.
    enabled: true,
    // 'top' | 'bottom' — only used when the page offers no container.
    location: 'top',
    dismissLabel: 'Dismiss',
  },
})
```

Where a sale leads, and the wording on the link, both come from the promotion itself —
only the merchant running the campaign knows what it is offering. A sale with no
destination set shows the dismiss action as its only button.

To render sales entirely yourself, turn the built-in surfaces off and listen for
`promotion-shown`, or read them from the `usePromotions()` composable in
`@moonbase.sh/vue`.

## Newsletter sign-up forms

A sign-up form on your own page, in your own design, can go straight to Moonbase. Mark it
with `data-moonbase-form="subscribe"` and the widget sends it in the background instead of
letting the browser load a new page:

```html
<form data-moonbase-form="subscribe">
  <input name="name" placeholder="Name" maxlength="200">
  <input name="email" type="email" placeholder="you@example.com" required>
  <button>Subscribe</button>
</form>
```

The widget reads the fields by name:

| Field | |
| --- | --- |
| `email` | Required. |
| `name` | Optional, up to 200 characters. |
| `groups` | Optional group IDs, comma-separated in one field or repeated (checkboxes, say). Added to `groups.subscribe`, as `mb_groups` is on a link. |

The emails signed up for are the ones `communicationPreferences.show` names, as for the
drawer's own subscribe form, and whether the visitor has to confirm by email first is your
account's double opt-in setting. The form is yours, so the consent wording is too: the drawer's
form says "By subscribing you agree to receive newsletter and product update emails", and
yours should say as much.

### Showing the result

By default the drawer opens on the outcome: "You're subscribed!", or "Check your email to
finish" under double opt-in. If the server turns the email down, the drawer opens its own form,
filled in from yours, with the reason under the button, so the visitor can correct it there.

To keep everything on your page, add `data-moonbase-feedback="inline"`. The drawer then stays
closed, and the widget reports progress on the form itself for you to style:

```html
<form class="newsletter" data-moonbase-form="subscribe" data-moonbase-feedback="inline">
  <div class="fields">
    <input name="email" type="email" required>
    <button>Subscribe</button>
  </div>
  <p data-moonbase-message></p>
</form>
```

| `data-moonbase-state` | When |
| --- | --- |
| `submitting` | The request is out. The form's submit buttons are disabled until it returns, and on a store that asks for a captcha until the visitor has solved it. |
| `subscribed` | The visitor is on the list. |
| `confirmation_sent` | Double opt-in: a confirmation email went out, and the visitor is on the list once they click it. |
| `error` | The server turned the email down, the request failed, or the visitor closed the captcha unsolved. |

A `[data-moonbase-message]` element inside the form gets the outcome as text, worded as the
drawer words it, and is made a live region so screen readers announce it. Leave it out to show
your own wording instead:

```css
.newsletter:is([data-moonbase-state="subscribed"], [data-moonbase-state="confirmation_sent"]) .fields {
  display: none;
}
.newsletter[data-moonbase-state="error"] [data-moonbase-message] {
  color: #c00;
}
```

Style both success states. For a visitor who is not signed in, the status follows your double
opt-in setting rather than whether the address was new, so the form cannot be used to find out
who is subscribed already. With double opt-in on, nearly every sign-up ends in
`confirmation_sent`.

`data-moonbase-state` is set in the default mode as well, so a spinner can cover the moment
before the drawer opens.

### On a store that asks for a captcha

With the captcha on, the store refuses a newsletter sign-up until the visitor has solved it, and
your form has no room for the challenge. So the drawer opens with the challenge alone, "Confirm
you're not a robot to finish subscribing", in either mode. It usually solves itself within a
second or two. Once it is solved, the widget sends the form again: the drawer then shows the
outcome, or with `data-moonbase-feedback="inline"` closes, and the outcome goes on your form as
above.

If the visitor closes the drawer without solving it, nothing is sent, and the form ends in
`error` with "Please confirm you're not a robot." Sending it again opens the challenge again.

### Reacting in code

Every successful sign-up emits `subscribed`, from these forms and from the drawer's own:

```ts
Moonbase.on(MoonbaseEvent.Subscribed, ({ email, status, source }) => {
  // source is 'form' for a data-moonbase-form form, 'drawer' for the drawer's own
})
```

### Good to know

- The attribute hands the form to Moonbase: the widget claims the submit before any other
  script on the page sees it, so a site builder's own form handling does not post it somewhere
  else as well. Your own submit listeners on that form do not run either; use the `subscribed`
  event instead.
- Loaded from the CDN, the widget holds a form submitted before it is ready and sends it once
  `setup()` has run. Installed from npm, call `setup()` early: until then the browser submits
  the form as a plain form.
- A signed-in customer subscribes the address they type in, which is usually their own.
- Forms inside a shadow root are not picked up.
- An unknown `data-moonbase-form` value is not submitted, and logs a warning to the console.
- To open the drawer's own form instead, filled in, call `Moonbase.subscribe({ email, name })`
  or link to `?mb_intent=subscribe&mb_email=...&mb_name=...`.

## Events

Subscribe to widget lifecycle events with `Moonbase.on(...)`.

Available events include:

- `signed-in`
- `signed-up`
- `signed-out`
- `subscribed`
- `storefront-updated`
- `promotion-shown`
- `promotion-clicked`
- `promotion-dismissed`
- `redeemed-voucher`
- `joined-group`
- `downloaded-product`
- `activated-product`
- `added-to-cart`
- `coupon-rejected`
- `checkout-initiated`
- `checkout-billing-details-added`
- `checkout-coupon-applied`
- `checkout-coupon-removed`
- `checkout-payment-started`
- `checkout-payment-failed`
- `checkout-closed`
- `checkout-completed`

The checkout's own progress, from billing details to payment, is reported only by the checkout
that opens on your page:

- `checkout-billing-details-added` fires each time the buyer saves their billing details.
- `checkout-payment-started` fires for each attempt to pay, and is followed by
  `checkout-payment-failed` or `checkout-completed`.
- `checkout-payment-failed` carries `error`, with `reason` and `paymentMethod` (such as `card`).
  The reason is one of `Declined` (by the card issuer or fraud screening: try another card),
  `AuthenticationFailed` (3-D Secure not passed), `CardDetails` (a wrong number, security code or
  postcode, or an expired card), `ProcessingError` (trying again may work) or `Other`. The card
  issuer's own decline code is never passed on. The checkout stays open for the buyer to try again.
- `checkout-coupon-applied` and `checkout-coupon-removed` carry the `code`. A code the checkout
  refuses fires `coupon-rejected`, with the `message` the buyer saw.
- `checkout-closed` carries `completed`, and `checkoutOrder`, the order the checkout was open for.
  Its `order` is the cart, which for a direct purchase is a different order.

During a full-page redirect to the hosted checkout (`checkout.redirect`, and on phones by default)
your page is not loaded, so only `checkout-completed` reaches it, once the buyer is back. To follow
every checkout, hosted included, subscribe a webhook to `OrderCheckoutInitiated` (billing details
saved) and `OrderPaymentFailed`.

```ts
Moonbase.on(MoonbaseEvent.CheckoutPaymentFailed, ({ order, error }) => {
  console.log(`Payment for ${order.id} failed: ${error.reason}`)
})
```

`signed-in` and `signed-up` fire on the page where the customer signs in, including through a link in an email. `signed-out` also fires when the session ends without anyone signing out on the page: in another tab, or elsewhere (for example a password change on another device), which the widget finds out when it next renews the token.

## Calling your own API

`Moonbase.getAccessToken()` resolves to the signed-in customer's access token, or `null` when nobody is signed in. Send it to your own API to act on the customer's behalf. It's renewed first when it's about to run out, so call it for each request rather than keeping a copy.

```ts
const token = await Moonbase.getAccessToken()
const response = await fetch('https://api.example.com/presets', {
  headers: token ? { Authorization: `Bearer ${token}` } : {},
})
```

The token is a JWT signed with your account's key. Verify it on your server against the JWK Set at `https://<account-id>.moonbase.sh/api/key/jwks`, and check all of:

- the `typ` header is `at+jwt` and the algorithm is `RS256`
- the issuer (`iss`) is your Account ID
- the audience (`aud`) is `https://moonbase.sh`
- the expiry (`exp`) is present and hasn't passed

License tokens are signed with the same key and issuer, so a check that skips the audience or the expiry accepts a license file as a sign-in. The customer's ID is the `sub` claim. With [jose](https://github.com/panva/jose):

```ts
import { createRemoteJWKSet, jwtVerify } from 'jose'

const jwks = createRemoteJWKSet(new URL('https://acme.moonbase.sh/api/key/jwks'))

const { payload } = await jwtVerify(token, jwks, {
  typ: 'at+jwt',
  algorithms: ['RS256'],
  issuer: 'acme',
  audience: 'https://moonbase.sh',
  requiredClaims: ['exp'],
})
// payload.sub is the customer's ID
```

Don't read the widget's `localStorage` entry yourself: it holds the refresh token as well, which must never leave the browser.

## Integrations

The widget can forward its commerce and auth events to common marketing/analytics SDKs without any extra glue code. Enable a provider by passing its config in `integrations`:

```ts
Moonbase.setup('https://demo.moonbase.sh', {
  integrations: {
    metaPixel: { pixelId: '1234567890' },
    googleAnalytics: { measurementId: 'G-XXXXXXX' },
    googleTagManager: { containerId: 'GTM-XXXXXXX' },
    klaviyo: { companyId: 'ABC123' },
    tiktokPixel: { pixelId: 'C12345...' },
  },
})
```

The widget never loads SDK scripts itself — it only forwards to globals the host page has already initialised (`window.fbq`, `window.gtag`, `window.dataLayer`, `window.klaviyo`, `window.ttq`). If a global is missing when an event fires, the forwarding is a silent no-op (with a one-time `console.warn` so a misconfiguration is discoverable).

### You install the SDK; Moonbase forwards to it

You load each SDK with its own official snippet, exactly as you would without Moonbase — Moonbase only needs the same id so it can forward. Load order doesn't matter: if the global appears after `setup()`, forwarding begins as soon as it's there. For example, Meta Pixel:

```html
<!-- Your existing Meta Pixel snippet -->
<script>
  !function(f,b,e,v,n,t,s){/* …standard fbq loader… */}(window,document,'script',
  'https://connect.facebook.net/en_US/fbevents.js');
  fbq('init', '1234567890');
  fbq('track', 'PageView');
</script>
```

```ts
// Hand Moonbase the same id so it can forward commerce/auth events to fbq:
Moonbase.setup('https://demo.moonbase.sh', {
  integrations: { metaPixel: { pixelId: '1234567890' } },
})
```

The same pattern applies to GA4 (`gtag`), GTM (`dataLayer`), Klaviyo (`klaviyo`), and TikTok (`ttq`): install the vendor snippet, then pass the matching id. For Klaviyo, both the modern onsite object (`klaviyo.identify()` / `klaviyo.track()`) and the legacy `klaviyo.push([...])` queue are supported automatically. The `googleTagManager.containerId` is informational/reserved — GTM reads from `window.dataLayer` regardless of which container loaded it, so it isn't used at runtime.

### Consent

Forwarding is unconditional: Moonbase forwards to whatever SDK global is present, and relies on **that SDK's own consent state** (e.g. Google Consent Mode v2, or your CMP gating `fbq`/`ttq`). To suppress forwarding entirely until consent is granted, omit the provider from `setup()` and add it once consent lands:

```ts
onConsentGranted(() => Moonbase.configure({ integrations: { metaPixel: { pixelId: '1234567890' } } }))
```

`configure({ integrations })` can add, change, or remove providers at any time. Merge semantics apply per provider: pass a provider to add or update it, pass it as `undefined` (or `null`) to remove it, and omit it to leave it unchanged — so granting one provider after consent never disturbs the others.

```ts
// Stop forwarding to Meta Pixel, leave every other provider running:
Moonbase.configure({ integrations: { metaPixel: undefined } })
```

### Identity

`signed-in` / `signed-up` forward an identify call when an email/id is available (Meta advanced match, GA4 `user_id`, Klaviyo identify, TikTok identify). On `signed-out`, Moonbase clears what each SDK supports (GA4 `user_id` is reset, GTM gets a `logout` push); Meta, TikTok, and Klaviyo expose no per-user logout, so their profiles persist for the session.

### Data conventions

Forwarded order `value` is the amount the customer pays — `total.due`, post-discount and **tax-inclusive** (no separate tax field is sent). Per-item prices are the net (post-discount) unit price. `checkout-initiated` is the one exception: an order has no settled total until billing details are entered, so its `value` is the cart's own post-discount total and excludes tax. `checkout-completed` events carry the order id as a dedup key (Meta `eventID`, TikTok `event_id`, GA4/GTM `transaction_id`) so a completion isn't double-counted.

### Event mapping

| Moonbase event       | Meta Pixel              | GA4                  | GTM (`dataLayer`)    | Klaviyo                  | TikTok Pixel          |
| -------------------- | ----------------------- | -------------------- | -------------------- | ------------------------ | --------------------- |
| `added-to-cart`      | `AddToCart`             | `add_to_cart`        | `add_to_cart`        | `Added to Cart`          | `AddToCart`           |
| `checkout-initiated` | `InitiateCheckout`      | `begin_checkout`     | `begin_checkout`     | `Started Checkout`       | `InitiateCheckout`    |
| `checkout-completed` | `Purchase`              | `purchase`           | `purchase`           | `Placed Order`           | `CompletePayment`     |
| `signed-in`          | advanced match          | `login` + `user_id`  | `login`              | `identify`               | `identify`            |
| `signed-up`          | `CompleteRegistration`  | `sign_up` + `user_id`| `sign_up`            | `identify`               | `CompleteRegistration`|
| `signed-out`         | —                       | clears `user_id`     | `logout`             | —                        | —                     |

For any event not listed (or a provider that isn't built in), use `Moonbase.on(...)` and forward by hand.
