Back to home

Building a CRM on Top of Someone Else's App

How I built a team CRM around browser-session state, signed upstream requests, realtime sockets, permissions, and automation.

typescript
bun
elysia
redis
websockets

Overview

The hard part was not the CRM.

Users, organizations, roles, notes, schedules, saved replies, billing, audit logs - all of that is normal application code. Not trivial, but normal. The weird part was that the CRM had to sit on top of an upstream creator platform that was built for humans clicking through a web app, not for teams running shared operational workflows.

There was no clean integration layer for the full product surface we needed. So the backend had to do two things at once: behave like a first-party browser session when talking upstream, and behave like a boring multi-tenant SaaS backend everywhere else.

That tension shaped most of the system.

Server / Backend

Upstream integration, account state, realtime delivery, permissions, and automation.

What I Built

The backend became an operational layer for teams managing multiple creator accounts. It handled shared inboxes, media vault workflows, saved scripts, scheduled content, subscriber notes, employee schedules, sensitive-word lists, analytics, billing state, referrals, and role-scoped access.

Behind that, it maintained linked upstream accounts with their own cookies, authorization tokens, client IDs, session IDs, user agents, and proxy configuration. Requests were signed, cached, retried, and marked for re-authentication when the session stopped being valid.

The mental model was:

Store the session exactly as the browser sees it. Generate the headers the upstream API expects. Route account-specific traffic through the right network path. Cache the expensive reads. Detect broken auth early. Wrap the whole thing in organization permissions so employees only see and touch what they are allowed to.

That was the whole product.

Browser-Shaped API Requests

The upstream API expected more than an Authorization header.

Each request needed browser-like metadata: cookies, user agent, client ID, session ID, timestamp headers, origin and referer headers, and a derived request-check value. That check value was generated from a key pulled from the upstream web app, the request path, and the account’s client identity, then hashed into the expected format.

The key itself was not static. A scheduled job refreshed it hourly. If the backend did not have a key yet, the first request path could discover it and store it in shared process state.

There was also a service-worker bypass parameter added to outbound requests. Without that, some API behavior could be affected by assumptions meant for the first-party web client. The backend needed direct API responses, not web-app caching behavior.

This was the first major workaround: the integration was less “call an API” and more “recreate the minimum viable browser request.”

The wrapper around outbound reads ended up looking roughly like this:

