Embedded applications — access token integration
This guide is for the third-party team building an embedded app. The UYSOT CRM opens your app
inside an iframe and passes context to it — the lead, the user, and most importantly a
short-lived JWT access token — via postMessage. Your app receives that message,
verifies the token, and uses the context it carries.
On this page:
- Overview
- The secret key
- The postMessage payload
- Pages
- Token details and verification
- Code samples
- Security recommendations
- FAQ
1. Overview
- A user opens a CRM page your app is registered for. UYSOT mints a short-lived JWT access token, signed with your secret key.
- The CRM opens your
endpointURL in aniframe— the URL itself carries no token. - Once loaded, the CRM sends your
iframea payload viawindow.postMessage(...), containingaccessTokenandleadContext. - Your app (its frontend or backend) consumes that payload.
:::info The token is the security boundary The token is signed with your secret key, so only you and UYSOT hold it — that shared secret is what establishes trust between the two sides. :::
2. The secret key
Where it comes from
Embedded configuration lives on the application — one application, one embedded config:
| Field | Meaning |
|---|---|
endpoint | The URL opened in the iframe. Must be HTTPS |
pages | Which CRM pages your app appears on (LEAD_WIDGET_PANEL, STATS — see 3.1) |
enabled | While false the app is not rendered anywhere |
secretKey | The JWT signing key — returned in clear text only on creation or when regenerateSecret: true |
So the secret is handed to you once. Store it safely — it is never displayed again.
One key per connection
The config's secretKey is only half the story. The key that actually signs a token belongs
to the installation — one company's connection to your app — and the application-level
secretKey is used only as a fallback for older connections that have no per-installation key
yet. This is deliberate: with a single app-wide key, one tenant's leaked key would verify (and
forge) every other tenant's tokens.
| Application type | Where you get the signing key |
|---|---|
PUBLIC (OAuth) | embedded_secret in the authorization_code token response — see OAuth. Never returned on refresh_token |
PRIVATE (PAT) | The embedded config response (secretKey), or the installation's regenerate-secret call in the CRM |
So store keys as a map installationId → secret. Which one to use for a given token is not a
guess: the JWT header carries a kid (section 4). The postMessage payload's installationId
matches it, and both are also the id the CRM shows for that connection.
Format
- A 64-character lowercase hex string.
- Example:
9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 - That is the hex encoding of 32 random bytes.
⚠️ Key bytes — careful!
:::warning Do not hex-decode The HMAC key is the UTF-8 bytes of the secret string itself (64 ASCII bytes), not the 32 bytes you get by hex-decoding it.
HMAC key = UTF-8_bytes("9f86d081...a08") // ✅ correct (64 bytes)
HMAC key = hex_decode("9f86d081...a08") // ❌ wrong (32 bytes)
Use the key as a plain string. :::
Regeneration
Regenerating one installation's secret affects only that company's tokens; regenerating the application-level key affects only the connections still on the fallback. Either way the old key stops working immediately for the affected connections and new tokens are signed with the new one. Because tokens live 15 minutes, a rotation is felt within one refresh cycle — update your store before the CRM mints the next token.
3. The postMessage payload (CRM frontend → your iframe)
Once the iframe has loaded, the CRM frontend sends it the following JSON via
window.postMessage(payload, targetOrigin):
{
"embeddedApplicationEvent": "uysot:embedded-application:context-update",
"applicationId": 12,
"installationId": 87,
"name": "Acme widget",
"logo": "files/com-22/smallest_format/260609-044427-955997fc-41be-4c66.png",
"url": "https://acme.example/uysot/widget",
"accessToken": "eyJhbGciOi...",
"expiresIn": 900,
"leadContext": {
"leadId": 99436,
"name": "Ali Valiyev",
"status": "New contact",
"responsibleId": 252,
"phone": "+998332132132",
"phones": [
"+998332132132",
"991119090"
],
"balance": 0
}
}
Payload fields
| Field | Type | Always present? | Meaning |
|---|---|---|---|
embeddedApplicationEvent | string | ✅ Yes | Message type. Always "uysot:embedded-application:context-update" — filter on it to ignore other message events |
applicationId | number (int) | ✅ Yes | Your application id |
installationId | number (int) | ✅ Yes | The company ↔ application installation id |
name | string | ✅ Yes | Application name (for display) |
logo | string | null | ⚠️ May be absent | Application logo path; null when unset |
url | string | ✅ Yes | Your iframe URL (the configured endpoint) |
accessToken | string (JWT) | ✅ Yes | The important one — a short-lived signed JWT (section 4) |
expiresIn | number | ✅ Yes | Token lifetime in seconds (900 = 15 minutes) |
leadContext | object | null | ⚠️ Context-dependent | Lead context; present on lead pages, otherwise null |
:::note Be tolerant of older payloads
Earlier CRM releases sent openApiTokenId instead of applicationId / installationId, and
avatar instead of logo. A browser tab loaded before an update can still send the old names, so
accept both (payload.applicationId ?? payload.openApiTokenId).
:::
3.1 Pages
Your app is rendered on the CRM pages listed in its config's pages set — one iframe per
registered application:
page | Where it appears | leadContext |
|---|---|---|
LEAD_WIDGET_PANEL | The widget panel on a lead card | provided |
STATS | The statistics section | null |
The page your app was opened on is also a claim inside the token (section 4), so you can render
the right view without trusting the payload.
leadContext fields
| Field | Type | Note |
|---|---|---|
leadId | number (long) | Lead identifier |
name | string | Lead name |
status | string | Pipeline status name (localized text) |
responsibleId | number (int) | null | Assigned employee id |
phone | string | null | First phone of the primary contact |
phones | string[] | All phone numbers of the lead (trimmed, de-duplicated) |
balance | number | Lead balance |
:::warning leadContext is not signed
leadContext travels outside the JWT, in the clear, and is unsigned. Use it for display
only. Take security-relevant values (companyId, leadId) from the token claims, not from
the payload — and optionally cross-check leadContext.leadId against the token's leadId claim.
:::
Receiving the message (JS inside the iframe)
window.addEventListener("message", (event) => {
// 1) Check the origin — only accept messages from the UYSOT CRM domain
const ALLOWED_ORIGINS = ["https://crm.uysot.uz"]; // the UYSOT domain(s) given to you
if (!ALLOWED_ORIGINS.includes(event.origin)) return;
const payload = event.data;
// 2) Filter by event type — ignore everything else
if (payload?.embeddedApplicationEvent !== "uysot:embedded-application:context-update") return;
// 3) Send accessToken to your backend and verify it with the secret (section 4).
// Trust the payload only after verification succeeds.
fetch("/api/verify-embedded-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accessToken: payload.accessToken })
})
.then((r) => r.json())
.then((verified) => {
// verified — trusted claims read from the token (companyId, page, leadId...)
// payload.leadContext — extra display data
renderWidget(verified, payload.leadContext);
})
.catch(() => showError("Invalid embedded token"));
});
:::note Verification needs the secret
Because accessToken is HMAC-signed, verifying it requires the secret key. The secret must live
only on your backend (section 6), so verify the token there — never in the browser.
:::
Token refresh
The accessToken lasts 15 minutes. Before it expires, the CRM frontend sends another
context-update event with a fresh token (or reloads the iframe). Keep your message
listener open and re-verify the token on every context-update. You never store or refresh
tokens yourself — you simply verify whatever arrives.
4. Token details and verification
Algorithm and header
- Algorithm:
HS256(HMAC-SHA256) - Header:
{ "alg": "HS256", "typ": "JWT", "kid": "87" }
kid is the installation id as a string. It sits in the header, so you can read it before
verifying the signature — which is exactly the point: it tells you which key to verify with.
companyId cannot serve that purpose, because it is a claim and claims are only trustworthy
after verification.
Pick the key by kid, verify, then trust the claims. If kid is missing (a connection still
on the fallback key) use the application-level secretKey.
Claims
| Claim | Type | Always present? | Meaning |
|---|---|---|---|
companyId | number (int) | ✅ Yes | The UYSOT company (tenant) identifier |
page | string | ✅ Yes | Which page was opened (LEAD_WIDGET_PANEL, STATS) |
exp | number (NumericDate) | ✅ Yes | Expiry time (Unix epoch, seconds) |
employeeId | number (int) | ⚠️ May be absent | The employee who requested the token |
userId | number (int) | ⚠️ Optional | Reserved — never sent today, so treat it as absent |
leadId | number (long) | ⚠️ Context-dependent | The lead the app was opened for |
:::note Claims that are not sent
iat, nbf, iss, sub and aud are not included. Optional claims are omitted entirely
when absent — the key is missing, not null.
:::
Example (decoded payload)
{
"companyId": 12,
"page": "LEAD_WIDGET_PANEL",
"employeeId": 305,
"leadId": 100,
"exp": 1749601800
}
Verification steps (what your backend does)
- Take
accessTokenfrom thepostMessagepayload (frontend → your backend). - Read the
kidheader and look up that installation's secret; fall back to the application-level key whenkidis absent. Reject the token if you hold no key for it. - Verify the JWT signature with HS256 and the secret key as a UTF-8 string.
- Check
exp— reject expired tokens (401). - Read
companyIdandpageand render the right content; useleadIdwhen you need the lead. - Recommended: confirm
pageis one of the values you expect (LEAD_WIDGET_PANEL,STATS). - Recommended: if you use
leadContext, compareleadContext.leadIdwith the token'sleadIdclaim, and check that the payload'sinstallationIdequals the header'skid.
:::info The token is self-contained No extra call to UYSOT is needed to validate it. :::
5. Code samples (token verification)
- Node.js
- Java
- Python
const jwt = require("jsonwebtoken");
// Your own store: installationId -> 64-char hex string, used AS A STRING
const secretsByInstallation = loadSecrets();
const FALLBACK_SECRET = process.env.UYSOT_EMBEDDED_SECRET; // app-level key, older connections
function verifyAccessToken(token) {
// The kid header names the connection — read it BEFORE verifying, to choose the key
const { header } = jwt.decode(token, { complete: true }) ?? {};
const secret = secretsByInstallation[header?.kid] ?? FALLBACK_SECRET;
if (!secret) throw new Error("No key for installation " + header?.kid);
try {
// jsonwebtoken takes the key as a UTF-8 string — exactly what we need
const payload = jwt.verify(token, secret, { algorithms: ["HS256"] });
// payload.companyId, payload.page, payload.leadId, ...
return payload;
} catch (e) {
// TokenExpiredError or JsonWebTokenError
throw new Error("Invalid embedded access token: " + e.message);
}
}
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import java.nio.charset.StandardCharsets;
// The kid header names the connection; decoding the header needs no key
String kid = JWT.decode(token).getKeyId();
String secret = secrets.getOrDefault(kid, System.getenv("UYSOT_EMBEDDED_SECRET"));
// IMPORTANT: the UTF-8 bytes of the secret string (NOT hex-decoded)
Algorithm algorithm = Algorithm.HMAC256(secret.getBytes(StandardCharsets.UTF_8));
DecodedJWT jwt = JWT.require(algorithm)
.build()
.verify(token); // exp is checked automatically; expired tokens throw
Integer companyId = jwt.getClaim("companyId").asInt();
String page = jwt.getClaim("page").asString();
Long leadId = jwt.getClaim("leadId").asLong(); // null when absent
import jwt # PyJWT
FALLBACK_SECRET = os.environ["UYSOT_EMBEDDED_SECRET"] # app-level key, older connections
def verify_access_token(token: str) -> dict:
# The kid header names the connection — read it before verifying
kid = jwt.get_unverified_header(token).get("kid")
secret = SECRETS.get(kid, FALLBACK_SECRET) # your installationId -> secret map
# PyJWT encodes a str key as UTF-8 — the correct behaviour here
return jwt.decode(token, secret, algorithms=["HS256"])
# exp is checked automatically; expired tokens raise jwt.ExpiredSignatureError
6. Security recommendations
- Always check the origin of the
messageevent. Make sureevent.originis the UYSOT CRM domain you were given — otherwise any site could post a forged message into your iframe. - Filter on
embeddedApplicationEvent. Only handle"uysot:embedded-application:context-update"messages. - Let the token carry the trust. Don't rely on the clear-text payload fields (
leadContextand friends) — take trusted values from verified token claims. - Never put the secret in the frontend. It belongs in your backend (env or a secret manager), and verification happens there.
- Always check the signature. Without it, anyone can mint a token.
- Check
exp(most libraries do this for you). - HTTPS only.
- Don't log the full
accessToken. - If the key is compromised, ask UYSOT to regenerate the secret.
7. FAQ
Do I read the token from the URL?
No. The token is not in the iframe URL — the CRM frontend delivers it inside the postMessage
payload (accessToken). Listen with window.addEventListener("message", ...) (section 3).
My message listener never fires.
Check that: (1) the listener is registered before the iframe finishes loading; (2) your
event.origin filter is correct (log it temporarily); (3) you are comparing
embeddedApplicationEvent correctly.
I decoded the token but the signature doesn't validate.
Two usual causes. First, hex-decoding the secret — use it as a plain 64-character string
(section 2). Second, verifying with the wrong key: tokens are signed with the installation's
key, so choose it by the kid header (section 4), not by a single app-wide secret.
The token stopped working after 15 minutes.
That's expected. The CRM sends a fresh context-update before expiry — keep the listener open and
re-verify each new token.
leadContext or leadId is sometimes missing.
They are context-dependent. Outside a lead page you get leadContext: null (and no leadId
claim). Handle the null case.
Can I trust the data in leadContext?
For display only. It is unsigned — take security-relevant values from the token claims, and
optionally compare leadContext.leadId with the token's leadId.
One company connected my app, then another did — do I need two keys?
Yes. Each connection has its own signing key, delivered in that connection's
authorization_code token response as embedded_secret. Keep them in an
installationId → secret map and select by kid.
What if several embedded apps run on one page? The CRM mints a separate token per iframe, each signed with that app's own secret, and posts a separate message to each. You only ever receive tokens signed with your key.