# Identity JWT for Generic stores

> Mint a short-lived HS256 identity token on your server for signed-in customers and pass it to the widget, with Python, Node.js and PHP examples.

Source: https://shopassist.coec.dk/docs/orders-and-identity/generic-identity-jwt · Updated 2026-10-01

On a Generic store, your server tells ShopAssist who is signed in by minting a short-lived JWT. The widget asks your page for it when the shopper opens the chat.

## Get the identity secret [#secret]

In **Settings → Connection → Signed-in customers**, click **Create identity secret**. The secret is shown **once**, base64-encoded, with its key id (`k_…`). Store both in your server's secret store. The HMAC key is the **decoded bytes** of the secret, not the base64 text.

## Token claims [#claims]

| Claim                        | Value                                                         |
| ---------------------------- | ------------------------------------------------------------- |
| Header `alg` / `typ` / `kid` | `HS256` / `JWT` / your key id                                 |
| `iss`                        | `generic:sto_…` (your store id)                               |
| `aud`                        | `shopassist`                                                  |
| `sub`                        | Your customer id (string, up to 128 characters)               |
| `sid`                        | The widget session id passed to your identify callback        |
| `iat`, `exp`                 | Now, and at most 10 minutes later (the examples use 5)        |
| `jti`                        | At least 128 random bits                                      |
| `email`, `name` (optional)   | Shown to your team on handoff only; never used to find orders |

`sid` is the widget session id that the widget passes to the `identify` callback.

## Server side [#server]

**Python**

```python
# Python (PyJWT)
import base64, secrets, time, jwt
def mint_shopassist_token(store_id: str, key_id: str, secret_b64: str, customer_id: str, session_id: str) -> str:
    now = int(time.time())
    claims = {"iss": f"generic:{store_id}", "aud": "shopassist", "sub": str(customer_id), "sid": session_id,
              "iat": now, "exp": now + 300, "jti": secrets.token_hex(16)}
    return jwt.encode(claims, base64.b64decode(secret_b64), algorithm="HS256", headers={"kid": key_id})
```

**Node.js**

```js
// Node.js (jose)
import { SignJWT } from "jose"
import { randomBytes } from "node:crypto"
export async function mintShopAssistToken({ storeId, keyId, secretB64, customerId, sessionId }) {
  return new SignJWT({ sid: sessionId })
    .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: keyId })
    .setIssuer(`generic:${storeId}`)
    .setAudience("shopassist")
    .setSubject(String(customerId))
    .setIssuedAt()
    .setExpirationTime("5m")
    .setJti(randomBytes(16).toString("hex"))
    .sign(Buffer.from(secretB64, "base64"))
}
```

**PHP**

```php
<?php // PHP (firebase/php-jwt ^6)
use Firebase\JWT\JWT;
function mint_shopassist_token(string $storeId, string $keyId, string $secretB64, string $customerId, string $sessionId): string {
    $now = time();
    $claims = ['iss' => "generic:$storeId", 'aud' => 'shopassist', 'sub' => $customerId, 'sid' => $sessionId,
               'iat' => $now, 'exp' => $now + 300, 'jti' => bin2hex(random_bytes(16))];
    return JWT::encode($claims, base64_decode($secretB64), 'HS256', $keyId);
}
```

Expose it as an endpoint on your site, for example `POST /my-server/shopassist-token`. The endpoint MUST be POST, send `Cache-Control: no-store`, authenticate the shopper with your own session, and return `null` for guests.

## Browser side [#browser]

```js
ShopAssist.identify(async ({ sessionId }) => {
  const r = await fetch("/my-server/shopassist-token", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ session_id: sessionId }),
  })
  return r.ok ? ((await r.json()).token ?? null) : null
})
```

Call `ShopAssist.identify(null)` when the customer signs out. The provider is called with a timeout of 8 seconds; return `null` for guests.

## Verification errors [#errors]

If a token is rejected, the widget continues as a guest and the dashboard shows the reason (`identity_invalid`):

| Reason | Meaning |
| --- | --- |
| `malformed` | The token is not a valid JWT. |
| `algorithm` | The token is not signed with HS256. |
| `unknown_kid` | The `kid` header does not match the store's current or previous identity key. |
| `signature` | The signature does not verify with the identity secret. |
| `issuer` | `iss` is not `generic:{store_id}` (or the platform issuer). |
| `audience` | `aud` is not `shopassist`. |
| `session_mismatch` | `sid` is not the widget session that requested the token. |
| `expired` | The token has expired. |
| `not_yet_valid` | `iat` or `nbf` lies in the future (check the server clock). |
| `lifetime_too_long` | `exp − iat` is longer than 300 seconds. |

Never put the token in your page HTML or local storage, and never mint it in the browser: anyone with the secret can sign in as any customer. If the secret leaks, [rotate it immediately](/docs/orders-and-identity/identity-secret-rotation).
