Documentation

Complete reference for the ManyRows platform. AppKit React SDK, Vanilla JS, Client and Server APIs.

Overview

AppKit is an optional convenience layer for React apps. It handles authentication, JWT management, and runtime data fetching so you don't have to. You provide an appId and it handles the rest. If you're not using React, or prefer full control, you can call the REST API directly.

  • Email + password login flow with OTP verification
  • App-scoped JWT with auto-refresh
  • Fetches user roles, permissions, flags, and config
  • Edit display name, logout from modal
  • Render your own UI after auth via React context
  • Clean API: single appId for all requests

CORS Setup

Required: Add your domain to CORS origins

AppKit makes API requests from your domain to ManyRows. You must add your domain (e.g., https://yourapp.com) to the allowed CORS origins in the app's settings (Apps page), or requests will be blocked by the browser.

Install

npm bash
npm i @manyrows/appkit-react
Exports typescript
import {
  // Core
  AppKit,          // Main wrapper component
  AppKitAuthed,    // Render children only when authenticated
  useAppKit,       // Access context: snapshot, logout, refresh, etc.

  // Convenience hooks
  useUser,         // Current user (id, email, name)
  useToken,        // JWT token for API calls
  useAuthFetch,    // fetch() wrapper with automatic Bearer token
  useRoles,        // All roles for current user ,string[]
  useRole,         // Check a single role        ,boolean
  usePermissions,  // All permissions            ,string[]
  usePermission,   // Check a single permission  ,boolean
  useFeatureFlags, // All feature flags          ,{ key, enabled }[]
  useFeatureFlag,  // Check a single flag        ,boolean
  useConfig,       // All config values          ,{ key, type, value }[]
  useConfigValue,  // Get a single config value  ,T

  // Account & security hooks
  useUpdateProfile, useSetPassword,
  useSessions, useRevokeSession, useRevokeOtherSessions,
  usePasskeys, useRegisterPasskey, useRenamePasskey, useDeletePasskey,
  useStartTOTPSetup, useEnableTOTP, useDisableTOTP, useRegenerateBackupCodes,
  useIdentities, useDisconnectIdentity,
  useDeleteAccount, useRequestEmailChange, useVerifyEmailChange,
  useUserFields, useUpdateUserFields, useRequestReauthCode,
  isPasskeySupported, isPasskeyCancelled, isPasskeyAlreadyRegistered,

  // Organization hooks
  useOrganization, useOrganizationList, useSetActiveOrganization,
  useCreateOrganization, useRenameOrganization, useArchiveOrganization,
  useOrganizationMembers, useSetOrganizationMember, useRemoveOrganizationMember,
  useOrganizationInvites, useCreateOrganizationInvite, useRevokeOrganizationInvite,

  // Theming
  useTheme,        // Access color tokens       ,{ colorMode, tokens }

  // Types
  type AppKitTheme, // Theme config type
} from "@manyrows/appkit-react";

Vanilla JavaScript (no React)

You can use AppKit without React by loading the runtime script directly. The runtime exposes window.ManyRows.AppKit with an init() method.

index.html html
<!-- Load the AppKit runtime -->
<!-- Recommended: add integrity="sha384-..." crossorigin="anonymous"
     with the hash from your install console, so the browser refuses a
     tampered runtime script. -->
<script src="https://auth.your-domain.com/appkit/assets/appkit.js" defer></script>

<!-- Container for the auth UI -->
<div id="manyrows-app"></div>

<!-- Your app (hidden until authenticated) -->
<div id="my-app" style="display: none;">
  <p>You are signed in!</p>
  <button id="logout-btn">Log out</button>
</div>

<script>
  window.addEventListener('load', () => {
    const handle = window.ManyRows.AppKit.init({
      containerId: 'manyrows-app',
      workspace: 'your-workspace',
      appId: 'your-app-id',

      onReady: (info) => {
        console.log('AppKit ready:', info.appId);
      },

      onState: (snapshot) => {
        if (snapshot.status === 'authenticated') {
          // User is logged in
          const account = snapshot.appData?.account;
          const roles = snapshot.appData?.roles ?? [];
          const permissions = snapshot.appData?.permissions ?? [];
          const token = snapshot.jwtToken;

          console.log('User:', account?.email);
          console.log('Roles:', roles);
          console.log('Token:', token);

          document.getElementById('manyrows-app').style.display = 'none';
          document.getElementById('my-app').style.display = 'block';
        } else if (snapshot.status === 'unauthenticated') {
          document.getElementById('manyrows-app').style.display = 'block';
          document.getElementById('my-app').style.display = 'none';
        }
      },

      onError: (err) => console.error('AppKit error:', err),
    });

    // Log out
    document.getElementById('logout-btn')?.addEventListener('click', () => {
      handle.logout();
    });

    // Refresh state manually
    // handle.refresh();

    // Get current state at any time
    // const state = handle.getState();

    // Subscribe to state changes (returns unsubscribe function)
    // const unsub = handle.subscribe((snapshot) => { ... });

    // Clean up
    // handle.destroy();
  });
</script>
containerId required string
ID of the DOM element where the auth UI will be rendered.
workspace required string
Your workspace slug.
appId required string
Your app ID.
onReady (info) => void
Called when the runtime is initialized and the app is resolved.
onState (snapshot) => void
Called on every state change. Use snapshot.status to check auth state ("checking", "authenticated", "unauthenticated").
onReadyState (snapshot) => void
Called only when the user is authenticated and app data is available.
onError (err) => void
Called when an error occurs during initialization.

Handle methods

handle.getState() snapshot | null
Returns the current state snapshot.
handle.subscribe(fn) () => void
Subscribe to state changes. Returns an unsubscribe function.
handle.refresh() void
Re-fetch the auth state and app data from the server.
handle.logout() Promise<void>
Log the user out and clear the session.
handle.setToken(token) void
Manually set a JWT token (advanced use).
handle.destroy() void
Clean up the runtime and remove the UI.

The snapshot structure is the same as in the React wrapper. See onState in the React docs above for the full shape. Key fields: snapshot.status, snapshot.jwtToken, snapshot.appData.account, snapshot.appData.roles, snapshot.appData.permissions.

Basic Usage

Minimal setup tsx
import { AppKit } from "@manyrows/appkit-react";

export default function Portal() {
  return (
    <AppKit
      workspace="your-workspace"
      appId="your-app-id"
      onReady={(info) => console.log("ready:", info.appId)}
      onError={(err) => console.error(err.code, err.message)}
    />
  );
}

Only workspace and appId are required. The runtime script loads automatically. Use the src prop only if self-hosting.

Custom branding above the login form tsx
<AppKit
  workspace="your-workspace"
  appId="your-app-id"
  authHeader={
    <div style={{ textAlign: "center", marginBottom: 16 }}>
      <img src="/logo.svg" alt="My App" height={40} />
      <h2>Welcome to My App</h2>
    </div>
  }
>
  {/* ... */}
</AppKit>
Public app with auth routes tsx
<AppKit
  workspace="your-workspace"
  appId="your-app-id"
  publicAccess
  authRoutes={{
    login: "/login",
    register: "/register",
    "forgot-password": "/forgot-password",
  }}
  authRedirect="/dashboard"
>
  <MyApp />
</AppKit>

authRoutes automatically shows/hides the auth UI based on the current URL, opens the correct screen (login, register, or forgot-password), and updates the URL when the user navigates between auth screens. authRedirect navigates the user to the given path after they authenticate. You can still use hideAuthUI, initialScreen, and onScreenChange individually for full control.

Props

workspace required string
Workspace slug for the /x/{workspace}/... routes
appId required string
App ID created in ManyRows
src string
Runtime bundle URL. Defaults to the production AppKit script. Only needed if self-hosting.
containerId string
Mount point ID. Auto-generated if omitted.
theme AppKitTheme
Customize colors and appearance. Free-tier knobs: primaryColor, backgroundColor, and colorMode. Richer branding (fonts, radius, card background, custom CSS, white-label) is a paid feature configured in the admin panel — see Branding below.
onReady (info) => void
Fires when app is resolved and ready
onError (err) => void
Fires on any runtime or load error
onState (snapshot) => void
Fires on every state update
onReadyState (snapshot) => void
Fires when authenticated with appData present
authHeader React.ReactNode
Optional content rendered above the login/register card. Use this to add your own branding (logo, app name, welcome text) above the authentication form.
publicAccess boolean
When true, children are always visible regardless of auth state. Use for apps that are publicly accessible with optional login. Defaults to false.
hideAuthUI boolean
When true, the runtime's built-in auth/login UI is hidden. Use with publicAccess to control when the login screen is shown via routing.
authRoutes Partial<Record<"login" | "register" | "forgot-password", string>>
Map auth screens to URL paths. Automatically derives initialScreen, hideAuthUI, and onScreenChange from the current URL. Explicit props take precedence. Partial,omit any screen you don't need routable.
authRedirect string
When set alongside authRoutes, automatically navigates to this path after the user authenticates while on an auth route.
initialScreen "login" | "register" | "forgot-password"
Which auth screen to show initially. Defaults to the login screen. Derived automatically when using authRoutes.
onScreenChange (screen: "login" | "register" | "forgot-password") => void
Called when the user navigates between auth screens (e.g. clicks "Create account" or "Forgot password?"). Derived automatically when using authRoutes.
embedded boolean
When true, the auth form skips its full-viewport wrapper (no minHeight: 100vh, no vertical centering, no background) and flows inline in the parent container. Use for sidebars, modals, or inline sections. Defaults to false.
hideLoadingUI boolean
Hide the default loading indicator while the AppKit runtime is being loaded.
hideErrorUI boolean
Hide the default error UI when AppKit fails to initialize.
loadAppRuntime boolean
When true, AppKit also fetches /a/runtime on bootstrap to populate featureFlags and config on the snapshot. Defaults to false,apps that don't read either field should leave it off to skip the round trip on every page load.
labels Record<string, string>
Override any user-facing text in the auth UI. Pass a partial object with only the keys you want to change,all other strings default to English. See the Custom Text Labels section below for available keys.
debug boolean
Enable verbose console logging for debugging. Silent by default.

Rendering Authed UI

Use AppKitAuthed to render your UI only after the user is logged in. Access state via useAppKit().

Host-side authed UI tsx
import { AppKit, AppKitAuthed, useUser, useAppKit } from "@manyrows/appkit-react";

function MyApp() {
  const user = useUser();
  const { logout } = useAppKit();

  return (
    <div>
      <header style={{ display: "flex", justifyContent: "space-between", padding: 16 }}>
        <h1>My App</h1>
        <div>
          {user && <span>{user.email}</span>}
          <button onClick={logout}>Log out</button>
        </div>
      </header>
      <main>
        {user && <p>Welcome, {user.email}</p>}
      </main>
    </div>
  );
}

export default function Page() {
  return (
    <AppKit workspace="your-workspace" appId="your-app-id">
      <AppKitAuthed fallback={null}>
        <MyApp />
      </AppKitAuthed>
    </AppKit>
  );
}

Host-side rendering

When you provide children, AppKit suppresses the runtime's default authed screen,so you control the experience entirely.

useAppKit()

The hook returns state and actions from the AppKit context.

Context value typescript
const {
  status,          // "idle" | "loading" | "mounted" | "error"
  error,           // ManyRowsAppKitError | null
  readyInfo,       // { workspace, appId, ... } | null
  snapshot,        // runtime state snapshot
  isAuthenticated, // boolean
  handle,          // raw runtime handle

  refresh,         // () => void
  logout,          // () => Promise<void>
  setToken,        // (tok: string | null) => void
  destroy,         // () => void
  info,            // () => readyInfo | null
} = useAppKit();

Convenience Hooks

These hooks extract specific values from the AppKit context. They must be used inside an AppKit provider.

useUser() AppKitAccount | null
Current authenticated user,email, name, and account ID.
useToken() string | null
Raw JWT string for making authenticated API calls.
useAuthFetch() (input, init?) => Promise<Response>
Returns a fetch-compatible function that automatically includes the Bearer token in the Authorization header. Use this for making authenticated API calls to your own backend. The returned function is memoized and updates when the token changes.
useRoles() string[]
All roles assigned to the current user in this app.
useRole(role) boolean
Check if the user has a specific role. e.g. useRole("admin")
usePermissions() string[]
All permissions granted to the current user in this app.
usePermission(perm) boolean
Check if the user has a specific permission. e.g. usePermission("posts:edit")
useFeatureFlags() { key, enabled }[]
All feature flags with their enabled/disabled state.
useFeatureFlag(key) boolean
Check if a feature flag is enabled. e.g. useFeatureFlag("beta_ui")
useConfig() { key, type, value }[]
All public config values delivered to this app.
useConfigValue(key, default?) T
Get a single config value by key with an optional default. e.g. useConfigValue("max_items", 50)
useSetPassword() ({ password, currentPassword? }) => Promise<void>
Returns a function to change the signed-in user's password. currentPassword is required whenever the user already has one. Users with no password yet (OAuth/passkey-only) can't install one through this hook ,the server gates that path on a recent email code; use the built-in Profile dialog's password tab instead.
useUpdateProfile() ({ displayName }) => Promise<void>
Returns a function to update the user's display name. Call refresh() afterwards to reload the snapshot.
Example usage tsx
import {
  useUser, useRoles, usePermission,
  useFeatureFlag, useConfigValue,
} from "@manyrows/appkit-react";

function MyPage() {
  const user = useUser();
  const roles = useRoles();
  const canEdit = usePermission("posts:edit");
  const showBeta = useFeatureFlag("beta_ui");
  const maxItems = useConfigValue<number>("max_items", 50);

  return (
    <div>
      <h1>Hello, {user?.email}</h1>
      <p>Roles: {roles.join(", ")}</p>
      {canEdit && <button>Edit</button>}
      {showBeta && <BetaFeature />}
      <p>Showing up to {maxItems} items</p>
    </div>
  );
}

Account & Security Hooks

Headless wrappers over the client API's account-management surface — everything the built-in Profile dialog can do, for building your own account UI. All return an async function you call on demand; errors throw with the server's error code as the message.

useSessions() () => Promise<AppKitSession[]>
Lists the user's active sessions across devices. The session making the call has current: true.
useRevokeSession() (sessionId) => Promise<void>
Signs out another device. The current session can't be revoked this way — use logout().
useRevokeOtherSessions() () => Promise<{ revoked }>
"Log out everywhere else" — revokes all of the user's other sessions in one call; the current session stays signed in. Resolves with the number revoked.
usePasskeys() () => Promise<AppKitPasskey[]>
Lists the user's registered passkeys.
useRegisterPasskey() ({ name? }?) => Promise<AppKitPasskey>
Runs the full WebAuthn registration ceremony (challenge → browser prompt → store). Gate your UI with isPasskeySupported(); detect user cancellation with isPasskeyCancelled(err) and an already-enrolled authenticator with isPasskeyAlreadyRegistered(err).
useRenamePasskey() (id, { name }) => Promise<void>
Renames a passkey.
useDeletePasskey() (id, reauth?) => Promise<void>
Deletes a passkey. Sensitive — pass { password } or { code } (see re-auth below).
useStartTOTPSetup() (reauth) => Promise<{ secret, uri }>
Begins authenticator-app 2FA enrollment. Render uri as a QR code; finish with useEnableTOTP. Calling it again discards the previous pending secret.
useEnableTOTP() ({ code }) => Promise<{ backupCodes }>
Verifies the first code and enables 2FA. Show the returned backup codes once — they are not retrievable later.
useDisableTOTP() (reauth) => Promise<void>
Disables 2FA (sensitive — re-auth required).
useRegenerateBackupCodes() ({ password }) => Promise<{ backupCodes }>
Replaces the backup-code set. Password only — no email-code path on this endpoint.
useIdentities() () => Promise<AppKitIdentity[]>
Lists linked sign-in identities (Google, external IdPs, …).
useDisconnectIdentity() (provider) => Promise<void>
Unlinks a provider. Always succeeds — recovery is via the email flows, so add your own confirmation UX.
useRequestEmailChange() ({ newEmail, password }) => Promise<void>
Starts an email change; 6-digit codes go to BOTH the current and the new address.
useVerifyEmailChange() ({ oldCode, newCode }) => Promise<void>
Completes the change with both codes — the old-address code approves it, the new-address code proves inbox ownership. Refreshes the snapshot.
useDeleteAccount() ({ password }) => Promise<void>
Permanently deletes the account at this app, then signs out. Rejects with error.forbidden when the app disables self-serve deletion.
useUserFields() / useUpdateUserFields() () / (values) => Promise<AppKitUserField[]>
Read and update client-visible custom user fields. Update takes a flat key→value map, not the field array.
useRequestReauthCode() () => Promise<void>
Emails the signed-in user a 6-digit code for the sensitive hooks' { code } path.
Re-authentication pattern tsx
const requestReauthCode = useRequestReauthCode();
const disableTOTP = useDisableTOTP();

// Password user:
await disableTOTP({ password });

// Passwordless (OAuth/passkey-only) user:
await requestReauthCode();               // emails a 6-digit code
await disableTOTP({ code: enteredCode }); // user types the code

Organization Hooks

Self-serve organization management for org-enabled apps. The active org and membership list live on the snapshot; member and invite pages are fetched on demand. Authorization is enforced server-side by the caller's org tier (owner / admin / member).

useOrganization() AppKitOrganization | null
The session's active organization.
useOrganizationList() AppKitOrganization[]
Every org the user belongs to (for a switcher).
useSetActiveOrganization() (orgId) => Promise<AppKitOrganization>
Switches the active org and refreshes roles/permissions.
useCreateOrganization() ({ name, slug? }) => Promise<org>
Self-serve creation (app policy permitting); creator becomes owner.
useRenameOrganization() / useArchiveOrganization() (orgId, …) => Promise<void>
Rename (owner/admin); archive (owner-only).
useOrganizationMembers() (orgId, opts?) => Promise<page>
Paged member listing with search ({ page, pageSize, search }).
useSetOrganizationMember() / useRemoveOrganizationMember() (orgId, userId, …) => Promise<void>
Change a member's tier/roles, or remove (pass your own user id to leave). Demoting or removing the last owner rejects with error.conflict.
useOrganizationInvites() / useCreateOrganizationInvite() / useRevokeOrganizationInvite() (orgId, …) => Promise
List, send, and revoke email invites (owner/admin).

Lifecycle Callbacks

onReady (info) => void
App resolved and ready
onState (snapshot) => void
Every state change,useful for debugging or syncing external state
onReadyState (snapshot) => void
Authenticated with appData present ,imperative "authed gate"
onError (err) => void
Any error during load or runtime,log or show your own error UI
onSignIn (user) => void
Fired when the user becomes signed in — on a fresh login and when an existing session resolves after mount. A remount re-fires it for the same session, so guard one-shot side effects (analytics, redirects) with a ref.
onSignOut () => void
Fired only on a real sign-out (logout, revocation, expiry) — not when the initial state resolves to unauthenticated.

onReady fires once the App ID is resolved. It does not mean the user is logged in,use onReadyState or AppKitAuthed for that.

Auth & JWT

AppKit uses bearer tokens stored in localStorage under an app-scoped key. Each session is bound to a single appId; the server rejects tokens whose appId claim doesn't match the app being called.

  • On mount: loads JWT from localStorage
  • On 401: clears token → unauthenticated state
  • setToken(tok) lets the host inject/clear tokens
  • logout() calls the backend then clears locally

XSS hygiene

Since JWT is in localStorage, follow standard security practices: CSP, output escaping, dependency auditing.

Google OAuth

If the app has a Google OAuth Client ID configured, AppKit automatically shows a "Sign in with Google" button on the login screen. Configure the Client ID and optional Client Secret in the app's auth settings in the admin panel.

API Endpoints

These are the REST API endpoints that AppKit uses under the hood. You can call them directly from any language or framework, AppKit is not required. All authenticated requests include the JWT in the Authorization header.

Client API endpoints text
# Non-exhaustive — see the sections below for full coverage.

# App resolution + identity
GET   /x/{workspace}/apps/{appId}/                                # Resolve app (public)
GET   /x/{workspace}/apps/{appId}/a/me                            # Identity + roles/permissions
GET   /x/{workspace}/apps/{appId}/a/runtime                       # Flags, config, app data
GET   /x/{workspace}/apps/{appId}/a/check-permission              # Check a permission

# Email + password / OTP
POST  /x/{workspace}/apps/{appId}/auth/                           # Request OTP
POST  /x/{workspace}/apps/{appId}/auth/verify                     # Verify OTP
POST  /x/{workspace}/apps/{appId}/auth/password                   # Password sign-in
POST  /x/{workspace}/apps/{appId}/auth/register                   # Self-register
POST  /x/{workspace}/apps/{appId}/auth/forgot-password            # Start password reset
POST  /x/{workspace}/apps/{appId}/auth/reset-password             # Complete password reset

# Magic links
POST  /x/{workspace}/apps/{appId}/auth/request-magic-link         # Send magic link
GET   /x/{workspace}/apps/{appId}/auth/magic-link                 # Consume magic link

# Social OAuth (Google, Apple, Microsoft, GitHub)
POST  /x/{workspace}/apps/{appId}/auth/google                     # Google ID-token sign-in
GET   /x/{workspace}/apps/{appId}/auth/google/authorize           # Google auth-code start
GET   /x/{workspace}/apps/{appId}/auth/google/callback            # Google auth-code callback
GET   /x/{workspace}/apps/{appId}/auth/apple/authorize            # Apple OAuth start
POST  /x/{workspace}/apps/{appId}/auth/apple/callback             # Apple OAuth callback
GET   /x/{workspace}/apps/{appId}/auth/microsoft/authorize        # Microsoft OAuth start
GET   /x/{workspace}/apps/{appId}/auth/microsoft/callback         # Microsoft OAuth callback
GET   /x/{workspace}/apps/{appId}/auth/github/authorize           # GitHub OAuth start
GET   /x/{workspace}/apps/{appId}/auth/github/callback            # GitHub OAuth callback

# Passkeys (WebAuthn)
POST  /x/{workspace}/apps/{appId}/auth/passkey/login/begin        # Start passkey sign-in
POST  /x/{workspace}/apps/{appId}/auth/passkey/login/finish       # Finish passkey sign-in
POST  /x/{workspace}/apps/{appId}/a/passkey/register/begin        # Start passkey registration
POST  /x/{workspace}/apps/{appId}/a/passkey/register/finish       # Finish passkey registration
GET   /x/{workspace}/apps/{appId}/a/passkeys                      # List current user's passkeys
PATCH /x/{workspace}/apps/{appId}/a/passkeys/{id}                 # Rename a passkey
DELETE /x/{workspace}/apps/{appId}/a/passkeys/{id}                # Remove a passkey

# TOTP (2FA)
POST  /x/{workspace}/apps/{appId}/auth/totp/verify                # Verify TOTP at sign-in
POST  /x/{workspace}/apps/{appId}/auth/totp/setup-init            # Begin TOTP enrolment
POST  /x/{workspace}/apps/{appId}/auth/totp/setup-complete        # Complete TOTP enrolment

# Connected social identities
GET   /x/{workspace}/apps/{appId}/a/me/identities                 # List linked accounts
DELETE /x/{workspace}/apps/{appId}/a/me/identities/{provider}     # Unlink a provider

# Session lifecycle
POST  /x/{workspace}/apps/{appId}/auth/refresh                    # Refresh JWT
POST  /x/{workspace}/apps/{appId}/auth/logout                     # Public logout
POST  /x/{workspace}/apps/{appId}/a/logout                        # Authenticated logout

Simple routing

Just pass your appId,works with AppKit or direct API calls.

Theming

Customize AppKit's appearance using the theme prop. You can set a brand color and control light/dark mode.

Custom primary color tsx
<AppKit
  workspace="your-workspace"
  appId="your-app-id"
  theme={{ primaryColor: "#a234be" }}
>
  {/* ... */}
</AppKit>

The primary color affects buttons, links, focus rings, and other accent elements.

Dark mode

Set colorMode to "dark" for dark mode, or "auto" to follow the user's system preference. Default is "light".

Dark mode example tsx
<AppKit
  workspace="your-workspace"
  appId="your-app-id"
  theme={{ colorMode: "dark" }}
>
  {/* All AppKit components render in dark mode */}
</AppKit>
Auto mode (follows system preference) tsx
<AppKit
  workspace="your-workspace"
  appId="your-app-id"
  theme={{ colorMode: "auto", primaryColor: "#a234be" }}
>
  {/* Follows prefers-color-scheme media query */}
</AppKit>

Theme options

The theme prop accepts three free-tier knobs:

primaryColor string
Brand/accent color (hex). Default: #1976d2
backgroundColor string
Page background and surface color.
colorMode "light" | "dark" | "auto"
Light, dark, or follow the system preference. Default: "light"

Branding & white-label (paid)

Deeper branding — custom fonts, corner radius, card background, arbitrary --ak-* CSS variables, and removing the "Powered by ManyRows" badge — is a paid feature. It's configured per-app in the ManyRows admin panel and applied automatically to your auth UI, so it isn't set through the client theme prop.

All styling is done via CSS custom properties on a scoped .ak-root element. Your host page styles are never affected.

useTheme() hook

Access the resolved color tokens in your own components via the useTheme() hook. Returns the current colorMode and all tokens.

Using tokens in custom components tsx
import { useTheme } from "@manyrows/appkit-react";

function MyCard({ children }) {
  const { colorMode, tokens } = useTheme();

  return (
    <div style={{
      backgroundColor: tokens.surfacePrimary,
      color: tokens.textPrimary,
      border: `1px solid ${tokens.borderDefault}`,
      borderRadius: 8,
      padding: 16,
    }}>
      {children}
    </div>
  );
}

Custom Text Labels

Override any user-facing string in the login and registration screens with the labels prop. Pass a partial object,only the keys you set are overridden; everything else defaults to English.

Spanish example tsx
<AppKit
  workspace="your-workspace"
  appId="your-app-id"
  labels={{
    signInTitle: "Iniciar sesión",
    signIn: "Entrar",
    signingIn: "Iniciando sesión…",
    createAccount: "Crear cuenta",
    forgotPassword: "¿Olvidaste tu contraseña?",
    emailLabel: "Correo electrónico",
    emailPlaceholder: "[email protected]",
    passwordLabel: "Contraseña",
    passwordPlaceholder: "Ingresa tu contraseña",
  }}
>
  {/* ... */}
</AppKit>

Available Keys

All keys are optional. Defaults shown in parentheses.

Headings

signInTitle "Sign in"
Main sign-in screen heading.
setPasswordTitle "Set password"
Set/reset password heading.
setYourPasswordTitle "Set your password"
Password reset flow heading.
checkYourEmailTitle "Check your email"
OTP email sent heading.
createAccountTitle "Create account"
Registration heading.
verifyYourEmailTitle "Verify your email"
Email verification heading.
setAPasswordTitle "Set a password"
Optional password setup heading (after OTP/Google sign-up).
twoFactorTitle "Two-factor authentication"
TOTP verification heading.

Descriptions

enterEmailAndPassword "Enter your email and password."
Sign-in form description.
enterEmailForCode "Enter your email to receive a sign-in code."
OTP sign-in description.
enterEmailForPasswordCode "Enter your email to receive a code for setting your password."
Password reset description.
weSentCodeTo "We sent a 6-digit code to {email}."
OTP sent confirmation. Use {email} placeholder.
enterEmailToGetStarted "Enter your email to get started."
Registration description.
setPasswordOptional "Add a password so you can also sign in with email and password. You can skip this for now."
Optional password setup description.
enterTotpCode "Enter the 6-digit code from your authenticator app."
TOTP input description.
enterBackupCode "Enter one of your backup codes."
Backup code input description.

Labels & Placeholders

emailLabel "Email"
Email input label.
emailPlaceholder "[email protected]"
Email input placeholder.
passwordLabel "Password"
Password input label.
passwordPlaceholder "Enter your password"
Password input placeholder.
newPasswordLabel "New password"
New password input label.
newPasswordPlaceholder "At least 10 characters"
New password input placeholder.
confirmPasswordLabel "Confirm password"
Confirm password input label.
confirmPasswordPlaceholder "Re-enter your password"
Confirm password input placeholder.
codeLabel "6-digit code"
OTP code input label.
codePlaceholder "123456"
OTP code input placeholder.
backupCodeLabel "Backup code"
Backup code input label.
backupCodePlaceholder "Enter backup code"
Backup code input placeholder.

Buttons & Links

signInWithGoogle "Sign in with Google"
Google sign-in button text.
signingInWithGoogle "Signing in..."
Google sign-in loading text.
signIn "Sign in"
Sign-in button text.
signingIn "Signing in…"
Sign-in loading text.
forgotPassword "Forgot password?"
Forgot password link text.
createAccount "Create account"
Create account button/link text.
continueButton "Continue"
Continue button text.
sending "Sending…"
Sending state text.
sendCode "Send code"
Send code button text.
setPassword "Set password"
Set password button text.
settingPassword "Setting password…"
Set password loading text.
verify "Verify"
Verify button text.
verifying "Verifying…"
Verify loading text.
creatingAccount "Creating account…"
Creating account loading text.
backToSignIn "Back to sign in"
Back to sign-in link text.
changeEmail "Change email"
Change email link text.
back "Back"
Back button text.
skipForNow "Skip for now"
Skip optional password setup.
useAuthenticatorCode "Use authenticator code"
Switch to TOTP code input.
useBackupCode "Use a backup code"
Switch to backup code input.
keepMeSignedIn "Keep me signed in"
Checkbox label for persistent session.
alreadyHaveAccount "Already have an account? Sign in"
Link to switch to sign-in from registration.
logOutAllSessions "Log out of all other sessions"
Checkbox label on password sign-in.

Messages

checkEmailForCode "Check your email for a 6-digit code."
Success message after sending OTP.
checkEmailForPasswordResetCode "Check your email for a 6-digit code to set your password."
Success message after sending password reset OTP.
passwordSetSuccess "Password set successfully! You can now sign in."
Success message after setting password.
tooManyRequests "Too many requests. Please try again in {minutes} minute{s}."
Rate limit error. Use {minutes} and {s} placeholders.
tooManyRequestsGeneric "Too many requests. Please wait a bit and try again."
Generic rate limit error (no Retry-After header).
invalidCredentials "Invalid email or password."
Wrong credentials error.
accessDenied "Access denied."
403 error message.
accessDeniedNeedAccount "Access denied. You may need to create an account first."
403 with account creation hint.
requestFailed "Request failed."
Generic request failure.
requestFailedWithStatus "Request failed ({status})."
Request failure with status code. Use {status} placeholder.

Password Strength

strengthTooShort "Too short"
Password shorter than 10 characters.
strengthWeak "Weak"
Score 2 of 5.
strengthFair "Fair"
Score 3 of 5.
strengthGood "Good"
Score 4 of 5.
strengthStrong "Strong"
Score 5 of 5.

Misc

orDivider "Or sign in with"
Divider above the OAuth provider row when an email form is also shown.
oauthOnlyPrompt "Choose how you'd like to continue."
Subtitle shown under the title when the app is configured for OAuth-only sign-in (no email form).
oauthOnlyRegisterHint "Don't have an account? It'll be created on your first sign-in."
Footer hint shown in OAuth-only mode when self-registration is enabled, in place of the "Create account" link.
newHerePrompt "New here?"
Lead-in text in the footer band before the "Create account" link.
enterCodeFromEmail "Enter the code from the email."
Hint below OTP input.
codeMustBe6Digits "Code must be 6 digits."
OTP validation error.
passwordsDoNotMatch "Passwords do not match"
Confirm password mismatch error.

The "Powered by ManyRows" branding is not overridable.

Troubleshooting

runtime not found error
Script didn't load,check src URL or bundle the runtime manually
onReady never fires symptom
App ID invalid or not found,check onError for details
403 after login symptom
User lacks access,grant roles or check App configuration
401 loop symptom
Token invalid,call setToken(null) and re-auth

Client API

For browser and mobile apps. Base URL: /x/{wsSlug}/apps/{appId}

Authentication

POST /auth Public

Request an OTP login code. Send { "email": "[email protected]", "appId": "app-uuid" } and a 6-digit code will be emailed. Requires appId. This is the default auth method used by AppKit.

POST /auth/verify Public

Verify OTP code and start a session. Send { "email": "[email protected]", "code": "123456" } and receive a JWT session token.

Request body json
{ "email": "[email protected]", "code": "123456", "rememberMe": false }
POST /auth/password Public

Login with email and password. Send { "email": "[email protected]", "password": "...", "appId": "app-uuid" }. Requires appId.

Request body json
{ "email": "[email protected]", "password": "<password>", "rememberMe": false }
rememberMe,what it does text
Optional boolean (default false) on every login endpoint.

When true:
- The session's expires_at is set to max(app.SessionTTL,
  app.RememberMeTTL) at login,app.RememberMeTTL falls back to
  30 days when not overridden in the admin UI.
