@first-to-fly/crm-sdk
v1.4.3
Published
CRM SDK — embed third-party apps inside the CRM, or embed the CRM inside your own SaaS (proxy-free, signed-assertion or API-key auth)
Readme
@first-to-fly/crm-sdk
The CRM SDK supports two integration directions:
| You want to… | Import | Runs in |
|---|---|---|
| Embed an app inside the CRM (guest app in the CRM's iframe) | @first-to-fly/crm-sdk | browser |
| Embed the CRM inside your SaaS (host the CRM's iframe) | @first-to-fly/crm-sdk/embed-host + @first-to-fly/crm-sdk/embed-server | browser + server |
The first is documented under Quick Start. The second — the proxy-free, cookie-less embed — is under Embedding the CRM in your SaaS.
Installation
npm / bun
npm install @first-to-fly/crm-sdk
# or
bun add @first-to-fly/crm-sdkScript tag
<script src="https://your-crm-domain.com/sdk/v1/app.js"></script>When loaded via script tag, window.CrmApp is available globally.
Quick Start
ESM / TypeScript
import { CrmApp } from '@first-to-fly/crm-sdk';
CrmApp.on('ready', (context) => {
console.log('Connected!', context.workspace_id);
console.log('Conversation:', context.conversation_id);
console.log('Contact:', context.contact_id);
});
CrmApp.on('context_update', (context) => {
console.log('Context changed:', context);
});
// Signal readiness to the CRM host
CrmApp.init();Script tag
<script src="https://your-crm-domain.com/sdk/v1/app.js"></script>
<script>
CrmApp.on('ready', function (context) {
document.getElementById('info').textContent =
'Workspace: ' + context.workspace_id;
});
CrmApp.init();
</script>API Reference
CrmApp.init()
Signals to the CRM host that your app is ready to receive context. Must be called once after setting up event listeners.
CrmApp.on(event, listener)
Register an event listener.
| Event | Description |
|-------|-------------|
| 'ready' | Fired once after init() when the host sends the initial context |
| 'context_update' | Fired when the user switches conversations or the context changes |
The listener receives a CrmContext object:
interface CrmContext {
token: string | null; // Short-lived JWT for authenticated API calls
workspace_id: string; // Current workspace ID
conversation_id: string | null; // Current conversation (if in conversation sidebar)
contact_id: string | null; // Current contact (if available)
}CrmApp.off(event, listener)
Remove a previously registered event listener.
CrmApp.getContext()
Returns the current CrmContext synchronously, or null if init() hasn't completed yet.
CrmApp.invoke(action, data?)
Request an action from the CRM host. Returns a Promise that resolves with the result or rejects on error/timeout (30s).
// Example: request iframe resize
const result = await CrmApp.invoke('resize', { height: 500 });PostMessage Protocol
For advanced use cases, the SDK communicates with the CRM host via window.postMessage.
iframe → Host
Messages include { source: 'crm-app', type, ... }:
| Type | Description |
|------|-------------|
| app_ready | Sent by init() to signal the app is ready |
| invoke | Sent by invoke() with invoke_id, action, data |
Host → iframe
Messages include { source: 'crm-host', type, ... }:
| Type | Description |
|------|-------------|
| init | Sent after app_ready, contains context |
| context_update | Sent when context changes, contains updated context |
| response | Response to an invoke, contains invoke_id and result or error |
Embedding the CRM in your SaaS
Render the CRM as a cross-origin iframe in your app, authenticated without cookies
and without a reverse proxy. Your server obtains a short-lived CRM bearer (via one
of the two auth options below) and hands it to the iframe over a secure MessageChannel.
Two modules do the heavy lifting: embed-server (Node/Bun) and embed-host (browser).
New here? Start with the walkthrough below. It takes you from zero to a working embed using the API-key flow (what new systems use). The reference sections that follow it document the API and edge cases.
Integration walkthrough — new system, start to finish
The whole integration is three moving parts:
1. YOUR SERVER exchanges app_id + app_secret + {your user} → a short-lived CRM bearer
2. YOUR FRONTEND renders <CrmEmbed> (or embed-host) which loads the CRM iframe with that bearer
3. (optional) YOUR SERVER calls CRM REST APIs with the same bearerYou never touch cookies, JWTs, or a reverse proxy. The SDK does the token handshake.
Step 0 — Get your credentials (one-time)
Ask the CRM team for four things and put them in your environment:
| Value | Example env var | What it is |
|---|---|---|
| App ID | CRM_APP_ID | Public identifier for your integration (emb_…) |
| App secret | CRM_APP_SECRET | Server-only secret — never ship to the browser (sk_…) |
| Exchange base URL | CRM_BASE_URL | CRM API origin your server calls, e.g. https://api.crm.example.com |
| Embed origin | CRM_EMBED_URL | CRM app origin the iframe loads, e.g. https://crm.example.com |
A CRM admin binds your app to exactly one CRM workspace — that's the data your
users will see. Your users are matched into that workspace by verified email, so
the email you send in Step 1 must be one the CRM trusts.
Step 1 — Add a session endpoint on your server
This is the only server code you need — a POST endpoint that swaps your credentials +
the signed-in user for a short-lived CRM bearer. Keep app_secret on the server —
this endpoint is the trust boundary; authenticate it as you would any of your own APIs.
createCrmSessionHandler gives you a ready-made Web (Request) => Response handler
(Next.js route handlers, Hono, Bun.serve, Deno, Cloudflare Workers). You supply an
authenticate callback that returns the signed-in user; it runs the exchange and maps
errors to HTTP responses for you:
// app/api/crm/session/route.ts (Next.js) — or any Web-standard handler
import { createCrmSessionHandler } from '@first-to-fly/crm-sdk/embed-server';
export const POST = createCrmSessionHandler({
appId: process.env.CRM_APP_ID!,
appSecret: process.env.CRM_APP_SECRET!,
crmBaseUrl: process.env.CRM_BASE_URL!,
authenticate: async (req) => {
const user = await getMyUser(req); // your auth — throw to reject (→ 401)
return {
id: user.id, // stable id in YOUR system
email: user.email, // verified email — the CRM matches the user by this
role: 'member', // 'member' | 'admin' (CRM caps to the install's max role)
name: user.name, // optional
};
},
});On Express/Koa (which don't use Web Request/Response), call
createEmbedSessionWithKey directly instead — see
Server: the exchange.
Step 2 — Drop the CRM into your frontend
React (recommended) — one component; it handles the iframe, the MessageChannel
handshake, bearer refresh on expiry, and loading/error/retry. crmSession('/api/crm/session')
is a helper that POSTs to your Step 1 endpoint and returns the shape <CrmEmbed> wants:
import { CrmEmbed, crmSession } from '@first-to-fly/crm-sdk/embed-react';
export function CrmPage() {
return (
<div style={{ height: '100vh' }}>
<CrmEmbed
crmEmbedOrigin={import.meta.env.VITE_CRM_EMBED_URL} // the embed origin from Step 0
getSession={crmSession('/api/crm/session')}
/>
</div>
);
}getSession() returns { accessToken, workspaceId, expiresIn? } — pass your own async
function if you need custom fetch options. The component sizes to its container, so give
the parent a real height.
Not using React? Use the low-level embed-host instead — see
Browser: host the iframe.
That's a working embed. Steps 3 and 4 are optional.
Step 3 — (Optional) Put CRM sections in YOUR navigation
By default the CRM shows its own in-iframe nav. If you'd rather drive it from your
own app shell, set hostProvidesNav (hides the CRM's nav) and steer it via the ref:
import { useRef } from 'react';
import { CrmEmbed, crmSession, type CrmEmbedHandle } from '@first-to-fly/crm-sdk/embed-react';
function Shell() {
const crm = useRef<CrmEmbedHandle>(null);
return (
<>
<nav>
<button onClick={() => crm.current?.navigate('/')}>Dashboard</button>
<button onClick={() => crm.current?.navigate('/contacts')}>Contacts</button>
<button onClick={() => crm.current?.navigate('/leads')}>Leads</button>
</nav>
<CrmEmbed
ref={crm}
crmEmbedOrigin={import.meta.env.VITE_CRM_EMBED_URL}
getSession={crmSession('/api/crm/session')}
hostProvidesNav // hide the CRM's own nav
initialPath="/contacts" // deep-link on first load
onRouteChange={(path) => console.log('CRM at', path)} // keep your nav in sync
/>
</>
);
}navigate() never reloads the iframe (it's a postMessage), so switching sections is
instant and preserves CRM state. See Host-driven navigation.
Step 4 — (Optional) Call CRM APIs from your server
The same bearer from Step 1 works against the typed REST client — handy for syncing data or building your own screens. See Calling CRM APIs from your backend:
import { createCrmApiClient } from '@first-to-fly/crm-sdk/api';
const crm = createCrmApiClient({
crmBaseUrl: process.env.CRM_BASE_URL!,
accessToken: session.accessToken, // from createEmbedSessionWithKey
});
const { items } = await crm.contacts.list({ page: 1, pageSize: 25 });Environment checklist
| Where | Variable | Notes |
|---|---|---|
| Server | CRM_APP_ID | from Step 0 |
| Server | CRM_APP_SECRET | server-only — never expose to the browser |
| Server | CRM_BASE_URL | exchange/API origin (https://api.crm.…) |
| Frontend | CRM_EMBED_URL / VITE_CRM_EMBED_URL | embed origin (https://crm.…), matches the iframe origin exactly |
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Session endpoint 4xx, SUBAPP_DISABLED | your app isn't enabled / not bound to a workspace | ask the CRM admin to enable + bind it |
| Session endpoint 4xx, IDENTITY_CONFLICT | the email maps to a different existing CRM user | send a verified email that matches (or none exists yet, so it links) |
| Iframe blank / refuses to load | crmEmbedOrigin mismatch, or your origin isn't in the CRM's frame-ancestors | match the origin exactly; ask the CRM team to allowlist your origin |
| Iframe loads then goes blank after a while | bearer expired and getSession didn't return a fresh one | make getSession re-call your endpoint every time (don't cache) |
| navigate() seems to do nothing | called before the handshake finished | it no-ops until wired — it'll work once the iframe is ready; wire it via the ref |
Security model (worth knowing)
- The bearer is short-lived and scoped to one workspace (the one bound to your app). An embed token is pinned to that workspace — it can't be pivoted to another.
- Users are identified by verified email and just-in-time linked into the workspace;
the
roleyou request is capped by the install's max role. app_secretis the only long-lived secret. It stays on your server behind the Step 1 endpoint — the browser only ever sees short-lived bearers.
How it works
Auth is a single credential exchange — no cookies, no reverse proxy, no keypair:
[your server] [browser] [CRM]
createEmbedSessionWithKey(user) ─exchange─▶ CRM bearer
│
createCrmEmbedHost({ iframe, getBearer })
│ ready → channel-init → ack
└──────── auth-tokens ─────────▶ CRM iframe loadsServer: the exchange (reference)
The walkthrough covers the fast path
with createCrmSessionHandler. Under the hood it calls createEmbedSessionWithKey,
which you can also use directly if you wire the endpoint yourself:
import { createEmbedSessionWithKey } from '@first-to-fly/crm-sdk/embed-server';
// Endpoint your frontend calls to (re)obtain a CRM bearer:
app.post('/api/crm/session', requireAuth, async (req, res) => {
const user = req.user; // your authenticated user
const session = await createEmbedSessionWithKey(
{
appId: process.env.CRM_APP_ID!,
appSecret: process.env.CRM_APP_SECRET!,
user: {
id: user.id, // stable id in YOUR system
email: user.email, // verified email — the CRM matches the user by this
role: 'member', // 'member' | 'admin' (CRM caps to the install's max role)
name: user.name, // optional
avatar_url: user.avatar, // optional
},
},
{ crmBaseUrl: process.env.CRM_BASE_URL! }, // e.g. https://api.crm.example.com
);
// { accessToken, expiresIn, workspaceId, user, role }
res.json(session);
});A rejected exchange throws CrmExchangeError with .status and .code
(e.g. SUBAPP_DISABLED, IDENTITY_CONFLICT). createCrmSessionHandler maps that to
the HTTP response for you.
Browser: host the iframe (low-level)
<CrmEmbed> (below) is the easy path for React. If you're not on React, wire the host
yourself with embed-host. The crmSession() helper turns your /session endpoint
into the getSession/getBearer callbacks:
import { createCrmEmbedHost, buildCrmEmbedUrl, crmSession } from '@first-to-fly/crm-sdk/embed-host';
const CRM_ORIGIN = 'https://app.crm.example.com';
const getSession = crmSession('/api/crm/session'); // POSTs, returns { accessToken, workspaceId }
const { workspaceId } = await getSession();
const iframe = document.querySelector('#crm') as HTMLIFrameElement;
iframe.src = buildCrmEmbedUrl(CRM_ORIGIN, workspaceId);
const host = createCrmEmbedHost({
iframe,
crmOrigin: CRM_ORIGIN,
getBearer: async () => ({ accessToken: (await getSession()).accessToken }),
onRouteChange: (path) => console.log('CRM navigated to', path),
onError: (err) => console.error(err),
});
// Drive the CRM from your own nav:
host.navigate('/contacts');
// On unmount:
host.dispose();Requirements & gotchas
getBeareris called on connect and whenever the CRM's session expires — always return a fresh bearer (call your/sessionendpoint again).crmSession()does this.crmOriginmust exactly match the iframe's origin, and the CRM must allow your origin in itsframe-ancestors(the CRM team configures this).
React embed component
If you use React, @first-to-fly/crm-sdk/embed-react wraps all of the above
(iframe, MessageChannel handshake, bearer refresh on expiry, and
loading/error/retry states) into a single drop-in <CrmEmbed> component. You
just supply getSession (returns the CRM bearer + the workspace to embed) and
the CRM embed origin.
npm install @first-to-fly/crm-sdk reactimport { CrmEmbed, crmSession } from '@first-to-fly/crm-sdk/embed-react';
<CrmEmbed
crmEmbedOrigin="https://crm.example.com"
getSession={crmSession('/api/crm/session')}
/>getSession() must resolve to { accessToken, workspaceId, expiresIn? } —
typically from your own backend endpoint running createCrmSessionHandler /
createEmbedSessionWithKey. The crmSession(url) helper wraps the fetch; pass your
own async function if you need custom fetch options. Optional props: className,
style, title, onReady, onError, renderLoading, renderError.
Host-driven navigation
The host (e.g. an app shell) can drive the embedded CRM's section navigation and supply its own chrome:
ref→navigate(path)— imperatively navigate the embedded CRM without reloading the iframe (e.g. from your own nav). No-ops safely before the iframe is wired.onRouteChange?(path)— fires when the CRM navigates internally; use it to keep a host-supplied nav in sync.hostProvidesNav?— whentrue, the CRM hides its own in-iframe nav (the host supplies it), signalled via?hostnav=1on the iframe URL.initialPath?— a CRM sub-path (e.g./contacts) to deep-link on first load only; later navigation goes through the ref'snavigate().
import { useRef } from 'react';
import { CrmEmbed, crmSession, type CrmEmbedHandle } from '@first-to-fly/crm-sdk/embed-react';
function Shell() {
const crm = useRef<CrmEmbedHandle>(null);
return (
<>
<button onClick={() => crm.current?.navigate('/contacts')}>Contacts</button>
<CrmEmbed
ref={crm}
crmEmbedOrigin="https://crm.example.com"
getSession={crmSession('/api/crm/session')}
hostProvidesNav
initialPath="/contacts"
onRouteChange={(path) => console.log('CRM at', path)}
/>
</>
);
}React is an optional peer dependency — only pull in this entry if you use
React. The low-level embed-host remains available for non-React consumers.
Calling CRM APIs from your backend
Once your server has a CRM bearer (from createEmbedSessionWithKey) — or an
X-API-Key for the public endpoints — use the typed REST client from
@first-to-fly/crm-sdk/api to call CRM resources directly. Every method returns
the unwrapped data (the { success, data } envelope is stripped for you).
The typed resource methods (contacts, leads, …) hit /api/* routes that
require the Bearer token. An apiKey client should instead call request()
against the /public/* endpoints (see Server-to-server).
Errors — every failure throws a CrmApiError (with .status, .code,
.body), so you can always catch (e) { if (e instanceof CrmApiError) ... }:
| Case | status | code |
|---|---|---|
| non-2xx response | the HTTP status | error.code / error string / HTTP_<status> |
| 2xx with { success: false } (server mislabel) | the HTTP status (e.g. 200) | as above |
| network / DNS / connection failure | 0 | NETWORK_ERROR |
| request exceeded timeoutMs | 0 | TIMEOUT |
Timeouts — pass timeoutMs on the client (applies to all requests) and/or
per request() call; it's enforced with AbortController. If unset, requests
have no timeout.
const crm = createCrmApiClient({
crmBaseUrl: process.env.CRM_BASE_URL!,
accessToken: session.accessToken,
timeoutMs: 10_000, // 10s default for every request
});Query arrays — array query values serialize as repeated params
(?tag=a&tag=b). Note the CRM's tags filter on contacts/leads instead expects
a single comma-separated string (?tags=a,b), so pass that field as a joined
string, not an array.
The request/response types are generated from the CRM's real zod route contracts, so they can't drift from the server (see "Regenerating types" below).
import { createCrmApiClient, CrmApiError } from '@first-to-fly/crm-sdk/api';
import { createEmbedSessionWithKey } from '@first-to-fly/crm-sdk/embed-server';
// 1) Get a bearer for the acting user (full API surface):
const session = await createEmbedSessionWithKey(
{ appId, appSecret, user },
{ crmBaseUrl: process.env.CRM_BASE_URL! },
);
const crm = createCrmApiClient({
crmBaseUrl: process.env.CRM_BASE_URL!, // e.g. https://api.crm.example.com
accessToken: session.accessToken, // → Authorization: Bearer <token>
});
// Typed contacts — args & return types come from the CRM zod contracts:
const { items, totalCount } = await crm.contacts.list({ page: 1, pageSize: 25, search: 'ada' });
const contact = await crm.contacts.create({ name: 'Ada Lovelace', email: '[email protected]' });
// Send a message in a conversation:
const message = await crm.conversations.messages.send(conversationId, {
message: 'Thanks for reaching out!',
});
// Generic escape hatch for any endpoint — still unwraps `data`:
const closeReasons = await crm.request<string[]>('GET', '/leads/close-reasons');
try {
await crm.contacts.get('does-not-exist');
} catch (err) {
if (err instanceof CrmApiError) {
console.error(err.status, err.code); // e.g. 404 "Contact not found"
}
}Server-to-server with an API key
For machine-to-machine calls you can pass an X-API-Key instead of a bearer:
const crm = createCrmApiClient({
crmBaseUrl: process.env.CRM_BASE_URL!,
apiKey: process.env.CRM_API_KEY!, // crm_live_... → X-API-Key
});
// API keys currently guard ONLY /api/public/* (create lead, batch leads, send email).
// Use request() for those routes:
await crm.request('POST', '/public/leads', { body: { name: 'Web lead', email: '[email protected]' } });Provide exactly one of
accessTokenorapiKey— passing neither or both throws. Bearer covers the full API surface; an API key today only covers/api/public/*, so the typed resource methods (contacts, leads, …) generally require a bearer.
Available resources
contacts, leads, products (.list/.get/.create/.update/.delete),
tasks (.list/.create/.update), conversations
(.list/.get/.update + .messages.list/.send), and tags (.list/.create) —
plus the generic request<T>(method, path, { query?, body? }).
Regenerating types
The contract types live in src/generated/crm-contracts.ts and are generated
from crm-be's zod schemas. After a CRM contract change, regenerate them from the
crm-be package:
bun run gen:sdk-contractsLicense
MIT
