# Pass your user into the chat (signed app context)

Published at https://app.legotechapps.com/developers/app-context/ · Base URL `https://legotechapps.com/lt/api`

Your app (or website) opens a LegoTechApps chatbot and tells it **which of your users is writing** — user id, app version, plan, device, language — without the user typing anything and without the bot trusting the phone. The bot answers with that in mind, and every action and webhook carries it as `data.context`, so a bug report already says which user, which version, which device.

## 1. The flow

1. The user taps **Help** in your app.
2. Your app asks **your server** for a token (your own endpoint, your own login).
3. Your server signs a short-lived token with the bot's **app context secret**.
4. The app opens `https://legotechapps.com/chat/<link key>?lang=fr&ctx=<token>` in an in-app browser.
5. LegoTechApps verifies the signature. Valid → the conversation is tagged with your user. Invalid or expired → ignored silently; the chat still works.

**The secret lives only on your server.** Never ship it inside the app: anyone can unpack an app and read it, and then sign tokens for any user.

Get the secret in **Dashboard → Chatbot → Advanced → Connect your app → Generate secret**. It is shown once. **Rotate** makes a new one and keeps the old one valid for 24 hours, so apps in the field do not break while your server switches.

## 2. The token

```
token = base64url(payloadJSON) + "." + base64url(HMAC_SHA256(secret, base64url(payloadJSON)))
```

- base64url = base64 with `-` and `_` instead of `+` and `/`, **no `=` padding**.
- The HMAC is computed over the **base64url string** of the JSON — not over the JSON itself.
- The secret is used as-is (its UTF-8 bytes), including its `lcs_` prefix.

### Payload fields

| Field | Required | Type | Meaning |
|---|---|---|---|
| `v` | yes | 1 | Format version. Always `1`. |
| `app` | yes | string | Your app or site id, e.g. `com.example.app`. |
| `iat` | yes | unix seconds | When you signed it. |
| `exp` | yes | unix seconds | When it stops being accepted. **24 h is plenty**; more than 7 days after `iat` is refused. |
| `app_name` | no | string | Human name of the app. |
| `uid` | no | string | Your user id. |
| `pid` | no | string/number | A second id (e.g. a row number). |
| `ver`, `build` | no | string | App version and build. |
| `plan` | no | string | The user's plan in your app. |
| `device`, `os` | no | string | e.g. `iPhone16,1`, `iOS 26.1`. |
| `lang` | no | string | The app's language, e.g. `fr`. |
| `email`, `name` | no | string | Shown to the bot as data. **Not consent** — see Security. |

Other fields are dropped. Each value is cut to a short length (up to 160 characters). The whole token may be at most **4096 bytes**. Clock skew of 5 minutes is tolerated on `iat` and `exp`.

## 3. Signing code

Each sample prints the same token for the same input. Check yours against the test vector below.

### PHP

```php
function b64url(string $s): string { return rtrim(strtr(base64_encode($s), '+/', '-_'), '='); }

function lt_sign_ctx(array $payload, string $secret): string {
    $p = b64url(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
    return $p . '.' . b64url(hash_hmac('sha256', $p, $secret, true));
}

$now = time();
$token = lt_sign_ctx(['v' => 1, 'app' => 'com.example.app', 'uid' => $userId,
                      'iat' => $now, 'exp' => $now + 86400], getenv('LT_APP_CONTEXT_SECRET'));
```

### Node.js

```js
import { createHmac } from "node:crypto";
const b64url = (buf) => Buffer.from(buf).toString("base64url");

export function signCtx(payload, secret) {
  const p = b64url(JSON.stringify(payload));
  return p + "." + b64url(createHmac("sha256", secret).update(p).digest());
}

const now = Math.floor(Date.now() / 1000);
const token = signCtx({ v: 1, app: "com.example.app", uid: userId, iat: now, exp: now + 86400 },
                      process.env.LT_APP_CONTEXT_SECRET);
```

### Python

```python
import base64, hashlib, hmac, json, os, time

def b64url(b: bytes) -> str:
    return base64.urlsafe_b64encode(b).rstrip(b"=").decode()

def sign_ctx(payload: dict, secret: str) -> str:
    p = b64url(json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode())
    return p + "." + b64url(hmac.new(secret.encode(), p.encode(), hashlib.sha256).digest())

now = int(time.time())
token = sign_ctx({"v": 1, "app": "com.example.app", "uid": user_id, "iat": now, "exp": now + 86400},
                 os.environ["LT_APP_CONTEXT_SECRET"])
```

### Shell (curl / openssl) — for testing

```bash
SECRET='lcs_test_vector_not_a_real_secret_0123456789'
JSON='{"v":1,"app":"com.example.app","uid":"user-42","ver":"2.3.1","plan":"Pro","lang":"fr","iat":1767225600,"exp":1767312000}'
P=$(printf '%s' "$JSON" | openssl base64 -A | tr '+/' '-_' | tr -d '=')
SIG=$(printf '%s' "$P" | openssl dgst -sha256 -hmac "$SECRET" -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
echo "$P.$SIG"
```