- The flag is persisted on the session row, so refresh-token
  rotation keeps applying the long TTL,without this, the first
  refresh would shrink it back to the app default.
- The TOTP verify step recovers rememberMe from the signed
  challenge token, not the request body, so a stolen challenge
  can't be used to upgrade the session length.

Wired to AppKit's "Keep me signed in" checkbox out of the box.
POST /auth/forgot-password Public

Request a password reset code. Send { "email": "[email protected]", "appId": "app-uuid" } and a 6-digit reset code will be emailed. Requires appId.

POST /auth/reset-password Public

Reset password with code. Send { "email": "...", "code": "123456", "newPassword": "newpassword", "appId": "app-uuid" }. Requires appId. Optionally include "logoutAll": false to keep existing sessions (defaults to revoking all). Apps with password reuse prevention enabled reject any of the 5 most recently used passwords with error.passwordRecentlyUsed (the reset code is consumed; request a new one to retry).

POST /auth/google Public

Google OAuth login (web). Send the Google ID token from GSI popup.

Request body json
{ "credential": "<google-id-token>", "rememberMe": false }
GET /auth/google/authorize Public

Start Google OAuth Authorization Code Flow. Returns a URL to redirect the user to Google sign-in. Requires client secret to be configured.

Response json
{ "url": "https://accounts.google.com/o/oauth2/v2/auth?...", "state": "<csrf-state>" }
POST /auth/google/callback Public

Complete Google OAuth Authorization Code Flow. Exchange the authorization code from Google for session tokens.

Request body json
{ "code": "<auth-code-from-google>", "state": "<state-from-authorize>" }
POST /auth/register Public

Register a new account. Sends an OTP code to the provided email. Requires appId and email. The app must have registration enabled.

POST /auth/refresh Public

Refresh the access token. Send { "refreshToken": "..." } and receive a new token pair. Response includes expiresIn (access-token TTL, seconds) and refreshExpiresIn (refresh-token TTL, seconds),cookie-mode SDKs use the latter as the refresh-cookie Max-Age so remember-me sessions stay alive client-side as long as the server-side refresh token.

POST /auth/request-magic-link Public

Email a one-time login link to the user. Send { "email": "[email protected]", "rememberMe": false }. Always returns 200 unless rate-limited or the input is malformed,the response never leaks whether the email exists. Requires the app to have an appUrl configured so the link can route the user back to the SDK.

GET /auth/magic-link Public

Consume a magic-link token (the link delivered by /auth/request-magic-link opens here). On success the SDK lands on the app's appUrl with a freshly-minted session. Tokens are single-use and short-lived.

POST /auth/totp/verify Public

Verify a TOTP code (or backup code) after password or Google login returned totpRequired: true. Send the challengeToken from the login response and a 6-digit code. Returns a JWT session on success.

Request body json
{ "challengeToken": "<token-from-login>", "code": "123456" }
Note on rememberMe at the TOTP step text
The TOTP verify endpoint does NOT accept rememberMe in the
body. It is recovered from the signed challenge token issued
by the originating /auth/password or /auth/google call, so a
stolen challenge can't be used to upgrade session length.
POST /a/logout JWT

Invalidate the current session. Clears the JWT on the server side.

Identity

GET /a/me JWT

Combined identity for the current user: profile + app-scoped claims (roles, permissions) in one round trip. app.name is a server-computed display label (product name + environment) — there is no editable app name.

Response json
{
  "user": {
    "id": "90f2f5fe-6fa5-4c66-827a-2ec0127f709b",
    "email": "[email protected]",
    "enabled": true,
    "emailVerifiedAt": "2026-04-17T22:22:17Z",
    "passwordSetAt": "2026-04-17T22:22:17Z",
    "totpEnabled": false,
    "source": "invited"
  },
  "workspaceName": "Acme",
  "app": {
    "name": "Acme (Dev)",
    "hasAccess": true,
    "roles": ["admin"],
    "permissions": ["posts:read", "posts:write"],
    "transportMode": "local"
  }
}

Permissions

GET /a/check-permission JWT

Check whether the current user has a specific permission in this app. Returns { "allowed": true/false }.

permission query string
The permission slug to check (e.g. posts:read)
Response body json
{
  "allowed": true,
  "permission": "posts:read"
}

Profile

POST /a/set-password JWT

Set or update the account password. Send { "password": "..." }. Minimum 10 characters. If the user already has a password, currentPassword is also required. Returns { "ok": true } on success. When the app has password reuse prevention enabled (admin: Apps → Security → Passwords), reusing any of the 5 most recent passwords fails with error.passwordRecentlyUsed.

GET /a/me/fields JWT

Returns all client-visible user fields and their values for the current user.

PATCH /a/me/fields JWT

Update user-editable fields for the current user. Send a JSON object with field keys and values.

Request body json
{ "first_name": "Alice", "preferred_theme": "dark" }

To reset a forgotten password, use the forgot-password / reset-password flow instead.

Two-Factor Authentication (TOTP)

POST /a/totp/setup JWT

Generate a new TOTP secret for the current user. Returns the secret and an otpauth:// URI for QR code display. The secret is stored but not yet active, must be confirmed via the enable endpoint.

Response json
{
  "secret": "JBSWY3DPEHPK3PXP",
  "uri": "otpauth://totp/Manyrows:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Manyrows"
}
POST /a/totp/enable JWT

Verify a TOTP code and activate 2FA. Send { "code": "123456" }. Returns an array of one-time backup codes. TOTP must have been set up first via the setup endpoint.

Response json
{ "backupCodes": ["a1b2c3d4", "e5f6a7b8", "..."] }
POST /a/totp/disable JWT

Disable 2FA. Requires password confirmation. Send { "password": "..." }. Returns { "ok": true }.

POST /a/totp/backup-codes JWT

Regenerate backup codes. Requires password confirmation. Send { "password": "..." }. Returns a new set of one-time backup codes (replaces the old ones).

When 2FA is enabled, password and Google login will return totpRequired: true with a challengeToken. Use /auth/totp/verify to complete the login.

Sessions

GET /a/me/sessions JWT

List all active sessions for the current user. Each entry includes the session ID, creation time, last seen time, user agent, IP, and whether it is the current session.

Response json
{
  "sessions": [
    {
      "id": "uuid",
      "createdAt": "2025-01-15T10:30:00Z",
      "lastSeenAt": "2025-01-15T12:00:00Z",
      "userAgent": "Mozilla/5.0 ...",
      "ip": "203.0.113.1",
      "current": true
    }
  ]
}
DELETE /a/me/sessions/{sessionId} JWT

Revoke another session. Cannot revoke the current session, use POST /a/logout instead.

DELETE /a/me/sessions JWT

"Log out everywhere else" — revoke all other sessions for the current user in one call. The session making the request stays signed in. Fires a session.revoked webhook when anything was revoked.

