Skip to content

Repository files navigation

Build your own platform on Base44

Base44 is an AI app builder: describe an app, get a real, deployed app. This repo shows how to put that builder inside your own product, so your users build apps under your brand, your login and your governance — and those apps can read and write your product's data.

This README is a walkthrough. It has four steps, each one a boundary you have to get right, with the working code in this repo next to it. Everything here is generic; Sunny, the work-management app you'll see in the code, is just the example product being extended.

This repository contains two references:

  • examples/white-label-minimal/: the focused, local shared-account guide companion. Start here to learn the build loop without Sunny's database or sign-in.
  • src/: the full Sunny product and production-oriented service-user integration described below.

Live example: https://sunny44.com


The shape of the thing

   YOUR PRODUCT (this repo)                      BASE44
   ─────────────────────────                     ──────
   your users, your DB, your login
            │
            │  1. user clicks "Connect"
            ├──────────────────────────────────► provision a service principal
            │                                    (a robot identity, one per user)
            │                                    mint a ~1h access token for it
            │  ◄──────────────────────────────
            │  token stored server-side only
            │
            │  2. user describes an app
            ├──────────────────────────────────► createApp → build → deploy
            │     (server-side allow-list)        the app is owned by *that user's*
            │                                     principal, inside your workspace
            │
            │  3. the built app runs
            │  ◄────────────────────────────────  it calls back into your data API
            │      POST /api/sunny                with a shared token, over CORS

Five boundaries, five steps — four calling out to Base44, one where it calls back in:

Step Boundary Code
1 Your own auth and database. Your shell is not a Base44 app. src/lib/auth.ts, prisma/schema.prisma, src/lib/rls.ts
2 One Base44 identity per user, so their apps are theirs. src/lib/base44Link.ts, src/app/api/base44/link/route.ts
3 A server-side proxy in front of Base44's REST API. src/app/api/base44/platform/route.ts
4 A public API the built apps call, plus instructions teaching them how. src/app/api/sunny/route.ts, src/lib/builderInstructions.ts
5 Signed inbound webhooks, so you learn about deletions at all — and act on them. src/app/api/base44/webhooks/route.ts, src/lib/base44WebhookSignature.ts, src/lib/base44AppMirror.ts

Before you start

You need, from Base44:

What Why
An enterprise workspace with the platform capability enabled All your users' apps live in it, so you have one place to govern and offboard
Its workspace id Sent as X-Active-Workspace-Id on every platform call
A workspace API key (b44k_…) with the user_tokens:mint scope Vends per-user access tokens
Optionally a second key with service_users:provision Creates the per-user identities. Splitting the two is the safer setup — see step 2
The platform host your workspace is served from Base of every REST call
An app folder id The one folder your platform files its apps into, so listing apps means listing that folder

Those land in env as BASE44_ORG_ID, BASE44_SVC_KEY, BASE44_PROVISION_KEY, BASE44_PLATFORM_HOST, BASE44_APPS_FOLDER_ID (see .env.example). All server-only — none of them may ever reach the browser.


Step 1 — Your product owns its users and data

The first decision is the one people get wrong: your platform shell is not a Base44 app. It has its own login, its own database, and it never uses @base44/sdk for its own data. Base44 is a service you call, not the substrate you live in.

In this repo that means NextAuth (Google) for sessions and Postgres + Prisma for data:

// src/lib/auth.ts — the session, turned into an actor
const actor = await requireSessionUser();   // { email, role } — 401s if not signed in

Because the shell owns its data, it also owns row-level security. Every user-owned query goes through one helper, and one module is allowed to run those queries:

// src/lib/rls.ts
export const scopedWhere = (actor) => ({ createdBy: actor.email });

No role widens that predicate — an admin is an ordinary reader of their own rows, so nobody's dashboard fills up with someone else's boards, apps or widgets.

Sharing, when you want it, is a property of the row, never of the caller. A board carries a visibility flag, so reads use a second predicate built from the first:

// src/lib/rls.ts — reads only
export const readWhere = (actor, model) =>
  model === "Board" ? { OR: [scopedWhere(actor), { visibility: "shared" }] } : scopedWhere(actor);

