Skip to content

Integrate customer identity with your application

This guide is for developers connecting a shop, customer portal, or SaaS application to the SupportWunder chat widget.

How it works

  1. Your page embeds the widget loader as before and tells it which integration it belongs to and how to obtain an assertion.
  2. When the widget opens, its iframe creates a short-lived challenge and asks the loader for an assertion over the challenge nonce. The loader calls your getAssertion(nonce) callback.
  3. Your callback calls your own backend, which checks its normal session, builds a signed JWT (RS256) for the signed-in account and returns it.
  4. The widget exchanges the assertion for a 15-minute customer session and shows that account's conversations. It renews the session in the background with a fresh assertion while it is open, and forgets everything when you call logout().

Nothing in this flow trusts the browser about who is signed in: the only proof is your backend's signature over a nonce the widget just created. The public embed key, a typed email address and any user ID sent by the browser never identify a customer.

One-time setup in SupportWunder

An organization owner does this under Settings → Customer identity:

  1. Create an integration. Enter the exact origins of your application, one per line (https://shop.example, https://app.shop.example). Lowercase, no path, no trailing slash, default ports omitted. Note the integration ID and the issuer urn:supportwunder:customer:<integration ID>.
  2. Generate an RSA key pair on your server and register the public key with a key ID (kid, 1–64 characters of A–Z a–z 0–9 _ -). Never upload or paste the private key.
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out customer-identity-private.pem
openssl pkey -in customer-identity-private.pem -pubout -out customer-identity-public.pem
chmod 600 customer-identity-private.pem

Accepted keys: RSA 2048–4096 bits, public exponent 65537, PEM -----BEGIN PUBLIC KEY-----. At most three unrevoked keys per integration.

The assertion

A compact JWS with exactly these header fields and claims. Anything else is rejected.

Header: alg: "RS256", typ: "sw-customer-identity+jwt", kid: "<your key id>".

ClaimValue
issurn:supportwunder:customer:<integration ID>
audsupportwunder:customer-identity:v1 (a string, not a list)
subThe account's stable, never reused identifier, at most 128 characters. See below.
iatInteger Unix seconds, now
expInteger Unix seconds, at most 300 s after iat
jtiFresh random ID, 22–128 characters of A–Z a–z 0–9 _ - (at least 128 bits of randomness)
nonceExactly the nonce your callback received
nameOptional display name, at most 200 characters
emailOptional, one address, at most 254 characters
email_verifiedOptional boolean; only together with email

Rules that matter in practice:

Your assertion endpoint

Add one endpoint to your backend, reachable by your signed-in users only. It:

Python (Django or any WSGI framework)

import secrets
import time

import jwt  # PyJWT >= 2.15.1

INTEGRATION_ID = "0d9b1f2a-6c4e-4b6a-9f31-2c7e8a1d5e60"   # from Settings → Customer identity
KEY_ID = "2026-10"                                        # the kid you registered
PRIVATE_KEY = open("/etc/myapp/customer-identity-private.pem", "rb").read()  # server-side only

NONCE_ALLOWED = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")


def customer_identity_assertion(request):
    # 1. Only a signed-in user of YOUR application. request.user comes from your session
    #    cookie; nothing in the request body is trusted for identity.
    if not request.user.is_authenticated:
        return json_response({"detail": "login_required"}, status=401)
    # 2. The nonce is opaque: just bound its shape.
    nonce = request.json().get("nonce")
    if not isinstance(nonce, str) or not 20 <= len(nonce) <= 128 or set(nonce) - NONCE_ALLOWED:
        return json_response({"detail": "invalid_request"}, status=400)
    now = int(time.time())
    claims = {
        "iss": f"urn:supportwunder:customer:{INTEGRATION_ID}",
        "aud": "supportwunder:customer-identity:v1",
        "sub": str(request.user.pk),                 # stable, never reused
        "iat": now,
        "exp": now + 120,                            # <= 300 s
        "jti": secrets.token_urlsafe(24),            # 32 chars, 192 bits
        "nonce": nonce,
        "name": request.user.get_full_name()[:200],
    }
    # Omit both optional email claims when the account has no non-empty address.
    email = request.user.email
    if isinstance(email, str) and email.strip():
        claims["email"] = email
        claims["email_verified"] = bool(request.user.email_verified)
    token = jwt.encode(claims, PRIVATE_KEY, algorithm="RS256",
                       headers={"kid": KEY_ID, "typ": "sw-customer-identity+jwt"})
    response = json_response({"assertion": token})
    response["Cache-Control"] = "no-store"
    return response

Node (Express or any HTTP framework)

import { randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import jwt from "jsonwebtoken";           // or `jose`

const INTEGRATION_ID = "0d9b1f2a-6c4e-4b6a-9f31-2c7e8a1d5e60";
const KEY_ID = "2026-10";
const PRIVATE_KEY = readFileSync("/etc/myapp/customer-identity-private.pem"); // server-side only
const NONCE_RE = /^[A-Za-z0-9_-]{20,128}$/;

app.post("/api/customer-identity/assertion", requireLogin, (req, res) => {
  // req.user comes from YOUR session middleware; the body carries only the nonce.
  const { nonce } = req.body ?? {};
  if (typeof nonce !== "string" || !NONCE_RE.test(nonce)) {
    return res.status(400).json({ detail: "invalid_request" });
  }
  const now = Math.floor(Date.now() / 1000);
  const claims = {
    iss: `urn:supportwunder:customer:${INTEGRATION_ID}`,
    aud: "supportwunder:customer-identity:v1",
    sub: String(req.user.id),                       // stable, never reused
    iat: now,
    exp: now + 120,                                 // <= 300 s
    jti: randomBytes(24).toString("base64url"),     // 32 chars, 192 bits
    nonce,
    name: (req.user.displayName ?? "").slice(0, 200),
  };
  const email = req.user.email;
  if (typeof email === "string" && email.trim()) {
    claims.email = email;
    claims.email_verified = Boolean(req.user.emailVerified);
  }
  const token = jwt.sign(
    claims,
    PRIVATE_KEY,
    { algorithm: "RS256", keyid: KEY_ID, header: { typ: "sw-customer-identity+jwt" } },
  );
  res.set("Cache-Control", "no-store").json({ assertion: token });
});

With jsonwebtoken, pass iat/exp yourself as above (do not combine with expiresIn), so the header contains only alg, typ and kid.

The page

Keep the one-line embed. Configure identity before the loader runs so the widget starts in identified mode and never reads an earlier guest session from this browser:

<script>
  window.supportwunderIdentity = {
    integrationId: "0d9b1f2a-6c4e-4b6a-9f31-2c7e8a1d5e60",
    getAssertion: async function (nonce) {
      const response = await fetch("/api/customer-identity/assertion", {
        method: "POST",
        credentials: "same-origin",
        headers: { "Content-Type": "application/json", "X-CSRFToken": readCsrfToken() },
        body: JSON.stringify({ nonce: nonce }),
      });
      if (!response.ok) throw new Error("no assertion");
      return (await response.json()).assertion;   // must be a string
    },
  };
</script>
<script src="https://supportwunder.com/widget.js" data-key="YOUR_EMBED_KEY" async></script>

Only include the first block on pages where the visitor is signed in. On a public page, leave it out: the widget then runs as the usual guest widget (plus the email code option if the owner enabled it).

If the sign-in state changes without a page load (single-page applications), use the API:

// after login
window.supportwunder.identify({ integrationId: "0d9b1f2a-…", getAssertion });
// on logout — ALWAYS call this; it clears the account in the widget and in your other tabs
window.supportwunder.logout();

open(), close() and toggle() keep working as before. The loader never stores the assertion; it passes it to its own iframe once. A callback that is missing, throws, returns a non-string or takes longer than ten seconds results in the widget offering the email code or guest path instead — no error details leave your page.

Logout

Call window.supportwunder.logout() from your application's logout handler before the page navigates away, or on the page the user lands on after logging out if it still embeds the widget. The widget revokes its session on the server and tells other widget instances on your origin to clear theirs (a token-free BroadcastChannel message). A missed call is bounded by the 15-minute session lifetime; a stolen assertion is bounded by its five-minute lifetime and single use.

Key rotation

  1. Generate a new key pair on your server.
  2. Register the new public key with a new kid and an overlap of 1–7 days.
  3. Switch your endpoint to sign with the new key and kid.
  4. After the overlap the old key stops being accepted for new assertions. Revoke it explicitly if the private key may have leaked: revocation also ends every session created with it.

Troubleshooting

SymptomLikely cause
Widget offers email/guest instead of signing inCallback missing, threw, returned a non-string, or took > 10 s; or the page origin is not in the integration's allowed origins; or the integration is disabled / has no active key.
Exchange rejected (the widget shows the sign-in choice again)Wrong iss/aud/typ, unknown or revoked kid, exp - iat > 300 s, clock skew > 30 s, a claim not in the table, nonce from an earlier challenge, or a replayed jti.
Customers are signed out after ~15 minutesThe host page did not answer the renewal callback (for example, the application session expired). This is the designed fallback.
Two accounts see different history for the same emailExpected: identity is the stable sub per integration, never the email address.

API error codes

Errors use {"detail": "<code>"}. Relevant responses are:

StatusCodesMeaning
400invalid_request, invalid_emailMalformed request or email address.
401invalid_challenge, invalid_assertion, invalid_code, invalid_sessionProof or session was not accepted.
403origin_not_allowed, key_revoked, widget_disabledThe origin, key, or widget cannot sign in.
404customer_identity_disabled, integration_unavailable, email_login_disabledThe feature or configured integration is unavailable.
409subject_changedThe signed-in account changed; start a fresh sign-in.
410challenge_expiredCreate a new challenge and request a fresh assertion or code.
413payload_too_largeThe request exceeded the endpoint's size limit.
429rate_limited, cooldownWait for the Retry-After interval before retrying.
503store_unavailable, delivery_failed, busyA required service is temporarily unavailable.