Response json
{ "revoked": 2 }
Per-app session policy text
Five session-lifecycle knobs are configurable per app from the
admin UI (Apps → Sessions → Lifetime). Operators set these; the
SDK reads them through the same auth flows you already use.

  sessionTtlMinutes        Absolute lifetime of a session from
                           creation. The session dies at this hard
                           cap regardless of activity.
                           Default: 7 days (10080).

  rememberMeTtlMinutes     Applied when the user opts into the
                           "Keep me signed in" checkbox at login.
                           The session row's expires_at uses
                           max(sessionTtl, rememberMeTtl), so an
                           app with a long absolute TTL is never
                           shortened by a shorter remember-me.
                           Default: 30 days (43200).

  idleTimeoutMinutes       Refresh is refused when the session
                           hasn't been touched for this long; the
                           session then dies once the current
                           access token expires. Combine with
                           sessionTtl for the banking-style
                           "absolute + idle" pair.
                           Default: off (no idle enforcement).

  accessTokenTtlMinutes    JWT access-token lifetime. Trades the
                           JWT-replay window against refresh-call
                           frequency. Don't drop below ~5 min
                           unless your SDK can handle refresh
                           storms.
                           Default: 15 minutes.

  maxSessionsPerUser       Per-app cap on active sessions per
                           user. Logging in beyond this prunes the
                           oldest session by last_seen_at. Banking
                           apps set 1; productivity tools 10–20.
                           Default: 5.

Operators self-hosting can additionally override the global
defaults via env vars (MANYROWS_*),see the install's README.
Per-app values always win when set.

Email Change

POST /a/me/request-email-change JWT

Request an email change. Requires the current password and the new email address. Sends 6-digit codes to BOTH addresses: an approval code to the current email and a verification code to the new one.

Request body json
{ "password": "current-password", "newEmail": "[email protected]" }
POST /a/me/verify-email-change JWT

Complete the email change with both codes. A mismatch on either code returns the same generic error.invalidCode.

Request body json
{ "oldCode": "222333", "newCode": "123456" }

Account Deletion

POST /a/me/delete JWT

Permanently delete the current user's account. Requires the current password for confirmation. Removes the user, all auth scopes, and revokes all sessions.

Request body json
{ "password": "current-password" }

Runtime Data

GET /a/runtime JWT

Get app data. Returns feature flags (client-scoped) and config values (public) for the authenticated user.

Client API only returns client-scoped flags and public config. Use the Server API to access server-scoped flags and private config.

JWT Authentication

After logging in, include the JWT in all authenticated requests:

Authorization header http
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

Server API

For backend services. Base URL: /x/your-workspace/api/v1 (all endpoint paths below are relative to it). Requests are rate limited per API key; over-budget calls get 429 with a Retry-After header.

Scope: app members

Every endpoint is scoped to users who are members of this app. A key for one app only sees and acts on users who have signed in to it — a user who exists only in a sibling app that shares the same user pool is not visible (returns 404). The user pool shares credentials across apps; it is not an access boundary. For isolation between apps, give them separate user pools.

API Key Authentication

Include your API key in the X-API-Key header. Keys are created in workspace settings.

Request header http
X-API-Key: mr_a1b2c3d4_yourSecretKey

Keep it secret

Never expose API keys in client-side code or public repositories.

Delivery

GET /apps/{appId} API Key

Get all feature flags and config values for the app. Includes server-scoped flags and private config. The app ID maps to a specific product and environment.

appId uuid
Your app ID (found in the Apps section of your product)

The response carries an ETag. Send it back as If-None-Match when you poll and you'll get 304 Not Modified with no body whenever the config and flags are unchanged — cheap to poll frequently.

Permission Check

GET /apps/{appId}/check-permission API Key

Check whether a specific user has a permission in this app. Use this from your backend to authorize actions on behalf of a user.

accountId query string (uuid)
The user ID (a member of this app) to check permissions for. Param is named accountId for historical reasons; it accepts a user ID.
permission query string
The permission slug to check (e.g. posts:read)
Response body json
{
  "allowed": true,
  "permission": "posts:read",
  "accountId": "uuid"
}

Roles & permissions catalog

Discover the role and permission slugs you can assign — useful before calling PUT /users/{userId}/roles.

GET /apps/{appId}/roles API Key

List the product's roles, each with the permission slugs it grants. Returns { "roles": [{ "slug", "name", "permissions": ["..."] }] }.

GET /apps/{appId}/permissions API Key

List the product's permissions. Returns { "permissions": [{ "slug", "name" }] }.

You can also define RBAC programmatically:

POST /apps/{appId}/roles API Key

Create a role (body { "slug", "name", "permissions": ["..."] }). GET / PATCH / DELETE /apps/{appId}/roles/{slug} read, update (name and/or permissions), and delete it.

POST /apps/{appId}/permissions API Key

Create a permission (body { "slug", "name" }). GET / PATCH / DELETE /apps/{appId}/permissions/{slug} read, rename, and delete it.

List members

List the app's members. Returns each member's email, name, and role slugs.

GET /apps/{appId}/users API Key

List members with roles in this app. Supports pagination and a search substring filter. (To fetch one user, see the Users section below.)

page int (query)
Page number (0-based, default 0)
pageSize int (query)
Results per page (default 50, max 200)
search string (query)
Filter by email substring (case-insensitive)
Response body json
{
  "members": [
    {
      "userId": "uuid",
      "email": "[email protected]",
      "name": "Alice Example",
      "enabled": true,
      "emailVerifiedAt": "2025-01-15T10:30:00Z",
      "passwordSetAt": "2025-01-15T10:30:00Z",
      "lastLoginAt": "2025-01-18T08:12:00Z",
      "source": "registered",
      "addedAt": "2025-01-15T10:30:00Z",
      "roles": ["editor", "reviewer"]
    }
  ],
  "total": 42,
  "page": 0,
  "pageSize": 50
}

Users

Look up a user by email or ID. Returns user info, roles, permissions, and field values.

GET /apps/{appId}/[email protected] API Key

Find a member of this app by email.

GET /apps/{appId}/users/{userId} API Key

Find a user by ID.

Response json
{
  "user": {
    "id": "uuid",
    "email": "[email protected]",
    "enabled": true,
    "emailVerifiedAt": "2025-01-15T10:30:00Z",
    "passwordSetAt": "2025-01-15T10:30:00Z",
    "totpEnabled": false,
    "source": "registered"
  },
  "roles": ["editor"],
  "permissions": ["read", "write"],
  "fields": [
    {
      "id": "uuid",
      "userId": "uuid",
      "userFieldId": "uuid",
      "value": "Alice",
      "updatedAt": "2025-01-15T10:30:00Z",
      "updatedBy": "uuid"
    }
  ]
}

Provision a user

POST /apps/{appId}/users API Key

Create a user in the app's pool and add them to this app, in one call. If a user with that email already exists in the pool it's reused and ensured to be a member — the call is idempotent. Returns 201 when a new identity was created, 200 when an existing one was reused (see the created flag). On re-provision, a non-empty roles list replaces the user's roles; omitting it preserves them (use PUT …/roles to clear).

Request body json
{ "email": "[email protected]", "emailVerified": true, "roles": ["editor"] }
email string (required)
The user's email address.
emailVerified bool
Mark the address verified (you vouch for it). Defaults to unverified.
roles string[] (slugs)
Roles to assign in this app.
sendInvite bool
Email the user a branded invitation after provisioning (requires an App URL; the response's invited flag says whether it sent).

Provision users in bulk

POST /apps/{appId}/users:batch API Key

Provision up to 100 users in one call, all with the same optional roles (body { "emails": [...], "emailVerified": false, "roles": [...] }). Roles are resolved once — a bad slug fails the whole request — then each email is provisioned independently and reported in results, so one bad email doesn't sink the rest. Idempotent per email.

Response json
{ "results": [
  { "email": "[email protected]", "userId": "…", "created": true },
  { "email": "bad", "error": "error.invalidEmail" }
] }

Replace a user's roles

PUT /apps/{appId}/users/{userId}/roles API Key

Replace the user's roles in this app with the given set (this is a full replace, not a merge). Send role slugs — the same slugs the read endpoints return. An empty array clears all roles and revokes the user's active sessions for the app. Returns the resulting roles. The user must already be a member of this app.

Request body json
{ "roles": ["editor", "reviewer"] }
POST /apps/{appId}/users/{userId}/roles/{slug} API Key

Grant a single role without touching the user's others (idempotent); DELETE at the same path revokes it. Both return the resulting role slugs — handy for incremental changes that would otherwise need a read-modify-write of the full set.

Direct permissions

GET /apps/{appId}/users/{userId}/permissions API Key

List the user's direct permission overrides (slugs) — per-user grants on top of whatever their roles already provide.

PUT /apps/{appId}/users/{userId}/permissions API Key

Replace the user's direct permissions (full set of slugs, body { "permissions": ["posts:read"] }). Returns the resulting slugs.

Revoke a user's sessions

DELETE /apps/{appId}/users/{userId}/sessions API Key

Force-logout: delete all of the user's sessions for this app. Use it when deactivating a user or after a credential change. The user must be a member of the app. Returns { "revoked": <count> }.

GET /apps/{appId}/users/{userId}/sessions API Key

List the user's active sessions for this app ({ "sessions": [{ "id", "createdAt", "lastSeenAt", "expiresAt", "userAgent", "ip" }] }).

DELETE /apps/{appId}/users/{userId}/sessions/{sessionId} API Key

Revoke a single session.

Set / clear password

PUT /apps/{appId}/users/{userId}/password API Key

Set or replace the user's password (body { "password": "…" }), enforced against the app's password policy. Existing sessions are left intact — revoke them separately to force re-login.

DELETE /apps/{appId}/users/{userId}/password API Key

Clear the user's password — email+password sign-in is disabled until a new one is set (OAuth and passkeys still work).

Email verification

PUT /apps/{appId}/users/{userId}/email-verified API Key

Mark the user's email verified or unverified (body { "verified": true }) — use when your own flow has confirmed or invalidated the address. It's a pool-level attribute, so it applies across every app sharing the pool. Marking unverified is blast-radius-wide: if an app requires verified email, the user is blocked from all apps in the pool until re-verified — use it deliberately.

PUT /apps/{appId}/users/{userId}/enabled API Key

Enable/disable the identity pool-wide (body { "enabled": false }) — a ban distinct from per-app suspend. Disabling blocks sign-in to every app sharing the pool and revokes all the user's sessions.

PUT /apps/{appId}/users/{userId}/email API Key

Change the user's email (body { "email": "[email protected]" }) and mark it verified — you vouch for it. The address must be unique in the pool (409 otherwise).

Account recovery & credentials

DELETE /apps/{appId}/users/{userId}/totp API Key

Reset (disable) the user's 2FA so a user who lost their authenticator can re-enroll.

POST /apps/{appId}/users/{userId}/unlock API Key

Clear a failed-login lockout, restoring sign-in immediately.

GET /apps/{appId}/users/{userId}/identities API Key

List the user's linked SSO providers. Unlink one with DELETE .../identities/{provider}.

GET /apps/{appId}/users/{userId}/passkeys API Key

List the user's passkeys. Remove one with DELETE .../passkeys/{passkeyId}.

Authentication history

GET /apps/{appId}/users/{userId}/auth-logs API Key

A member's authentication-event history for this app — sign-ins, password changes, status changes — newest first. Paginated via page / pageSize; returns { "logs": [...], "total", "page", "pageSize" }.

GET /apps/{appId}/auth-logs API Key

The same history app-wide (all users) — for ingesting auth events into your own SIEM/analytics. Filter with since / until (RFC3339, for incremental pulls) and outcome (success/failure). Add format=csv to download the page as CSV instead of JSON (works on both auth-log endpoints).

Remove a user

DELETE /apps/{appId}/users/{userId} API Key

Remove the user from this app — drops their membership, roles, permission overrides, and sessions for the app. If that leaves the user in no other app, the pool identity is deleted too; otherwise it's kept (still used by another app sharing the pool). The response says which happened: { "removedFromApp": true, "identityDeleted": false }.

Suspend / re-enable a user

PATCH /apps/{appId}/users/{userId} API Key

Set the user's status in this app: disabled blocks them from signing in to this app (and revokes their active sessions for it); active restores access. Per-app — a user shared across apps in one pool is unaffected elsewhere.

Request body json
{ "status": "disabled" }

Generate a sign-in link

POST /apps/{appId}/users/{userId}/magic-link API Key

Generate a one-time passwordless sign-in link for a member and return it for your backend to deliver (e.g. in your own onboarding email). Requires the app's primary auth method to be Magic Link and an App URL to be set. Optional body { "rememberMe": true }. Returns { "url": "...", "expiresAt": "..." }; the link expires in 15 minutes and is single-use.

User Fields

Read user field definitions, and read or write per-user values. Field definitions are managed in the admin panel; values can be set from your backend.

GET /apps/{appId}/user-fields API Key

List all user field definitions.

GET /apps/{appId}/user-fields/users/{userId} API Key

List a single user's field values.

PUT /apps/{appId}/user-fields/{userFieldId}/users/{userId} API Key

Set a user's value for a field. The value is validated against the field's type (string, bool, or date). Body: { "value": ... }.

DELETE /apps/{appId}/user-fields/{userFieldId}/users/{userId} API Key

Clear a user's value for a field.

Config & feature flags

PUT /apps/{appId}/config/{configKey} API Key

Set this app's value for a public or private config key (body { "value": ... }, matching the key's type). Secret keys are rejected — their values are sealed client-side, so set them in the dashboard.

