Signed app context
Pass your user into the chat.
Your server signs a short-lived token; your app opens the chat with it. The bot knows which of your users is writing — and every action and webhook carries it as data.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.
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.
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
- The user taps Help in your app.
- Your app asks your server for a token (your own endpoint, your own login).
- Your server signs a short-lived token with the bot's app context secret.
- The app opens
https://legotechapps.com/chat/<link key>?lang=fr&ctx=<token>in an in-app browser. - 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
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
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
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
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:
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:
{
"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" }
}
}
contextandcontext_tokenare 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_tokenis the raw token as received: re-verify it with your own copy of the secret and trust that overcontext.customerappears 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'semail/name.continue_urlis signed by LegoTech, opens this conversation (with its history) for 14 days. Put it in any email or WhatsApp update you send.attachmentsare screenshots the customer attached in the chat before the action: re-encoded PNG/JPEG, URLs valid 24 hours — download them when the webhook arrives.inputvalues 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 oncase.updated, together withstageandfix_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:
# 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
expshort (24 h max recommended). - A token proves your server vouched for a user at
iat. Anyone holding the token can use it untilexp, 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). |