LegoTechApps

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

  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)))

Payload fields

FieldRequiredTypeMeaning
vyes1Format version. Always 1.
appyesstringYour app or site id, e.g. com.example.app.
iatyesunix secondsWhen you signed it.
expyesunix secondsWhen it stops being accepted. 24 h is plenty; more than 7 days after iat is refused.
app_namenostringHuman name of the app.
uidnostringYour user id.
pidnostring/numberA second id (e.g. a row number).
ver, buildnostringApp version and build.
plannostringThe user's plan in your app.
device, osnostringe.g. iPhone16,1, iOS 26.1.
langnostringThe app's language, e.g. fr.
email, namenostringShown 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

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

{
  "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" }
  }
}

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):

MethodPathPurpose
GET/v1/app-users/conversations?app=…&uid=…[&bot_id=]That user's conversations, cases and full transcripts
GET/v1/conversations/{id}/transcriptOne 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

8. Troubleshooting

Symptom (Test a token says)Cause
Not a token: "Found +, / or ="You used standard base64. Use base64url and strip = padding.
Wrong signatureYou 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).
Expiredexp passed. Sign a new token whenever the chat opens; don't cache it in the app for days.
Not valid yetYour server clock is ahead by more than 5 minutes (iat in the future). Use NTP.
Not a token: exp more than 7 daysLifetime too long. Use 24 h.
Valid here, but the bot doesn't know the userThe link was opened without &ctx=, or the token was cut (URL too long / not encoded).

Related: Chatbot integrations — actions, webhooks, cases.