async function getFromUpstream(
  rawUrl: string,
  context: RequestContext,
  linkedAccount?: LinkedAccount,
  useProxy = false
) {
  if (linkedAccount?.forceReauth) return null;

  if (!context.upstream.checkKey) {
    context.setUpstreamCheckKey(await refreshCheckKey());
  }

  const [timestamp, offset] = jitteredTimestamp();
  const clientId =
    linkedAccount?.clientId ??
    (await getClientId(linkedAccount?.userAgent ?? getBrowserUserAgent()));

  const url = new URL(rawUrl);
  url.searchParams.set("service-worker-bypass", "true");

  const response = await http.get(url.toString(), {
    agent: useProxy ? proxyAgent(linkedAccount?.proxyUrl) : undefined,
    headers: {
      "User-Agent": linkedAccount?.userAgent ?? getBrowserUserAgent(),
      "X-Client-Id": clientId,
      "X-Client-Timestamp": String(timestamp),
      "X-Client-Check": await getClientCheck(url, context, linkedAccount),
      "X-Timestamp-Info": JSON.stringify({ offset, server: timestamp, client: timestamp }),
      "X-Session-Id": linkedAccount?.sessionId ?? "",
      Cookie: linkedAccount?.cookies ?? "",
      Authorization: linkedAccount?.authorization ?? "",
      Referer: "https://upstream.example/",
      Origin: "https://upstream.example",
    },
  });

  if (response.status === 401 && linkedAccount) {
    await db.linkedAccounts.update({
      where: { id: linkedAccount.id },
      data: { forceReauth: true, cachedAccountState: { grabbed: false } },
    });
  }

  return response.data;
}
async function getFromUpstream(
  rawUrl: string,
  context: RequestContext,
  linkedAccount?: LinkedAccount,
  useProxy = false
) {
  if (linkedAccount?.forceReauth) return null;

  if (!context.upstream.checkKey) {
    context.setUpstreamCheckKey(await refreshCheckKey());
  }

  const [timestamp, offset] = jitteredTimestamp();
  const clientId =
    linkedAccount?.clientId ??
    (await getClientId(linkedAccount?.userAgent ?? getBrowserUserAgent()));

  const url = new URL(rawUrl);
  url.searchParams.set("service-worker-bypass", "true");

  const response = await http.get(url.toString(), {
    agent: useProxy ? proxyAgent(linkedAccount?.proxyUrl) : undefined,
    headers: {
      "User-Agent": linkedAccount?.userAgent ?? getBrowserUserAgent(),
      "X-Client-Id": clientId,
      "X-Client-Timestamp": String(timestamp),
      "X-Client-Check": await getClientCheck(url, context, linkedAccount),
      "X-Timestamp-Info": JSON.stringify({ offset, server: timestamp, client: timestamp }),
      "X-Session-Id": linkedAccount?.sessionId ?? "",
      Cookie: linkedAccount?.cookies ?? "",
      Authorization: linkedAccount?.authorization ?? "",
      Referer: "https://upstream.example/",
      Origin: "https://upstream.example",
    },
  });

  if (response.status === 401 && linkedAccount) {
    await db.linkedAccounts.update({
      where: { id: linkedAccount.id },
      data: { forceReauth: true, cachedAccountState: { grabbed: false } },
    });
  }

  return response.data;
}

The request-check value was deliberately small but account-specific. The annoying part was getting the key in the first place: it lived inside the upstream web bundle, so the backend had to fetch the app shell, find the current hashed JavaScript file, pull out the expression that produced the key, and then use that key to sign later API paths.

async function refreshCheckKey() {
  const html = await http.get("https://upstream.example", {
    headers: {
      "User-Agent": getBrowserUserAgent(),
      "Accept-Encoding": "gzip, compress, deflate",
    },
  });

  const bundlePath = html.data.match(/\ssrc\s*=\s*"(main\..*?\.js)"/i)?.[1];
  if (!bundlePath) throw new Error("Could not find upstream bundle");

  const bundle = await http.get(`https://upstream.example/${bundlePath}`, {
    headers: {
      "User-Agent": getBrowserUserAgent(),
      "Accept-Encoding": "gzip, compress, deflate",
    },
  });

  const expression = bundle.data
    .match(/this\.checkKey_=([^;]*)(?:,this)/i)?.[1]
    ?.split(",this")[0];

  if (!expression) throw new Error("Could not find request-check key");

  return runInLockedSandbox(expression);
}

function cyrb53(input: string, seed = 0) {
  let h1 = 0xdeadbeef ^ seed;
  let h2 = 0x41c6ce57 ^ seed;

  for (let i = 0; i < input.length; i++) {
    const char = input.charCodeAt(i);
    h1 = Math.imul(h1 ^ char, 2654435761);
    h2 = Math.imul(h2 ^ char, 1597334677);
  }

  h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^
    Math.imul(h2 ^ (h2 >>> 13), 3266489909);
  h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^
    Math.imul(h1 ^ (h1 >>> 13), 3266489909);

  return 4294967296 * (2097151 & h2) + (h1 >>> 0);
}