DELETE /apps/{appId}/config/{configKey} API Key

Clear this app's value for a config key.

PUT /apps/{appId}/features/{flagKey} API Key

Set this app's feature-flag override (body { "enabled": true, "roles": ["beta"] }); roles optionally targets role slugs.

DELETE /apps/{appId}/features/{flagKey} API Key

Clear this app's override so the flag falls back to its default.

POST /apps/{appId}/config-keys API Key

Define a config key — the schema itself (body { "key", "exposure", "valueType" }). GET (list) / GET / PATCH / DELETE /apps/{appId}/config-keys/{key} read, update, and delete them. (The endpoints above set per-app values.)

POST /apps/{appId}/feature-flags API Key

Define a feature flag (body { "key", "scope", "defaultEnabled" }). GET (list) / GET / PATCH / DELETE /apps/{appId}/feature-flags/{key} read, update, and delete them. (The endpoints above set per-app overrides.)

The effective config and flags are read in one shot via the delivery response (GET /apps/{appId}/). To inspect a single raw value/override for read-modify-write, GET /apps/{appId}/config/{key} and GET /apps/{appId}/features/{key} return exactly what you set (404 if nothing is set; secret values stay sealed).

Webhooks

Register endpoints that ManyRows POSTs auth events to (sign-ups, logins, password changes, passkey changes, etc.) — up to 10 per app. The HMAC signing secret is returned once on create (rotate it anytime with POST /apps/{appId}/webhooks/{webhookId}/rotate-secret) and redacted on every later read. Subscribable events: user.register, user.created, user.login, user.logout, user.password_change, user.password_reset, user.email_change, user.passkey_register, user.passkey_delete, user.delete.

GET /apps/{appId}/webhooks API Key

List the app's webhooks. Register one with POST /apps/{appId}/webhooks (body { "url": "...", "events": [...] }).

PATCH /apps/{appId}/webhooks/{webhookId} API Key

Update a webhook's URL, events, status, or description. Fetch one with GET and remove it with DELETE at the same path.

Response Format

Response body json
{
  "workspaceId": "...",
  "projectId": "...",
  "appId": "...",
  "updatedAt": "2024-01-15T10:30:00Z",

  "config": {
    "public": [{ "key": "app_name", "type": "string", "value": "My App" }],
    "private": [{ "key": "rate_limit", "type": "int", "value": 1000 }],
    "secrets": [{ "key": "stripe_key", "type": "string", "isSet": true }]
  },

  "flags": {
    "client": [{ "key": "new_ui", "enabled": true }],
    "server": [{ "key": "new_algorithm", "enabled": false }]
  }
}

Config buckets

  • public → safe for clients
  • private → server only
  • secrets → isSet only, no values

Flag scopes

  • client → frontend flags
  • server → backend flags

Examples

cURL bash
curl "https://auth.your-domain.com/x/your-workspace/api/v1/apps/your-app-id" \
  -H "X-API-Key: $MANYROWS_API_KEY"
Node.js javascript
const res = await fetch(
  "https://auth.your-domain.com/x/your-workspace/api/v1/apps/your-app-id",
  { headers: { "X-API-Key": process.env.MANYROWS_API_KEY } }
);
const { flags, config } = await res.json();
Python python
import os, requests

res = requests.get(
    "https://auth.your-domain.com/x/your-workspace/api/v1/apps/your-app-id",
    headers={"X-API-Key": os.environ["MANYROWS_API_KEY"]}
)
data = res.json()
Go go
package main

import (
    "encoding/json"
    "net/http"
    "os"
)

func main() {
    req, _ := http.NewRequest("GET",
        "https://auth.your-domain.com/x/your-workspace/api/v1/apps/your-app-id", nil)
    req.Header.Set("X-API-Key", os.Getenv("MANYROWS_API_KEY"))

    res, _ := http.DefaultClient.Do(req)
    defer res.Body.Close()

    var data map[string]any
    json.NewDecoder(res.Body).Decode(&data)
}
Java java
import java.net.http.*;
import java.net.URI;

var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
    .uri(URI.create("https://auth.your-domain.com/x/your-workspace/api/v1/apps/your-app-id"))
    .header("X-API-Key", System.getenv("MANYROWS_API_KEY"))
    .GET()
    .build();

var response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());

Organizations

Turn your app into a multi-tenant app: end users belong to one or more organizations (tenants) within the same app, each with its own members and tiers. ManyRows owns organization identity, membership, and tiers; your backend provisions and manages them through the API-key server endpoints below.

Overview

An organization is an app-scoped tenant. A typical SaaS shape: your customer "Acme" signs in once, creates an organization, and adds teammates — all inside your single ManyRows app. The same user can belong to several organizations and has an independent tier in each.

Enabling organizations

Organizations are off by default. Turn them on per app in the admin dashboard under Apps → (your app) → Organizations, toggle Organizations enabled and save. Until it is on, POST /organizations returns 409. The same page lists every organization in the app, with read-only member views and rename/archive controls for operators.

Identity & membership

ownerUserId and userId are the end user's ManyRows user id — the same value as the sub claim in their JWT. A user must already be a member of the app (i.e. have signed in at least once) before they can be added to an organization. To bring in someone who doesn't have an app account yet, send them an email invite instead (see Invites).

Member tiers

owner tier
Full control of the organization. An organization always keeps at least one active owner — removing or demoting the last owner returns 409.
admin tier
Manage members and organization settings.
member tier
Belongs to the organization; in-app permissions come from the user's app roles.

Authorizing requests

For an org-enabled app, a user's effective roles and permissions are resolved within their active organization, and ManyRows re-checks that membership on every request — a removed member or archived organization loses access immediately. The lightweight membership gate GET /organizations/{orgId}/members/{userId} returns the tier or 404, so your backend can use it as a per-request authorization check.

Provisioning

Create and manage organizations. All paths are relative to the server API base (see API Key Auth) and authenticate with your app's API key.

POST /apps/{appId}/organizations API Key

Create an organization and seed ownerUserId as its first active owner. The owner must be a member of this app. Returns 409 if organizations are not enabled for the app.

name string (body, required)
Display name (max 200 chars).
slug string (body, optional)
URL-safe handle. Derived from the name when omitted, and made unique within the app automatically (acme, acme-2, …).
ownerUserId uuid (body, required)
The end user to seed as owner (their JWT sub).
Response body json · 201
{
  "id": "uuid",
  "appId": "uuid",
  "name": "Acme Inc",
  "slug": "acme-inc",
  "status": "active"
}
GET /apps/{appId}/organizations?userId={userId} API Key

List the active organizations a user is an active member of, with their tier in each. Use this to render a user's org switcher.

Response body json
{
  "organizations": [
    { "id": "uuid", "name": "Acme Inc", "slug": "acme-inc", "orgRole": "owner" }
  ]
}
GET /apps/{appId}/organizations/{orgId} API Key

Fetch one organization. Returns 404 if it does not belong to this app or is not active.

PATCH /apps/{appId}/organizations/{orgId} API Key

Rename an organization. Body: { "name": "New Name" }.

DELETE /apps/{appId}/organizations/{orgId}?actorUserId={userId} API Key

Permanently delete an organization and its memberships. Returns 204. Owner-only: pass actorUserId — the acting end user's JWT sub — who must be an active owner of the organization. The call is rejected with 400 if actorUserId is missing and 403 if that user is not an owner. (Operators can instead archive an organization from the admin dashboard, which keeps the record and is reversible.)

Members

Manage who belongs to an organization and their tier. Members must already be members of the app.

GET /apps/{appId}/organizations/{orgId}/members API Key

