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
appIdfor 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 i @manyrows/appkit-react
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.
<!-- 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 workspace required string appId required string onReady (info) => void onState (snapshot) => void snapshot.status to check auth
state ("checking",
"authenticated",
"unauthenticated").
onReadyState (snapshot) => void onError (err) => void Handle methods
handle.getState() snapshot | null handle.subscribe(fn) () => void handle.refresh() void handle.logout() Promise<void> handle.setToken(token) void handle.destroy() void
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
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.
<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> <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 /x/{workspace}/... routes
appId required string src string containerId string theme AppKitTheme 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 onError (err) => void onState (snapshot) => void onReadyState (snapshot) => void authHeader React.ReactNode publicAccess boolean true, children are always
visible regardless of auth state. Use for apps that are
publicly accessible with optional login. Defaults to
false.
hideAuthUI boolean 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>> initialScreen,
hideAuthUI, and
onScreenChange from the current
URL. Explicit props take precedence. Partial,omit any
screen you don't need routable.
authRedirect string authRoutes,
automatically navigates to this path after the user
authenticates while on an auth route.
initialScreen "login" | "register" | "forgot-password" authRoutes.
onScreenChange (screen: "login" | "register" | "forgot-password") =>
void authRoutes.
embedded boolean 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 hideErrorUI boolean loadAppRuntime boolean 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> debug boolean Rendering Authed UI
Use AppKitAuthed to render your UI only
after the user is logged in. Access state via
useAppKit().
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.
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 useToken() string | null useAuthFetch() (input, init?) => Promise<Response> 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[] useRole(role) boolean useRole("admin") usePermissions() string[] usePermission(perm) boolean usePermission("posts:edit") useFeatureFlags() { key, enabled }[] useFeatureFlag(key) boolean useFeatureFlag("beta_ui") useConfig() { key, type, value }[] useConfigValue(key, default?) T useConfigValue("max_items", 50) useSetPassword() ({ password, currentPassword? }) => Promise<void> 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> refresh() afterwards to reload
the snapshot.
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[]> current: true.
useRevokeSession() (sessionId) => Promise<void> logout().
useRevokeOtherSessions() () => Promise<{ revoked }> usePasskeys() () => Promise<AppKitPasskey[]> useRegisterPasskey() ({ name? }?) => Promise<AppKitPasskey> isPasskeySupported(); detect
user cancellation with
isPasskeyCancelled(err) and
an already-enrolled authenticator with
isPasskeyAlreadyRegistered(err).
useRenamePasskey() (id, { name }) => Promise<void> useDeletePasskey() (id, reauth?) => Promise<void> { password } or
{ code } (see re-auth below).
useStartTOTPSetup() (reauth) => Promise<{ secret, uri }> uri as a QR code; finish with
useEnableTOTP. Calling it again
discards the previous pending secret.
useEnableTOTP() ({ code }) => Promise<{ backupCodes }> useDisableTOTP() (reauth) => Promise<void> useRegenerateBackupCodes() ({ password }) => Promise<{ backupCodes }> useIdentities() () => Promise<AppKitIdentity[]> useDisconnectIdentity() (provider) => Promise<void> useRequestEmailChange() ({ newEmail, password }) => Promise<void> useVerifyEmailChange() ({ oldCode, newCode }) => Promise<void> useDeleteAccount() ({ password }) => Promise<void> error.forbidden
when the app disables self-serve deletion.
useUserFields() /
useUpdateUserFields() () / (values) => Promise<AppKitUserField[]> useRequestReauthCode() () => Promise<void> { code } path.
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 useOrganizationList() AppKitOrganization[] useSetActiveOrganization() (orgId) => Promise<AppKitOrganization> useCreateOrganization() ({ name, slug? }) => Promise<org> useRenameOrganization() /
useArchiveOrganization() (orgId, …) => Promise<void> useOrganizationMembers() (orgId, opts?) => Promise<page> { page, pageSize, search }).
useSetOrganizationMember() /
useRemoveOrganizationMember() (orgId, userId, …) => Promise<void> error.conflict.
useOrganizationInvites() /
useCreateOrganizationInvite() /
useRevokeOrganizationInvite() (orgId, …) => Promise Lifecycle Callbacks
onReady (info) => void onState (snapshot) => void onReadyState (snapshot) => void appData present
,imperative "authed gate"
onError (err) => void onSignIn (user) => void onSignOut () => void 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.
# 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.
<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".
<AppKit
workspace="your-workspace"
appId="your-app-id"
theme={{ colorMode: "dark" }}
>
{/* All AppKit components render in dark mode */}
</AppKit> <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 #1976d2 backgroundColor string colorMode "light" | "dark" | "auto" "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.
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.
<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" setPasswordTitle "Set password" setYourPasswordTitle "Set your password" checkYourEmailTitle "Check your email" createAccountTitle "Create account" verifyYourEmailTitle "Verify your email" setAPasswordTitle "Set a password" twoFactorTitle "Two-factor authentication" Descriptions
enterEmailAndPassword "Enter your email and password." enterEmailForCode "Enter your email to receive a sign-in code." enterEmailForPasswordCode "Enter your email to receive a code for setting your
password." weSentCodeTo "We sent a 6-digit code to {email}." {email} placeholder.
enterEmailToGetStarted "Enter your email to get started." setPasswordOptional "Add a password so you can also sign in with email and
password. You can skip this for now." enterTotpCode "Enter the 6-digit code from your authenticator app." enterBackupCode "Enter one of your backup codes." Labels & Placeholders
emailLabel "Email" emailPlaceholder "[email protected]" passwordLabel "Password" passwordPlaceholder "Enter your password" newPasswordLabel "New password" newPasswordPlaceholder "At least 10 characters" confirmPasswordLabel "Confirm password" confirmPasswordPlaceholder "Re-enter your password" codeLabel "6-digit code" codePlaceholder "123456" backupCodeLabel "Backup code" backupCodePlaceholder "Enter backup code" Buttons & Links
signInWithGoogle "Sign in with Google" signingInWithGoogle "Signing in..." signIn "Sign in" signingIn "Signing in…" forgotPassword "Forgot password?" createAccount "Create account" continueButton "Continue" sending "Sending…" sendCode "Send code" setPassword "Set password" settingPassword "Setting password…" verify "Verify" verifying "Verifying…" creatingAccount "Creating account…" backToSignIn "Back to sign in" changeEmail "Change email" back "Back" skipForNow "Skip for now" useAuthenticatorCode "Use authenticator code" useBackupCode "Use a backup code" keepMeSignedIn "Keep me signed in" alreadyHaveAccount "Already have an account? Sign in" logOutAllSessions "Log out of all other sessions" Messages
checkEmailForCode "Check your email for a 6-digit code." checkEmailForPasswordResetCode "Check your email for a 6-digit code to set your
password." passwordSetSuccess "Password set successfully! You can now sign in." tooManyRequests "Too many requests. Please try again in {minutes}
minute{s}." {minutes} and
{s} placeholders.
tooManyRequestsGeneric "Too many requests. Please wait a bit and try again." invalidCredentials "Invalid email or password." accessDenied "Access denied." accessDeniedNeedAccount "Access denied. You may need to create an account
first." requestFailed "Request failed." requestFailedWithStatus "Request failed ({status})." {status} placeholder.
Password Strength
strengthTooShort "Too short" strengthWeak "Weak" strengthFair "Fair" strengthGood "Good" strengthStrong "Strong" Misc
orDivider "Or sign in with" oauthOnlyPrompt "Choose how you'd like to continue." oauthOnlyRegisterHint "Don't have an account? It'll be created on your
first sign-in." newHerePrompt "New here?" enterCodeFromEmail "Enter the code from the email." codeMustBe6Digits "Code must be 6 digits." passwordsDoNotMatch "Passwords do not match" The "Powered by ManyRows" branding is not overridable.
Troubleshooting
runtime not found error src URL or bundle the runtime
manually
onReady never fires symptom onError for details
403 after login symptom 401 loop symptom setToken(null) and re-auth
Client API
For browser and mobile apps. Base URL:
/x/{wsSlug}/apps/{appId}
Authentication
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.
Verify OTP code and start a session. Send
{ "email": "[email protected]", "code": "123456" }
and receive a JWT session token.
{ "email": "[email protected]", "code": "123456", "rememberMe": false }
Login with email and password. Send
{ "email": "[email protected]", "password": "...", "appId": "app-uuid" }.
Requires appId.
{ "email": "[email protected]", "password": "<password>", "rememberMe": false } 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.
Request a password reset code. Send
{ "email": "[email protected]", "appId": "app-uuid" }
and a 6-digit reset code will be emailed. Requires
appId.
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).
Google OAuth login (web). Send the Google ID token from GSI popup.
{ "credential": "<google-id-token>", "rememberMe": false } Start Google OAuth Authorization Code Flow. Returns a URL to redirect the user to Google sign-in. Requires client secret to be configured.
{ "url": "https://accounts.google.com/o/oauth2/v2/auth?...", "state": "<csrf-state>" } Complete Google OAuth Authorization Code Flow. Exchange the authorization code from Google for session tokens.
{ "code": "<auth-code-from-google>", "state": "<state-from-authorize>" }
Register a new account. Sends an OTP code to the provided
email. Requires appId and
email. The app must have
registration enabled.
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.
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.
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.
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.
{ "challengeToken": "<token-from-login>", "code": "123456" } 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.
Invalidate the current session. Clears the JWT on the server side.
Identity
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.
{
"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
Check whether the current user has a specific permission in this
app. Returns
{ "allowed": true/false }.
permission query string posts:read)
{
"allowed": true,
"permission": "posts:read"
} Profile
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.
Returns all client-visible user fields and their values for the current user.
Update user-editable fields for the current user. Send a JSON object with field keys and values.
{ "first_name": "Alice", "preferred_theme": "dark" } To reset a forgotten password, use the forgot-password / reset-password flow instead.
Two-Factor Authentication (TOTP)
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.
{
"secret": "JBSWY3DPEHPK3PXP",
"uri": "otpauth://totp/Manyrows:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Manyrows"
}
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.
{ "backupCodes": ["a1b2c3d4", "e5f6a7b8", "..."] }
Disable 2FA. Requires password confirmation. Send
{ "password": "..." }.
Returns { "ok": true }.
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
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.
{
"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
}
]
}
Revoke another session. Cannot revoke the current session,
use POST /a/logout instead.
"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.
{ "revoked": 2 } 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
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.
{ "password": "current-password", "newEmail": "[email protected]" }
Complete the email change with both codes. A mismatch on either
code returns the same generic
error.invalidCode.
{ "oldCode": "222333", "newCode": "123456" } Account Deletion
Permanently delete the current user's account. Requires the current password for confirmation. Removes the user, all auth scopes, and revokes all sessions.
{ "password": "current-password" } Runtime Data
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: 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.
X-API-Key: mr_a1b2c3d4_yourSecretKey
Keep it secret
Never expose API keys in client-side code or public repositories.
Delivery
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
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
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) accountId
for historical reasons; it accepts a user ID.
permission query string posts:read)
{
"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.
List the product's roles, each with the permission slugs it
grants. Returns
{ "roles": [{ "slug", "name", "permissions": ["..."] }] }.
List the product's permissions. Returns
{ "permissions": [{ "slug", "name" }] }.
You can also define RBAC programmatically:
Create a role (body
{ "slug", "name", "permissions": ["..."] }).
GET /
PATCH /
DELETE /apps/{appId}/roles/{slug}
read, update (name and/or permissions), and delete it.
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.
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) pageSize int (query) search string (query) {
"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.
Find a member of this app by email.
Find a user by ID.
{
"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
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).
{ "email": "[email protected]", "emailVerified": true, "roles": ["editor"] } email string (required) emailVerified bool roles string[] (slugs) sendInvite bool invited
flag says whether it sent).
Provision users in bulk
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.
{ "results": [
{ "email": "[email protected]", "userId": "…", "created": true },
{ "email": "bad", "error": "error.invalidEmail" }
] } Replace a user's roles
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.
{ "roles": ["editor", "reviewer"] }
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
List the user's direct permission overrides (slugs) — per-user grants on top of whatever their roles already provide.
Replace the user's direct permissions (full set of slugs, body
{ "permissions": ["posts:read"] }).
Returns the resulting slugs.
Revoke a user's sessions
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> }.
List the user's active sessions for this app
({ "sessions": [{ "id", "createdAt", "lastSeenAt", "expiresAt", "userAgent", "ip" }] }).
Revoke a single session.
Set / clear password
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.
Clear the user's password — email+password sign-in is disabled until a new one is set (OAuth and passkeys still work).
Email verification
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.
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.
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
Reset (disable) the user's 2FA so a user who lost their authenticator can re-enroll.
Clear a failed-login lockout, restoring sign-in immediately.
List the user's linked SSO providers. Unlink one with
DELETE .../identities/{provider}.
List the user's passkeys. Remove one with
DELETE .../passkeys/{passkeyId}.
Authentication history
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" }.
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
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
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.
{ "status": "disabled" } Generate a sign-in link
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.
List all user field definitions.
List a single user's field values.
Set a user's value for a field. The value is validated against
the field's type (string, bool, or date). Body:
{ "value": ... }.
Clear a user's value for a field.
Config & feature flags
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.
Clear this app's value for a config key.
Set this app's feature-flag override (body
{ "enabled": true, "roles": ["beta"] });
roles optionally targets role slugs.
Clear this app's override so the flag falls back to its default.
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.)
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.
List the app's webhooks. Register one with
POST /apps/{appId}/webhooks
(body { "url": "...", "events": [...] }).
Update a webhook's URL, events, status, or description. Fetch one with
GET and remove it with
DELETE at the same path.
Response Format
{
"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 "https://auth.your-domain.com/x/your-workspace/api/v1/apps/your-app-id" \ -H "X-API-Key: $MANYROWS_API_KEY"
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(); 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() 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)
} 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 409.
admin tier member tier 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.
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) slug string (body, optional) acme,
acme-2, …).
ownerUserId uuid (body, required) sub).{
"id": "uuid",
"appId": "uuid",
"name": "Acme Inc",
"slug": "acme-inc",
"status": "active"
} 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.
{
"organizations": [
{ "id": "uuid", "name": "Acme Inc", "slug": "acme-inc", "orgRole": "owner" }
]
}
Fetch one organization. Returns 404
if it does not belong to this app or is not active.
Rename an organization. Body: { "name": "New Name" }.
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.
List every member of the organization with their email, tier, and status.
{
"members": [
{
"userId": "uuid",
"email": "[email protected]",
"orgRole": "owner",
"status": "active"
}
]
}
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) email.email string (body) orgRole string (body) owner,
admin, or
member (defaults to member).
{
"userId": "uuid",
"email": "[email protected]",
"orgRole": "admin",
"status": "active"
}
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" }.
Change a member's tier. Body: { "orgRole": "admin" }.
Demoting the last owner returns 409.
Returns 204.
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.
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) orgRole string (body, optional) owner,
admin, or
member (defaults to member).
invitedByUserId uuid (body, optional) {
"id": "uuid",
"email": "[email protected]",
"orgRole": "admin",
"status": "pending",
"createdAt": "2026-06-07T10:00:00Z",
"expiresAt": "2026-06-14T10:00:00Z"
} List the organization's pending invites.
{
"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"
}
]
}
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.
Create an organization; the caller is seeded as
owner. Allowed only when the app's
org_creation_policy is
self_serve — otherwise
403.
List the organizations the caller belongs to, each with their tier.
Rename the organization. Requires owner or admin.
Archive the organization (reversible; members lose access).
owner only. Permanent delete and
restore stay operator-side (admin dashboard / server API).
List members with their tiers. Any active member.
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.
Remove a member (owner/admin)
or leave the org (self). Admins can't remove an owner; removing
the last owner returns 409.
Email an invite (email,
orgRole, optional
roleIds).
owner/admin;
an admin can't invite an owner.
List pending invites. owner/admin.
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 <timestamp>.<body> (the
X-Webhook-Timestamp value, a
literal ., then the raw request
body), prefixed with
sha256=.
X-Webhook-Timestamp string X-Webhook-Event string user.login,
user.password_change).
X-Webhook-Delivery uuid 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.
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)
);
} 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) 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
} 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:
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|consentandmax_agesession-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:
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.
The issuer metadata (discovery document). Cacheable for 5 minutes.
The public signing keys (the advertised
jwks_uri). Served from the
install root or the custom auth domain, not the app base.
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.
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.
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.
RFC 7009 revocation, client-authenticated. Accepts a refresh or access token; revoking either kills the underlying session, so both stop working.
RFC 7662 introspection, client-authenticated. Unknown, expired,
or revoked tokens come back as
{"active": false} — never an
error.
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.
Consent Screen
By default the flow completes without asking the user anything
beyond sign-in. For third-party RPs you usually want an explicit
approval step: turn on Require consent on the
app's OIDC provider card (admin dashboard, the app's auth
methods page). With it on, the hosted consent page lists the
requested scopes and the user approves or denies before the code
is issued; a denial redirects back with
error=access_denied.
Leave the toggle off when the app's OIDC client is your own first-party site — a consent screen between your login page and your product is just friction.
Remembered grants
Approvals are remembered per user and app as the union of every
scope the user has ever approved. A returning user only sees the
consent screen again when the request includes a scope they
haven't approved yet — asking for
openid email after a previous
openid email profile grant is
silent, while adding offline_access
re-prompts.
Interaction with prompt
-
prompt=consentforces the consent screen even when a remembered grant covers the request -
prompt=noneforbids interaction: when consent would be needed, the request fails witherror=consent_requiredinstead of showing a page (andlogin_requiredwhen there is no usable session)
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.
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).
go get github.com/manyrows/manyrows-auth-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
} 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.
npm install github:manyrows/manyrows-node
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",
}); 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.
pip install git+https://github.com/manyrows/manyrows-python.git
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. 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. 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.
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. 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() 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": "error.unauthorized",
"message": "You are not authorized to perform this action."
} 400 Bad Request 401 Unauthorized 403 Forbidden 404 Not Found 409 Conflict 429 Too Many Requests Retry-After header before retrying.
500 Server Error