async function getClientCheck(url: URL, context: RequestContext, linkedAccount?: LinkedAccount) {
  const clientId =
    linkedAccount?.clientId ??
    (await getClientId(linkedAccount?.userAgent ?? getBrowserUserAgent()));

  const input = `${context.upstream.checkKey}_${url.pathname}_${clientId}`;
  return BigInt(cyrb53(input)).toString(16).replace(/^0+/, "");
}
async function refreshCheckKey() {
  const html = await http.get("https://upstream.example", {
    headers: {
      "User-Agent": getBrowserUserAgent(),
      "Accept-Encoding": "gzip, compress, deflate",
    },
  });

  const bundlePath = html.data.match(/\ssrc\s*=\s*"(main\..*?\.js)"/i)?.[1];
  if (!bundlePath) throw new Error("Could not find upstream bundle");

  const bundle = await http.get(`https://upstream.example/${bundlePath}`, {
    headers: {
      "User-Agent": getBrowserUserAgent(),
      "Accept-Encoding": "gzip, compress, deflate",
    },
  });

  const expression = bundle.data
    .match(/this\.checkKey_=([^;]*)(?:,this)/i)?.[1]
    ?.split(",this")[0];

  if (!expression) throw new Error("Could not find request-check key");

  return runInLockedSandbox(expression);
}

function cyrb53(input: string, seed = 0) {
  let h1 = 0xdeadbeef ^ seed;
  let h2 = 0x41c6ce57 ^ seed;

  for (let i = 0; i < input.length; i++) {
    const char = input.charCodeAt(i);
    h1 = Math.imul(h1 ^ char, 2654435761);
    h2 = Math.imul(h2 ^ char, 1597334677);
  }

  h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^
    Math.imul(h2 ^ (h2 >>> 13), 3266489909);
  h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^
    Math.imul(h1 ^ (h1 >>> 13), 3266489909);

  return 4294967296 * (2097151 & h2) + (h1 >>> 0);
}

async function getClientCheck(url: URL, context: RequestContext, linkedAccount?: LinkedAccount) {
  const clientId =
    linkedAccount?.clientId ??
    (await getClientId(linkedAccount?.userAgent ?? getBrowserUserAgent()));

  const input = `${context.upstream.checkKey}_${url.pathname}_${clientId}`;
  return BigInt(cyrb53(input)).toString(16).replace(/^0+/, "");
}

That is a lot of machinery for one header. But without that header, otherwise valid authenticated requests would fail because they did not look like requests produced by the first-party web client.

Operational risk: Getting it wrong was not a harmless 401 either. Badly signed requests could invalidate the linked account’s browser session. That meant the team could suddenly lose access to inboxes, scheduled work, and paid workflows until the creator re-authenticated. For high-revenue accounts, even a few blocked hours could mean real money lost for both the creator and the organization managing them. It was worse when the creator had two-factor authentication enabled and was only available during a narrow part of the day.

Sessions As Fragile State

Linked accounts were treated as fragile, account-specific state.

Each one stored its own cookies, authorization token, user agent, client ID, session ID, and proxy URL. The backend did not assume one global client identity could safely operate every account. It also randomized request timestamps slightly so every call did not look mechanically identical.

When an upstream request returned unauthorized, the account was marked as needing re-authentication. That mattered more than retrying. A bad retry loop can make a broken session look like a flaky system, and repeated bad calls could turn a recoverable integration issue into a full account lockout. Marking the account forced the problem into a visible state the user could fix.

Rate limits and bad requests were handled explicitly. Retries existed, but they were short. The goal was not to brute-force through the upstream platform. The goal was to fail in a way the CRM could understand.

WebSockets On Both Sides

The app had its own WebSocket layer for authenticated users. That part was normal: login, select an organization, subscribe to updates, clean up intervals on disconnect.

The less normal part was that the backend could also open upstream WebSocket connections for one or more linked accounts. It authenticated those sockets with the linked account’s session data, forwarded useful event payloads to the frontend, and filtered out frames that contained session material.

So a browser connected to our backend, and the backend connected to the upstream realtime service. The frontend did not need to know how upstream auth worked, and it never needed direct access to the linked account token.

There was a second realtime channel too: Redis pub/sub for organization updates. When messages, scripts, vault items, posts, reports, roles, schedules, or audit logs changed, the backend published a small invalidation event. Connected clients received the event only if their permissions allowed it.

Realtime was not just “broadcast everything.” It was permission-filtered cache invalidation.

The backend treated organization updates as invalidation messages with access metadata attached:

type OrgUpdateMessage = {
  queryKey: OrgUpdateKey;
  organizationId: string;
  timestamp: number;
  requiresPermission?: Permission;
  requiresFlag?: PermissionFlag;
};

async function sendOrgUpdate(
  organizationId: string,
  info: Omit<OrgUpdateMessage, "organizationId" | "timestamp">
) {
  await redis.publish(
    `org:${organizationId}:updates`,
    JSON.stringify({
      ...info,
      organizationId,
      timestamp: Date.now(),
    })
  );
}

async function canReceiveOrgUpdate(message: OrgUpdateMessage, user: User) {
  if (!message.requiresPermission) return true;

  const permission = await checkSimplePermission(
    user,
    message.requiresPermission,
    message.organizationId
  );

  if (!permission.hasPermission) return false;
  if (!message.requiresFlag) return true;

  return (permission.flags & message.requiresFlag) !== 0;
}
type OrgUpdateMessage = {
  queryKey: OrgUpdateKey;
  organizationId: string;
  timestamp: number;
  requiresPermission?: Permission;
  requiresFlag?: PermissionFlag;
};

async function sendOrgUpdate(
  organizationId: string,
  info: Omit<OrgUpdateMessage, "organizationId" | "timestamp">
) {
  await redis.publish(
    `org:${organizationId}:updates`,
    JSON.stringify({
      ...info,
      organizationId,
      timestamp: Date.now(),
    })
  );
}

async function canReceiveOrgUpdate(message: OrgUpdateMessage, user: User) {
  if (!message.requiresPermission) return true;

  const permission = await checkSimplePermission(
    user,
    message.requiresPermission,
    message.organizationId
  );

  if (!permission.hasPermission) return false;
  if (!message.requiresFlag) return true;

  return (permission.flags & message.requiresFlag) !== 0;
}

The WebSocket handler subscribed to exactly one organization’s Redis channel after the user selected an organization they belonged to. Every update was checked again before delivery:

subscriber.on("message", async (_channel, payload) => {
  const message = JSON.parse(payload) as OrgUpdateMessage;

  if (await canReceiveOrgUpdate(message, socket.data.user)) {
    socket.send(JSON.stringify({ type: "org_update", data: message }));
  }
});
subscriber.on("message", async (_channel, payload) => {
  const message = JSON.parse(payload) as OrgUpdateMessage;

  if (await canReceiveOrgUpdate(message, socket.data.user)) {
    socket.send(JSON.stringify({ type: "org_update", data: message }));
  }
});

Permissions As Flags, Not Roles

A simple role enum would not have worked.

The product needed distinctions like:

  • can view messages
  • can send messages
  • can unsend messages
  • can view scheduled content
  • can draft scheduled content
  • can manage employees
  • can edit one creator but not another

Instead of creating a role for every combination, permissions were modeled as grouped records with a scope and a bitmask.

Plain-English version: each employee got a checklist for each feature. The scope said which accounts or people the checklist applied to. The flags said which boxes were checked. So instead of inventing roles like “message sender but not scheduler for Account A and Account B,” the backend could store “messages: view + send, scope: these linked accounts” and evaluate that everywhere.

Question Stored as Example
What feature is this about? Permission Messages, schedules, vault, employees
Where does it apply? Scope Only yourself, your group, or the whole organization
What actions are allowed? Bitmask flags View, add, edit, delete, export, import, mark read, block
Which linked accounts are included? Allow-list Account A and Account C, but not Account B

The bitmask part was just a compact way to store many yes/no answers in one number. For example, VIEW = 1, ADD = 2, and EDIT = 4. If someone had VIEW + ADD, the stored value was 3. Checking access was a fast operation: does the stored number include the bit for the action being attempted?