### Test vector

- Secret: `lcs_test_vector_not_a_real_secret_0123456789`
- Payload JSON (exactly these bytes): `{"v":1,"app":"com.example.app","uid":"user-42","ver":"2.3.1","plan":"Pro","lang":"fr","iat":1767225600,"exp":1767312000}`
- Expected token:

```
eyJ2IjoxLCJhcHAiOiJjb20uZXhhbXBsZS5hcHAiLCJ1aWQiOiJ1c2VyLTQyIiwidmVyIjoiMi4zLjEiLCJwbGFuIjoiUHJvIiwibGFuZyI6ImZyIiwiaWF0IjoxNzY3MjI1NjAwLCJleHAiOjE3NjczMTIwMDB9.3r7GxwtW80uXbuy4Ack-lrPg4JlNmMlKvCzN_e154F0
```

The vector's `exp` is in the past on purpose: it checks your encoding and HMAC, not your clock. With your real secret, paste a fresh token into **Connect your app → Test a token** to see "Valid" and the decoded payload.

## 4. Opening the chat

**Share link (most apps).** Open `https://legotechapps.com/chat/<link key>?lang=<xx>&ctx=<token>` (the dashboard shows the ready link) in **SFSafariViewController** on iOS or **Custom Tabs** on Android — not the system browser, so the user stays in your app. URL-encode the token (it is already URL-safe). The chat page reads `ctx` once, keeps it for that tab, and removes it from the address bar.

**Your own chat screen.** Send the token as `ctx` with each message:

```bash
curl -X POST https://legotechapps.com/lt/api/bot/web/<wk_key> \
  -H "Content-Type: application/json" \
  -d '{"session":"a1b2c3d4e5f6a7b8","message":"My export failed","ctx":"<token>"}'
```

`session` is your own random string per conversation (8–40 letters/digits); keep it to continue the same thread.

## 5. What the bot does with it

