--- title: Login Through Webhook tags: [auth, backend, hmac, outbound-webhook, sso, dropins] --- Every time a shopper authenticates into Simply Club — through the phone+OTP fast channel, the server-handoff channel, or the legacy channel — the Simply server can emit a signed outbound webhook to your backend carrying the shopper's verified Simply identity plus a short-lived, single-use **SSO ticket**. Your backend can use that ticket to identify the shopper and, optionally, start a native storefront session (Shopify Multipass, WordPress `wp_set_auth_cookie`, a custom JWT, etc.). > **TL;DR** — Enable the outbound webhook in the Dashboard. On every login, Simply will POST a signed JSON payload to your URL. The payload contains the shopper's Simply identity and a single-use `ssoTicket`. Exchange that ticket for the full identity by calling `POST /api/v1/sso/redeem`. What you do with the identity to create a storefront session is your adapter's responsibility — Simply gives you the identity and the ticket; the last inch is yours. --- ## 1. When to use it Use the outbound login event when **your backend needs to act on a Simply login** — regardless of how the shopper authenticated: - Auto-log the shopper into your platform (Shopify, WooCommerce, custom app) when they authenticate in the Simply widget. - Associate a Simply member with a platform-side user account on first login. - Trigger loyalty or personalization workflows the moment a member is identified. - Any platform — custom code, WordPress, WooCommerce, Shopify — that can receive a webhook and optionally issue a storefront session. **Do not** use the outbound event as an inbound webhook (the `order-webhook` is separate and unrelated). The outbound login event fires on every authenticated login, not on orders. --- ## 2. Enabling it on a POI (Dashboard) The outbound channel is **opt-in per POI**. A POI with no active outbound config produces zero outbound HTTP calls. 1. Open the SimplyClub Dashboard → your POI → **Webhooks → Outbound Webhook**. 2. Enter an `https://` destination URL (only HTTPS is accepted). 3. Click **Save URL** — a modal opens and shows your signing secret **exactly once**. Copy it to your backend's secret store before closing. 4. Flip the **Enable** toggle to on. Delivery starts on the next login. **Multiple configs per POI.** Each config has its own URL, secret, and `isActive` state. To rotate a secret: click **Rotate signing secret**, copy the new secret, redeploy your verifier with the new secret, then disable or delete the old config. **Secret shown once.** The Dashboard never shows the raw secret again after the creation modal. If you lose it, rotate or delete the config and create a new one. --- ## 3. Signed neutral payload schema Simply POSTs a JSON body to your URL. The payload is **platform-neutral** — it contains no Shopify `gid://`, no `poiCustomerId`, no platform-specific identifier of any kind. The only identity key is the shopper's Simply member identity. ```json { "event": "user.logged_in", "eventId": "550e8400-e29b-41d4-a716-446655440000", "poiId": "6821f55303f86ff040461ef4", "referrer": "yourstore.com", "member": { "id": "12345", "phone": "0501234567", "tier": "Gold", "points": 1500 }, "isNewUser": false, "issuedAt": "2026-06-21T22:00:00.000Z", "ssoTicket": "" } ``` ### 3.1 Field reference | Field | Type | Description | |---|---|---| | `event` | `"user.created"` \| `"user.logged_in"` | `user.created` for a brand-new Simply Club member created at this login; `user.logged_in` for a returning member. | | `eventId` | string (UUID) | Stable dedupe id for this delivery event. Use this to deduplicate at-least-once delivery (see §5). | | `poiId` | string | The POI (Point of Interaction) that the shopper authenticated at. | | `referrer` | string | The Storefront origin (i.e. yourStore.com) | `member.id` | string \| number | The shopper's Simply Club member id. | | `member.phone` | string | The shopper's phone number (normalized Israeli format, e.g. `"0501234567"`). | | `member.tier` | string \| undefined | Member tier / group (e.g. `"Gold"`), if present. | | `member.points` | number \| undefined | Member point balance, if present. | | `isNewUser` | boolean | `true` only for brand-new Simply Club members created at this login. | | `issuedAt` | string (ISO-8601) | Timestamp when the event was emitted. Also echoed in the `X-Simply-Timestamp` header. | | `ssoTicket` | string | Opaque single-use SSO ticket (5-minute TTL). Exchange via `POST /api/v1/sso/redeem`. **Never log this value — it is a credential.** | **No platform-specific id.** The outbound payload deliberately contains no Shopify customer id, WooCommerce user id, or any other platform identifier. Simply's identity key is phone number only. If you need to link this Simply identity to a platform account, use `member.phone` as the lookup key on your side. --- ## 4. Signature verification Every delivery includes three HTTP headers: | Header | Value | |---|---| | `X-Simply-Signature` | `HMAC-SHA256(signingSecret, rawBodyBytes)`, base64url-encoded. | | `X-Simply-Timestamp` | ISO-8601 timestamp from `payload.issuedAt`. Use for replay-window checks. | | `X-Simply-Event-Id` | Same as `payload.eventId`. Convenient for routing/logging without parsing the body. | ### 4.1 Reference verification snippet (Node.js) ```js const crypto = require('crypto'); function verifySimplySignature(rawBody, receivedSig, signingSecret) { // HMAC-SHA256 is computed over the RAW request body bytes, not a re-serialized // object. Always use the raw body buffer — never JSON.parse then JSON.stringify. const expected = crypto .createHmac('sha256', signingSecret) .update(rawBody) // rawBody must be a Buffer or string with the exact bytes received .digest('base64url'); return crypto.timingSafeEqual( Buffer.from(expected, 'utf8'), Buffer.from(receivedSig, 'utf8'), ); } // Express example (requires express.raw() or body-parser rawBody option): app.post('/simply-webhook', express.raw({ type: 'application/json' }), (req, res) => { const sig = req.headers['x-simply-signature']; if (!verifySimplySignature(req.body, sig, process.env.SIMPLY_WEBHOOK_SECRET)) { return res.status(401).json({ error: 'Invalid signature' }); } const payload = JSON.parse(req.body.toString()); // ... process payload res.sendStatus(200); }); ``` > **Critical:** Compute HMAC over the **raw body bytes**, not over a re-serialized object. If you call `JSON.parse` first then `JSON.stringify` the result, key ordering or whitespace may differ, causing verification to fail even for a valid payload. **Tampered-body detection.** If the body is modified in transit (by a proxy, a CDN, or an attacker), the HMAC computed server-side will not match. Your verifier should reject any request where `verifySimplySignature` returns `false`. ### 4.2 Replay-window check The `X-Simply-Timestamp` header echoes `issuedAt`. To protect against replay attacks, reject requests where the timestamp is more than N seconds old (5–15 minutes is typical): ```js const ts = new Date(req.headers['x-simply-timestamp']); const ageMs = Date.now() - ts.getTime(); if (isNaN(ts) || ageMs > 10 * 60 * 1000) { // reject if > 10 minutes old return res.status(401).json({ error: 'Timestamp too old' }); } ``` --- ## 5. Dedupe via eventId Simply delivers each event **at least once**. The delivery worker retries failed deliveries with exponential backoff (up to 5 attempts, up to ~1 hour between retries). Your endpoint may receive the same event more than once. **Required:** Deduplicate on `eventId` (a stable UUID per event, not per retry): ```js // Idempotent handler example (pseudo-code) const alreadyProcessed = await db.events.exists({ eventId: payload.eventId }); if (alreadyProcessed) { return res.sendStatus(200); // acknowledge — already handled } await db.events.insert({ eventId: payload.eventId }); // ... handle payload ``` Your endpoint should **always return 2xx** for successfully received events (even if you choose to skip processing). Non-2xx responses trigger a retry. --- ## 6. The `POST /api/v1/sso/redeem` flow The `ssoTicket` in the payload is an opaque, single-use credential. To exchange it for a verified Simply identity, your backend calls the redeem endpoint: ``` POST https://dropins.simplyclub.co.il/api/v1/sso/redeem Authorization: Bearer {keyId}.{secret} Content-Type: application/json ``` The `Authorization` header uses the same Bearer format as the Server-Handoff integration key (`keyId.secret` — see the Dashboard's **Settings → Integrations** tab to create a key). The POI is resolved server-side from the integration key; there is no `:poiId` URL segment. **Request body:** ```json { "ticket": "" } ``` **Success response (HTTP 200):** ```json { "success": true, "data": { "member": { /* full member object */ }, "poiId": "6821f55303f86ff040461ef4" }, "error": null } ``` **Failure response (HTTP 401) — generic, all paths:** ```json { "success": false, "data": { "errorCode": "SSO_REDEEM_INVALID", "message": "SSO ticket is invalid, expired, or already redeemed", "friendlyMessage": "The SSO ticket could not be redeemed.", "friendlyMessageHebrew": "כרטיס ה-SSO אינו תקין, פג תוקפו, או כבר מומש." }, "error": null } ``` ### 6.1 Redeem rules - **Single-use.** The ticket is burned on the first successful redeem. A second call with the same ticket returns 401. - **5-minute TTL.** Tickets expire 5 minutes after issuance. Expired tickets return 401. - **POI-bound.** The ticket is bound to the POI that issued it. Redeeming a ticket with an integration key from a different POI returns 401. - **Generic failure.** All failure paths (not-found, already-redeemed, expired, wrong-POI, missing-ticket) return the identical 401 body. This prevents information disclosure — callers cannot probe which failure reason applies. ### 6.2 Timing the redeem call The delivery worker sends the webhook and your handler should redeem the ticket promptly. The 5-minute TTL is generous for normal operation, but if your webhook handler queues the event for later processing, ensure the dequeued handler runs within 5 minutes of the `issuedAt` timestamp. **Node.js example:** ```js async function redeemSsoTicket(ticket) { const res = await fetch( 'https://dropins.simplyclub.co.il/api/v1/sso/redeem', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.SIMPLY_KEY_ID}.${process.env.SIMPLY_KEY_SECRET}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ ticket }), } ); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(`SSO redeem failed: ${body?.data?.errorCode ?? res.status}`); } const { data } = await res.json(); return data; // { member, poiId } } ``` --- ## 7. The "last inch" is your adapter's responsibility Simply provides: - A **verified Simply identity** (`member.id`, `member.phone`, `member.tier`, `member.points`, `isNewUser`). - A **single-use SSO ticket** that lets your backend confirm the identity with a server-to-server call. Simply does **not** provide: - Shopify Multipass token generation. - WordPress `wp_set_auth_cookie` calls. - OIDC / OAuth token issuance. - Webhook-to-browser correlation (getting the storefront session into the shopper's browser after your backend receives a server-side webhook). - Customer account creation on your platform. These "last inch" steps are your adapter's job. The outbound login event gives you a trusted, verified identity to act on — how you materialize a session on your platform is platform-specific and intentionally out of Simply's scope. **Webhook-to-browser correlation pattern (common approach):** 1. Your webhook handler receives the event and redeems the ticket → gets the Simply member identity. 2. Your handler mints a short-lived one-time token keyed to the `eventId` (or a session correlator you embedded in the `poiId` namespace). 3. The shopper's browser polls your backend for that token (or you push it via WebSocket / SSE) and uses it to initiate a platform login. The specifics depend on your platform and architecture. Simply provides the trusted identity signal; the wiring to your storefront's session layer is yours. --- ## 8. Delivery guarantees and failure handling The Simply delivery worker uses **exponential backoff with up to 5 attempts**: | Attempt | Delay before retry | |---|---| | 1st failure | ~5 seconds | | 2nd failure | ~10 seconds | | 3rd failure | ~20 seconds | | 4th failure | ~40 seconds | | 5th failure (final) | Marked as permanently failed — no further retries | A permanently failed delivery is recorded for operator review. Your webhook endpoint should **return 2xx promptly** (before any slow processing) and handle the work asynchronously to avoid accidental non-2xx responses due to processing timeouts. **Delivery timeout:** Each attempt has a 10-second HTTP timeout. Your endpoint must respond within 10 seconds. --- ## 9. Setup checklist - [ ] Entered an `https://` URL in Dashboard → Webhooks → Outbound Webhook. - [ ] Copied the signing secret into your backend's secret store before closing the reveal modal. - [ ] Toggled the outbound webhook **Enabled**. - [ ] Backend verifier computes HMAC over the **raw body bytes** (not re-serialized JSON). - [ ] Implement `eventId`-based deduplification for at-least-once delivery. - [ ] Redeem the `ssoTicket` within 5 minutes of `issuedAt` via `POST /api/v1/sso/redeem`. - [ ] Endpoint returns 2xx promptly; heavy processing is async. - [ ] Understand the "last inch" is your platform adapter's job (§7).