npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-sdk

Script 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 bearer

You 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 role you request is capped by the install's max role.
  • app_secret is 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 loads

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

  • getBearer is called on connect and whenever the CRM's session expires — always return a fresh bearer (call your /session endpoint again). crmSession() does this.
  • crmOrigin must exactly match the iframe's origin, and the CRM must allow your origin in its frame-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 react
import { 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:

  • refnavigate(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? — when true, the CRM hides its own in-iframe nav (the host supplies it), signalled via ?hostnav=1 on the iframe URL.
  • initialPath? — a CRM sub-path (e.g. /contacts) to deep-link on first load only; later navigation goes through the ref's navigate().
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 accessToken or apiKey — 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-contracts

License

MIT