SimplyClub

Login Through Webhook

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.

{
  "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": "<opaque-base64url-string>"
}

3.1 Field reference

FieldTypeDescription
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.
eventIdstring (UUID)Stable dedupe id for this delivery event. Use this to deduplicate at-least-once delivery (see §5).
poiIdstringThe POI (Point of Interaction) that the shopper authenticated at.
referrerstringThe Storefront origin (i.e. yourStore.com)
member.idstring | numberThe shopper’s Simply Club member id.
member.phonestringThe shopper’s phone number (normalized Israeli format, e.g. "0501234567").
member.tierstring | undefinedMember tier / group (e.g. "Gold"), if present.
member.pointsnumber | undefinedMember point balance, if present.
isNewUserbooleantrue only for brand-new Simply Club members created at this login.
issuedAtstring (ISO-8601)Timestamp when the event was emitted. Also echoed in the X-Simply-Timestamp header.
ssoTicketstringOpaque 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:

HeaderValue
X-Simply-SignatureHMAC-SHA256(signingSecret, rawBodyBytes), base64url-encoded.
X-Simply-TimestampISO-8601 timestamp from payload.issuedAt. Use for replay-window checks.
X-Simply-Event-IdSame as payload.eventId. Convenient for routing/logging without parsing the body.

4.1 Reference verification snippet (Node.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):

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):

// 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:

{ "ticket": "<the ssoTicket value from the payload>" }

Success response (HTTP 200):

{
  "success": true,
  "data": {
    "member": { /* full member object */ },
    "poiId": "6821f55303f86ff040461ef4"
  },
  "error": null
}

Failure response (HTTP 401) — generic, all paths:

{
  "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:

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:

AttemptDelay 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).