List every member of the organization with their email, tier, and status.

Response body json
{
  "members": [
    {
      "userId": "uuid",
      "email": "[email protected]",
      "orgRole": "owner",
      "status": "active"
    }
  ]
}
POST /apps/{appId}/organizations/{orgId}/members API Key

Add a member by userId or email. The user must already be a member of the app — an email with no app account returns 409 (ask them to sign in first).

userId uuid (body)
The user to add. Provide this or email.
email string (body)
Resolve the user by email instead of id.
orgRole string (body)
Tier to assign — owner, admin, or member (defaults to member).
Response body json · 201
{
  "userId": "uuid",
  "email": "[email protected]",
  "orgRole": "admin",
  "status": "active"
}
GET /apps/{appId}/organizations/{orgId}/members/{userId} API Key

Return a single member's tier, or 404 if they are not an active member. Designed as a fast per-request authorization gate. Response: { "userId": "uuid", "orgRole": "admin", "status": "active" }.

PATCH /apps/{appId}/organizations/{orgId}/members/{userId} API Key

Change a member's tier. Body: { "orgRole": "admin" }. Demoting the last owner returns 409. Returns 204.

DELETE /apps/{appId}/organizations/{orgId}/members/{userId} API Key

Remove a member from the organization. Removing the last owner returns 409. Returns 204.

Invites

Invite someone to an organization by email — including people who don't have an app account yet. The invite email contains a one-click link that joins them to the organization and signs them in (a new account is created on the spot, like a magic link). The auth server owns the email + accept flow; your backend only creates, lists, and revokes invites.

POST /apps/{appId}/organizations/{orgId}/invites API Key

Create a pending invite and email it. The app must have its App URL configured (the accept link points back to it, else 400). Returns 409 if a pending invite already exists for that email, or if they're already a member.

email string (body, required)
The invitee's email address.
orgRole string (body, optional)
Tier to grant on accept — owner, admin, or member (defaults to member).
invitedByUserId uuid (body, optional)
The inviting user, recorded on the invite.
Response body json · 201
{
  "id": "uuid",
  "email": "[email protected]",
  "orgRole": "admin",
  "status": "pending",
  "createdAt": "2026-06-07T10:00:00Z",
  "expiresAt": "2026-06-14T10:00:00Z"
}
GET /apps/{appId}/organizations/{orgId}/invites API Key

List the organization's pending invites.

Response body json
{
  "invites": [
    {
      "id": "uuid",
      "email": "[email protected]",
      "orgRole": "admin",
      "status": "pending",
      "invitedByEmail": "[email protected]",
      "createdAt": "2026-06-07T10:00:00Z",
      "expiresAt": "2026-06-14T10:00:00Z"
    }
  ]
}
DELETE /apps/{appId}/organizations/{orgId}/invites/{inviteId} API Key

Revoke a pending invite (the link stops working). Returns 204, or 404 if there's no pending invite with that id.

Accepting an invite

The invitee clicks the link in the email; the auth server validates the token, creates their account + app membership if they don't have one yet (no prior sign-up needed), adds them to the organization with the invited tier, marks the invite accepted, and signs them in. Your app implements no accept endpoint — it's auth-server-hosted. The organization simply appears for the user (in GET /organizations?userId=) once they accept. Invites expire after 7 days; a revoked or expired link is refused.

Self-serve (AppKit)

The endpoints above are the server API (API key). Your app's end-users can also manage their own organizations directly from AppKit, authenticated by their session — no backend round-trip. These live under /a/organizations, are available only when the app has organizations enabled, and enforce the same owner / admin / member matrix for the acting user (see Overview). An org the caller can't see returns 404; an action above the caller's tier returns 403.

POST /a/organizations JWT

Create an organization; the caller is seeded as owner. Allowed only when the app's org_creation_policy is self_serve — otherwise 403.

GET /a/organizations JWT

List the organizations the caller belongs to, each with their tier.

PATCH /a/organizations/{orgId} JWT

Rename the organization. Requires owner or admin.

DELETE /a/organizations/{orgId} JWT

Archive the organization (reversible; members lose access). owner only. Permanent delete and restore stay operator-side (admin dashboard / server API).

GET /a/organizations/{orgId}/members JWT

List members with their tiers. Any active member.

PATCH /a/organizations/{orgId}/members/{userId} JWT

Change a member's tier (orgRole) and/or project roles (roleIds, validated against the app's role catalog). owner/admin; only an owner may grant or alter owner, admins can't act on an owner, and demoting the last owner returns 409.

DELETE /a/organizations/{orgId}/members/{userId} JWT

Remove a member (owner/admin) or leave the org (self). Admins can't remove an owner; removing the last owner returns 409.

POST /a/organizations/{orgId}/invites JWT

Email an invite (email, orgRole, optional roleIds). owner/admin; an admin can't invite an owner.

GET /a/organizations/{orgId}/invites JWT

List pending invites. owner/admin.

DELETE /a/organizations/{orgId}/invites/{inviteId} JWT

Revoke a pending invite. owner/admin.


Webhooks

When a webhook is configured, every delivery includes a cryptographic signature so your server can verify the request is authentic.

Request Headers

X-Webhook-Signature string
HMAC-SHA256 hex digest of the signed string <timestamp>.<body> (the X-Webhook-Timestamp value, a literal ., then the raw request body), prefixed with sha256=.
X-Webhook-Timestamp string
Unix timestamp (seconds) of when the delivery was signed. It is part of the signed string, so include it when recomputing the signature. Reject deliveries whose timestamp falls outside a tolerance window (e.g. ±5 minutes) to defeat replay.
X-Webhook-Event string
The event name that triggered the delivery (e.g. user.login, user.password_change).
X-Webhook-Delivery uuid
A unique UUID identifying this delivery. Use it for idempotency or debugging.

Verifying Signatures

To verify a webhook delivery: read the X-Webhook-Timestamp header, build the signed string <timestamp>.<body>, compute its HMAC-SHA256 using your webhook secret, prepend sha256=, then compare the result to the X-Webhook-Signature header. Also reject the delivery if the timestamp is outside your tolerance window.

Always use a constant-time comparison function (e.g. crypto.timingSafeEqual, hmac.compare_digest) to prevent timing attacks.

Node.js javascript
const crypto = require("crypto");

function verifyWebhook(secret, payload, timestamp, signatureHeader) {
  const signed = timestamp + "." + payload;
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(signed)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signatureHeader)
  );
}
Python python
import hashlib, hmac

def verify_webhook(secret: str, payload: bytes, timestamp: str, signature_header: str) -> bool:
    signed = timestamp.encode() + b"." + payload
    expected = "sha256=" + hmac.new(
        secret.encode(), signed, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header)
Go go
package webhook

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
)

func Verify(secret, payload []byte, timestamp, signatureHeader string) bool {
    mac := hmac.New(sha256.New, secret)
    mac.Write([]byte(timestamp))
    mac.Write([]byte("."))
    mac.Write(payload)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return subtle.ConstantTimeCompare(
        []byte(expected), []byte(signatureHeader),
    ) == 1
}
Java java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;

public static boolean verifyWebhook(
    String secret, byte[] payload, String timestamp, String signatureHeader
) throws Exception {
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256"));
    mac.update(timestamp.getBytes());
    mac.update((byte) '.');
    byte[] hash = mac.doFinal(payload);
    StringBuilder hex = new StringBuilder("sha256=");
    for (byte b : hash) hex.append(String.format("%02x", b));
    return MessageDigest.isEqual(
        hex.toString().getBytes(), signatureHeader.getBytes()
    );
}

OIDC Provider

Every ManyRows app can act as a standards-compliant OpenID Connect provider. Point any service that supports "Sign in with OIDC" — Grafana, Outline, a partner product, your own second app — at your app's issuer and it authenticates against your app's user base.

Overview

Each app is its own OIDC issuer. The issuer base is the app's URL on your install — or simply https://<authDomain> when the app has a custom auth domain — and the discovery document lives at the standard well-known path beneath it:

Discovery URL url
https://auth.your-domain.com/x/your-workspace/apps/your-app-id/.well-known/openid-configuration

The document is public and CORS-enabled; relying parties (RPs) that support discovery configure themselves from it. It returns 404 until OIDC is enabled on the app — its presence is the signal that the IdP exists.

What the provider supports

  • Authorization-code flow only, with PKCE (S256) mandatory — also for confidential clients
  • ES256-signed id_tokens and access tokens, verifiable against the install-wide JWKS
  • Scopes: openid (required), email, profile, offline_access (grants a rotating refresh token)
  • prompt=none|login|consent and max_age session-freshness handling
  • Token revocation, introspection, and RP-initiated logout

Configuring a relying party

The app ID is the client_id. Redirect URIs are exact-match against the list registered on the app — no prefix or wildcard matching. A typical RP configuration:

RP configuration text
issuer:        https://auth.your-domain.com/x/your-workspace/apps/your-app-id
client_id:     your-app-id
client_secret: only for confidential clients — omit entirely for public clients
redirect_uri:  https://yourapp.com/auth/callback   # must be registered exactly
scopes:        openid email profile

Endpoints

All paths below are relative to the app's issuer base (https://auth.your-domain.com/x/your-workspace/apps/{appId}, or the custom auth domain root). The one exception is JWKS, which is install-wide — one key set verifies tokens from every app.

GET /.well-known/openid-configuration Public

The issuer metadata (discovery document). Cacheable for 5 minutes.

GET /.well-known/jwks.json Public

The public signing keys (the advertised jwks_uri). Served from the install root or the custom auth domain, not the app base.

GET /oidc/authorize Public

Browser entry point of the code flow. Sends the user through the hosted login (and consent, when required) pages, then 302-redirects back to redirect_uri with ?code= (+ state). Codes are single-use and expire after 60 seconds; replaying a consumed code revokes every token minted from it.

POST /oidc/token

Exchanges a code (authorization_code + PKCE code_verifier) or rotates a refresh token (refresh_token) for tokens. Client auth via HTTP Basic or form fields; public clients send no secret.

GET /oidc/userinfo Bearer

The user's claims, filtered by the token's granted scope: sub always, email / email_verified with the email scope, preferred_username with profile. Also accepts POST.

POST /oidc/revoke