const PermissionFlag = {
  VIEW: 1,
  ADD: 2,
  EDIT: 4,
  DELETE: 8,
  EXPORT: 16,
  IMPORT: 32,
} as const;

const canViewAndSend = PermissionFlag.VIEW | PermissionFlag.ADD;

if ((canViewAndSend & PermissionFlag.ADD) !== 0) {
  // The employee can perform this action.
}
const PermissionFlag = {
  VIEW: 1,
  ADD: 2,
  EDIT: 4,
  DELETE: 8,
  EXPORT: 16,
  IMPORT: 32,
} as const;

const canViewAndSend = PermissionFlag.VIEW | PermissionFlag.ADD;

if ((canViewAndSend & PermissionFlag.ADD) !== 0) {
  // The employee can perform this action.
}

Creator access had another layer: allow-lists. An employee could have a permission in general but only for specific linked accounts. The same permission checks were used by HTTP routes and realtime update delivery, which kept the access model consistent.

That is also why the app could not just pass the creator account session through to the employee’s client. A linked creator session was effectively the creator’s full upstream account. If an employee had that directly, they could bypass the CRM permission layer and reach restricted areas the organization did not intend to expose: creator-made paid content, transaction history, payout and bank details, account settings, and other sensitive surfaces. The backend had to hold that session, perform the upstream request, then return only the data and actions allowed by the employee’s CRM permissions.

That was the second major design decision: permissions needed to describe operations, not titles.

The creator-scoped check combined three ideas: the employee’s permission groups, the requested operation, and whether the linked account was in any allowed-account list:

export async function checkAccountPermission(
  user: User,
  permission: Permission,
  organizationId: string,
  targetAccountId: string
) {
  const employee = await db.organizationUsers.findFirst({
    where: { userId: user.id, organizationId },
    include: { allowedAccounts: true },
  });

  const groups = await db.permissionGroups.findMany({
    where: {
      organizationId,
      organizationUsers: { some: { userId: user.id } },
    },
    include: {
      granularPermissions: true,
      organizationUsers: { include: { allowedAccounts: true } },
    },
  });

  let scope: PermissionScope | null = null;
  let flags = 0;

  const canSeeAllAccounts = groups.some((group) =>
    group.granularPermissions.some(
      (entry) => entry.permission === "ACCOUNTS" && entry.scope === "ALL_ORG"
    )
  );

  for (const group of groups) {
    for (const entry of group.granularPermissions) {
      if (entry.permission !== permission) continue;

      const accountAllowed =
        canSeeAllAccounts ||
        employee?.allowedAccounts.some((account) => account.id === targetAccountId) ||
        group.organizationUsers.some((member) =>
          member.allowedAccounts.some((account) => account.id === targetAccountId)
        );

      if (!accountAllowed) continue;

      scope = morePermissiveScope(scope, entry.scope);
      flags |= entry.flags;
    }
  }

  return { hasPermission: scope !== null, scope, flags };
}
export async function checkAccountPermission(
  user: User,
  permission: Permission,
  organizationId: string,
  targetAccountId: string
) {
  const employee = await db.organizationUsers.findFirst({
    where: { userId: user.id, organizationId },
    include: { allowedAccounts: true },
  });

  const groups = await db.permissionGroups.findMany({
    where: {
      organizationId,
      organizationUsers: { some: { userId: user.id } },
    },
    include: {
      granularPermissions: true,
      organizationUsers: { include: { allowedAccounts: true } },
    },
  });

  let scope: PermissionScope | null = null;
  let flags = 0;

  const canSeeAllAccounts = groups.some((group) =>
    group.granularPermissions.some(
      (entry) => entry.permission === "ACCOUNTS" && entry.scope === "ALL_ORG"
    )
  );

  for (const group of groups) {
    for (const entry of group.granularPermissions) {
      if (entry.permission !== permission) continue;

      const accountAllowed =
        canSeeAllAccounts ||
        employee?.allowedAccounts.some((account) => account.id === targetAccountId) ||
        group.organizationUsers.some((member) =>
          member.allowedAccounts.some((account) => account.id === targetAccountId)
        );

      if (!accountAllowed) continue;

      scope = morePermissiveScope(scope, entry.scope);
      flags |= entry.flags;
    }
  }

  return { hasPermission: scope !== null, scope, flags };
}

