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 viasetConfig({ 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 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:
- 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.secretand 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
keyIdonly; the secret half lives only in your secret store.
- Click Add key, give it a label (e.g.
- Toggle
handoffEnabledon. A switch above the keys list. With the switch off, every handoff request returns403 HANDOFF_DISABLEDregardless 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 returnsHANDOFF_REPLAY_DETECTED. iat <= exp. A future-datediatis 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 suppliedphoneoremail. Both must match an existing member — the handoff channel does not auto-create members. AcustomerIdthat 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:poiIdsegment; 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:
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:
| 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:
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:
- During loader boot (right after
setConfigresolves), readshandoffTokenfrom the config. - POSTs
{ token }to/api/v1/users/handoff/login/:poiId. - On 200: stores the returned Bearer + device fingerprint, sets the
current-membersignal, mounts the authenticated UI. - 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. - 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>
| Environment | BASE_URL |
|---|---|
| Production | https://dropins.simplyclub.co.il |
| Staging | https://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).
| 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 checksexp - iat <= 60regardless. iatcan’t be in the future. Defense-in-depth against negative-window attacks.- Encrypted at rest. Raw HMAC secrets are encrypted with
ENCRYPTION_SECRETin 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/Referermust 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 returns429 HANDOFF_RATE_LIMIT_EXCEEDED. - Audit trail written post-burn.
lastUsedAt,lastUsedIp,lastUsedUserAgentare 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
handoffEnabledtoggle 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. -
customerIdmatches the UDF value stored for this shopper in SimplyClub. - At least one of
phoneoremailis 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.