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
- Your page embeds the widget loader as before and tells it which integration it belongs to and how to obtain an assertion.
- 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. - Your callback calls your own backend, which checks its normal session, builds a signed JWT (RS256) for the signed-in account and returns it.
- 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:
- 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 issuerurn:supportwunder:customer:<integration ID>. - Generate an RSA key pair on your server and register the public key with a key ID
(
kid, 1–64 characters ofA–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>".
| Claim | Value |
|---|---|
iss | urn:supportwunder:customer:<integration ID> |
aud | supportwunder:customer-identity:v1 (a string, not a list) |
sub | The account's stable, never reused identifier, at most 128 characters. See below. |
iat | Integer Unix seconds, now |
exp | Integer Unix seconds, at most 300 s after iat |
jti | Fresh random ID, 22–128 characters of A–Z a–z 0–9 _ - (at least 128 bits of randomness) |
nonce | Exactly the nonce your callback received |
name | Optional display name, at most 200 characters |
email | Optional, one address, at most 254 characters |
email_verified | Optional boolean; only together with email |
Rules that matter in practice:
submust be immutable and never reassigned, including after account deletion. Use the account's primary key or a dedicated UUID. Email addresses and usernames are not acceptable: they get recycled, and a recycled subject would hand a new person the old person's history.- Derive
subfrom your authenticated server-side session. Never from a user ID, email or form field in the request. - Keep the clock accurate.
expmay be at most five minutes afteriat;iatmay be at most 30 seconds in the future. Do not addnbf(if you do, it must already have passed). - Do not put roles, permissions, plan names or internal IDs into the token. They are ignored for authorization and only increase what a leaked token reveals.
email_verifiedrecords that your application verified the address. SupportWunder does not re-verify it and never labels the person as verified to agents.
Your assertion endpoint
Add one endpoint to your backend, reachable by your signed-in users only. It:
- requires your normal login session and your usual CSRF or same-origin protection;
- accepts a
noncein the JSON body; - returns
{"assertion": "<jwt>"}withCache-Control: no-store; - is not callable cross-origin by other sites (do not add a permissive CORS policy).
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
- Generate a new key pair on your server.
- Register the new public key with a new
kidand an overlap of 1–7 days. - Switch your endpoint to sign with the new key and
kid. - 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
| Symptom | Likely cause |
|---|---|
| Widget offers email/guest instead of signing in | Callback 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 minutes | The 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 email | Expected: identity is the stable sub per integration, never the email address. |
API error codes
Errors use {"detail": "<code>"}. Relevant responses are:
| Status | Codes | Meaning |
|---|---|---|
| 400 | invalid_request, invalid_email | Malformed request or email address. |
| 401 | invalid_challenge, invalid_assertion, invalid_code, invalid_session | Proof or session was not accepted. |
| 403 | origin_not_allowed, key_revoked, widget_disabled | The origin, key, or widget cannot sign in. |
| 404 | customer_identity_disabled, integration_unavailable, email_login_disabled | The feature or configured integration is unavailable. |
| 409 | subject_changed | The signed-in account changed; start a fresh sign-in. |
| 410 | challenge_expired | Create a new challenge and request a fresh assertion or code. |
| 413 | payload_too_large | The request exceeded the endpoint's size limit. |
| 429 | rate_limited, cooldown | Wait for the Retry-After interval before retrying. |
| 503 | store_unavailable, delivery_failed, busy | A required service is temporarily unavailable. |