---
title: Frontend Embedding Guide
---
This guide walks you through embedding the Dropins into a website (custom HTML, WordPress, WooCommerce, or any non-Shopify storefront). It is the practical companion to the **Technical Documentation** — start here if you want the shortest path from "we want loyalty on our site" to "it's live".
> **Shopify?** Don't follow this guide. Install the official **SimplyClub Shopify app** — it wires the snippet, cart sync, webhooks, and `transactionCode` capture for you. The guide below targets WordPress, WooCommerce, custom HTML, and any other host system.
---
## 1. Before you embed — get your `poiId`
Every Dropin call is scoped to a **POI (Point of Interaction)** — your store/location. You cannot initialize the Dropin without one.
1. Contact SimplyClub support / your account manager.
2. You will receive a `poiId` (an opaque string, e.g. `6821f55303f86ff040461ef4`).
3. Keep it somewhere you can paste into your site's `
` or footer script block.
That's it for the manual step — the rest is copy-paste.
---
## 2. Minimal embed — copy this into your site
Paste this snippet once, near the end of `` (or anywhere a `
```
> **Tip — staging:** if SimplyClub support gives you a test `poiId`, also pass `env: "test"` so the Dropin talks to the staging API.
That is enough to render SimplyClub's default starter button and run the OTP / dashboard flows. Everything beyond this is opt-in: cart sync, user sync, custom button, embedded CTA, event handlers.
### 2.1 Server-handoff — skip the phone+OTP screen entirely
If your **backend** has already authenticated the shopper, you can hand them into SimplyClub with no shopper-visible login UI at all. Mint an HMAC-signed token on your server, pass it into `setConfig({ handoffToken })`, and the widget mounts the authenticated UI directly.
```html
```
**Prereqs (one-time, in the Dashboard's Integrations tab):**
1. Mint at least one HMAC key — copy the secret from the show-once modal into your backend's secret store.
2. Flip the `handoffEnabled` toggle on.
**Behavior:**
- The token is evaluated **before** fast-login or legacy. A valid handoff overrides both.
- A stale, expired, or replayed token → widget falls back silently to phone+OTP (no shopper-visible error).
- Tokens are single-use, max-age 60s, and identity is `customerId` + a contact (phone or email) cross-check — see the Server Handoff Login doc. Mint a fresh token per page-render — never cache.
Full spec (token format, signing algorithm, error envelope, admin keys CRUD) is in the dedicated [**Login From Backend**](/content/login/server-handoff-login) article.
---
## 3. Sync host state into the Dropin
The Dropin can only personalize what you tell it about. Call these whenever native state changes:
```js
// When the user logs in to your site
instance.commands.setUser({
id: "host-user-123",
firstName: "Dana",
lastName: "Cohen",
email: "dana@example.com",
phone: "+972501234567", // prefilled in OTP screen
});
// When the user logs out
instance.commands.setUser(null);
// Whenever the cart changes (add / remove / quantity / discount)
instance.commands.setCart({
token: "host-cart-token", // optional but recommended — must be `token`, not `cartToken`
items: [
{ id: "line-1", sku: "ABC-001", title: "Olive Oil 500ml", price: 39.9, quantity: 2 },
{ id: "line-2", sku: "XYZ-777", title: "Pickles Jar", price: 12.0, quantity: 1 },
],
});
```
**Important:**
- `price` must be the **net** unit price after any native host-side discounts. SimplyClub calculates benefits on top of what the customer would actually pay.
- Either `title` or `name` works for the display field — `title` is preferred (matches the Shopify cart shape, so you can usually pass the native cart object through with minimal mapping). If both are present, `title` wins.
- You can include any extra Shopify/Woo fields you already have (`variant_id`, `image`, `url`, `description`, `extended_attributes`, etc.) — they're stored and used for richer UI / rules. Only the fields above are required.
If the cart changes **after** the user has already applied benefits, call `instance.commands.resetBenefitSelections()` to force a recalculation.
---
## 4. Connect to events
These are the events your site **must** subscribe to in order to apply discounts, capture transaction codes, and route auth requests. They are exposed via `instance.On.*` (returns `{ off() }` for cleanup).
| Event | When it fires | What to do |
|---|---|---|
| `On.benefitSelectionsChanged(data)` | User clicks "Apply" inside the rewards screen or embedded CTA | Apply `data.simply.transaction.selectedDiscountsAndActions.totalAmount` as a host-side discount named `sc_club_discount`. See §5 of the Technical Doc for the full payload. |
| `On.transactionCodeCreated(code)` | A SimplyClub transaction is opened | Persist `code` as order metadata. You will send it back in the payment webhook. |
| `On.redirectToLogin()` | A guest tries to access a gated Dropin | Redirect to your login page or open your login modal. |
| `On.addItemToCart(e)` _(reserved — not currently dispatched in the current build)_ | Dropin asks the host to add a free / reward item to the cart | Add `e.product` to your native cart → call `setCart(...)` → call `e.resolve()` on success. |
| `On.membershipProductSelected(e)` | User picks a membership SKU in the Dropin | Add `e.product` → `setCart(...)` → `e.resolve()`. |
| `On.userLoggedIn(member)` | OTP succeeded inside the Dropin | Optional — refresh your UI / treat the user as authenticated on your side. |
| `On.goToCheckout(data)` _(reserved — not currently dispatched in the current build)_ | Dropin asks the host to navigate to checkout | Redirect to your native checkout URL (e.g. `window.location.href = '/checkout'`). |
The benefit-selections payload also carries `data.source: "modal" | "embed"` — `"modal"` for the main rewards screen, `"embed"` for the in-page CTA. Use it to decide whether to redirect to checkout (modal) or stay on the page (embed).
Example wiring:
```js
instance.On.benefitSelectionsChanged((data) => {
if (data.statusInfo.errorCode !== 0) return; // halt + clear any sc_club_discount
const total = data.simply.transaction.selectedDiscountsAndActions.totalAmount;
applyHostDiscount({ code: "sc_club_discount", amount: total });
saveTransactionCode(data.simply.transaction.number);
if (data.source !== "embed") window.location.href = "/checkout";
});
instance.On.transactionCodeCreated((code) => {
saveOrderMetadata({ simplyTransactionCode: code });
});
instance.On.redirectToLogin(() => {
window.location.href = "/login";
});
// Reserved — not currently dispatched in the current build.
instance.On.addItemToCart((e) => {
addToHostCart(e.product)
.then(() => { instance.commands.setCart(getCurrentHostCart()); e.resolve(); });
});
instance.On.membershipProductSelected((e) => {
addToHostCart(e.product)
.then(() => { instance.commands.setCart(getCurrentHostCart()); e.resolve(); });
});
```
---
## 5. Optional — embedded Call-to-Action
For inline placements (product page, mini-cart, sidebar) use the membership CTA web component. It renders the join/upgrade card and the embedded benefits selector, all in one element.
**Load the module once** (separate from the main loader):
```html
```
Then drop the element anywhere:
```html
```
The CTA reads its `poiId` and member state from the main drop-in instance, so `dropInLoader.setConfig({ poiId })` must run on the same page.
The CTA dispatches one DOM event you should listen for — the **guest-state login click**:
```html
```
Benefit application from inside the embedded CTA still fires the same `instance.On.benefitSelectionsChanged` event — there is nothing extra to wire.
---
## 6. WordPress recipe
WordPress has no special API for Dropins — it's plain HTML + script. Pick whichever insertion point you prefer:
* **Header/footer plugin** (e.g. *Insert Headers and Footers*, *WPCode*) — paste the §2 snippet into the **footer** section. Survives theme updates.
* **`footer.php`** — paste before ``. Will be overwritten by theme updates unless you use a child theme.
* **Custom HTML block** — for inline placement of `` inside a page or post. WordPress will strip custom elements from paragraph blocks; the Custom HTML block preserves them verbatim.
* **Custom HTML widget** — for sidebar / footer widget areas.
### 6.1 WooCommerce — full integration patterns
The recipes below are drawn from the production **wp-dropins** plugin (`Simply Club DropIns for WooCommerce`) and address every gotcha that comes up in a real WC integration.
**Use `wp_localize_script` to bridge PHP → JS.** Configure the dropin from your plugin/theme so secrets and per-user data come from the server:
```php
wp_localize_script('simply-dropins-wc', 'simplyDropinsWcData', [
'poiId' => $poi_id,
'env' => $env, // "prod" | "test" | "local"
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('simply_dropins_nonce'),
'user' => $current_user_data, // { id, firstName, lastName, email, phone } or null
'loginUrl' => wc_get_page_permalink('myaccount'),
'checkoutUrl' => wc_get_checkout_url(),
'hasSavedBenefits' => !empty(WC()->session->get('simply_benefits')),
]);
```
**Init**:
```js
const data = window.simplyDropinsWcData;
const instance = await dropInLoader.setConfig({
poiId: data.poiId,
env: data.env || 'prod',
});
```
> **Note — widget language is server-driven.** The widget loads its language from the SimplyClub server (Hebrew); there is no client-side language config option to set. Any `language_code` / `languageCode` value passed to `setConfig` is currently ignored.
**Sync user + cart**:
```js
if (data.user) {
instance.commands.setUser(data.user);
} else {
instance.commands.logout();
instance.commands.hide();
}
```
**Cart-sync with a re-entrancy guard.** WooCommerce fires `wc_fragments_refreshed` and `updated_checkout` as a side-effect of *our own* AJAX, which would re-sync the cart and reset the freshly-applied benefits. Listen only to **genuine user mutations** and guard against your own work:
```js
let isApplyingBenefits = false;
const syncCart = (isCartChange) => {
jQuery.post(data.ajaxUrl, { action: 'simply_get_cart', nonce: data.nonce }, (res) => {
if (!res.success) return;
instance.commands.setCart(res.data);
if (isCartChange && instance.commands.resetBenefitSelections) {
instance.commands.resetBenefitSelections();
}
});
};
syncCart(false); // initial sync — no reset
// Listen to GENUINE cart mutations only.
// Do NOT listen to wc_fragments_refreshed or updated_checkout — they self-trigger
// from the apply-benefits flow and create a reset loop.
jQuery(document.body).on('added_to_cart removed_from_cart updated_cart_totals', (e) => {
if (isApplyingBenefits) return; // our own AJAX → ignore
syncCart(true); // user-initiated → reset selections
});
```
**Apply benefits + WC fees.** On `benefitSelectionsChanged`, POST the selection to your AJAX handler, which stores it in `WC()->session` and turns it into a negative `add_fee()` (`'sc_club_discount'`). Skip the checkout redirect when `source === 'embed'` so the user stays put:
```js
instance.On.benefitSelectionsChanged((sel) => {
isApplyingBenefits = true;
jQuery.post(data.ajaxUrl, {
action: 'simply_apply_benefits',
nonce: data.nonce,
benefits: sel || [],
}, (res) => {
isApplyingBenefits = false;
if (!res.success) return;
if (sel.source !== 'embed' && data.checkoutUrl) {
window.location.href = data.checkoutUrl;
} else {
jQuery(document.body).trigger('update_checkout');
jQuery(document.body).trigger('wc_fragment_refresh');
instance.commands.hide();
}
});
});
```
PHP side (in your AJAX handler):
```php
WC()->session->set('simply_benefits', $benefits);
WC()->cart->calculate_totals();
// elsewhere, on woocommerce_cart_calculate_fees:
$cart->add_fee(__('SimplyClub Rewards', 'simply-dropins-wc'),
-$discount_amount, true, 'sc_club_discount');
```
**Resume-after-login.** Guests who hit a gated screen are sent to `/my-account/`. Save the current URL before redirecting, then re-open the dropin once they come back:
```js
const LOGIN_RETURN_KEY = 'simplyclub-session-login-redirect';
instance.On.redirectToLogin(() => {
try { localStorage.setItem(LOGIN_RETURN_KEY, window.location.href); } catch {}
window.location.href = data.loginUrl;
});
// After init, if a saved return URL exists and we now have a user, re-open
if (data.user && localStorage.getItem(LOGIN_RETURN_KEY)) {
localStorage.removeItem(LOGIN_RETURN_KEY);
setTimeout(() => instance.commands.show(), 1000);
}
```
**Force-logout cookie.** When the WP user signs out, the dropin's own session needs to be cleared too. Set a short-lived cookie from PHP on `wp_logout`, then read & honour it from JS:
```js
const forceLogout = document.cookie.split(';').some(
c => c.trim().startsWith('simply_force_logout=')
);
if (forceLogout) {
instance.commands.logout();
instance.commands.hide();
// clear the cookie across every plausible domain scope
}
// Also wire common logout selectors:
jQuery(document.body).on('click', 'a[href*="action=logout"], a[href*="customer-logout"]', () => {
instance.commands.logout();
instance.commands.hide();
});
```
**Seed the rewards screen on first load.** `dispatchExampleTransaction()` returns an example benefits payload that pre-populates the rewards UI so a returning customer sees something interesting immediately. Only do this once per session, and only if the user hasn't already chosen something:
```js
if (!data.hasSavedBenefits && instance.commands.dispatchExampleTransaction) {
instance.commands.dispatchExampleTransaction().then((seed) => {
if (!seed) return; // null when no transactionCode yet
persistBenefits(seed); // your own AJAX → simply_apply_benefits
});
}
```
**Custom CTA button.** A storefront / page-builder (Elementor, etc.) can drop a styled button that opens the rewards screen directly:
```html
```
**Real WC cart payload shape.** `simply_get_cart` returns a richer object than the minimal `{ token, items }`. Pass-through is fine — the Dropin reads what it needs:
```js
{
token: 'cust42_md5OfCartContents',
items: [
{
id: 'wc_line_key_abc', // line key
product_id: 123,
variant_id: 456,
title: 'Olive Oil 500ml',
price: 39.9,
final_price: 35.9, // after WC discounts
compare_at_price: 49.9, // when on sale
quantity: 2,
sku: '1004',
discounts: [{ title: 'SUMMER10', amount: 4 }],
total_discount: 8,
discount_allocations: [{ amount: '8.00', discount_application_index: 0 }],
extended_attributes: { tags: [...], collections: [...], metafields: [] }
}
],
discount_codes: ['SUMMER10'],
discount_applications: [
{ title: 'SUMMER10', value: '8.00', value_type: 'fixed_amount', target_type: 'order', ... }
],
totals: { subtotal: 79.8, total: 71.8, discount_total: 8 }
}
```
> **Important — fee filtering.** When you build `discount_applications`, **skip fees whose ID starts with `sc_club_`**. Those are SimplyClub's own discounts — reporting them back as external promos would cause SimplyClub to deduct points for amounts the customer didn't actually pay.
> **Token formula.** A cart `token` whose value changes on every mutation (e.g. `customerId + '_' + md5(items + discount_applications)`) lets the Dropin's backend detect a stale benefit calculation. A static token defeats the staleness check.
### 6.2 Where to put the CTA in WooCommerce
For the **mini-cart** the drawer is re-rendered after every AJAX add-to-cart. Use a `MutationObserver` with a duplicate guard:
```html
```
For WooCommerce **checkout** and **my-account** pages, the markup is static — static placement (template part or Custom HTML block) is enough; the observer is unnecessary overhead. The wp-dropins plugin uses `woocommerce_before_cart` / `woocommerce_before_checkout_form` action hooks to echo the element server-side.
---
## 7. Custom starter button
To use your own button as the launcher (instead of the default Dropin button):
```html
```
The Dropin attaches a click handler that opens the modal and follows the state machine (guest → OTP, member → dashboard).
You can also open the modal imperatively from any code path:
```js
instance.commands.show(); // default tab (auto-picks based on auth state)
instance.commands.show('transactionBenefits'); // jump straight to the benefits screen
instance.commands.hide();
```
Valid `show()` tab names: `"dashboard"`, `"settings"`, `"notifications"`, `"renewal"`, `"howToGetPoints"`, `"transactionBenefits"`. Any other value falls through to the default view.
Other useful commands:
```js
instance.commands.logout(); // sign the member out of SimplyClub (call when host logs out)
instance.commands.getCart(); // Promise
instance.commands.getSession(); // Promise
instance.commands.getTransactionCode(); // Promise
instance.commands.getBenefitSelections();// Promise
```
### 7.1 Opening a specific tab from your own elements
To open the Dropin straight to a tab from your own buttons or links — including a reusable `data-sc-open` pattern for wiring many elements at once (the recipe used in our Shopify integration) — see the dedicated guide: **[Open a Specific Tab](/content/dropins/open-a-specific-tab)**.
---
## 8. Troubleshooting
| Symptom | Likely cause |
|---|---|
| Nothing appears on the page | `poiId` is wrong, or the loader URL was blocked (check the Network tab and console). |
| `` renders empty | The CTA module import is missing or hasn't run yet. Confirm the `