Skip to main content

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:


1. Overview​

  1. A user opens a CRM page your app is registered for. UYSOT mints a short-lived JWT access token, signed with your secret key.
  2. The CRM opens your endpoint URL in an iframe — the URL itself carries no token.
  3. Once loaded, the CRM sends your iframe a payload via window.postMessage(...), containing accessToken and leadContext.
  4. 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:

FieldMeaning
endpointThe URL opened in the iframe. Must be HTTPS
pagesWhich CRM pages your app appears on (LEAD_WIDGET_PANEL, STATS — see 3.1)
enabledWhile false the app is not rendered anywhere
secretKeyThe 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 typeWhere 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​

FieldTypeAlways present?Meaning
embeddedApplicationEventstring✅ YesMessage type. Always "uysot:embedded-application:context-update" — filter on it to ignore other message events
applicationIdnumber (int)✅ YesYour application id
installationIdnumber (int)✅ YesThe company ↔ application installation id
namestring✅ YesApplication name (for display)
logostring | null⚠️ May be absentApplication logo path; null when unset
urlstring✅ YesYour iframe URL (the configured endpoint)
accessTokenstring (JWT)✅ YesThe important one — a short-lived signed JWT (section 4)
expiresInnumber✅ YesToken lifetime in seconds (900 = 15 minutes)
leadContextobject | null⚠️ Context-dependentLead 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:

pageWhere it appearsleadContext
LEAD_WIDGET_PANELThe widget panel on a lead cardprovided
STATSThe statistics sectionnull

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​

FieldTypeNote
leadIdnumber (long)Lead identifier
namestringLead name
statusstringPipeline status name (localized text)
responsibleIdnumber (int) | nullAssigned employee id
phonestring | nullFirst phone of the primary contact
phonesstring[]All phone numbers of the lead (trimmed, de-duplicated)
balancenumberLead 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​

ClaimTypeAlways present?Meaning
companyIdnumber (int)✅ YesThe UYSOT company (tenant) identifier
pagestring✅ YesWhich page was opened (LEAD_WIDGET_PANEL, STATS)
expnumber (NumericDate)✅ YesExpiry time (Unix epoch, seconds)
employeeIdnumber (int)⚠️ May be absentThe employee who requested the token
userIdnumber (int)⚠️ OptionalReserved — never sent today, so treat it as absent
leadIdnumber (long)⚠️ Context-dependentThe 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)​

  1. Take accessToken from the postMessage payload (frontend → your backend).
  2. Read the kid header and look up that installation's secret; fall back to the application-level key when kid is absent. Reject the token if you hold no key for it.
  3. Verify the JWT signature with HS256 and the secret key as a UTF-8 string.
  4. Check exp — reject expired tokens (401).
  5. Read companyId and page and render the right content; use leadId when you need the lead.
  6. Recommended: confirm page is one of the values you expect (LEAD_WIDGET_PANEL, STATS).
  7. Recommended: if you use leadContext, compare leadContext.leadId with the token's leadId claim, and check that the payload's installationId equals the header's kid.

:::info The token is self-contained No extra call to UYSOT is needed to validate it. :::


5. Code samples (token verification)​

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

6. Security recommendations​

  • Always check the origin of the message event. Make sure event.origin is 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 (leadContext and 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.