Scheduled Jobs Instead Of One Giant Worker

Automation was split into small scheduled jobs.

One job refreshed the request-signing key. One job scanned for newly expired subscribers. Another processed queued follow-up actions every few seconds. Other hourly jobs updated smart lists and promo-code tracking.

Redis was the glue. When a newly expired subscriber was detected, the backend wrote a short-lived queue entry. Later jobs picked up those entries, checked whether enough time had passed, and performed the action. This avoided doing everything inside the scan loop and made delays explicit.

The delay mattered. Some actions only made sense after the subscription had actually ended. The system also stored outreach records so the same person would not be contacted repeatedly inside a cooldown window.

Follow-up automation could create a conversation, attach vault media, generate a one-time offer, send the message, and store the resulting contact record. It was not just a cron that sent text. It was a small workflow engine built from API calls the upstream app already understood.

Smart Lists

Static lists were not enough because the useful categories changed every day.

The upstream app had lists, but those lists were manual buckets. The CRM needed lists that behaved more like saved rules:

  • “Put everyone who spent between $100 and $250 into this list.”
  • “Put subscribers who have been active for 3-6 months into this list.”
  • “Put people whose subscription expired in the last 7 days into this recovery list.”
  • “Put people who joined in the last 3 days into this welcome-flow list.”
  • “Remove people when they no longer match the rule.”

The backend had a lightweight segmentation engine that recalculated those memberships from current account state. A smart list could be based on total spend, subscription length, recently expired status, expiry windows, or new subscriber status.

Smart list type What it meant operationally
Total spend Group supporters into spend bands, such as low, mid, and high value.
Subscription length Group active subscribers by how long they had been subscribed.
Recently expired Build a short-window win-back list after a subscription ended.
Expiry period Track subscribers who crossed a configured expiry threshold.
New subscriber Build a list of people who joined within the last few days.

Each run fetched the relevant upstream data, computed who should be in each segment, compared that with the existing upstream list contents, then added or removed people as needed. Spend-band and expiry-window lists were especially important because they could remove people who no longer belonged there, not just add new matches.

Promo-code tracking worked the same way. The backend watched active subscribers for promo usage, recorded matched subscriber IDs, and optionally pushed them into a configured list.

This turned lists from manual buckets into operational views.

The smart-list job was intentionally boring: fetch current upstream state, calculate the desired membership, compare it with existing list contents, then apply the diff. The real value was that teams could build messaging, retention, and support workflows on top of lists that stayed current without daily hand-sorting.

export async function updateSmartLists(context: JobContext) {
  const smartLists = await db.smartLists.findMany();

  for (const list of smartLists) {
    const account = await db.linkedAccounts.findUnique({
      where: { id: list.accountId },
    });

    if (!account) continue;

    const source = await fetchSegmentSource(account, context, list.type);
    const desired = calculateDesiredMembership(list, source);
    const existing = await getExistingMembership(account, context, list.segments);

    for (const item of desired.toRemove(existing)) {
      await removeFromList(account, context, item);
    }

    for (const item of desired.toAdd(existing)) {
      await addToList(account, context, item);
    }
  }
}
export async function updateSmartLists(context: JobContext) {
  const smartLists = await db.smartLists.findMany();

  for (const list of smartLists) {
    const account = await db.linkedAccounts.findUnique({
      where: { id: list.accountId },
    });

    if (!account) continue;

    const source = await fetchSegmentSource(account, context, list.type);
    const desired = calculateDesiredMembership(list, source);
    const existing = await getExistingMembership(account, context, list.segments);

    for (const item of desired.toRemove(existing)) {
      await removeFromList(account, context, item);
    }

    for (const item of desired.toAdd(existing)) {
      await addToList(account, context, item);
    }
  }
}

App / Client

Electron as the controlled browser layer around the CRM experience.

The Desktop App Had To Be A Browser

The desktop client had the same problem from the other direction.

It could not just be a dashboard. The upstream platform already owned the login flow, the session cookies, the device identifiers, the upload rules, the realtime events, and a lot of browser-shaped assumptions. Rebuilding all of that as a clean API client would have been brittle. So the app became a controlled browser with a CRM wrapped around it.

The mental model was:

Open the upstream login in an isolated window. Let the user authenticate normally. Watch the successful account request. Pull the session token from browser storage, the cookies from Electron, and the account identity from the response. Close the window. Hand the rest of the app a clean linked-account record.

That was the boundary.

Inside the app, everything looked normal: React screens, Redux state, query caches, organization selection, permissions, chat tools, vault uploads, notifications, updates. The CRM still had its own organization login and employee session. That login controlled access to the team’s workspace; linked-account authentication was a separate flow handled at the browser edge.

The CRM's own organization login. This authenticated employees into the management app, separate from the upstream linked-account login flow.

At the edge, Electron did the ugly browser work.

The dashboard hid the browser-session machinery behind normal operational views: accounts, work queues, permissions, and daily status.

Uploads were the clearest example. The app did not upload media through one friendly endpoint. It requested an upload session, sliced the file into the part size the upstream service expected, uploaded each part to signed storage URLs, collected ETags, completed the upload, then polled until the processed media object existed. Only after that could it move the media into the selected album.

The vault made multi-step signed upload and album-management flows feel like ordinary media management.

That sounds like backend work, but it had to happen from the client because the signed upload URLs expected browser-like headers and cross-origin behavior. Electron’s request hooks became part of the integration layer: add the right origin, referrer, fetch metadata, and upload headers; patch permissive response headers where the upload flow needed them; keep the rest of the app unaware that any of this existed.

Realtime had the same shape. The client opened its own socket to the CRM backend, authenticated with the local session, selected an organization, then asked the backend to connect the relevant upstream accounts. Incoming events were normalized into UI updates and desktop notifications. If a message arrived, the app could request the sender profile, show a native notification, and route the click back into the right conversation.

Messages were the clearest place where upstream realtime events, backend permissions, and team workflows had to line up.
Notifications turned backend socket events into a visible operations feed instead of making users poll for account changes.

The same controlled-client idea applied to auditability. Since the CRM mediated sensitive actions instead of handing employees raw upstream sessions, it could attach actions to organization users, permission groups, and linked accounts.

The audit log gave managers a record of sensitive organization activity that the upstream app was never designed to expose for teams.

None of this was elegant in the abstract. It was useful because the user saw one inbox, one vault, one set of tools, and one permission model. The client absorbed the fact that the underlying platform wanted a human browser session, not a team operations console.

Scope

This backend was not trying to be a general integration framework.

It was built for one awkward job: make a team CRM feel stable while the system underneath depended on browser-session state, signed requests, upstream realtime sockets, proxy routing, rate limits, and account-specific authentication.

The useful part was not any single trick. It was the boundary.

At the edge, the backend treated the upstream platform as volatile: cache reads, sign requests, keep sessions isolated, retry briefly, mark re-authentication clearly, and never leak tokens to the frontend.

Inside that boundary, it behaved like a normal SaaS app: organizations, employees, permission groups, audit logs, schedules, live updates, billing, saved content, reports, and automation.

Most of the work was making those two worlds meet without making the user think about either one.

Let's build better software for the web.

Available for full-stack web application projects.

© 2026 Nolan Martin. Crafted with SvelteKit.