SimplyClub

Login From Backend

auth backend hmac dropins server-mint

Log a shopper into SimplyClub from your backend, with no phone+OTP screen and no shopper interaction. The host backend mints a short-lived HMAC-SHA256 token; the widget exchanges it for an authenticated session. The session it issues is byte-identical to the one phone+OTP would have issued — every other Dropin feature works the same way.

TL;DR — Your backend mints a short-lived token by calling POST /api/v1/handoff/mint-token. The widget receives it via setConfig({ handoffToken }) and POSTs it to /api/v1/users/handoff/login/:poiId. Single-use, 60-second max age, silent fallback to fast-login on failure.


1. When to use it

Use server-handoff when your backend already knows who the shopper is at the moment the page renders:

  • Member account area after the shopper logged in on your site.
  • Post-checkout receipt / thank-you page (the order knows the customer).
  • Magic-link landing page (the link itself proved identity).
  • Mobile app embedding the Dropin in a webview after native auth.

Do not use it on anonymous pages — your backend has nothing to sign for them. For anonymous traffic, enable fast-login (phone + OTP) instead.

The three login channels — handoff, fast (phone+OTP), and legacy (storefront-asserted) — coexist on the same POI. The widget evaluates them in handoff → fast → legacy order. A valid handoff token short-circuits the rest; a missing or invalid one silently falls through.


2. The flow at a glance

┌────────────────────┐    1. shopper hits the page
│  Shopper's browser │ ─────────────────────────────────► ┌────────────────────────┐
└────────────────────┘                                    │   Your host backend    │
         ▲                                                │ (knows the shopper)    │
         │                                                └─────────┬──────────────┘
         │ 2. page rendered with                                    │
         │    handoffToken inlined                                  │ mints a fresh
         │                                                          │ HMAC-signed
         │ ┌────────────────────────┐                               │ token
         └─┤    Dropin widget       │                               │
           │  setConfig({           │ ◄─────────────────────────────┘
           │    poiId,              │
           │    handoffToken,       │ 3. POST /api/v1/users/handoff/login/:poiId
           │  })                    │ ────────────────────────────────────────┐
           └────────────────────────┘                                         ▼
                                                                  ┌────────────────────────┐
                                                                  │ SimplyClub Dropins API │
                                                                  │  - verify HMAC         │
                                                                  │  - burn the nonce      │
                                                                  │  - look up member by   │
                                                                  │    customerId + UDF    │
                                                                  │  - cross-check contact │
                                                                  │  - issue session       │
                                                                  └────────────────────────┘

Failures (bad signature, expired, replayed, key revoked) come back as a generic 401 and the widget silently falls back to fast-login or legacy — the shopper sees no error.


3. One-time setup (Dashboard)

Before any code, lock in the mental model — the two pieces of material in play have very different lifetimes and live in very different places:

Integration key (long-lived)Handoff token (per page-load)
keyId + secret{keyId}.{payload}.{sig}
Created in the Dashboard, secret shown onceMinted by your backend on every page render
Lives in your backend’s secret store, never in the browserBriefly inlined in the HTML, dies on first use
Used to sign tokensUsed to log in one shopper, once
Lifetime: until you revoke itLifetime: ≤ 60 s and single-use

If the secret leaks: anyone can impersonate any shopper at this POI — rotate the key immediately. If a handoff token leaks: blast radius is ~60 s + one nonce + one specific phone. That asymmetry is the whole reason the model is split in two.

Open the SimplyClub Dashboard → your POI → Settings → Integrations. Two things live here, and you need both:

  1. Mint at least one HMAC key.
    • Click Add key, give it a label (e.g. web-prod-2026-06), submit.
    • A modal opens with keyId.secret and a Copy button.
    • The raw secret is shown exactly once. Paste it into your backend’s secret store before closing the modal. If you lose it, mint a new key — there is no “show again” option.
    • The list page afterward shows the keyId only; the secret half lives only in your secret store.
  2. Toggle handoffEnabled on. A switch above the keys list. With the switch off, every handoff request returns 403 HANDOFF_DISABLED regardless of token validity. Keys and the flag are independent — minting keys doesn’t auto-enable.

Multiple keys per POI. Rotation works as mint new → deploy → revoke old. Revocation is instant (no caches anywhere). A grayed-out “Revoked” group at the bottom of the list keeps revoked keys visible for audit.


4. Token format

The handoff token is a compact custom format — intentionally not a JWT (no alg header means no alg: none attack surface; the keyId is structural, not metadata):

{keyId}.{base64url(payload)}.{base64url(hmac)}
PartValue
keyIdThe public identifier from the Dashboard (e.g. key_a1b2c3d4e5f60718).
payloadThe JSON object from §4.1, base64url-encoded.
hmacHMAC-SHA256(rawSecret, "{keyId}.{base64url(payload)}"), base64url-encoded.