- The context is stored on the conversation (the newest valid token wins).
- Each reply the bot writes sees one line, marked as **data, not instructions**: `Verified app context: app "Subtitles AI", user id "…", app version "7.5.10", …`. It does not ask for things it already knows.
- Every webhook about that conversation carries it as **`data.context`**: `action.called` (and the action's own signed POST), `case.updated`, `lead.captured` (and the other tool events). Example:

```json
{
  "id": "6f1c…", "type": "action.called", "created_at": "2026-11-08T10:00:00+00:00",
  "business_id": 5, "bot": { "id": 74, "name": "Support" },
  "data": {
    "ref": "C-48213", "action": "report_bug", "title": "Report a bug",
    "input": { "description": "Export stops at 90%" },
    "conversation_id": 1234, "channel": "web",
    "reply_url": "https://legotechapps.com/lt/api/v1/cases/C-48213/reply",
    "status_url": "https://legotechapps.com/lt/api/v1/cases/C-48213/status",
    "dashboard_url": "https://legotechapps.com/?view=dashboard#chatbot/inbox",
    "continue_url": "https://legotechapps.com/chat/lk_…#continue=…",
    "context": { "v": 1, "app": "com.example.app", "uid": "user-42", "ver": "2.3.1", "plan": "Pro",
                 "lang": "fr", "iat": 1767225600, "exp": 1767312000 },
    "context_token": "eyJ2IjoxLC….3r7Gxw…",
    "attachments": [ { "type": "image", "url": "https://legotechapps.com/lt/api/bot/file/91?e=…&s=…",
                       "expires_at": "2026-11-09T10:00:00+00:00", "mime": "image/png", "bytes": 48213, "width": 1170, "height": 2532 } ],
    "customer": { "name": "Dana" }
  }
}
```

- `context` and `context_token` are present **only** when the conversation carries a token LegoTech verified. They are never filled from anything typed in the chat or sent as an action field. `context_token` is the raw token as received: re-verify it with your own copy of the secret and trust that over `context`.
- `customer` appears only when the action (or subscription) has **Include the customer's details** on, and holds only what the customer gave in the chat (name, and phone on WhatsApp) — never the token's `email`/`name`.
- `continue_url` is signed by LegoTech, opens this conversation (with its history) for 14 days. Put it in any email or WhatsApp update you send.
- `attachments` are screenshots the customer attached in the chat before the action: re-encoded PNG/JPEG, URLs valid **24 hours** — download them when the webhook arrives.
- `input` values are plain text (markup removed), each at most 2000 characters. Treat everything in the payload as untrusted data.
- `summary_he` (when present): a short Hebrew summary of the conversation for the business owner — what the user wants, what happened, what the bot did, what is open, status and next step. Also on `case.updated`, together with `stage` and `fix_version`.

## 6. Conversation history, cases and known issues

**Resume.** A chat opened with a valid token goes back to that user's previous conversation (keyed by `app` + `uid`), with its history and any answers you sent — even if the browser forgot everything. Without a token, the browser's own session is used.

**In the chat.** "What's the status of my report?" → the bot lists this user's cases and followed known issues. "Delete my chat data" → the bot asks once, then deletes their conversations, screenshots and the contact details the chat collected, and keeps a closed case (with no personal data) as the record.

**Known issues** — one case per problem; users who want updates are attached to it instead of opening their own:

```bash
# create or update (scope: knowledge)
curl -X PUT https://legotechapps.com/lt/api/v1/known-issues/export-90 -H "Authorization: Bearer ltk_…" -H "Content-Type: application/json" \
  -d '{"bot_id": 74, "title": "Export stops at 90%", "symptom": "Export freezes at 90% on long videos",
       "workaround": "Export in two parts", "fix_version": "7.5.11", "status": "open"}'
# → {"key":"export-90","ref":"C-19685","status":"open","followers":0,…}

curl https://legotechapps.com/lt/api/v1/known-issues?bot_id=74 -H "Authorization: Bearer ltk_…"          # list
curl https://legotechapps.com/lt/api/v1/cases/C-19685/followers -H "Authorization: Bearer ltk_…"          # who is attached (scope: cases)
curl -X POST https://legotechapps.com/lt/api/v1/cases/C-19685/reply-all -H "Authorization: Bearer ltk_…" \
  -H "Content-Type: application/json" -d '{"text": "Fixed in 7.5.11 — please update.", "status": "answered"}'
# → {"sent": 12, "not_sent": 1, "results": [{"conversation_id": 1234, "state": "sent"}, …]}
```

Moving a case to another stage — `POST /v1/cases/{ref}/status {"stage": "investigating" | "known_issue" | "fixed" | "answered" | "closed", "fix_version": "7.5.11"}` — tells the user (or every follower) too, unless `"notify": false`. Each follower gets the reply rewritten in their own language (send `"verbatim": true` to skip that). The WhatsApp 24-hour rule applies per follower; one outside it is reported as `not_sent`, never as sent. Setting `"status": "fixed"` on the known issue tells the bot to say which version fixes it.

**History and deletion API** (scope `cases`):

| Method | Path | Purpose |
|---|---|---|
| GET | `/v1/app-users/conversations?app=…&uid=…[&bot_id=]` | That user's conversations, cases and full transcripts |
| GET | `/v1/conversations/{id}/transcript` | One conversation's transcript |
| POST | `/v1/app-users/delete` `{app, uid, bot_id}` | Their "delete my data" request: conversations, screenshots, chat-collected contact details. Returns `{deleted_conversations, deleted_clients, record}` |

**Retention.** Conversations and cases are kept until the business deletes them (or the user asks the bot to). Screenshots are deleted after 30 days. Webhook delivery logs: 30 days (successful) / 90 days (failed).

**Limits.** Share link: 15 messages an hour per IP, 30 per conversation per hour, 25 messages per conversation; website widget / `/bot/web`: 40 per IP per hour. Messages are cut at 1500 characters. Each action at most 5 times per conversation per hour. Screenshots: PNG, JPG or WEBP (iPhone photos are converted by iOS), up to 5 MB, 5 per conversation per hour.

## 7. Security

- Keep the secret on your server. Sign a fresh token each time the chat opens; keep `exp` short (24 h max recommended).
- A token proves *your server* vouched for a user at `iat`. Anyone holding the token can use it until `exp`, so don't log full tokens.
- An invalid, tampered or expired token is **ignored silently** — the chat still works, just without context. Turn on **Require app context** only if the chat must never open any other way (then chats without a valid token are refused).
- An email or phone in the token is **not consent** to contact the user. The bot still asks before saving contact details.
- Verification uses constant-time comparison; rotating keeps the previous secret for 24 hours only.
- The bot's actions are what the owner configured (for example a bug report and a read-only status check). It is instructed never to claim it added credits, changed a plan or refunded anything; account changes are made by a person.
- Uploaded images are checked by their real type, re-encoded on our server (metadata and hidden payloads do not survive), stored privately and served only through short-lived signed URLs, never as HTML or SVG.

## 8. Troubleshooting

| Symptom (Test a token says) | Cause |
|---|---|
| Not a token: "Found +, / or =" | You used standard base64. Use base64url and strip `=` padding. |
| Wrong signature | You signed the JSON instead of its base64url string; or the secret has extra whitespace/newline; or you signed one JSON string and sent another (re-serialising after signing). |
| Expired | `exp` passed. Sign a new token whenever the chat opens; don't cache it in the app for days. |
| Not valid yet | Your server clock is ahead by more than 5 minutes (`iat` in the future). Use NTP. |
| Not a token: exp more than 7 days | Lifetime too long. Use 24 h. |
| Valid here, but the bot doesn't know the user | The link was opened without `&ctx=`, or the token was cut (URL too long / not encoded). |

Related: [Chatbot integrations — actions, webhooks, cases](https://app.legotechapps.com/developers/chatbot/).
