# 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](https://www.udibo.com/docs/identity/get-started). For runnable public package examples, use [starter projects](https://www.udibo.com/docs/identity/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](https://www.udibo.com/docs/identity/applications) 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://.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=,` 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: ```bash 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](https://www.udibo.com/docs/identity/webhooks) 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](https://www.udibo.com/docs/identity/social-login) 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](https://www.udibo.com/docs/identity/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](https://www.udibo.com/docs/identity/permissions#3-assign-a-role-to-someone). 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](https://www.udibo.com/docs/identity/organizations#organizations-inside-your-own-application) 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](https://www.udibo.com/docs/identity/webhooks) 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](https://www.udibo.com/docs/identity/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](https://www.udibo.com/docs/identity/permissions) and the [production checklist](https://www.udibo.com/docs/identity/production). _Last verified 2026-10-13._