Writes stay on scopedWhere(). A shared board is readable by the workspace and writable by its owner alone, which is why the two helpers exist instead of one: widening the owner predicate itself would have made every shared board editable by everyone who could see it.

That's the whole trick, and it's the single biggest correctness risk in a design like this — a missing predicate is a data leak. This repo pins it down with an ESLint rule that bans by-id update/delete (those can't carry an owner predicate) and a smoke test (npm run rls:smoke).

Takeaway: decide up front that Base44 is downstream of your auth, not the other way around.


Step 2 — Give each user their own Base44 identity

Now the interesting part. When your user builds an app, who owns it?

The lazy answer is "one API key for the whole deployment" — every app your platform ever builds belongs to one account. You can't show a user only their apps, can't offboard anyone, and one leaked key is everything.

The right answer is one Base44 identity per user. Base44 gives you a service principal: a workspace-owned robot account that can never log in. No password, no SSO identity, and a synthetic non-routable address (…@{workspace}.svc.base44.invalid). You address it by an opaque id you choose, and Base44 derives everything else from that.

2a. Provision the principal

// POST {host}/api/service/users     Authorization: <b44k_ key>   ← bare, no "Bearer"
{
  "service_external_id": "sunny-9f2c…",   // your opaque handle for this user
  "display_name": "Sunny user 9f2c…"
}
// → { service_external_id, user_id, email: "…@….svc.base44.invalid", role: "editor", created }

Idempotent: calling it for an existing principal returns created: false, so "Connect" is safe to press twice.

Two rules worth copying:

  • Derive the id, don't store it. src/lib/base44Link.ts computes it as sha256(workspaceId + ":" + userEmail). If it were random and stored only on a link row, then disconnect → reconnect would create a second principal and strand the first one's apps.
  • Never use the user's real email as the id. Base44 builds the principal's address from it, and the whole safety property is that this identity can't be logged into or mailed. Verify the address you get back is in the reserved .invalid domain before you mint against it.

2b. Mint a token that acts as it

// POST {host}/api/service/user-tokens     Authorization: <b44k_ key>
{ "service_external_id": "sunny-9f2c…" }
// → { access_token, refresh_token, expires_in }   ~1 hour

No OAuth redirect, no consent screen, no PKCE — you already own both sides. The workspace comes from the key, never from the request; that's your cross-tenant guarantee.

Mint never auto-provisions. An unknown principal is a 404, and that's the feature: it's what makes removing someone actually stick. Which is also why the two scopes are worth splitting — if the hot-path key can provision, a removed user just presses "Connect" and walks back in.

2c. Store it, refresh it, and never return it

// src/lib/base44Link.ts is the ONLY module that reads or writes tokens,
// and no function in it returns one to a caller:
export function linkStatus(link) {
  return { linked: , base44_user_email: , organization_id:  };  // booleans and display fields
}

Tokens live ~1h, so re-minting is routine. This repo re-mints proactively 5 minutes before expiry and once more on a mid-call 401, then gives up and asks the user to reconnect. Note the distinction that matters: a 429 or 5xx is a blip (leave the row alone and retry), a 4xx is a dead grant (downgrade to pending and show the Connect button). Conflating them turns a busy minute into a fleet-wide forced reconnect.

→ Full detail, lifecycle and every error code: docs/base44-identity.md


Step 3 — Call Base44 from your server, behind an allow-list

Your frontend must never hold a Base44 credential. So it calls you, and you call Base44:

browser → POST /api/base44/platform {action, …params} → your server → Base44 REST

src/app/api/base44/platform/route.ts is that proxy. Its design is a single table of allowed actions — the caller names an action, never a URL:

const OPS = {
  listApps:   { method: "GET",  path: (p) => `/api/apps?…folder_id=${appsFolderId()}` },
  createApp:  { method: "POST", path: () => "/api/apps", body: (p) => ({}) },
  sendMessage:{ method: "POST", path: (p) => `/api/apps/${p.appId}/chat/message`,},};

Nine actions, and that's the whole surface. Why an allow-list and not a passthrough: Base44 enforces OAuth scopes in its MCP tool layer, not on this REST surface, so apps:read apps:write does not constrain what a token can do here. Your allow-list is the actual limit. Never let a caller supply a path, a host, or a workspace id.

Three things every request carries:

headers: {
  Authorization: `Bearer ${accessToken}`,      // who: this user's principal
  "X-Active-Workspace-Id": orgId(),            // where: your governed workspace
  "Content-Type": "application/json",
}

And two things people get bitten by:

  • Timeouts. createApp, sendMessage and deployApp block on an LLM build turn — ~30s is normal. A 30s default timeout aborts working builds and blames the upstream. This repo uses 120s for those actions and 30s for everything else.
  • Validate ids. Anything interpolated into a path is checked against /^[A-Za-z0-9_-]+$/, or a caller can escape the allow-listed path shape.

→ Every endpoint, body, response and failure mode: docs/base44-platform-api.md


Step 4 — Build an app, and let it talk to your data

4a. Building

Creating an app is three calls in a fixed order (src/lib/base44Platform.ts):

const app = await createApp({ prompt, name, customInstructions });  // 1. create + first build turn
await fileAppsInFolder([app.id]);                                    // 2. into your folder
await AppOwnership.create({ app_id: app.id, app_name: app.name });   // 3. record who owns it

Why in that order:

  1. custom_instructions and initial_message both go in the create body. initial_message starts the first build inside that same call, so patching instructions afterwards is too late.
  2. /api/apps has no folder field on create, so a fresh app is briefly unfiled — and your app list reads from the folder. An unfiled app is invisible.
  3. Platform apps have no per-your-user owner (they're all in one workspace), so "which apps are mine?" is a join you keep locally.

Then getPreviewUrl for an iframe preview (the preview token has a 300s TTL — never cache it) and deployApp to publish.

4b. Teaching the app about your data

A built app doesn't know your product exists. Two mechanisms, and the split matters because every build turn pays for the instructions:

  • custom_instructions (src/lib/builderInstructions.ts) — short, always loaded. Describes the runtime (embedded in a sandboxed iframe, no login, short viewport) and routes: "load the sunny-platform skill before writing code that touches this data."
  • A Base44 skill (docs/sunny-platform-skill.md) — long, loaded on demand. The endpoint, actions, schemas and gotchas.

4c. The callback API

The built app runs on its own Base44 origin with no session in your product. So your data API is cross-origin and cookie-less, so the request has to carry its own identity.

POST https://your-host/api/sunny
Content-Type: application/json
Authorization: Bearer <viewer token>
{ "action": "listBoards" }

It has no actor of its own, so it must not reuse your session-based CRUD module, and must withhold owner emails from responses. It gets an actor from a viewer token: the page embedding the app mints one for whoever is signed in and posts it to the frame, and the app sends it as a bearer token. That's what makes an installed app answer with the installer's rows rather than its author's. Feed that subject the same predicates the rest of the product uses — the read one for reads, the owner one for writes — and an app sees exactly what its viewer could already open in your UI, shared rows included, while still writing only their own. Treat the contract as frozen once apps are built against it: they're deployed code you don't control.

→ Instructions, skills, the callback contract and CORS: docs/base44-built-apps.md


Step 5 — Let Base44 tell you what happened

Steps 1–4 are all your shell calling out. This is the one direction where Base44 calls in, and the reason it has to exist is deletion.

listApps is a poll. It costs a round trip per page load, it lags reality, and it cannot report a deletion at all — a trashed app simply stops appearing, which is indistinguishable from a failed call. There is no version of polling that tells you an app was deleted; there is only a list that used to have it. Webhooks carry the transition itself: app.created, app.published, app.unpublished, app.deleted, app.restored.

Every payload carries owner_service_external_id — the same value you stored on Base44Link.serviceExternalId in step 2 — so an event joins straight back to one of your users with one indexed lookup and no call to Base44. That join is the reason the field is in the payload.

Two things decide whether an integration here is correct:

The signature is the whole security boundary. The URL is public, so nothing in a request is trustworthy until it verifies — including the owner field that decides whose rows get touched. An unverified body is an attacker naming a victim. Base44 signs Ed25519 (v1a) over webhook-id + "." + webhook-timestamp + "." + raw body and publishes the public key; you only ever hold public keys. Read the body once as text and verify thatJSON.parse then re-serialize changes the bytes and breaks every signature.

app.deleted is trash, not erasure. Restorable for 30 days self-serve, longer through support. A receiver that purges on it is wrong for every app that comes back, which is what app.restored is for. So the projection marks the app trashed and keeps the row, and what it does remove is the shell's own: the dashboard pins and the ownership register (src/lib/base44AppMirror.ts). The owner gets a toast explaining it, claimed from a row rather than pushed — the deletion happened while nobody was looking, so telling them has to wait for them to come back.

That second half is the part polling could not have done. A trashed app drops out of listApps on its own, so the apps list would eventually self-correct — but a pinned widget renders from its own stored URL and is in no list. Without the event it sits on the dashboard indefinitely, showing an app that is not there.

Register once per environment, and it prints the public key to pin:

npm run webhook:register -- --url https://your-shell.example.com

--events narrows the subscription, and that is the lever worth knowing: the subscription is what makes Base44 write an outbox row at all, so an unselected event costs nothing rather than arriving and being discarded.

Verification material comes from one of two places, and BASE44_WEBHOOK_PUBLIC_KEYS picks. Pinned — paste the whpk_ keys — and verification makes no network call, so a receiver that cannot reach Base44 still works; the cost is applying a key rotation yourself, inside Base44's dual-sign overlap. Unset and it fetches the published set, which handles rotation on its own. The pinned value is a public key, not a secret: it verifies signatures and cannot make them.

npm run webhook:smoke              # the signature, case by case — no server, no database
npm run webhook:projection:smoke   # what a verified event does: removal, notice, restore, replay

→ Registration, delivery semantics, the retry ladder, and what this receiver deliberately leaves out: docs/base44-webhooks.md


Run this repo

cp .env.example .env     # Postgres, Google OAuth, and the BASE44_* vars
npm install
npm run db:migrate
npm run dev

Without the BASE44_* variables everything works except the builder: the bridge answers 501 bridge_misconfigured and the UI shows its "Connect" state. That's on purpose — you can explore the product before you have a workspace.

Checks, each one asserting a boundary above:

npm run typecheck
npm run lint
npm run rls:smoke        # step 1: the owner predicate, including the traps
npm run auth:smoke       # step 1: session → actor
npm run entities:smoke   # step 1: whitelisting, scoping, wire shape
npm run base44:smoke     # steps 2–3: token containment, allow-list, session keying
npm run sunny:smoke     # step 4: the public contract, action by action
npm run webhook:smoke    # step 5: the inbound signature, both key sources, negative controls
npm run webhook:projection:smoke   # step 5: what a verified event does to the shell's own rows

Troubleshooting

Symptom Likely cause
501 bridge_misconfigured A BASE44_* env var is missing. It's a deployment problem, not a user one — show the Connect gate, don't send them into a reconnect loop
403 on provision The workspace isn't enabled for platform app-building yet
409 on provision Something already sits at the synthetic address. Refuse — don't attach to it
404 on mint The principal doesn't exist. Provision first; mint never creates one
428 reauthorize_required The grant is gone (removed from the workspace, role changed, key lost its scope)
403 "scoped to MCP" The minted token's client_id has an MCP prefix; it's then valid only at /mcp, not REST
sendMessage times out at ~30s Your own timeout, not Base44's. Build turns need ~120s
A new app doesn't appear in the list It was created but never filed into the folder

Where to read next

Stack

Next.js (App Router) · TypeScript · Tailwind 4 · Postgres + Prisma · NextAuth (Google only) · deployed on Netlify with Neon. Platform infrastructure (src/lib, src/app) is strict TypeScript; the example product UI (src/components, src/views) is JSX with relaxed lint — it's the example, not the lesson.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages