--- title: Login From Backend tags: [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**](/content/login/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 **once** | Minted by your backend on every page render | | Lives in your backend's secret store, never in the browser | Briefly inlined in the HTML, dies on first use | | Used to **sign** tokens | Used to **log in** one shopper, once | | Lifetime: until you revoke it | Lifetime: ≤ 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)} ``` | Part | Value | |---|---| | `keyId` | The public identifier from the Dashboard (e.g. `key_a1b2c3d4e5f60718`). | | `payload` | The JSON object from §4.1, base64url-encoded. | | `hmac` | `HMAC-SHA256(rawSecret, "{keyId}.{base64url(payload)}")`, base64url-encoded. | ### 4.1 Payload fields | Field | Type | Required | Notes | |---|---|---|---| | `customerId` | string | yes | The POI's UDF value — the primary identity key for this member at this POI. | | `phone` | string | conditional | Israeli format; normalized server-side. Required unless `email` is supplied. | | `email` | string | conditional | Required unless `phone` is supplied. At least one of `phone`/`email` must be present. | | `firstName` | string | no | Retained for informational use; no longer affects member creation (no auto-create in handoff). | | `lastName` | string | no | Same. | | `exp` | number | yes | Unix epoch **seconds**. Server rejects if `exp <= now` → `HANDOFF_EXPIRED`. | | `iat` | number | yes | Unix epoch **seconds**. Server rejects if `iat > exp` → `HANDOFF_INVALID`; if `exp - iat > 60` → `HANDOFF_EXPIRED`. | | `nonce` | string | yes | Random, ≥ 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:** | Field | Type | Required | Notes | |---|---|---|---| | `customerId` | string | yes | The POI's UDF value for this shopper. 4xx if absent or empty. | | `phone` | string | conditional | Israeli format; signed verbatim (not normalized). Required unless `email` is supplied. | | `email` | string | conditional | Required unless `phone` is supplied. At least one of `phone`/`email` must be present. | | `firstName` | string | no | Informational; no member creation in this channel. | | `lastName` | string | no | Same. | > **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:** ```bash 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`):** ```json { "success": true, "data": { "handoffToken": "key_a1b2c3d4e5f60718..", "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:** | HTTP | `errorCode` | Meaning | |---|---|---| | `400` | `CUSTOMER_ID_REQUIRED` | `customerId` field is absent, null, or empty/whitespace string. | | `400` | `CUSTOMER_ID_INVALID` | `customerId` is present but not a non-empty string. | | `400` | `CONTACT_REQUIRED` | Both `phone` and `email` are absent or empty. At least one contact is required. | | `403` | `HANDOFF_DISABLED` | `handoffEnabled` is not toggled on for this POI. | | `404` | `POI_NOT_FOUND` | The integration key maps to a POI that no longer exists. | | `401` | `INTEGRATION_KEY_INVALID` | Authorization header missing, malformed, unknown keyId, revoked key, or wrong secret. All four failure paths return this identical code (timing-safe). | | `429` | `MINT_RATE_LIMIT_EXCEEDED` | More than 600 requests in 60 seconds from this keyId. | **Node.js example:** ```js 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`: ```html ``` 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: ``` | Environment | `BASE_URL` | |---|---| | Production | `https://dropins.simplyclub.co.il` | | Staging | `https://simply-drop-ins-2.onrender.com` | Request body: ```json { "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`): ```json { "success": true, "data": { "access": "", "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). | HTTP | `errorCode` | Meaning | |---|---|---| | `400` | `INVALID_POI_ID` | `poiId` is not a valid Mongo ObjectId. | | `401` | `HANDOFF_INVALID` | Token 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. | | `401` | `HANDOFF_EXPIRED` | `exp <= now` or `exp - iat > 60`. **Generic envelope.** | | `401` | `HANDOFF_REPLAY_DETECTED` | The token's `nonce` was already used. **Generic envelope.** | | `403` | `HANDOFF_DISABLED` | `poi.settings.handoffEnabled !== true`. | | `403` | `ORIGIN_REQUIRED` / `DOMAIN_NOT_ALLOWED` | `Origin` / `Referer` missing or not on the POI's domain allowlist. | | `404` | `POI_NOT_FOUND` | POI not found in Mongo. | | `429` | `HANDOFF_RATE_LIMIT_EXCEEDED` | More than 30 requests in 15 minutes from this IP. | | `500` | `INTERNAL_ERROR` | Uncaught 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.) | Method | Path | Body | Returns | |---|---|---|---| | `GET` | `/api/v1/poi/:poiId/integration-keys` | — | A 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 | Symptom | Likely cause | |---|---| | `403 HANDOFF_DISABLED` despite a key existing | The Integrations-tab toggle was never flipped. Keys and the flag are independent. | | `401 HANDOFF_INVALID` immediately after mint | Wrong key used to sign vs. the `keyId` in the token, or trailing whitespace / quoting issue in the secret. | | `401 HANDOFF_EXPIRED` on first attempt | Either 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 reload | The token was reused. Mint a fresh token on every render — never cache server-side or in the page HTML. | | `403 DOMAIN_NOT_ALLOWED` | `Origin` doesn't match the POI's allowlist. Add the domain in Dashboard → Settings → Access. | | `401 HANDOFF_INVALID` with a valid token | Most 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 UI | Open 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 Network | Two 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.