4.1 Payload fields

FieldTypeRequiredNotes
customerIdstringyesThe POI’s UDF value — the primary identity key for this member at this POI.
phonestringconditionalIsraeli format; normalized server-side. Required unless email is supplied.
emailstringconditionalRequired unless phone is supplied. At least one of phone/email must be present.
firstNamestringnoRetained for informational use; no longer affects member creation (no auto-create in handoff).
lastNamestringnoSame.
expnumberyesUnix epoch seconds. Server rejects if exp <= nowHANDOFF_EXPIRED.
iatnumberyesUnix epoch seconds. Server rejects if iat > expHANDOFF_INVALID; if exp - iat > 60HANDOFF_EXPIRED.
noncestringyesRandom, ≥ 16 chars, base64url-safe. Burned single-use server-side.

4.2 Mint-time rules

  • Max age 60 seconds. Mint the token immediately before page render — never pre-mint and cache.
  • Unique nonce per token. crypto.randomBytes(16).toString('base64url') (or any cryptographically random equivalent ≥16 chars). Re-using a nonce within the TTL window returns HANDOFF_REPLAY_DETECTED.
  • iat <= exp. A future-dated iat is rejected — it would otherwise defeat the max-age cap (the subtraction would go negative).
  • Identity is customerId + contact. The server looks up the member by the POI’s UDF value (customerId), then cross-checks it against the supplied phone or email. Both must match an existing member — the handoff channel does not auto-create members. A customerId that maps to no member, or whose stored contact differs from the token’s contact, is rejected.

5. Minting the token

Your backend mints a handoff token by calling the Simply mint endpoint — no crypto code on your side; Simply handles nonce generation and signing. This is a server-to-server call from your backend to the Simply API. Never call it from the browser.

  • Endpoint: POST /api/v1/handoff/mint-token — note: NO :poiId segment; the POI is resolved server-side from the integration key.
  • Auth: Authorization: Bearer {keyId}.{secret} where {keyId} is the public key identifier and {secret} is the raw secret shown once in the Dashboard. The merchant’s secret travels to Simply over TLS on each call — this is the main tradeoff vs. hand-rolled minting (in the hand-rolled approach, the secret never leaves your backend).
  • Rate limited: 600 requests per keyId per 1-minute window. Exceeding returns 429 MINT_RATE_LIMIT_EXCEEDED.

Request body:

FieldTypeRequiredNotes
customerIdstringyesThe POI’s UDF value for this shopper. 4xx if absent or empty.
phonestringconditionalIsraeli format; signed verbatim (not normalized). Required unless email is supplied.
emailstringconditionalRequired unless phone is supplied. At least one of phone/email must be present.
firstNamestringnoInformational; no member creation in this channel.
lastNamestringnoSame.

Note: Unknown fields (e.g. ttlSeconds) are silently ignored; the TTL is always hard-coded to 60 seconds server-side regardless of any field you send.

cURL example:

curl -X POST https://dropins.simplyclub.co.il/api/v1/handoff/mint-token \
  -H "Authorization: Bearer key_a1b2c3d4e5f60718.YOUR_RAW_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "customerId": "cust_001", "phone": "0501234567", "firstName": "Avi", "lastName": "Cohen" }'

Success response (200):

{
  "success": true,
  "data": {
    "handoffToken": "key_a1b2c3d4e5f60718.<base64url-payload>.<base64url-hmac>",
    "expiresAt": "2026-06-20T12:00:00.000Z"
  },
  "error": null
}

Use data.handoffToken directly as the handoffToken value in setConfig (§6). expiresAt is informational.

Error codes:

HTTPerrorCodeMeaning
400CUSTOMER_ID_REQUIREDcustomerId field is absent, null, or empty/whitespace string.
400CUSTOMER_ID_INVALIDcustomerId is present but not a non-empty string.
400CONTACT_REQUIREDBoth phone and email are absent or empty. At least one contact is required.
403HANDOFF_DISABLEDhandoffEnabled is not toggled on for this POI.
404POI_NOT_FOUNDThe integration key maps to a POI that no longer exists.
401INTEGRATION_KEY_INVALIDAuthorization header missing, malformed, unknown keyId, revoked key, or wrong secret. All four failure paths return this identical code (timing-safe).
429MINT_RATE_LIMIT_EXCEEDEDMore than 600 requests in 60 seconds from this keyId.

Node.js example:

async function getMintedHandoffToken({ customerId, phone, email, firstName, lastName }) {
  const res = await fetch(
    'https://dropins.simplyclub.co.il/api/v1/handoff/mint-token',
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.SIMPLY_KEY_ID}.${process.env.SIMPLY_KEY_SECRET}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ customerId, phone, email, firstName, lastName }),
    }
  );
  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    throw new Error(`Mint failed: ${body?.data?.errorCode ?? res.status}`);
  }
  const { data } = await res.json();
  return data.handoffToken;
}

Mint immediately before page render — the token expires in 60 seconds.


6. Passing the token to the widget

Inline the minted token into the page and pass it into setConfig:

<script type="module">
  import { dropInLoader } from "https://dropins.simplyclub.co.il/drop-ins/drop-in-loader/index.mjs";

  await dropInLoader.setConfig({
    poiId: "YOUR_POI_ID",
    handoffToken: "{{ token your backend just minted }}",
  });
</script>

That’s it — no extra event wiring is required just to log in.

When the exchange happens. The widget exchanges the token during the loader’s boot sequence — before any shopper interaction. By the time the membership CTA paints, the shopper is already authenticated; there is no “click to log in” step in the handoff flow. The same handoff branch also runs as a fallback inside the modal’s checkAuth if boot-time exchange failed (e.g. the page sat in bfcache long enough for exp to lapse and the user then opened the modal); the second attempt uses the same token still present in config.

What the widget does:

  1. During loader boot (right after setConfig resolves), reads handoffToken from the config.
  2. POSTs { token } to /api/v1/users/handoff/login/:poiId.
  3. On 200: stores the returned Bearer + device fingerprint, sets the current-member signal, mounts the authenticated UI.
  4. On any non-success: clears the token from in-memory config and falls through to fast-login (if fastLoginEnabled === true) or legacy. No shopper-visible error.
  5. On success: clears the token from in-memory config so subsequent code paths cannot re-submit it.

Don’t leave the token in the DOM after init. It’s already single-use, so a replay attempt is harmless — but minimizing dwell time still reduces casual exposure (e.g. in browser-extension snapshots).


7. The verify endpoint

The widget calls this for you; documented here for completeness and for backends that want to verify their own tokens against staging before going live.

POST {BASE_URL}/api/v1/users/handoff/login/:poiId
Content-Type: application/json
Origin: <your storefront origin>
EnvironmentBASE_URL
Productionhttps://dropins.simplyclub.co.il
Staginghttps://simply-drop-ins-2.onrender.com

Request body:

{ "token": "{keyId}.{base64url(payload)}.{base64url(hmac)}" }

Origin (or Referer) must be on the POI’s domain allowlist — the same poiDomainGuard rule the rest of the Dropin API uses. Add storefront domains under Dashboard → Settings → Access.

Successful response (200):

{
  "success": true,
  "data": {
    "access": "<Bearer token>",
    "member": { /* full member object — same shape as fast/legacy login */ },
    "isNewUser": false
  },
  "error": null
}

isNewUser is always false in the handoff channel — there is no member creation path. The handoff channel only logs in existing members identified by customerId + contact cross-check.

7.1 Error codes

All errors follow the standard { success: false, data: {...}, error: null } envelope. The errorCode is for server-side log triage — the friendlyMessage / friendlyMessageHebrew are intentionally generic across all verify failures so callers cannot probe for valid phones (no information leak).

HTTPerrorCodeMeaning
400INVALID_POI_IDpoiId is not a valid Mongo ObjectId.
401HANDOFF_INVALIDToken malformed, signature mismatch, unknown keyId, key revoked, iat > exp, missing customerId, missing contact, customerId maps to no member, or contact mismatch. Generic envelope — no information about which check failed is returned to the browser.
401HANDOFF_EXPIREDexp <= now or exp - iat > 60. Generic envelope.
401HANDOFF_REPLAY_DETECTEDThe token’s nonce was already used. Generic envelope.
403HANDOFF_DISABLEDpoi.settings.handoffEnabled !== true.
403ORIGIN_REQUIRED / DOMAIN_NOT_ALLOWEDOrigin / Referer missing or not on the POI’s domain allowlist.
404POI_NOT_FOUNDPOI not found in Mongo.
429HANDOFF_RATE_LIMIT_EXCEEDEDMore than 30 requests in 15 minutes from this IP.
500INTERNAL_ERRORUncaught server error. Safe to retry.

8. Admin API — keys CRUD

For Dashboard tooling or scripted key rotation. All three routes are gated vendorAuth + adminOnly — the same chain that fronts every other admin-scoped POI surface. (Origin validation belongs on the user-facing handoff endpoint, where the request actually comes from a storefront; admin calls originate from the Dashboard’s own host, so the storefront-allowlist check would 403 every legitimate call.)

MethodPathBodyReturns
GET/api/v1/poi/:poiId/integration-keysA bare array: [{ keyId, label, createdAt, lastUsedAt?, revokedAt?, revokedBy? }]. No secrets. IP/User-Agent are intentionally not exposed in the list for privacy.
POST/api/v1/poi/:poiId/integration-keys{ label } (1–50 chars){ keyId, secret }. The secret is returned exactly once — never again.
DELETE/api/v1/poi/:poiId/integration-keys/:keyId{ keyId }. Soft-delete — the row stays for audit, but every subsequent verify against keyId returns HANDOFF_INVALID. Revoking an unknown or already-revoked key returns 404 INTEGRATION_KEY_NOT_FOUND.

Key shape on the wire:

  • keyId = key_ + 16 hex chars (e.g. key_a1b2c3d4e5f60718).
  • secret = 32 random bytes, base64url-encoded (~43 chars).

The string your backend signs with is rawSecret. The keyId goes in the first segment of the token, in cleartext. Keys list shows keyId only.


9. Security model

  • Single-use nonces. A successful verify inserts { nonce, poiId, expiresAt } into a TTL collection with a unique index. Replay → 401.
  • Max age 60s. The merchant cannot widen this by setting their own exp — the server checks exp - iat <= 60 regardless.
  • iat can’t be in the future. Defense-in-depth against negative-window attacks.
  • Encrypted at rest. Raw HMAC secrets are encrypted with ENCRYPTION_SECRET in Mongo. The plaintext only exists in memory during a verify.
  • Instant revocation. No in-memory cache. Soft-delete sets revokedAt; the verify path checks it on every request.
  • No info leak. Every verify failure returns the same friendlyMessage. An attacker probing the endpoint with random tokens cannot distinguish “unknown member” from “bad signature”.
  • Origin gate. Origin / Referer must match the POI’s domain allowlist.
  • Rate limited. Dedicated handoffRateLimiter, separate from the OTP rate limiter, so a handoff burst doesn’t poison fast-login. 30 requests per IP per 15-minute window; exceeding returns 429 HANDOFF_RATE_LIMIT_EXCEEDED.
  • Audit trail written post-burn. lastUsedAt, lastUsedIp, lastUsedUserAgent are recorded after the nonce is burned — replayed tokens cannot pollute the audit row with attacker metadata.

10. Troubleshooting

SymptomLikely cause
403 HANDOFF_DISABLED despite a key existingThe Integrations-tab toggle was never flipped. Keys and the flag are independent.
401 HANDOFF_INVALID immediately after mintWrong key used to sign vs. the keyId in the token, or trailing whitespace / quoting issue in the secret.
401 HANDOFF_EXPIRED on first attemptEither the merchant set exp more than 60 s past iat (server enforces exp - iat <= 60 regardless of what the merchant signs), or server clock and merchant clock are skewed. Keep merchant NTP-synced.
401 HANDOFF_REPLAY_DETECTED on a page reloadThe token was reused. Mint a fresh token on every render — never cache server-side or in the page HTML.
403 DOMAIN_NOT_ALLOWEDOrigin doesn’t match the POI’s allowlist. Add the domain in Dashboard → Settings → Access.
401 HANDOFF_INVALID with a valid tokenMost common causes: (a) customerId not recognised at this POI — confirm the UDF mapping in Dashboard → Settings; (b) contact mismatch — the phone/email in the token does not match the member’s stored contact at SimplyClub; (c) UDF not configured for this POI (Dashboard → Settings → Integrations).
Widget falls back to phone+OTP instead of mounting authenticated UIOpen the Network tab — the /users/handoff/login call’s errorCode field shows the precise reason. The fallback is intentional on any 4xx/network error so the shopper can still log in.
Backend mints, page renders, but no /users/handoff/login POST appears in NetworkTwo common causes. (a) setConfig was called without a handoffToken field — confirm via await signal.get("simplyConfig") in the console. (b) A prior session was restored from secure storage and the loader correctly skipped handoff because the shopper is already authenticated — clear storage to re-test.

11. Checklist before going live

  • Minted at least one key in the Dashboard’s Integrations tab and copied the secret into your backend’s secret store.
  • Flipped the handoffEnabled toggle to on for the POI.
  • Storefront domain is on the POI’s domain allowlist (Settings → Access).
  • Backend mints a fresh token per page-render with a unique nonce.
  • Server clock is NTP-synced — exp - iat ≤ 60 seconds.
  • customerId matches the UDF value stored for this shopper in SimplyClub.
  • At least one of phone or email is included in the mint body (contact cross-check requires it).
  • phone (if supplied) is normalized (or at least Israeli-format raw) before signing.
  • The page does not log or otherwise leak the minted token (it’s single-use, but minimize dwell).
  • Tested the silent-fallback path: pass a deliberately invalid token and confirm the widget falls back to fast-login / legacy without showing an error.
  • On rotation: minted the new key, deployed it, then revoked the old one — not the other way around.