Skip to content
Udibo
On this page
View Markdown

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:

VariableValue
UDIBO_ISSUERYour tenant's issuer origin, https://<tenant-id>.udibo.com
APP_ORIGINhttp://localhost:8007; the registered callback is {APP_ORIGIN}/auth/callback
UDIBO_CLIENT_IDThe registered application's client ID
UDIBO_CLIENT_SECRETA secret created for that application
UDIBO_WEBHOOK_SECRETThe signing secret for your webhook endpoint at {APP_ORIGIN}/webhooks/udibo
DATABASE_URLThe Postgres database the projects, activity and sessions live in
SESSION_SECRETAt least 32 bytes of entropy that encrypts sessions and sign-ins in progress; new,old rotates it
APP_ENVOptional. Exactly development lets UDIBO_WEBHOOK_SECRET be absent, and every delivery is refused
PORTOptional. 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:

Shell
pnpm install --frozen-lockfile
cp .env.example .env    # then fill it in
pnpm db:migrate
pnpm dev                # http://localhost:8007

Register 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.