Build a B2B SaaS application
Availability: this walkthrough describes a service template available through repository access arranged with Udibo. It is not a public standalone starter. You can follow the same design in your own application using the first-login guide. For runnable public package examples, use starter projects.
The template is a React Router application served by Hono, with its projects, activity and sessions in Postgres through drizzle. It needs Node.js 26, pnpm and a Postgres database.
What the walkthrough covers
Sign in, then create a project in one organization and archive it. Switch to a second organization where the same person can read project details but has no New project action. Organizations in your own deployment start with no projects until someone creates one.
Projects, Activity and Settings have their own pages. The organization switcher keeps the selected team visible, and New organization creates another. Settings contains the organization's members, its invitations, each member's app access, and its name. Loading, empty and retry states keep the next action available when a request fails.
Before you start
You need an accessible Udibo Identity tenant, a development application configured as a first-party, confidential authorization-code client with refresh tokens, and a client secret. Choose opaque access tokens for these walkthroughs; they validate through introspection. See application registration for the exact settings.
The app requests openid profile email. Define application permissions in your
tenant's Access control → Permissions catalog: projects:read,
projects:write, and projects:archive. Under Access control → Roles,
create one role carrying projects:read and another carrying projects:write
and projects:archive. Permission names are not additional OAuth scopes.
Set up the example
Use http://localhost:8007 as the app origin and register
http://localhost:8007/auth/callback exactly. Set these environment values in
your local runtime:
| Variable | Value |
|---|---|
UDIBO_ISSUER | Your tenant's issuer origin, https://<tenant-id>.udibo.com |
APP_ORIGIN | http://localhost:8007; the registered callback is {APP_ORIGIN}/auth/callback |
UDIBO_CLIENT_ID | The registered application's client ID |
UDIBO_CLIENT_SECRET | A secret created for that application |
UDIBO_WEBHOOK_SECRET | The signing secret for your webhook endpoint at {APP_ORIGIN}/webhooks/udibo |
DATABASE_URL | The Postgres database the projects, activity and sessions live in |
SESSION_SECRET | At least 32 bytes of entropy that encrypts sessions and sign-ins in progress; new,old rotates it |
APP_ENV | Optional. Exactly development lets UDIBO_WEBHOOK_SECRET be absent, and every delivery is refused |
PORT | Optional. Where the server listens; 8007 by default |
The application checks every value at startup, and a missing one stops it with
its name. UDIBO_WEBHOOK_SECRET may be absent only when APP_ENV is exactly
development; in that case every webhook delivery is refused, and an unset or
any other APP_ENV still requires the secret. Keep secrets on the server.
SESSION_SECRET is required. It encrypts the sessions the app stores in its
database and the browser cookie that holds a sign-in in progress, between
Sign in and your tenant's redirect back. Generate one with
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))".
It accepts a comma-separated list, and every entry needs at least 32 bytes. The
first entry encrypts new sessions and the sign-in cookie; the entries after it
only open sessions created before a rotation. To rotate without signing everyone
out, set SESSION_SECRET=<new>,<old> and drop the old entry after 14 days, the
session lifetime. A sign-in started before the rotation and finished after it
fails, and the person signs in again.
In your copy of the template, install, migrate the database and start it:
pnpm install --frozen-lockfile
cp .env.example .env # then fill it in
pnpm db:migrate
pnpm dev # http://localhost:8007Register a webhook endpoint at your reachable HTTPS application origin plus
/webhooks/udibo. A hosted service cannot deliver to your laptop's localhost:
use a development tunnel or a deployed preview for that part of the trial.
Follow webhook registration and
select the organization events you want to receive.
1. Sign in and establish a session
The server discovers the tenant's endpoints, starts the authorization-code flow, exchanges the code, and keeps tokens behind a backend-for-frontend. The browser carries an application session cookie.
Login, signup, and consent run on the tenant's hosted pages. Configure sign-in methods there instead of rebuilding those pages inside the example.
Checkpoint: you return to the app after login, and a protected request is validated through introspection.
2. Run the organization screens in your own application
Create two organizations
from the example's own New organization page. The form posts to the example's API, which
forwards the signed-in person's access token to the caller-relative organization
API on your tenant's host: POST /api/organizations creates one and makes that
person its accepted owner in the same write,
POST /api/organizations/{id}/invitations offers a role to an address, and
GET /api/organizations/{id}/members reads the roster for any accepted member.
No scope, no dashboard access, and no machine credential — a
client_credentials token is refused on every verb, create included.
Your tenant decides what each caller may do from that caller's own accepted membership. The example's API relay preserves the tenant's refusal status and details; its screens show role-aware controls and explain refusals in product language. Those controls do not grant access: the tenant remains the authority on each request. Below the read tier every refusal is the same not-found answer, so these screens cannot be used to learn which organizations exist.
Accepting an invitation is the example's own page as well. Set a home URL of
{your app origin}/invitation under Settings → Branding and your tenant's
invitation emails open that page carrying the offer id, instead of the hosted
one. It sends a signed-out invitee through the example's own login with a
continuation back to the page, lists what is waiting with
GET /api/organizations/offers, and accepts the one the link named with
POST /api/organizations/offers/{offerId}/accept, the invitee's own token on
both. Treat the parameter as an identifier, not as text you interpolate: an
offer id is a uuid and nothing else, anything else is the invalid outcome, and
only an offer the listing came back with is ever accepted — the accept URL is
built from that value, so a crafted one can otherwise aim a different route of
your own application at whoever opens the link. Accepting stays bound to the
person: an invitation converts only for the verified holder of the address it
was sent to, a pending grant only for its own subject, and every refusal comes
back as a success status word the page renders rather than as an error. A tenant
with no home URL keeps the hosted page, which still works and still returns
people to your product. The return_to on the
landing link is validated once, when the invitation is written, and handed to
your page as data — the example checks it against its own origin again and drops
anything else.
Granting one of your application roles to a member who has already joined is
POST /api/organizations/{id}/members/{userId}/roles, the same call for every
other organization verb; the hosted organization pages serve Udibo's own host
only.
Switching the active organization is a link in the example too: its BFF forwards
the organization authorization parameter, so the person picks without leaving
your origin.
Checkpoint: create an organization, invite a second address, open the emailed link as that person while signed out, accept on the application's own landing, and confirm they end up acting in the organization and appear on the roster.
3. Keep customer organizations separate
Open your test user under Directory → Users and assign the projects:read
role tenant-wide. Then open Directory → Organizations → one of your two
organizations → Members. Choose App roles for that accepted test user
and assign the role carrying projects:write and projects:archive. Check that
the heading names the intended organization. Keep this assignment out of the
other organization and the tenant-wide roles list; see
assigning a role.
Use a dashboard administrator who may manage organizations.
Your own application can offer the same operation through
POST /api/organizations/{id}/members/{userId}/roles, called with a signed-in
organization owner's or admin's credential. The example does: once that first
grant is in place, an owner or admin grants teammates their roles from
Settings → App access. It never shows App access on the signed-in
person's own row, so nobody grants themselves a role, and the first grant comes
from the dashboard. The
organization API
documents the request.
The project list reads the validated token's permissions and active org_id,
then restricts its own rows to that organization. A credential answers for at
most one active organization. Permission to read in one must not expose
another's projects.
Select the organization where the person holds projects:write. In the example,
open Projects → New project, enter a name, and choose Create project.
The server requires projects:write and creates the project in the active
organization carried by the credential. It does not accept an organization in
the request body as proof of access. A new organization has no projects until
you complete this step.
The archive action obtains a project's organization from the application's own
data and asks POST /api/check about projects:archive in that organization.
It does not accept an organization supplied by the browser as proof of access.
Checkpoint: create and archive a project in the permitted organization. Switch to the other organization and confirm New project is unavailable. Have another test user with the creation role create a project there; the first person should be able to read it but be refused when archiving it. Also test an unknown project ID.
4. Receive organization changes
Verify the webhook signature over raw request bytes before parsing JSON. Enforce the timestamp tolerance and deduplicate deliveries. The example checks the timestamp within five minutes, records a retried delivery once, and shows each reader only the events of organizations they belong to.
Read the delivery contract for retry behavior and the current worker cadence. Treat webhook data as eventually delivered; authorize protected requests from current validated identity.
Make it your product
The example keeps its projects and activity in Postgres; replace them with your own tables. Keep organization ownership on each application row, and retain the cross-organization denial tests.
The organization screens are the example's own: creating, renaming and deleting an organization, inviting and withdrawing invitations, removing members, and granting application roles, each authorized by the caller's accepted membership. The rest of that surface is open to you on the same terms, such as defining the organization's own roles. See organizations for the address, the verbs and the authority tiers. The example does not call administrator-only tenant-management APIs using an end user's token, and neither should you.
No page of ours is on the path, and keeping it that way costs one setting: the
invitation landing is the example's own page, and the hosted one stays as the
fallback for a tenant that has configured no home URL. Granting an application
role to a member who has already joined is
POST /api/organizations/{id}/members/{userId}/roles, and on your tenant's host
/organizations/{id} redirects to Udibo's own site, so a link there would send
a person out of your product.
Continue with permissions and the production checklist.
Last verified 2026-10-13.