RFC 7009 revocation, client-authenticated. Accepts a refresh or access token; revoking either kills the underlying session, so both stop working.

POST /oidc/introspect

RFC 7662 introspection, client-authenticated. Unknown, expired, or revoked tokens come back as {"active": false} — never an error.

GET /oidc/end-session Public

RP-initiated logout. Revokes the browser's IdP session, then redirects to a registered post_logout_redirect_uri (+ state) or renders a minimal "signed out" page. Also accepts POST.

login_hint

When the RP already knows who is signing in, it can pass login_hint=<email> on the authorize request to prefill the email field of the hosted login page. It is prefill-only: the user can still edit the field, and the hint asserts nothing about identity. Values longer than 254 characters are silently dropped — never an error.

Authorize request with a hint url
GET /oidc/authorize?client_id=your-app-id
    &redirect_uri=https://yourapp.com/auth/callback
    &response_type=code&scope=openid%20email
    &code_challenge=...&code_challenge_method=S256
    &state=...&[email protected]

Server SDKs

Official libraries for integrating manyrows auth into your backend.

Go

The manyrows-auth-go module provides auth middleware for any Go HTTP server. It verifies the user's JWT locally against your install's JWKS, fetches /.well-known/jwks.json once, caches the keys in-process, refetches on a kid mismatch. No per-request round trip. Falls back to the mr_at HttpOnly cookie when no Authorization: Bearer header is present (cookie-mode AppKit).

Install bash
go get github.com/manyrows/manyrows-auth-go
Auth middleware go
import "github.com/manyrows/manyrows-auth-go/auth"

// Apply to your router (works with chi, gorilla/mux, std http)
r.Use(auth.Middleware(manyrowsBaseURL, workspaceSlug, appID))

// In a protected handler,safe behind Middleware
userID := auth.MustUserID(r.Context())

// Or check manually
userID, ok := auth.UserIDFromContext(r.Context())
if !ok {
    http.Error(w, "Unauthorized", 401)
    return
}
Server API Client go
import (
    "context"
    manyrows "github.com/manyrows/manyrows-auth-go"
)

ctx := context.Background()

client, err := manyrows.New(manyrows.Options{
    BaseURL:   "https://auth.your-domain.com",
    Workspace: "your-workspace",
    AppID:     "your-app-id",
    APIKey:    os.Getenv("MANYROWS_API_KEY"),
})
if err != nil { /* ... */ }

// Authorize
allowed, _ := client.HasPermission(ctx, userID, "posts:edit")

// Look up a user (by id or email)
user, _ := client.GetUser(ctx, userID)
user, _ = client.GetUserByEmail(ctx, "[email protected]")
// user.User.Email, user.Roles, user.Permissions, user.Fields

// List members + delivery (config + feature flags)
members, _ := client.ListUsers(ctx, manyrows.ListUsersParams{PageSize: 50})
delivery, _ := client.GetDelivery(ctx)

// --- Organizations (when orgs are enabled on the app) ---

org, _ := client.CreateOrganization(ctx, manyrows.CreateOrganizationInput{
    Name:        "Acme Inc",
    OwnerUserID: userID,
})

orgs, _ := client.ListOrganizationsForUser(ctx, userID) // user's orgs + tier in each

// Add an existing app member directly...
_, _ = client.AddOrganizationMember(ctx, org.ID, manyrows.AddOrgMemberInput{
    Email: "[email protected]", OrgRole: "admin",
})
// ...or email an invite to someone without an account yet (one-click join + sign-in)
invite, _ := client.CreateOrganizationInvite(ctx, org.ID, manyrows.CreateOrgInviteInput{
    Email: "[email protected]", OrgRole: "admin",
})
pending, _ := client.ListOrganizationInvites(ctx, org.ID)

// Typed errors carry the API code: manyrows.IsCode(err, manyrows.CodeInvitePending)

Node.js

The manyrows-node package lives at github.com/manyrows/manyrows-node. Express-style middleware and a Server API client for Node.js 18+. Verifies the user's JWT locally against your install's JWKS (built on jose), with TTL-cached keys and refetch on a kid mismatch. Falls back to the mr_at HttpOnly cookie when no Authorization: Bearer header is present (cookie-mode AppKit). Not yet on npm,install directly from git.

Install from git bash
npm install github:manyrows/manyrows-node
Auth middleware ts
import { expressMiddleware } from "@manyrows/manyrows-node";

app.use(expressMiddleware({
  baseURL: "https://auth.your-domain.com",
  workspaceSlug: "your-workspace",
  appId: "your-app-id",
}));

// In a protected handler,req.manyrowsUserId is set
app.get("/me", (req, res) => {
  res.json({ userId: req.manyrowsUserId });
});

// Or verify manually,supports Bearer header AND mr_at cookie
import { verifyToken, bearerToken, mrAtCookie } from "@manyrows/manyrows-node";
const token =
  bearerToken(req.headers.authorization) ??
  mrAtCookie(req.headers.cookie);
const userId = await verifyToken(token, {
  baseURL: "https://auth.your-domain.com",
  workspaceSlug: "your-workspace",
  appId: "your-app-id",
});
Server API Client ts
import { Client } from "@manyrows/manyrows-node";

const client = new Client({
  baseURL: "https://auth.your-domain.com",
  workspaceSlug: "your-workspace",
  appId: "your-app-id",
  apiKey: process.env.MANYROWS_API_KEY!,
});

// Check permission
const allowed = await client.hasPermission(userId, "posts:edit");

// Look up user (by ID or email)
const user = await client.getUser(userId);
const byEmail = await client.getUserByEmail("[email protected]");
// user.user.email, user.roles, user.permissions, user.fields

// List members
const members = await client.listMembers({ page: 0, pageSize: 50 });

// Get config + feature flags
const delivery = await client.getDelivery();
// delivery.config.public, delivery.flags.server

// List user field definitions
const fields = await client.listUserFields();

Python

The manyrows Python package lives at github.com/manyrows/manyrows-python. Verifies the user's JWT locally against your install's JWKS (built on PyJWT[crypto]), with TTL-cached keys and refetch on a kid mismatch. Sync + async clients, FastAPI / Flask helper snippets in the README, full type hints. Not yet on PyPI,install directly from git.

Install from git bash
pip install git+https://github.com/manyrows/manyrows-python.git
Auth: verify a bearer token python
from manyrows import bearer_token, mr_at_cookie, verify_token

# Inside any framework's request handler,supports Bearer header AND mr_at cookie:
token = (
    bearer_token(request.headers.get("Authorization"))
    or mr_at_cookie(request.headers.get("Cookie"))
)
user_id = verify_token(
    token,
    base_url="https://auth.your-domain.com",
    workspace_slug="your-workspace",
    app_id="your-app-id",
)
# Returns None on invalid / expired tokens; check before use.

# Async variant available as verify_token_async for FastAPI / asyncio.
Server API Client python
from manyrows import Client

client = Client(
    base_url="https://auth.your-domain.com",
    workspace_slug="your-workspace",
    app_id="your-app-id",
    api_key=os.environ["MANYROWS_API_KEY"],
)

# Check permission
allowed = client.has_permission(user_id, "posts:edit")

# Look up user (by ID or email)
user = client.get_user(user_id)
user = client.get_user_by_email("[email protected]")
# user.user.email, user.roles, user.permissions, user.fields

# List members
members = client.list_members(page=0, page_size=50)

# Get config + feature flags
delivery = client.get_delivery()
# delivery.config.public, delivery.flags.server

# AsyncClient mirrors the same surface for asyncio code.
View on GitHub

Java

The manyrows-java package lives at github.com/manyrows/manyrows-java. Verifies the user's JWT locally against your install's JWKS (built on nimbus-jose-jwt), with TTL-cached keys and refetch on a kid mismatch. Java 17+, Jackson + nimbus-jose-jwt deps. Not yet on Maven Central, clone the repo and copy the source files in, or build a JAR locally with mvn package.

Auth: verify a bearer token java
import com.manyrows.Auth;

// Inside any servlet / Spring filter / etc,supports Bearer header AND mr_at cookie:
String token = Auth.bearerToken(request.getHeader("Authorization"));
if (token == null) {
    token = Auth.mrAtCookie(request.getHeader("Cookie"));
}
String userId = Auth.verifyToken(token,
    "https://auth.your-domain.com",
    "your-workspace",
    "your-app-id");
// Returns null on invalid / expired tokens; check before use.
Server API Client java
import com.manyrows.Client;
import com.manyrows.Types.*;

Client client = new Client(
    "https://auth.your-domain.com",
    "your-workspace",
    "your-app-id",
    System.getenv("MANYROWS_API_KEY"));

// Check permission
boolean allowed = client.hasPermission(userId, "posts:edit");

// Look up user (by ID or email)
UserResult user = client.getUser(userId);
UserResult byEmail = client.getUserByEmail("[email protected]");
// user.user().email(), user.roles(), user.permissions(), user.fields()

// List members
MembersResult members = client.listMembers(0, 50);

// Get config + feature flags
Delivery delivery = client.getDelivery();
// delivery.config().publicConfig(), delivery.flags().server()
View on GitHub

Other Languages

There are no official SDKs for other languages yet. The auth flow is simple,verify the user's JWT locally against the install's JWKS at /.well-known/jwks.json (ES256). Pull the token from the Authorization: Bearer header or the mr_at cookie, validate the signature against a cached key matching the JWT's kid, and read the user ID from the sub claim.

The Go, Node, Python, and Java SDKs are short, readable reference implementations. Use an AI assistant to translate one into your language of choice.


Error Responses

Every error response carries a JSON body with a stable, machine-readable error code and a human-readable message. Branch on error, not on the message text.

Error body json
{
  "error": "error.unauthorized",
  "message": "You are not authorized to perform this action."
}
400 Bad Request
Missing or invalid parameters
401 Unauthorized
Missing or invalid authentication (JWT or API key)
403 Forbidden
Workspace, product, or environment not found / disabled
404 Not Found
The requested resource (user, role, session, etc.) does not exist
409 Conflict
The request conflicts with existing state (e.g. email already in use, slug taken)
429 Too Many Requests
Rate limit exceeded. Honor the Retry-After header before retrying.
500 Server Error
Internal error,retry later