# Authentication
Source: https://docs.sublay.io/v7/authentication
How Sublay handles user identity — built-in auth, external JWT integration, and OAuth.
Sublay supports three authentication models. You choose the one that fits how your application manages users, and they can coexist within the same project.
Sublay manages the full user lifecycle: sign-up, sign-in, and password reset using email and password. No external auth system needed.
Sign in with Google, GitHub, Apple, or Facebook. Supports linking multiple providers to one account.
Your backend signs a JWT. Sublay verifies it and creates or matches a user automatically. Keep your existing auth stack.
***
## Token Model
All three auth modes produce the same token pair: an **access token** and a **refresh token**.
| Token | Lifetime | Purpose |
| ------------- | ---------- | ----------------------------------------------------------------- |
| Access token | 30 minutes | Sent with every API request in the `Authorization: Bearer` header |
| Refresh token | 30 days | Used to obtain a new access token when the current one expires |
**Refresh token rotation** is enforced. Every time a refresh token is used, it is revoked and a new one is issued in its place. If the same refresh token is used twice (token reuse detected), the entire token family is revoked, signing out all sessions that shared it.
A 30-second grace period allows two requests that race to refresh the same token (e.g., two browser tabs loading simultaneously) to both succeed. The first request rotates the token; a second request arriving within that window receives the successor token instead of a reuse error.
The React/React Native SDK handles token storage and refresh automatically. When using the API directly, your code must exchange the refresh token for a new access token before each expiry.
***
## Built-in Auth
Sublay provides a complete email/password authentication system out of the box. Use it when you do not have an existing user identity system and want Sublay to manage everything.
**What it includes:**
* User sign-up with email and password
* Sign-in returning an access token + refresh token + user object
* Sign-out (revokes the refresh token)
* Password change (requires current password)
* Password reset via email link (requires an email provider configured in the dashboard)
Users created through built-in auth can link OAuth providers to their account later — see the OAuth section below.
See [SDK Reference → Authentication → Built-in](/sdk/authentication/built-in) for implementation guidance, or [API Reference → Auth](/api-reference/auth/sign-up) for the raw endpoint reference.
***
## External Auth
Use this model when your application already has its own user management system (Supabase Auth, Auth0, Firebase Auth, NextAuth, a custom system, or anything else) and you want Sublay to associate its data with those users — without rebuilding auth.
### How It Works
Sublay generates an RSA key pair (RS256) for your project. The **public key** is stored in Sublay. The **private key** is shown to you once — store it securely as a server-side environment variable. If it is lost or compromised, generate a new key pair from the dashboard.
When your application detects a logged-in user, make a request to your own backend. Your backend signs a JWT with your private key and returns it to the client.
The JWT payload must include:
| Claim | Value |
| ---------- | ------------------------------------------- |
| `sub` | Your external user ID (string) |
| `iss` | Your Sublay project ID |
| `userData` | Object with user profile fields (see below) |
The `userData` object can include:
| Field | Type | Notes |
| ---------------- | -------- | ------------------------------------------------ |
| `email` | `string` | Optional but recommended |
| `name` | `string` | Display name |
| `username` | `string` | Unique per project |
| `avatar` | `string` | URL of the user's profile picture |
| `bio` | `string` | Short bio |
| `location` | `string` | User's location |
| `birthdate` | `string` | ISO 8601 date |
| `metadata` | `object` | Public custom data |
| `secureMetadata` | `object` | Private custom data (not exposed to other users) |
The signed JWT is safe to pass to the client — it cannot be tampered with because the signature covers the payload using your private key.
Pass the signed JWT to `SublayProvider` via the `signedToken` prop (SDK), or call the verify external user API endpoint directly.
Sublay verifies the JWT against the project's stored public key. If valid, it looks up the user by the `sub` claim. If the user does not exist, it is created from the `userData` fields. If the user already exists, their profile fields are updated if anything has changed.
On success, Sublay returns its own access token + refresh token pair tied to this user.
### Profile Sync
Every time a valid external JWT is presented, Sublay compares the `userData` fields against the stored user record and updates any fields that have changed. Your user profiles in Sublay stay in sync with your external system automatically — no separate update call needed.
### Security
The private key must never leave your server. Signing happens server-side; the client only receives the finished JWT. Because Sublay verifies using your public key, a client cannot forge or modify user data.
See [SDK Reference → Authentication → External](/sdk/authentication/external) for implementation guidance.
***
## OAuth
OAuth allows users to sign in using a third-party provider. Sublay handles the full flow: PKCE generation, state management, callback processing, and user creation or linking.
**Supported providers:** Google, GitHub, Apple, Facebook
### Setup
Each provider must be configured in the project dashboard with:
* The provider's client ID and client secret
* A list of allowed redirect URIs (the URLs Sublay may redirect back to after authentication)
### Flow Overview
Call the authorize endpoint with the provider name and the `redirectAfterAuth` URL. Sublay creates a short-lived state record (10-minute TTL) and returns an authorization URL pointing to the provider. For Google, a PKCE code verifier and challenge are generated automatically.
Redirect the user to the authorization URL. They authenticate with the provider and are sent back to Sublay's callback endpoint.
Sublay exchanges the authorization code for the provider's tokens, fetches the user profile, and resolves which Sublay user to return using this priority:
1. **Existing identity** — if this provider account has signed in before, the matching user is returned immediately.
2. **Verified email match** — if the provider supplies a verified email that matches an existing user, the new identity is linked to that account automatically.
3. **New user** — if no match is found, a new user is created from the provider profile (name, email, avatar).
Sublay then redirects to the `redirectAfterAuth` URL with Sublay tokens appended as query parameters.
Your app reads the tokens from the redirect URL, stores them, and the user is signed in.
### Account Linking
A user can link multiple OAuth providers (and a password account) to a single Sublay user record. To link an additional provider, the user must be signed in, and the OAuth flow is initiated via the link endpoint rather than the authorize endpoint. The new identity is attached to the existing account.
This enables scenarios like "sign in with Google on a laptop and GitHub on another device" — both map to the same user.
See [SDK Reference → Authentication → OAuth](/sdk/authentication/oauth) for the full integration guide.
***
## Multi-Account Support
The SDK supports multiple simultaneously signed-in accounts in a single app instance. This is separate from account linking — it means multiple distinct user sessions can be held at once, with the ability to switch between them.
Use cases: apps where a person manages multiple accounts, shared devices, or testing flows.
See [SDK Reference → Authentication → Multi-Account](/sdk/authentication/multi-account) for the full guide.
***
## Authorization
Beyond authentication, Sublay enforces authorization on every request:
* Most write operations require an authenticated user (or a service API key)
* Ownership checks are enforced server-side — a user cannot modify another user's content through the normal API
* Space roles (owner, admin, moderator, member) control what is permitted within a space
* Service API keys bypass user-level authorization and are intended for server-side use only — see [Integration Options](/integration-options#service-api-key-recommended-for-server-to-server)
# Bundles
Source: https://docs.sublay.io/v7/bundles
Sublay features are modular. Each project is assembled from bundles — groups of features you install per project and can add or remove at any time.
Sublay is not one monolithic feature set. It's a collection of **bundles** — self-contained feature modules you turn on per project. A bundle groups everything a feature needs (its database tables and the endpoints and SDK hooks that operate on them), so your project only carries the features you actually use.
Every project gets its own isolated database schema. Installing a bundle provisions that bundle's tables into your project's schema and switches the feature on. Removing a bundle takes it back out.
Bundles are about **provisioning**, not pricing. Installing a bundle makes the feature available in your project; what each plan allows (quotas, AI access, etc.) is a separate concern. See [Semantic Search & AI](/semantic-search-ai) for an example of a feature that needs both its bundle *and* a paid plan.
## The core bundle
Every project always has the **`core`** bundle. It's installed automatically when the project is created and cannot be removed. Core provides the foundation that every other feature builds on:
* **Users, authentication & identity** — email/password accounts, OAuth identities, and OAuth provider configuration. See [Authentication](/authentication).
Because OAuth identity lives in core, [OAuth sign-in](/sdk/authentication/oauth) works on every project without installing anything extra.
## Available bundles
On top of core, you choose which of these to install. Most are independent — install only what you need — but a few depend on another bundle (see below).
| Bundle | Feature | What it adds |
| --------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `entities` | [Entities](/sdk/entities/overview) | The base content unit — posts, articles, listings, anything your users create — with views, drafts, and publishing |
| `comments` | [Comments](/sdk/comments/overview) | Threaded comments on entities |
| `reactions` | [Reactions](/sdk/reactions/overview) | Emoji reactions on entities and comments |
| `files-images` | [Storage](/sdk/storage/overview) | File and image uploads, including user avatars and banners |
| `follows` | [Follows](/sdk/relationships/overview) | Unidirectional follow relationships |
| `connections` | [Connections](/sdk/relationships/overview) | Bidirectional connection (friend-style) requests |
| `spaces` | [Spaces](/sdk/spaces/overview) | Hierarchical community spaces, membership, roles, and rules |
| `workspaces` | [Workspaces](/sdk/workspaces/overview) | Self-nesting SaaS/team workspaces with invitations and per-member authority |
| `chat` | [Chat](/sdk/chat/overview) | Real-time 1:1 and group conversations with message reactions |
| `collections` | [Collections](/sdk/collections/overview) | User-owned bookmarking and folders for saving entities |
| `moderation` | [Moderation](/sdk/moderation/overview) | Reports, report resolution, and user suspensions |
| `notifications` | [App Notifications](/sdk/app-notifications/overview) | In-app notification system |
| `push` | [Push Notifications](/push-notifications) | Native OS push delivery to iOS, Android, and Web |
| `ai-search` | [Semantic Search & AI](/sdk/search/overview) | Content embeddings, semantic search, and the AI ask endpoint |
| `reputation` | [Reputation](/data-models/reputation), [ReputationGrant](/data-models/reputation-grant) | Per-space reputation buckets with a maintained overall total on each user, plus reputation grants — reputation deliberately given by a user or minted by your app |
### Bundle dependencies
A couple of bundles build directly on another and can't be installed on their own:
* **`comments` and `collections` require `entities`.** Comments attach to an entity and collections save entities, so both need the `entities` bundle installed first. The dashboard pre-checks `entities` for you when you select either; installing them via the API without `entities` returns `409 database/bundle-prerequisite-missing`. For the same reason, `entities` can't be removed while `comments` or `collections` are still installed — remove those first.
* **`interest-matching` requires `ai-search`.** Interest matching builds on the content embeddings that `ai-search` provides, so `ai-search` must be installed first and can't be removed while `interest-matching` is present.
## Choosing bundles when you create a project
When you create a project in the [dashboard](https://dash.sublay.io), you pick the bundles you want. `core` is always included — you can't deselect it — and you can start with **core only** and add the rest later. Nothing is locked in at creation time.
## Adding and removing bundles later
Installed bundles are managed from the **Database** page of your project in the dashboard (`https://dash.sublay.io//database`). From there you can install a bundle you skipped, or remove one you no longer need.
Installing a bundle is an asynchronous operation — the dashboard shows it as **provisioning** while the tables are created, then **installed** once it's ready. This usually takes a moment.
**Removing a bundle is destructive.** Uninstalling drops the bundle's tables and the data in them, and clears references to that data from other bundles. For example, removing the `spaces` bundle deletes all spaces and clears the `spaceId` on any entities, comments, or conversations that pointed at a space. There is no undo — back up anything you need first.
Uninstalling the `reputation` bundle is doubly lossy: it drops the reputation buckets table and the grants table — destroying all grant history — **and** zeroes the `reputation` column on every user. Reinstalling starts fresh from zero — past scores are not recomputed. See [Reputation](/data-models/reputation).
## What happens if a bundle isn't installed
Feature endpoints and SDK hooks only work when their bundle is installed. If your app calls a feature whose bundle is missing, the API responds with a clear error instead of a generic failure:
| Situation | Response | Meaning |
| --------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Bundle not installed | `403` · `database/tables-not-available` | The feature isn't enabled for this project. The response includes a `dashboardUrl` pointing at the project's Database page so you can install it. |
| Bundle currently installing | `503` · `database/tables-provisioning` | The bundle is mid-install. This is transient — retry shortly. |
If you hit one of these while building, the fix is almost always to install the bundle for the feature you're using. Open the **Database** page in the dashboard and add it.
This is why a feature can be fully documented and supported but still return `database/tables-not-available` on your project — the docs describe the whole platform, while your project only runs the bundles you've installed.
# Claude Code Plugin
Source: https://docs.sublay.io/v7/claude-code
Install the Sublay plugin for Claude Code to give your coding agent an accurate, built-in map of the Sublay platform.
Sublay ships an official **Claude Code plugin** that installs a skill teaching your coding agent what the platform can do — every model, bundle, and capability — so it builds on what already exists instead of reinventing it or inventing what isn't there.
This is complementary to the [MCP Server](/mcp-server). The **plugin** gives the agent a curated map of the platform that it reaches for automatically; the **MCP server** lets it pull exact field- and endpoint-level detail from the docs on demand. Installing the plugin also wires up the docs MCP for you.
## Install
In Claude Code, add the marketplace and install the plugin:
```bash theme={null}
/plugin marketplace add sublay-io/claude-skills
/plugin install sublay@sublay-io
```
The first command registers Sublay's plugin marketplace; the second installs the `sublay` plugin from it.
## What you get
A complete, domain-organized map of Sublay — the mental model (project scoping, bundles, the Provider + Hook SDK pattern), every bundle, and each model and capability (entities, spaces, chat, custom tables, storage, semantic search & AI, and more). The agent consults it automatically when you work on anything Sublay-related, so it picks the right SDK hook, REST route, or dashboard surface.
The plugin bundles the public [docs MCP](/mcp-server) (`https://docs.sublay.io/mcp`, no auth), so when the agent needs exact hook signatures, params, or endpoint shapes, it can search the live docs without any extra setup.
## How it behaves
The skill is a **first point of contact, not a full spec**. It holds the shape of the platform — what exists and why — and defers precise, field-level detail to the docs (via the MCP or [docs.sublay.io](https://docs.sublay.io)). That keeps the agent from guessing at APIs while staying lightweight.
You don't need to invoke it manually — Claude Code loads it when your request involves Sublay. You can also call it explicitly with `/sublay:implementation-expert`.
## Updating
When Sublay ships new skills or updates, run:
```bash theme={null}
/plugin marketplace update sublay-io
```
then reinstall or update the plugin to pick up the latest version.
# Custom Tables
Source: https://docs.sublay.io/v7/custom-tables
Provision your own database tables for the remaining 20% of your data model — with SDK row access, table management, and a full dashboard data editor
Sublay ships pre-modeled tables for the features you'd otherwise have to build — users, entities, comments, spaces, reactions, and more. **Custom tables** cover the rest: the app-specific data that doesn't fit any built-in model. You define the table, Sublay provisions it inside your project's isolated schema, and you read and write its rows from the SDKs or the dashboard.
Custom tables live in the same per-project schema as your built-in data, so
they share your project's isolation and connection. They are addressed through
a dedicated `/db` surface that **only ever touches custom tables** — built-in
and bundle tables are never reachable through it.
## The `custom_` invisibility model
Every custom table is stored with a `custom_` prefix on its physical name, but you never type that prefix. You work with **logical** names everywhere:
* You create a table called `Events` → Sublay stores it physically as `custom_Events`.
* You read and write rows by calling `client.table("Events")` → Sublay resolves it to `custom_Events`.
* The dashboard displays it as `Events`.
The prefix is what guarantees the `/db` surface can never reach a built-in table. A request for a built-in name like `Comments` resolves to `custom_Comments`, which doesn't exist, so it `404`s — the built-in `Comments` table stays unreachable.
The prefix is applied **exactly once, unconditionally** — it is never skipped
if your name already starts with `custom_`. If you name a logical table
`custom_x`, it is physically stored as `custom_custom_x` and you continue to
address it as `custom_x`. There is no double-prefix surprise and no name
collision with the prefix mechanism.
## Managed columns
Every custom table is created with a set of **server-managed columns** that you never write to directly:
| Column | Type | When present | Purpose |
| ----------- | ------------- | ----------------------- | ---------------------------------------------- |
| `id` | `uuid` | Always | Primary key, defaulted to `gen_random_uuid()`. |
| `createdAt` | `timestamptz` | When `timestamps: true` | Set on insert. |
| `updatedAt` | `timestamptz` | When `timestamps: true` | Bumped automatically on every update. |
| `deletedAt` | `timestamptz` | When `paranoid: true` | Soft-delete marker. `null` for live rows. |
These columns are reserved at create time and at add-column time — you cannot define a column with any of these names. They are rejected from insert/update bodies (a write that includes `id`, `createdAt`, `updatedAt`, or `deletedAt` is refused). `deletedAt` is **read-visible** — you can filter and sort by it and surface it with `includeDeleted` — but never writable.
### Timestamps and soft-delete
Two flags, set when you create the table, control the managed columns:
* **`timestamps`** (default `true`) — emits `createdAt` / `updatedAt`. With timestamps on, the default sort for reads is `createdAt desc`; with timestamps off, the default sort is `id`.
* **`paranoid`** (default `false`, **requires `timestamps`**) — emits `deletedAt` and turns deletes into **soft deletes**. A delete sets `deletedAt` instead of removing the row; soft-deleted rows are excluded from reads by default and resurface only with `includeDeleted`. A [`restore`](/v7/node-sdk/tables#restore) clears `deletedAt`. Pass `force: true` to a delete to hard-delete a paranoid row.
A non-paranoid table has no `deletedAt` column, so every delete is a hard delete.
## Column types
A custom column is one of nine logical types:
| Logical type | Physical SQL type | Notes |
| ------------ | ------------------ | --------------------------- |
| `text` | `TEXT` | |
| `integer` | `BIGINT` | 64-bit integer. |
| `float` | `DOUBLE PRECISION` | |
| `decimal` | `NUMERIC` | Arbitrary-precision number. |
| `boolean` | `BOOLEAN` | |
| `date` | `DATE` | Calendar date, no time. |
| `timestamp` | `TIMESTAMPTZ` | Timestamp with time zone. |
| `uuid` | `UUID` | |
| `json` | `JSONB` | |
Each column carries a `nullable` flag and an optional `defaultValue` (validated against the column's type).
## Surfaces
Custom tables are reachable from every Sublay SDK and from the dashboard:
`client.table(name)` — find, create, update, delete, bulk, restore.
`client.tables` — create/drop tables and add/drop columns (service-key only).
`client.table(name)` row operations from any browser or JS runtime.
`useTable(name)` for React and React Native.
Table **management** (DDL — creating tables, adding columns) is available in two places only: the **Node SDK** (service-key) and the **dashboard**. The JS SDK and the React hook are row-only, because they authenticate as an end user and hold no service key.
## The dashboard data editor
The dashboard ships a full data editor for custom tables, under the project's **Database** view:
* **Schema management** — create a table (with the full type set, per-column nullable/default, and the `timestamps` / `paranoid` toggles), add a column to an existing table, and drop tables or columns. Drop actions open a confirmation dialog that previews what will be lost (e.g. the affected row count) and gate the action behind typing the table/column name.
* **Row editing** — insert and edit rows through type-aware inputs. Managed columns are not editable. (Row editing is offered for custom tables only — built-in tables stay read-only in the editor.)
* **Soft delete & restore** — on a paranoid table, the grid offers a **Show deleted** toggle; soft-deleted rows render dimmed with a **Restore** action.
* **Filter, sort, paginate** — browse rows with the same filter/sort/pagination contract the SDKs use.
Custom table names display with the `custom_` prefix stripped (`Events`, not `custom_Events`).
**Permissions.** Schema changes (create/drop table, add/drop column) require an
**owner or admin** role. Row writes (insert, edit, restore) require **owner or
editor** — the same gate as deleting a row.
## Limits
To keep a runaway script from exhausting your schema, custom tables are bounded:
* **100 tables** per project.
* **100 columns** per table.
* **100 rows** per bulk create or bulk delete call.
These ceilings are generous for legitimate data models and far below Postgres's own limits.
## Security: open row CRUD
**Row CRUD on the `/db` surface is currently open.** The row endpoints capture
the caller's identity (user token, service key) but **do not enforce any
authorization** — any caller who can reach your project can read and write
custom-table rows. There are no row-, field-, or table-level policies yet.
This is a deliberate, pre-release state. A hard authorization gate is planned
before general availability, and the captured identity context is the
integration point that policy layer will build on. **Do not store data in
custom tables that requires per-row or per-user access control until that gate
ships.**
Table **management** (DDL) is not open — it requires a service key (SDK) or an owner/admin role (dashboard).
# Use accounts
Source: https://docs.sublay.io/v7/hooks/auth/use-accounts
List all accounts linked in the current app instance
## Overview
`useAccounts` returns the list of all stored accounts and the currently active account. Use it to render an account switcher or display the signed-in user's identity.
## Usage Example
```tsx React theme={null}
import { useAccounts } from "@sublay/react-js";
function AccountSummaryBar() {
const { accounts, activeAccount, accountCount } = useAccounts();
return (
Signed in as: {activeAccount?.name ?? activeAccount?.email ?? "Guest"}
Total accounts: {accountCount}
{accounts.map((account) => (
{account.name ?? account.email}
))}
);
}
```
```tsx React Native theme={null}
import { useAccounts } from "@sublay/react-native";
function ActiveAccountLabel() {
const { activeAccount } = useAccounts();
return {activeAccount?.name ?? "Not signed in"};
}
```
## Returns
Array of all stored accounts, derived from the accounts map in Redux state.
Each one carries the profile summary, the two credential markers below, and
the `needsPushRebind` notification marker.
The account currently **selected**, or `null` when nothing is selected.
Selection and session are not the same thing, and the gap between them is a
state your UI has to render. An account can be selected with no live session
behind it — during an [`addAccount()`](/hooks/auth/use-add-account) flow, or
after a sign-in was refused at the account limit. Read `accessToken` from
[`useAuth`](/hooks/auth/use-auth) for whether a session exists; read this for
whose account the app is pointed at.
Total number of stored accounts.
`true` when there is no active account **because the user deliberately signed
out** — or removed the active account, deleted it, or had a stored credential
refused at launch — as opposed to "nothing has ever been selected". Both look like `activeAccount === null`; this is what
tells them apart, and it is persisted, so it survives a relaunch.
A launch that could not *reach* the server does not set it: the stored account
stays selected and its session is restored later. Only a refusal counts.
While it is `true`, the SDK will not auto-select a stored account at launch.
It clears as soon as any account is successfully activated.
`true` when an account was actually refused admission because the map was
already at the 5-account limit. Clears on the next successful admission and
on any removal. See [`useAddAccount`](/hooks/auth/use-add-account) for how it
differs from `canAddAccount` and from
[`wouldExceedAccountLimit`](/sdk/authentication/multi-account#three-signals-for-the-account-cap).
**Render off it — do not read it eagerly.** The clear is dispatched from an
effect, so it lands one render *after* a successful admission rather than
synchronously inside the call that caused it. Reading it immediately after
`await`ing a sign-in returns the value from before that effect flushed, which
may be a `true` left over from an earlier, unrelated refusal. See
[When `accountLimitReached` clears](/sdk/authentication/multi-account#when-accountlimitreached-clears).
## StoredAccount Type
| Property | Type | Description |
| ----------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `id` | `string` | User ID |
| `name` | `string \| null` | Display name |
| `username` | `string \| null \| undefined` | Username. `undefined` on entries stored by an SDK version before this field existed — absent means *unknown*, not "no username" |
| `email` | `string \| null` | Email address |
| `avatar` | `string \| null` | Avatar URL |
| `tokenExpiresAt` | `number` | When this account's stored refresh token expires, as an epoch in **milliseconds**. `0` means unknown |
| `needsReauth` | `boolean` | `true` once a transition into this account has actually been refused — the credential is dead |
| `needsPushRebind` | `boolean` | `true` when this account's notifications are paused until it is next opened |
`StoredAccount` is a superset of the `AccountSummary` shape stored in the
account map, so code that only reads `id`/`name`/`username`/`email`/`avatar` is
unaffected.
## Telling a Live Account From a Dead One
A stored account can stop working while it is sitting in the switcher. Two
fields let you show that **before** the user taps it, and you need both —
neither is sufficient on its own:
**Proactive.** Read from the stored refresh token's own `exp` claim when the
entry is written (a decode, never a verification and never a network call).
`0` means *unknown* — the token carried no readable numeric `exp`, so the SDK
recorded nothing rather than guessing. It sorts as already-expired
deliberately: treating a credential the SDK cannot read as fresh is the worse
failure.
**Reactive.** Set when a transition into this account is refused — by a switch,
or by the automatic restore at app launch, which is the most common way a
revoked credential is discovered. Cleared the moment the account is
successfully activated again, whether by a switch that works or by signing
into it afresh.
This catches every death `tokenExpiresAt` cannot see: reuse detection, a
password change, a remote sign-out-all, an admin revocation. All of those
destroy the token family while `exp` is still comfortably in the future. It is
also set without any network call when the stored entry carries no usable
credential at all, which is the same conclusion reached sooner.
A failed switch that could not reach the server does **not** set it — a flaky
network is not a dead account. `AccountTransitionError.credentialRejected` is
the same distinction, reported to the caller at the moment of failure; see
[`useSwitchAccount`](/hooks/auth/use-switch-account#telling-a-dead-account-from-a-dead-network).
```tsx theme={null}
const { accounts } = useAccounts();
const needsSignIn = (account: StoredAccount) =>
account.needsReauth || account.tokenExpiresAt <= Date.now();
accounts.map((account) => (
));
```
### Notifications paused: `needsPushRebind`
`true` when this account's push binding points at a device token this device no
longer holds, because the token rotated while the account was in the
background.
Its credential is fine and its data is fine — only the notification routing is
stale. Switching into the account re-creates the binding and clears this, so
the useful thing to render is an invitation to open it rather than a sign-in
prompt: *"Notifications paused — open to resume."*
Only ever raised for accounts that explicitly enabled push on this device. An
account that never asked for notifications has none to pause.
**Do not conflate it with `needsReauth`.** They mean opposite things about the
account's health, and saying the wrong one is wrong in both directions: telling
a user to sign in again when their notifications are merely stale asks for a
password they do not need, and treating a paused account as fine hides the one
thing a tap would fix.
Neither credential marker is a promise: `needsReauth: false` with a future
`tokenExpiresAt` means *nothing has gone wrong that the SDK knows of*, not that
the next switch is guaranteed to succeed. A switch can still fail, and when it
does the current session is left untouched — see
[When a transition fails](/sdk/authentication/multi-account#when-a-transition-fails).
On Expo, `avatar` and then `email` may be dropped from a stored entry if it
would otherwise exceed SecureStore's per-value limit. `id`, `name`, `username`,
the credential and the markers are never dropped. See
[Where accounts are stored](/sdk/authentication/multi-account#where-accounts-are-stored).
## Rendering the Signed-Out State
No account is auto-selected after a sign-out, removal or deletion, so `activeAccount` can be `null` while `accounts` is non-empty. That is the account-picker state:
```tsx theme={null}
import { useAccounts, useAuth } from "@sublay/react-js";
const { initialized, accessToken } = useAuth();
const { accounts, signedOut } = useAccounts();
if (!initialized) return ;
if (accessToken) return ;
if (signedOut && accounts.length > 0) return ;
return ;
```
Branch on `accessToken`, not on `activeAccount`. A selection is not a session:
`addAccount()` leaves the previous account selected while it clears the session,
so a selection-based gate would render your app for an account that has no
tokens behind it. `signedOut` then separates the picker state from a device
where nobody has ever signed in.
## Integration Guide
For multi-account integration guidance, see [Multi-Account](/sdk/authentication/multi-account).
# Add account
Source: https://docs.sublay.io/v7/hooks/auth/use-add-account
Link a new user account to the current app instance
## Overview
`useAddAccount` returns an `addAccount` function that clears the active auth state, allowing the user to sign into a new account. Previously stored accounts are not removed — they remain in the accounts map and can be restored with `useSwitchAccount`.
After the user completes sign-in, the new account is added to the accounts map automatically.
5 accounts can be stored simultaneously. `canAddAccount` is `false` when the
limit is reached. All 5 genuinely persist on every platform — see
[Where accounts are stored](/sdk/authentication/multi-account#where-accounts-are-stored).
## Usage Example
```tsx React theme={null}
import { useAddAccount } from "@sublay/react-js";
function AddAccountButton() {
const { addAccount, canAddAccount } = useAddAccount();
return (
);
}
```
```tsx React Native theme={null}
import { useAddAccount } from "@sublay/react-native";
function AddAccountButton() {
const { addAccount, canAddAccount } = useAddAccount();
return (
);
}
```
## Returns
Clears the current access token, refresh token, and user state from Redux,
along with the outgoing account's cached API data and account-scoped feature
state. The existing accounts map is preserved. After calling this function,
the app should display sign-in UI for the new account.
**Nothing it does reaches the stored account map.** The selection is left
exactly where it was and `signedOut` is not touched — opening a sign-in screen
is this surface's state, not the device's. On the web the map is broadcast to
every other tab, so a write here would be an instruction to all of them.
That is also what makes **abandoning** the flow safe. The user backs out and
quits; the map still names the account they were in, so the next launch
restores that account rather than moving them into a different one. Until
then this surface is in the shape the SDK uses everywhere for *stepped out
without signing out*: an account is still selected, but there is no live
session behind it. Re-tapping that same account in a switcher signs back into
it rather than no-opping.
A no-op when `canAddAccount` is `false`.
`true` if fewer than 5 accounts are currently stored. `false` at the limit.
`true` when an account was actually **refused** admission because the map was
already full. Clears on the next successful admission and on any removal — one
render *after*, not synchronously; see the warning below.
### `canAddAccount` vs `accountLimitReached`
They answer different questions and are both needed:
| | `canAddAccount` | `accountLimitReached` |
| ------------ | ----------------------------------------------------------------- | ---------------------------------------------------------- |
| Kind | Predicate — "is there room right now?" | Event — "did an admission just get refused?" |
| Derived from | The map size, on every render | A refusal, latched in the store |
| Typical use | Enable/disable the "Add account" button *before* the user commits | Render the error *after* a sign-in was rejected at the cap |
A third signal, [`wouldExceedAccountLimit`](/sdk/authentication/multi-account#three-signals-for-the-account-cap),
answers the same question as `canAddAccount` but *for a specific user id* — which
matters because signing back into an account this device already stores is never
an admission and works at the cap. Use it when you already know whose account is
coming back.
**`accountLimitReached` clears asynchronously.** The clear rides the same
effect that records a successful admission, so it lands **one render after**
the sign-in call resolves — not synchronously inside it. This is fine for
rendering, and wrong for reading:
```tsx theme={null}
await signInWithEmailAndPassword({ email, password });
if (accountLimitReached) showCapError(); // ⛔ reads the PRE-effect value
```
That read can return a `true` left over from an earlier, unrelated refusal.
Render the flag instead of sampling it, and the next render has the settled
value.
`accountLimitReached` matters because not every refusal has a call to reject. It is set by **every** entry point that refuses an account:
| Entry point | Also surfaces as an error? |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `signUpWithEmailAndPassword` | Yes — before any network call |
| `signInWithEmailAndPassword` | Yes — after the account is resolved |
| External auth (`signedToken`) | The provider's `auth/initialize` action rejects with the reason; the app **renders** signed-out, with the selection left where it was |
| OAuth, web and Expo | **No** — the flag is the only channel |
It is also readable from [`useAccounts`](/hooks/auth/use-accounts).
`canAddAccount` is not a substitute. A sign-in can start without ever going
through `addAccount()` — a plain sign-in form, an OAuth return, or a map that
filled in another tab while a request was in flight — and only
`accountLimitReached` covers those. Conversely the flag is an event: it stays
`false` until something is actually refused, so it cannot pre-disable a button.
For what the refusal leaves behind on each path, see
[Reaching the account limit](/sdk/authentication/multi-account#reaching-the-account-limit).
## Integration Guide
For multi-account integration guidance, see [Multi-Account](/sdk/authentication/multi-account).
# Use auth
Source: https://docs.sublay.io/v7/hooks/auth/use-auth
Access auth state and perform sign-up, sign-in, sign-out, and token management
## Overview
`useAuth` is the primary authentication hook. It returns current token state and functions for all built-in auth operations: sign-up, sign-in, sign-out, password change, setting an initial password, and manual token refresh.
## Usage Example
```tsx React theme={null}
import { useAuth } from "@sublay/react-js";
function SignInForm() {
const { initialized, accessToken, signInWithEmailAndPassword, signOut } = useAuth();
if (!initialized) return
Loading...
;
if (accessToken) {
// `signOut` rejects when the server refuses the push unbind — in which
// case nothing was torn down and the user should retry.
return (
);
}
return (
);
}
```
```tsx React Native theme={null}
import { useAuth } from "@sublay/react-native";
function SignInButton() {
const { initialized, accessToken, signInWithEmailAndPassword } = useAuth();
if (!initialized) return null;
if (accessToken) return null;
return (
## Returns
`useAuth` returns an object with the following fields:
`true` once the SDK has attempted to restore a session from the stored refresh
token. Always check this before rendering auth-dependent UI.
The current JWT access token, or `null` if no user is signed in. Expires every
30 minutes; the SDK refreshes it automatically.
The current JWT refresh token, or `null` if no user is signed in.
Manually set a refresh token in Redux state. Useful when integrating external
auth flows that hand tokens directly to the SDK.
Create a new account and sign in. Accepts a `SignUpWithEmailAndPasswordProps`
object. Throws if registration fails.
**Parameters:**
* `email` (required) — User's email address
* `password` (required) — Password
* `name` (optional) — Display name
* `username` (optional) — Unique username
* `avatar` (optional) — Avatar URL
* `bio` (optional) — Bio text
* `location` (optional) — `{ latitude, longitude }`
* `birthdate` (optional) — Date of birth
* `metadata` (optional) — Public custom fields
* `secureMetadata` (optional) — Private custom fields
* `avatarFile` (optional) — Avatar image file to upload
* `avatarOptions` (optional) — Image processing options for the avatar
* `bannerFile` (optional) — Banner image file to upload
* `bannerOptions` (optional) — Image processing options for the banner
Also throws [`ACCOUNT_LIMIT_MESSAGE`](#the-account-limit) when the device
already stores 5 accounts. Normally this happens before any network call, so
no account is created — but if the fifth slot fills while the request is in
flight, the refusal lands after it and the created user record persists.
Sign in with an existing account. Accepts `{ email: string; password: string }`.
Throws if credentials are invalid, and throws
[`ACCOUNT_LIMIT_MESSAGE`](#the-account-limit) if this would be a **sixth**
account on the device. Signing back in to an account already stored on the
device always succeeds, even at the limit.
Sign out the current account. Sends a sign-out request to the server to revoke
the refresh token family — and, once this device has a stored push identifier, to
unbind that account's notifications in the same transaction — then clears
local auth state.
**It rejects only when the server refuses the unbind.** In that case nothing is
torn down locally either: the account keeps its entry and its credential so the
user can retry, rather than being left receiving notifications from an account
they can no longer reach. Every other failure — no network, a throttled or
otherwise rejected request, a device that never registered for push — still
signs out locally, because a user must always be able to sign out.
**It does not sign you into another account.** If other accounts are stored,
they stay in the map but none is activated — see
[Multi-Account](/sdk/authentication/multi-account).
Change the password for the currently authenticated user. Accepts
`{ password: string; newPassword: string }`. The current password must be
correct. Throws if verification fails.
**Every other session for that user ends, and the caller's does not.** The
access token the request is authenticated with names the session it was minted
from, so the server knows which one is asking and nothing extra is sent. The
user stays signed in where they are standing; every other device must sign in
again with the new password.
**It also clears that user's push bindings on every *other* device, and keeps
this one's.** The hook sends the device identifier it already holds, which is
what lets the server single out the calling handset and spare exactly its
binding — so notifications keep arriving where the user is standing, and stop
on the devices they just locked out. Nothing re-binds a device from its live
session, so the others stay quiet until each is next opened. On a device that
has never registered for push there is no identifier to send and no binding to
keep, and every one of the user's bindings goes. See
[Change Password](/api-reference/auth/change-password).
Only this user's sessions are ended. Other accounts stored on this device
belong to other users and are untouched. The mirror case is the one to plan
for: when this user changes their password somewhere else, the copy of their
account stored here as a background account dies — and a stored account killed
that way still has a valid-looking expiry, so it is only discovered by trying,
which is what `needsReauth` on
[`useAccounts`](/hooks/auth/use-accounts#telling-a-live-account-from-a-dead-one)
records.
An access token minted before the change keeps working until it expires, up to
30 minutes, so "other devices no longer work" is about their refresh, not their
next request.
Set an **initial** password for an OAuth-only user who has no password yet.
Accepts `{ newPassword: string }`. Unlike `changePassword`, there is no
current password to verify. Throws if the user already has a password
(`auth/already-password-authenticated`) — use `changePassword` in that case.
Manually trigger an access token refresh using the stored refresh token.
Declared `() => Promise`: it resolves with the new access token and
**rejects** on every path that cannot produce one — a failed refresh, no
stored refresh token to present, or no `projectId`. It never resolves
`undefined`. The underlying `requestNewAccessTokenThunk` rejects in the same
cases, so `fulfilled.match(result)` is `false` when no refresh token is
present. The SDK handles refresh
automatically in most cases; use this only when you need to force a refresh.
This refreshes the **active** account only — it presents whatever refresh
token is in auth state. Switching to a *stored* account no longer goes through
this thunk: `switchAccount` exchanges the target's credential out of band,
before it touches the live session, so it cannot disturb the account you are
currently signed into. If you were observing `auth/requestNewAccessToken`
actions to detect account switches, watch the accounts slice instead. See
[When a transition fails](/sdk/authentication/multi-account#when-a-transition-fails).
## The Account Limit
A device stores at most **5 accounts**. `signUpWithEmailAndPassword` and `signInWithEmailAndPassword` reject when a sixth would be added, throwing an `Error` whose message is exported as `ACCOUNT_LIMIT_MESSAGE`:
```tsx theme={null}
import { useAuth, ACCOUNT_LIMIT_MESSAGE } from "@sublay/react-js";
try {
await signInWithEmailAndPassword({ email, password });
} catch (err) {
if (err instanceof Error && err.message === ACCOUNT_LIMIT_MESSAGE) {
// Prompt the user to sign out of one of the stored accounts.
}
}
```
Two behaviours worth knowing:
* **Sign-up is normally refused before the network call**; sign-in is refused after it, because only the server can say which account the credentials belong to. That is deliberate — checking the typed email against the stored accounts would lock users out of their own account whenever the stored email is stale, absent or cased differently. (A sign-up whose fifth slot fills *mid-request* is refused afterwards, and that user record does persist — see [what a refusal leaves behind](/sdk/authentication/multi-account#what-is-left-behind).)
* **A refused sign-in leaves the currently active account signed in.** Nothing is torn down, the accounts map is untouched, and the session the server created for the refused attempt is signed back out, so nothing is left dangling.
The same refusal also sets `accountLimitReached` on [`useAccounts`](/hooks/auth/use-accounts) and [`useAddAccount`](/hooks/auth/use-add-account). That flag is the **only** channel on the OAuth paths, which cannot reject their caller — see [Reaching the account limit](/sdk/authentication/multi-account#reaching-the-account-limit).
## Controlling Error Logging
Handled SDK failures are logged with `console.error`. To change or silence that:
```ts theme={null}
import { setSublayLogLevel } from "@sublay/react-js";
setSublayLogLevel("silent"); // "error" (default) | "warn" | "silent"
```
It is a bare setter rather than a `SublayProvider` prop because the setting is process-global — a prop would be silently last-mount-wins in an app that mounts two providers. It is coarse by design: it silences all SDK logging, including unexpected failures. Errors are still returned to the calling hooks; only the console output changes.
## Integration Guide
For full integration guidance, see [Built-in Auth](/sdk/authentication/built-in).
# Confirm account deletion
Source: https://docs.sublay.io/v7/hooks/auth/use-confirm-account-deletion
Verify the emailed code and permanently delete the authenticated user's account
## Overview
`useConfirmAccountDeletion` returns a function that submits the confirmation code from [`useRequestAccountDeletion`](/hooks/auth/use-request-account-deletion) and **permanently deletes** the authenticated user's account. On success, the local session is torn down just like a sign-out: the deleted account is removed from the multi-account map and **no other account is activated**, even if the user has others signed in. The app renders signed-out.
Deletion is **immediate and irreversible**. There is no grace period and no
recovery once this resolves.
Deleting an account ends the session and leaves nothing active, even when other accounts remain stored — read `activeAccount` from [`useAccounts`](/hooks/auth/use-accounts) and render your own next screen when it is `null`.
## Usage Example
```tsx React theme={null}
import { useConfirmAccountDeletion } from "@sublay/react-js";
import { useState } from "react";
function ConfirmDeletionForm() {
const confirmAccountDeletion = useConfirmAccountDeletion();
const [code, setCode] = useState("");
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
await confirmAccountDeletion({ code });
// Account is gone and the session has been cleared.
} catch (err: any) {
setError(err.message ?? "Deletion failed.");
}
};
return (
);
}
```
```tsx React Native theme={null}
import { useConfirmAccountDeletion } from "@sublay/react-native";
function ConfirmDeletionScreen({ code }: { code: string }) {
const confirmAccountDeletion = useConfirmAccountDeletion();
return (
## Parameters
The returned function accepts:
The one-time confirmation code the user received by email. Must match the most recently issued code exactly.
## Returns
The hook returns an async function that resolves to `void` on success. It throws if the code is invalid or expired, or if the deletion fails.
You don't need to call `signOut` afterward — the hook clears the local session
for you. Any other stored accounts stay in the map, but none of them is
activated; the app lands signed-out with the account picker available.
## See Also
* [`useRequestAccountDeletion`](/hooks/auth/use-request-account-deletion)
* [Confirm Account Deletion API reference](/api-reference/auth/confirm-account-deletion)
# Fetch OAuth identities
Source: https://docs.sublay.io/v7/hooks/auth/use-oauth-identities
List and manage OAuth identities linked to the current user
This hook requires the user to be authenticated.
## Overview
`useOAuthIdentities` fetches the list of OAuth identities linked to the current user's account and provides a function to unlink them. Use it to build a connected accounts settings page.
## Usage Example
```tsx theme={null}
import { useEffect } from "react";
import { useOAuthIdentities } from "@sublay/react-js";
function LinkedIdentities() {
const { identities, fetchIdentities, unlinkIdentity, isLoading, error } =
useOAuthIdentities();
useEffect(() => {
fetchIdentities();
}, []);
if (isLoading) return
);
}
```
## Returns
Array of OAuth identities linked to the current user. Initially empty; call
`fetchIdentities` to populate.
Whether the user has a password set. `false` for OAuth-only accounts, and
until `fetchIdentities` has run. Use it to prompt the user to
[set an initial password](/hooks/auth/use-auth) (via `useAuth().setPassword`)
before they unlink their last identity.
Async function that fetches the identities list from the server and updates
local state. Returns `void`.
Async function that unlinks a specific identity. Accepts `{ identityId: string }`.
On success, removes the identity from the local `identities` array. Fails
if it would be the last identity and the user has no password set.
`true` while a fetch or unlink request is in progress.
Error message from the most recent failed operation, or `null` if no error.
## OAuthIdentity Type
| Property | Type | Description |
| ------------------- | ---------------- | -------------------------------------------------- |
| `id` | `string` | Unique identity ID (UUID) |
| `provider` | `string` | OAuth provider name (e.g., `"google"`, `"github"`) |
| `providerAccountId` | `string` | The user's ID at the provider |
| `email` | `string \| null` | Email returned by the provider |
| `name` | `string \| null` | Name returned by the provider |
| `avatar` | `string \| null` | Avatar URL returned by the provider |
| `isVerified` | `boolean` | Whether the provider marked the email as verified |
| `createdAt` | `string` | ISO timestamp of when the identity was linked |
## See Also
* [OAuth integration guide](/sdk/authentication/oauth)
* [List Identities API reference](/api-reference/oauth/list-identities)
* [Unlink Identity API reference](/api-reference/oauth/unlink-identity)
# Use OAuth sign-in
Source: https://docs.sublay.io/v7/hooks/auth/use-oauth-sign-in
Initiate OAuth sign-in, link OAuth providers, and handle the callback — web only
## Overview
`useOAuthSignIn` handles the full redirect-based OAuth flow for web apps. It provides three functions:
* **`initiateOAuth`** — redirect an unauthenticated user to the provider's authorization page to sign in or sign up.
* **`linkOAuthProvider`** — redirect an already-authenticated user to link an additional OAuth provider to their account.
* **`handleOAuthCallback`** — call this on the OAuth callback page to extract tokens from the URL fragment and set the authenticated session.
This page documents the `@sublay/react-js` hook, which uses `window.location` for the redirect-based web flow. For Expo apps, `@sublay/expo` exports a
`useOAuthSignIn` hook with the **same API** that opens the system browser and returns via a deep link — see [OAuth Authentication → Expo Integration](/sdk/authentication/oauth#expo-integration).
## Usage Example
**Initiating sign-in:**
```tsx theme={null}
import { useOAuthSignIn } from "@sublay/react-js";
function SignInPage() {
const { initiateOAuth, isLoading, error } = useOAuthSignIn();
return (
<>
{error &&
{error}
}
>
);
}
```
**Handling the callback:**
```tsx theme={null}
import { useEffect } from "react";
import { useOAuthSignIn } from "@sublay/react-js";
function OAuthCallbackPage() {
const { handleOAuthCallback } = useOAuthSignIn();
useEffect(() => {
handleOAuthCallback();
}, []);
return
Signing you in...
;
}
```
**Linking an additional provider:**
```tsx theme={null}
import { useOAuthSignIn } from "@sublay/react-js";
function LinkGitHub() {
const { linkOAuthProvider } = useOAuthSignIn();
return (
);
}
```
## Returns
Starts the OAuth sign-in / sign-up flow for unauthenticated users. Redirects
the browser to the provider's authorization page.
**Parameters:**
* `provider` (required) — OAuth provider identifier (e.g. `"google"`, `"github"`).
* `redirectAfterAuth` (optional) — URL to redirect to after authentication. Defaults to the current page URL.
Links an additional OAuth provider to the currently authenticated user. The
user must already be signed in. Redirects the browser to the provider's
authorization page.
**Parameters:**
* `provider` (required) — OAuth provider identifier (e.g. `"google"`, `"github"`).
* `redirectAfterAuth` (optional) — URL to redirect to after linking. Defaults to the current page URL.
Reads tokens from the URL fragment (`#accessToken=...&refreshToken=...`) after
the provider redirects back to your app. On success, stores the tokens and
initializes the session. Returns `true` if tokens were found and set, `false`
otherwise.
Call this inside a `useEffect` on your callback page.
It is **synchronous and returns before the user's identity is known**, so a
`true` return means "tokens were found", not "this account was admitted". If
the device already stores 5 accounts and this is a sixth, the session is
signed back out and `accountLimitReached` is raised a moment later.
`true` while the authorization request is in progress. Stays `true` after the
redirect is triggered (since the page navigates away).
Error message if the OAuth flow fails, or `null` when there is no error.
**The account limit does not appear here.** Reaching the 5-account limit is
discovered after the provider returns, and this hook has no call left to
reject — read `accountLimitReached` from
[`useAccounts`](/hooks/auth/use-accounts) or
[`useAddAccount`](/hooks/auth/use-add-account) instead. This is the one place
OAuth differs from email sign-in and external auth, which do throw. See
[OAuth → The account limit](/sdk/authentication/oauth#the-account-limit).
## Integration Guide
For full OAuth setup instructions, see [OAuth Authentication](/sdk/authentication/oauth).
# Remove account
Source: https://docs.sublay.io/v7/hooks/auth/use-remove-account
Remove a linked account from the current app instance
## Overview
`useRemoveAccount` signs out and removes a specific account from the stored accounts map. It sends a sign-out request to the server to revoke the account's refresh token family — and, once this device has a stored push identifier, to unbind that account's push notifications in the **same transaction** — before removing any local state.
If the removed account is the currently active one, **the session ends and no account is left active**, even when other accounts remain stored. The app renders signed-out; choosing what happens next is yours.
**Removal is atomic once push is involved.** If this device has a stored push identifier, the sign-out request carries it, and if the server **refuses the unbind** nothing is torn down: `removeAccount` **rejects**, `error` carries the reason, and the account keeps its entry and its credential so the user can retry. Its signature is `({ userId }) => Promise`, so nothing warns you at compile time: `await removeAccount(...)` needs a `catch`.
The strictness is scoped to a **server refusal of the unbind itself** — the sign-out endpoint answering `auth/device-deregistration-failed` or `auth/sign-out-failed`, the two codes it returns when its transaction rolled back. Everything else is best-effort: no network, a throttled or migrating project, a rejected body, the generic `auth/server-error`, or a device with no stored push identifier. An offline user can still remove an account locally.
One more code arrives on a **success** and never blocks: `auth/push-unbind-status-unknown`, returned with `200` when the server signed the account out without attempting an unbind because it could not determine whether the project has push devices. `removeAccount` resolves, the account is removed, and the SDK warns — the binding may still be live, so it is worth surfacing, but it is not worth making an account unremovable over.
A stored push identifier is **device** state, not an account's consent: on native the SDK records one when the device already holds OS notification permission, whether or not this particular account ever registered. When the account had no binding, the unbind removes nothing and the removal completes exactly as it would without one.
Removing an account never activates a different one. Read `activeAccount` from [`useAccounts`](/hooks/auth/use-accounts) and render your own next screen when it is `null`.
## Usage Example
```tsx React theme={null}
import { useAccounts, useRemoveAccount } from "@sublay/react-js";
function AccountList() {
const { accounts } = useAccounts();
const { removeAccount, isRemoving, error } = useRemoveAccount();
return (
{accounts.map((account) => (
{account.name ?? account.email}
))}
{error &&
{error}
}
);
}
```
```tsx React Native theme={null}
import { useAccounts, useRemoveAccount } from "@sublay/react-native";
function AccountItem({ userId }: { userId: string }) {
const { removeAccount, isRemoving } = useRemoveAccount();
return (
## Parameters
The hook returns a `removeAccount` function that accepts:
The ID of the account to remove.
## Returns
Async function that removes the specified account. It **rejects** without
doing anything when `userId` is not in the accounts map — with an
[`AccountTransitionError`](/sdk/authentication/multi-account#telling-a-dead-account-from-a-dead-network)
carrying `accountNotFound: true`, so a stale id is distinguishable from a
credential failure without matching on message text — or when no `projectId`
is available. Runtime errors during removal set `error` **and reject** the
returned promise. Removing a non-active account leaves the current session
untouched.
`true` while the removal is in progress.
Error message if the removal failed, or `null` if no error occurred.
## Integration Guide
For multi-account integration guidance, see [Multi-Account](/sdk/authentication/multi-account).
# Request account deletion
Source: https://docs.sublay.io/v7/hooks/auth/use-request-account-deletion
Email the authenticated user a one-time code to confirm self-service account deletion
## Overview
`useRequestAccountDeletion` returns a function that starts the self-service account-deletion flow for the currently authenticated user. It emails the user a 6-digit confirmation code (valid for 10 minutes) and does **not** delete anything on its own. Pass the code the user receives to [`useConfirmAccountDeletion`](/hooks/auth/use-confirm-account-deletion) to finish.
Only works for accounts with an email on file. Accounts without one (e.g.
anonymous or foreign-id users) must be deleted server-side with a service key.
## Usage Example
```tsx React theme={null}
import { useRequestAccountDeletion } from "@sublay/react-js";
function DeleteAccountButton({ onCodeSent }: { onCodeSent: () => void }) {
const requestAccountDeletion = useRequestAccountDeletion();
const handleClick = async () => {
try {
await requestAccountDeletion();
onCodeSent(); // reveal the "enter your code" step
} catch (err: any) {
// e.g. auth/no-email-on-file
console.error(err.response?.data?.error ?? "Could not start deletion.");
}
};
return ;
}
```
```tsx React Native theme={null}
import { useRequestAccountDeletion } from "@sublay/react-native";
function DeleteAccountButton({ onCodeSent }: { onCodeSent: () => void }) {
const requestAccountDeletion = useRequestAccountDeletion();
return (
## Parameters
The returned function takes no arguments — it always acts on the currently authenticated user.
## Returns
The hook returns an async function. That function resolves to:
`true` when the request completes without a server error.
## See Also
* [`useConfirmAccountDeletion`](/hooks/auth/use-confirm-account-deletion)
* [Request Account Deletion API reference](/api-reference/auth/request-account-deletion)
# Request password reset
Source: https://docs.sublay.io/v7/hooks/auth/use-request-password-reset
Send a password reset email to a user
## Overview
`useRequestPasswordReset` returns a function that sends a password reset email to the specified address. The server always responds with a generic success message regardless of whether the address is registered, to prevent user enumeration.
## Usage Example
```tsx React theme={null}
import { useRequestPasswordReset } from "@sublay/react-js";
function ForgotPasswordForm() {
const requestPasswordReset = useRequestPasswordReset();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const form = e.currentTarget;
try {
const result = await requestPasswordReset({
email: form.email.value,
});
alert(result.message);
} catch (err) {
console.error(err);
}
};
return (
);
}
```
```tsx React Native theme={null}
import { useRequestPasswordReset } from "@sublay/react-native";
function ForgotPasswordButton({ email }: { email: string }) {
const requestPasswordReset = useRequestPasswordReset();
return (
## Parameters
The returned function accepts:
The email address to send the reset link to.
## Returns
The hook returns an async function. That function resolves to:
Always `true` when the request completes without a server error.
A generic success message. The same message is returned whether or not the
email address is registered.
The reset link in the email expires after 1 hour. The link points to a
server-hosted page that collects the new password and calls the
Reset Password endpoint.
## See Also
* [Request Password Reset API reference](/api-reference/auth/request-password-reset)
* [Reset Password API reference](/api-reference/auth/reset-password)
* [Built-in Auth guide](/sdk/authentication/built-in)
# Send verification email
Source: https://docs.sublay.io/v7/hooks/auth/use-send-verification-email
Send an email verification token or link to the authenticated user
## Overview
`useSendVerificationEmail` returns a function that sends a verification email to the currently authenticated user. The token expires after 5 minutes. Calling it on an already-verified user is a no-op (returns `{ success: true }` without sending).
## Usage Example
```tsx React — numeric code theme={null}
import { useSendVerificationEmail } from "@sublay/react-js";
function VerifyEmailPrompt() {
const sendVerificationEmail = useSendVerificationEmail();
return (
);
}
```
```tsx React — clickable link theme={null}
import { useSendVerificationEmail } from "@sublay/react-js";
function VerifyEmailPrompt() {
const sendVerificationEmail = useSendVerificationEmail();
return (
);
}
```
```tsx React Native theme={null}
import { useSendVerificationEmail } from "@sublay/react-native";
function VerifyEmailPrompt() {
const sendVerificationEmail = useSendVerificationEmail();
return (
## Parameters
The returned function requires a props object — `mode` is always required, and which other fields are required depends on it:
How the token is delivered.
* `"code"` — user receives a token to type into your app
* `"link"` — user receives a clickable button that verifies automatically
Character set for the token.
* Required when `mode: "code"` — there's no default, since `"hex"` (see below) produces something no one would want to type.
* Optional when `mode: "link"` (defaults to `"hex"`) — the token is embedded in a URL, never read by a human, so the format doesn't matter.
Length of the generated token (4–12).
* Required when `mode: "code"` and `tokenFormat` is `"numeric"`, `"alpha"`, or `"alphanumeric"`.
* Not allowed when `tokenFormat` is `"hex"` — hex ignores length and always produces a fixed 64-character token, so specifying one would be silently meaningless.
* Optional when `mode: "link"` (defaults to `6`).
URL to redirect to after link verification. Only valid with `mode: "link"`. Receives `?verified=true` or `?verified=false&error=...` as query params.
These requirements are enforced by TypeScript, not just documented — `sendVerificationEmail({ mode: "code", tokenFormat: "hex" })` compiles, but `sendVerificationEmail({ mode: "code" })` or `sendVerificationEmail({ mode: "code", tokenFormat: "numeric" })` (missing `tokenLength`) will fail to type-check.
## Returns
The hook returns an async function. That function resolves to:
`true` when the request completes without a server error.
## See Also
* [`useVerifyEmail`](/hooks/auth/use-verify-email)
* [Send Verification Email API reference](/api-reference/auth/send-verification-email)
* [Built-in Auth guide](/sdk/authentication/built-in)
# Sign out all
Source: https://docs.sublay.io/v7/hooks/auth/use-sign-out-all
Sign out of all linked accounts at once
## Overview
`useSignOutAll` signs out every stored account simultaneously. It sends a sign-out request for each account's refresh token — and, once this device has a stored push identifier, unbinds each account's push notifications in the same transaction — then clears local auth state.
**A partial failure keeps the accounts it could not sign out.** When the requests carry this device's push identifier, accounts whose request **succeeded** are removed and accounts whose unbind the server **refused** are kept with their credentials intact; the call then **rejects** with how many failed, so the user can retry. Its signature is `() => Promise`, so nothing warns you at compile time: `await signOutAll()` needs a `catch`. The live session ends either way — an access token is transient state, not the credential the guarantee is about.
As with [`useRemoveAccount`](/hooks/auth/use-remove-account), only a **server refusal of the unbind** blocks — `auth/device-deregistration-failed` or `auth/sign-out-failed`. Those are the two codes the server returns when it attempted the unbind inside its transaction and rolled the whole thing back. Everything else resolves: a transport failure carries no response, a throttled or migrating project is refused before the unbind is reached, and a device with **no stored push identifier** sends nothing to unbind in the first place. A user can always sign out.
A third code, `auth/push-unbind-status-unknown`, arrives on a **success** and never blocks: the server signed the account out without attempting an unbind because it could not determine whether the project has push devices. The SDK completes the sign-out and warns; that device may still be bound to the account until it registers again.
## Usage Example
```tsx React theme={null}
import { useSignOutAll } from "@sublay/react-js";
function SignOutAllButton() {
const { signOutAll } = useSignOutAll();
const handleClick = async () => {
try {
await signOutAll();
} catch (err) {
console.error("Sign out failed:", err);
}
};
return ;
}
```
```tsx React Native theme={null}
import { useSignOutAll } from "@sublay/react-native";
function SignOutAllButton() {
const { signOutAll } = useSignOutAll();
const handlePress = async () => {
try {
await signOutAll();
} catch (err) {
// Some accounts were kept — surface the reason and let the user retry.
showError(err);
}
};
return ;
}
```
## Returns
Async function that signs out all accounts. Sends a server-side sign-out
request for each stored refresh token, then resets auth state, resets the API
cache, and returns every account-scoped feature slice (chat, notifications,
entity/space lists, collections, table views) to its initial state. The live
session ends either way.
The accounts map is cleared entirely when every request came back clean. When
the server refused an unbind for one or more accounts, those accounts keep
their entries and credentials, every other account is still removed, and the
promise **rejects** — see the warning above.
Afterwards the app is in the **durable signed-out state**: `activeAccount` is
`null`, `signedOut` from [`useAccounts`](/hooks/auth/use-accounts) is `true`,
and no account is auto-selected at the next launch.
## Integration Guide
For multi-account integration guidance, see [Multi-Account](/sdk/authentication/multi-account).
# Switch account
Source: https://docs.sublay.io/v7/hooks/auth/use-switch-account
Switch the active user account
## Overview
`useSwitchAccount` switches the active session to a different stored account. It exchanges the target account's stored refresh token for a live session **first**, and only then clears the current auth state and swaps the new one in. If that exchange fails, nothing is touched — you stay signed in where you were.
## Usage Example
```tsx React theme={null}
import { useAccounts, useSwitchAccount } from "@sublay/react-js";
function AccountSwitcher() {
const { accounts, activeAccount } = useAccounts();
const { switchAccount, isSwitching, error } = useSwitchAccount();
return (
{accounts.map((account) => (
{account.name ?? account.email}
{/* Both markers, so a dead account is labelled before it is tapped. */}
{(account.needsReauth || account.tokenExpiresAt <= Date.now()) && (
— sign in again
)}
{account.id !== activeAccount?.id && (
)}
))}
{error &&
{error}
}
);
}
```
```tsx React Native theme={null}
import { useSwitchAccount } from "@sublay/react-native";
function SwitchButton({ userId }: { userId: string }) {
const { switchAccount, isSwitching } = useSwitchAccount();
return (
## Parameters
The hook returns a `switchAccount` function that accepts:
The ID of the account to switch to. Must be present in the stored accounts map.
## Returns
Async function that switches the active session. It **rejects** without doing
anything when the id is not in the accounts map — with an
`AccountTransitionError` carrying `accountNotFound: true` — or when no
`projectId` is available, and it rejects if the session for the target account
could not be established. See below. Its signature is
`({ userId }) => Promise`, so nothing warns you at compile time:
`await switchAccount(...)` without a `catch` becomes an unhandled rejection.
Calling it with the ID of the already-active account is a no-op **only while
that account has a live session**. When the selection names an account with no
session — after a refusal at the account limit, for instance — re-selecting it
runs the full transition, so the user has a way back in without restarting the
app.
`true` while the switch is in progress (validating the target's stored
credential, then swapping the session over).
Error message if the switch failed, or `null` if no error occurred.
## Failure Behavior
When the target account's stored refresh token cannot be exchanged for an access token — it expired, was revoked, or the stored entry has no usable token at all — the returned promise **rejects** and `error` is set.
**A failed switch leaves the current session completely intact.** The target's
credential is validated out of band before anything is torn down, so on failure
nothing has changed: the account you were using is still active, still holds its
tokens, and still has its cached data. Only the rejection happened.
* The previously active account is still active, with a live session. Nothing was signed out.
* **Both entries remain in the accounts map**, including the one that failed. That entry is what lets you render a "session expired" prompt for it.
* When the server was the one that refused the credential, that account's `needsReauth` is set, so the switcher can label it without trying again. A switch that failed because the request never reached the server does **not** set it.
* Switching with *no* account active — the account-picker state a user lands in after signing out — leaves nothing selected and marks the app signed out, so the next launch does not silently activate a different account.
```tsx theme={null}
import { AccountTransitionError } from "@sublay/react-js";
const { switchAccount, error } = useSwitchAccount();
async function onPick(userId: string) {
try {
await switchAccount({ userId });
// Session is live for `userId`.
} catch (err) {
// Not switched, and nothing else changed — the previous account is still
// signed in. `error` holds the reason.
if (err instanceof AccountTransitionError && err.credentialRejected) {
promptSignIn(userId); // the server refused the credential
} else {
showRetry(); // the request never got an answer
}
}
}
```
### Telling a dead account from a dead network
A transition failure rejects with an `AccountTransitionError`, and its two boolean discriminants
are the only way to tell those failures apart — so check with `instanceof` first, as the example
above does, rather than assuming the type. (The hook also throws a plain `Error` when no
`projectId` is configured, and [`removeAccount`](/hooks/auth/use-remove-account) rejects with the
server's own error, unchanged, when it refuses to unbind that account's push notifications.)
`true` when the server **refused** the stored credential — expired, revoked, reuse-detected,
invalidated by a password change or a remote sign-out-all — or when the stored entry carries no
usable credential at all. This is exactly the case in which `needsReauth` is set.
`false` when the exchange never got an answer, or the rotated successor could not be persisted.
Nothing is marked and the same account may work on the next attempt.
`true` when the `userId` is **not in the accounts map at all** — a stale id, typically from a
switcher rendered off a snapshot the map has since moved past. No network call went out, nothing
was marked, and no session was touched. Re-read the list from
[`useAccounts`](/hooks/auth/use-accounts) rather than prompting a sign-in for an account that is
not there.
Never `true` at the same time as `credentialRejected`; both are `false` for a transport failure.
Prompting a re-auth on every rejection tells a user with no signal that their account is dead, and
does it again every time they lose signal — which is what this discriminator exists to prevent.
`AccountTransitionError` and `ACCOUNT_TRANSITION_FAILED_MESSAGE` (its default message, used when
the server gave no reason) are both exported.
**The switch costs exactly one token exchange.** The refresh endpoint rotates
the token it is given, and validating the target's credential *is* that
exchange — it is not an extra probe in front of one.
## Integration Guide
For multi-account integration guidance, see [Multi-Account](/sdk/authentication/multi-account).
# Verify email
Source: https://docs.sublay.io/v7/hooks/auth/use-verify-email
Verify the authenticated user's email address using a token from a verification email
## Overview
`useVerifyEmail` returns a function that submits a verification token to the server. Used with `mode: "code"` from `useSendVerificationEmail`. On success, `user.isVerified` is updated immediately in the local Redux store.
## Usage Example
```tsx React theme={null}
import { useVerifyEmail } from "@sublay/react-js";
import { useState } from "react";
function VerifyCodeForm() {
const verifyEmail = useVerifyEmail();
const [code, setCode] = useState("");
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
await verifyEmail({ token: code });
// user.isVerified is now true locally
} catch (err: any) {
setError(err.response?.data?.error ?? "Verification failed.");
}
};
return (
);
}
```
```tsx React Native theme={null}
import { useVerifyEmail } from "@sublay/react-native";
function VerifyCodeScreen({ code }: { code: string }) {
const verifyEmail = useVerifyEmail();
return (
## Parameters
The returned function accepts:
The verification token entered by the user. Must match the token from the most recent verification email exactly.
## Returns
The hook returns an async function. That function resolves to:
`true` when verification succeeded.
On success, `user.isVerified` is optimistically set to `true` in the local
Redux store immediately — no need to refetch the user.
## See Also
* [`useSendVerificationEmail`](/hooks/auth/use-send-verification-email)
* [Verify Email API reference](/api-reference/auth/verify-email)
* [Built-in Auth guide](/sdk/authentication/built-in)
# Create entity
Source: https://docs.sublay.io/v7/hooks/entities/use-create-entity
Create a new entity with optional file and image uploads
**Requires the `entities` bundle.** A project without the entities bundle returns `403 database/tables-not-available`. See [Bundles](/bundles).
## Overview
`useCreateEntity` returns a function that creates a new entity. It supports both JSON and multipart form requests — the hook automatically selects multipart when images or files are included.
## Usage Example
```tsx theme={null}
import { useCreateEntity } from "@sublay/react-js";
function NewPost() {
const createEntity = useCreateEntity();
const handleCreate = async () => {
const entity = await createEntity({
title: "Hello World",
content: "My first post",
keywords: ["intro", "welcome"],
});
console.log("Created:", entity.id);
};
return ;
}
```
### With image upload
```tsx theme={null}
const entity = await createEntity({
title: "Photo post",
images: {
files: [imageFile],
options: { width: 1200, height: 800, fit: "cover" },
},
});
```
## Parameters
An ID from your own system to link this entity to an existing item.
An identifier for grouping entities by source (e.g., a section of your app).
The ID of the space this entity belongs to.
Entity title.
Entity body content.
Array of attachment objects. Flexible structure — can represent media URLs, file metadata, or any structured data.
Tags or keywords for filtering and discovery.
Users mentioned in the entity content.
Geographic location for the entity.
Arbitrary project-specific data. Limited to 10 KB.
Marks the entity's own NSFW flag. Defaults to `false`. This is only the entity's own flag — its effective NSFW status also inherits from its space. Sublay labels and filters NSFW content but does not blur or age-gate it. See [Entity NSFW flagging](/data-models/entity#nsfw-flagging).
If `true`, creates the entity as a draft (not publicly visible). Default `false`.
If `true`, the authenticated user is not associated as the entity author.
If `true`, the request fails if no authenticated user is present.
Images to upload and attach. Triggers a multipart form request.
Files to upload and attach. Triggers a multipart form request.
## Returns
The newly created entity. See [Entity data model](/data-models/entity).
# Use entity
Source: https://docs.sublay.io/v7/hooks/entities/use-entity
Access entity state and actions from EntityProvider
**Requires the `entities` bundle.** A project without the entities bundle returns `403 database/tables-not-available`. See [Bundles](/bundles).
## Overview
`useEntity` reads from the nearest `EntityProvider` in the component tree and returns the entity's state and actions. It does not fetch data itself — it accesses what `EntityProvider` has already loaded.
`useEntity` must be used inside an `EntityProvider`. See [EntityProvider & useEntity](/sdk/entities/provider-and-hook) for setup instructions.
## Usage Example
```tsx theme={null}
import { useEntity } from "@sublay/react-js";
function EntityContent() {
const { entity, updateEntity, deleteEntity } = useEntity();
if (!entity) return
Loading...
;
return (
{entity.title}
Views: {entity.views}
);
}
```
## Return Values
The current entity. `undefined` while loading, `null` if not found.
Direct state setter for the entity. Use for manual local state overrides.
Updates the entity on the server and syncs local state. Accepts `title`, `content`, `attachments`, `keywords`, `location`, `metadata`, and `mentions`.
Deletes the entity and sets local state to `undefined`.
# Fetch entity
Source: https://docs.sublay.io/v7/hooks/entities/use-fetch-entity
Fetch a single entity by its Sublay entity ID
**Requires the `entities` bundle.** A project without the entities bundle returns `403 database/tables-not-available`. See [Bundles](/bundles).
## Overview
`useFetchEntity` returns a function that fetches a single entity by its Sublay entity ID. Use this for on-demand fetches outside of `EntityProvider`.
## Usage Example
```tsx theme={null}
import { useFetchEntity } from "@sublay/react-js";
function EntityLoader({ entityId }: { entityId: string }) {
const fetchEntity = useFetchEntity();
const load = async () => {
const entity = await fetchEntity({ entityId, include: ["user", "topComment"] });
console.log(entity);
};
return ;
}
```
## Parameters
The Sublay entity ID to fetch.
Optional. Populate related fields. Accepted values: `"user"`, `"space"`, `"topComment"`, `"saved"`, `"files"`, `"grants"`.
## Returns
The fetched entity. When `spaceReputation` is requested, the embedded author carries an added `spaceReputation` number. See [Entity data model](/data-models/entity) and [Reputation](/data-models/reputation).
# Fetch entity by foreign ID
Source: https://docs.sublay.io/v7/hooks/entities/use-fetch-entity-by-foreign-id
Fetch a single entity by its foreign ID
**Requires the `entities` bundle.** A project without the entities bundle returns `403 database/tables-not-available`. See [Bundles](/bundles).
## Overview
`useFetchEntityByForeignId` returns a function that fetches an entity using the foreign ID from your system. Optionally auto-creates the entity if it does not yet exist.
## Usage Example
```tsx theme={null}
import { useFetchEntityByForeignId } from "@sublay/react-js";
function ArticleSocial({ articleId }: { articleId: string }) {
const fetchEntity = useFetchEntityByForeignId();
const load = async () => {
const entity = await fetchEntity({
foreignId: articleId,
createIfNotFound: true,
});
console.log(entity);
};
return ;
}
```
## Parameters
The foreign ID to look up. This is the identifier from your own system.
If `true`, automatically creates a new entity with this foreign ID when one does not exist.
Optional. Populate related fields. Accepted values: `"user"`, `"space"`, `"topComment"`, `"saved"`, `"files"`, `"grants"`.
## Returns
The fetched (or newly created) entity. See [Entity data model](/data-models/entity).
# Fetch entity by short ID
Source: https://docs.sublay.io/v7/hooks/entities/use-fetch-entity-by-short-id
Fetch a single entity by its auto-generated short ID
**Requires the `entities` bundle.** A project without the entities bundle returns `403 database/tables-not-available`. See [Bundles](/bundles).
## Overview
`useFetchEntityByShortId` returns a function that fetches an entity by its `shortId` — the auto-generated short identifier that Sublay assigns to every entity. Short IDs are useful for building readable sharing URLs.
## Usage Example
```tsx theme={null}
import { useFetchEntityByShortId } from "@sublay/react-js";
import { useParams } from "react-router-dom";
function SharePage() {
const { shortId } = useParams<{ shortId: string }>();
const fetchEntity = useFetchEntityByShortId();
const load = async () => {
const entity = await fetchEntity({ shortId });
console.log(entity);
};
return ;
}
```
## Parameters
The short ID to look up. This is the value from `entity.shortId`.
Optional. Populate related fields. Accepted values: `"user"`, `"space"`, `"topComment"`, `"saved"`, `"files"`, `"grants"`.
## Returns
The fetched entity. See [Entity data model](/data-models/entity).
# Fetch many entities
Source: https://docs.sublay.io/v7/hooks/entities/use-fetch-many-entities
Fetch a paginated, filtered, and sorted list of entities
**Requires the `entities` bundle.** A project without the entities bundle returns `403 database/tables-not-available`. See [Bundles](/bundles).
## Overview
`useFetchManyEntities` returns a function that fetches a paginated list of entities with full filtering and sorting support. This is the low-level hook underlying `EntityListProvider`. For continuous-scroll or load-more list UIs, use [`useEntityList`](/hooks/entity-lists/use-entity-list) instead, which wraps this hook with built-in pagination state.
## Usage Example
```tsx theme={null}
import { useFetchManyEntities } from "@sublay/react-js";
import { useEffect, useState } from "react";
function EntityFeed() {
const fetchEntities = useFetchManyEntities();
const [entities, setEntities] = useState([]);
useEffect(() => {
fetchEntities({
page: 1,
limit: 20,
sortBy: "score",
spaceId: "space_abc",
include: ["user"],
}).then((res) => setEntities(res.data));
}, []);
return (
{entities.map((e) => (
{e.title}
))}
);
}
```
## Parameters
Page number (1-indexed). Defaults to `1`.
Results per page. Defaults to server default.
Field to sort by. Options: `"createdAt"`, `"top"`, `"hot"`, `"controversial"`, or `"metadata."` to sort by a metadata field.
Sort by a specific reaction type count. Use with `sortBy: "reaction"`.
Sort direction: `"asc"` or `"desc"`.
Sort algorithm type. Options: `"auto"`, `"numeric"`, `"text"`, `"boolean"`, `"timestamp"`. Used when sorting by a metadata field.
Filter entities to a time window. Options: `"day"`, `"week"`, `"month"`, `"year"`.
Filter to entities associated with a specific source ID.
Filter to entities in a specific space.
Filter to entities created by a specific user.
If `true`, return only entities from users the authenticated user follows.
Filter by keywords. See [Entity List Filters](/sdk/entity-lists/filters) for the full filter schema.
Filter by title content. See [Entity List Filters](/sdk/entity-lists/filters).
Filter by body content. See [Entity List Filters](/sdk/entity-lists/filters).
Filter by attachments data. See [Entity List Filters](/sdk/entity-lists/filters).
Filter by geographic proximity. See [Entity List Filters](/sdk/entity-lists/filters).
Filter by metadata fields. See [Entity List Filters](/sdk/entity-lists/filters).
Filter by **effective** NSFW status (`entity.nsfw OR its space's nsfwEffective`). `"include-all"` (default) adds no predicate; `"exclude"` drops effective-NSFW entities; `"only"` returns only effective-NSFW entities. On projects without the spaces bundle it applies against the entity's own `nsfw` flag alone. See [Entity NSFW flagging](/data-models/entity#nsfw-flagging).
Populate related fields. Accepted values: `"user"`, `"space"`, `"topComment"`, `"saved"`, `"files"`, `"grants"`.
## Returns
Array of entities for the current page. When `spaceReputation` is requested, each embedded author carries an added `spaceReputation` number. See [Reputation](/data-models/reputation).
Current page number.
Number of results per page.
Total number of pages matching the filters.
Total count of entities matching the filters (across all pages).
`true` if there are additional pages available.
# Use entities
Source: https://docs.sublay.io/v7/hooks/entities/use-fetch-many-entities-wrapper
Stateful, paginated entity feed with sorting and filtering
**Requires the `entities` bundle.** A project without the entities bundle returns `403 database/tables-not-available`. See [Bundles](/bundles).
## Overview
`useFetchManyEntitiesWrapper` is a stateful wrapper around [`useFetchManyEntities`](/hooks/entities/use-fetch-many-entities) that manages pagination, sorting, and list state for you. Use it when you need a custom entity feed outside of `EntityListProvider` — for example, a user profile feed, a filtered search results page, or any custom list with infinite scroll.
All filters and sort options mirror those of `useFetchManyEntities`. Changing any sort option automatically resets the list and re-fetches from page 1.
## Usage Example
```tsx theme={null}
import { useFetchManyEntitiesWrapper } from "@sublay/react-js";
function UserFeed({ userId }: { userId: string }) {
const {
entities,
loading,
hasMore,
sortBy,
setSortBy,
loadMore,
} = useFetchManyEntitiesWrapper({
userId,
limit: 15,
defaultSortBy: "createdAt",
include: ["user"],
});
return (
{entities.map((entity) => (
{entity.title}
))}
{hasMore && (
)}
);
}
```
## Parameters
Number of entities per page. Default: `10`.
Filter to entities created by a specific user.
Filter to entities associated with a specific source ID.
Filter to entities in a specific space.
If `true`, return only entities from users the authenticated user follows.
Populate related fields. Accepted values: `"user"`, `"space"`, `"topComment"`, `"saved"`, `"files"`, `"grants"`.
Initial sort field. Default: `"createdAt"`.
Initial reaction type to sort by when `sortBy` is reaction-based. Default: `"upvote"`.
Initial sort direction. Default: `"desc"`.
Initial sort algorithm. Options: `"auto"`, `"numeric"`, `"text"`, `"boolean"`, `"timestamp"`. Used when sorting by a metadata field. Default: `"auto"`.
Filter entities to a time window. Options: `"day"`, `"week"`, `"month"`, `"year"`.
Filter by keywords. See [Entity List Filters](/sdk/entity-lists/filters).
Filter by title content. See [Entity List Filters](/sdk/entity-lists/filters).
Filter by body content. See [Entity List Filters](/sdk/entity-lists/filters).
Filter by attachments data. See [Entity List Filters](/sdk/entity-lists/filters).
Filter by geographic proximity. See [Entity List Filters](/sdk/entity-lists/filters).
Filter by metadata fields. See [Entity List Filters](/sdk/entity-lists/filters).
## Returns
The current list of fetched entities. Appended to on each `loadMore` call.
`true` while fetching the initial page or loading more.
`true` if additional pages are available.
Current sort field.
Current reaction type used for reaction-based sorting.
Current sort direction.
Current sort algorithm.
Change the sort field. Resets the list and re-fetches from page 1.
Change the reaction type for reaction-based sorting. Resets and re-fetches.
Change the sort direction. Resets the list and re-fetches from page 1.
Change the sort algorithm. Resets the list and re-fetches from page 1.
Load the next page of entities. Appends to the existing list.
# Hooks Reference
Source: https://docs.sublay.io/v7/hooks/overview
Low-level hooks for every Sublay feature
Browse the full reference for all Sublay hooks, organized by feature area. Each hook page documents its props, return values, and usage examples.
For higher-level integration guidance, see the [SDK Reference](/sdk/getting-started).
# Use user
Source: https://docs.sublay.io/v7/hooks/user/use-user
Access and update the authenticated user's own profile
## Overview
`useUser` is the primary hook for interacting with the currently authenticated user's profile. It provides the user's full data (as an `AuthUser`), loading and error states, an `updateUser` action, and derived [suspension](/sdk/moderation/suspensions) state (`isSuspended` / `activeSuspension`).
Updates are applied optimistically — the UI reflects changes immediately, and reverts automatically if the server request fails.
## Usage Example
```tsx theme={null}
import { useUser } from "@sublay/react-js";
function ProfileEditor() {
const { user, updating, updateUser } = useUser();
const handleSave = async () => {
await updateUser({ name: "Jane Doe", bio: "Building cool things." });
};
if (!user) return null;
return (
{user.name}
);
}
```
### Uploading an Avatar
```tsx theme={null}
import { useUser } from "@sublay/react-js";
function AvatarUpload({ file }: { file: File }) {
const { updateUser } = useUser();
const handleUpload = async () => {
await updateUser({
avatar: {
file,
options: { width: 200, height: 200, fit: "cover" },
},
});
};
return ;
}
```
## Return Values
The authenticated user's full profile. Returns `null` while loading or when no user is authenticated. See [User data model](/data-models/user).
`true` while the user's profile is being fetched for the first time.
`true` while an `updateUser` call is in progress.
Error message if an `updateUser` call fails. `null` when there is no error.
`true` while the authenticated user has an active [suspension](/sdk/moderation/suspensions). Derived from the user's `suspensions` using the same effective definition as the server.
The user's effective (furthest-reaching) active suspension, or `null` when they aren't suspended. `endDate: null` means the suspension is indefinite. Bind a "suspended until X" banner or a disabled composer to this. See [Suspensions](/sdk/moderation/suspensions).
Updates the authenticated user's profile. Applies optimistic updates immediately and reverts on failure.
Display name. Pass `null` to clear.
Unique username. Pass `null` to clear. Must be available.
Short biography. Limited to 300 characters.
Date of birth. Pass `null` to clear.
Avatar image. Pass a URL string, a file object with upload options, or `null` to remove.
Banner image. File upload only.
User's geographic location. Pass `null` to clear.
Arbitrary project-specific data. Limited to 10 KB. Visible to other users (embedded in entity and comment data).
Private custom fields excluded from all client-facing responses, including the authenticated user's own profile. Stored server-side only — use it for data you need to persist but never expose to any client.
Clears the current error state.
## Optimistic Updates
`updateUser` applies changes to local state before the server responds. This makes the UI feel instant. Fields that cannot be optimistically applied (file uploads, location) wait for the server response.
If the server returns an error, the hook automatically reverts the user state to its pre-update value.
File uploads (avatar as a `File`/`Blob`, and banner) are not applied optimistically because the final URL is unknown until the upload completes.
# Use user actions
Source: https://docs.sublay.io/v7/hooks/user/use-user-actions
Low-level actions for managing authenticated user state
## Overview
`useUserActions` is a low-level hook that provides direct access to the Redux actions and API mutations for the current user. In most cases you should use [`useUser`](/hooks/user/use-user) instead, which wraps `useUserActions` and provides a higher-level interface.
Use `useUserActions` directly when you need to manually set or clear user state — for example, in a custom authentication flow.
## Usage Example
```tsx theme={null}
import { useUserActions } from "@sublay/react-js";
function CustomAuth() {
const { setUser, clearUser } = useUserActions();
const handleExternalLogin = async (token: string) => {
const userData = await myAuthService.login(token);
setUser(userData); // Sync the authenticated user into Sublay state
};
const handleLogout = () => {
clearUser();
};
return (
<>
>
);
}
```
## Return Values
Manually sets the current user in Redux state. Validates that the user object has an `id` before setting — partial or empty objects are silently ignored.
Clears the current user from Redux state and resets any error.
Calls the update user API and manages loading and error state. Applies optimistic updates for non-file fields. Reverts to `currentUser` if the request fails.
The project ID.
The authenticated user's ID.
Fields to update. See [`useUser`](/hooks/user/use-user) for the full `UpdateUserParams` reference.
The current user snapshot used to revert state on failure.
Clears any error stored in the user slice.
Prefer [`useUser`](/hooks/user/use-user) for typical profile management. `useUserActions` is intended for cases where you need direct control over user state, such as custom authentication integrations.
# Check username availability
Source: https://docs.sublay.io/v7/hooks/users/use-check-username-availability
Check whether a username is available before setting it
## Overview
`useCheckUsernameAvailability` returns a function that checks whether a given username is available in the project. Use this to validate a username in real time before calling `updateUser`.
## Usage Example
```tsx theme={null}
import { useCheckUsernameAvailability } from "@sublay/react-js";
import { useState } from "react";
function UsernameInput() {
const checkAvailability = useCheckUsernameAvailability();
const [available, setAvailable] = useState(null);
const handleChange = async (username: string) => {
if (username.length < 3) return;
const result = await checkAvailability({ username });
setAvailable(result.available);
};
return (
);
}
```
## Parameters
The hook returns a function. That function accepts:
The username to check.
## Returns
`true` if the username is available, `false` if it is already taken.
# Fetch user
Source: https://docs.sublay.io/v7/hooks/users/use-fetch-user
Fetch a user's public profile by their Sublay user ID
## Overview
`useFetchUser` returns a function that fetches a user's public profile by their Sublay user ID. Use this when you have the user's `id` and need to load their profile on demand.
If the profile owner has blocked the signed-in caller, this returns `404` — the
same response as a user who does not exist. The reverse is not true: a user
*you* have blocked stays visible, so you keep a way to find and unblock them. Requires the `moderation` bundle.
## Usage Example
```tsx theme={null}
import { useFetchUser } from "@sublay/react-js";
import { useState } from "react";
function UserCard({ userId }: { userId: string }) {
const fetchUser = useFetchUser();
const [user, setUser] = useState(null);
const load = async () => {
const result = await fetchUser({ userId });
setUser(result);
};
return ;
}
```
## Parameters
The hook returns a function. That function accepts:
The Sublay user ID to fetch.
Optional. Pass `"files"` to populate `avatarFile` and `bannerFile` with full `File` objects.
## Returns
The user's public profile. When `spaceReputation` is requested, it carries an added `spaceReputation` number. See [User data model](/data-models/user) and [Reputation](/data-models/reputation).
# Fetch user by foreign ID
Source: https://docs.sublay.io/v7/hooks/users/use-fetch-user-by-foreign-id
Fetch a user's public profile by their foreign ID
## Overview
`useFetchUserByForeignId` returns a function that fetches a user's public profile using their `foreignId` — the ID from your own system that was associated with this user during authentication.
Use this when you have your own user ID and need to look up the corresponding Sublay user profile.
If the profile owner has blocked the signed-in caller, this returns `404` — the
same response as a user who does not exist. The reverse is not true: a user
*you* have blocked stays visible, so you keep a way to find and unblock them. Requires the `moderation` bundle.
## Usage Example
```tsx theme={null}
import { useFetchUserByForeignId } from "@sublay/react-js";
function UserCard({ myUserId }: { myUserId: string }) {
const fetchUser = useFetchUserByForeignId();
const load = async () => {
const user = await fetchUser({ foreignId: myUserId });
console.log(user);
};
return ;
}
```
## Parameters
The hook returns a function. That function accepts:
The foreign ID to look up. This is the identifier from your own system that was passed when the user was created or verified.
Optional. Pass `"files"` to populate `avatarFile` and `bannerFile` with full `File` objects.
## Returns
The user's public profile. When `spaceReputation` is requested, it carries an added `spaceReputation` number. See [User data model](/data-models/user) and [Reputation](/data-models/reputation).
# Fetch user by username
Source: https://docs.sublay.io/v7/hooks/users/use-fetch-user-by-username
Fetch a user's public profile by their username
## Overview
`useFetchUserByUsername` returns a function that fetches a user's public profile by their username. Use this for public profile pages where the URL contains a username slug.
If the profile owner has blocked the signed-in caller, this returns `404` — the
same response as a user who does not exist. The reverse is not true: a user
*you* have blocked stays visible, so you keep a way to find and unblock them. Requires the `moderation` bundle.
## Usage Example
```tsx theme={null}
import { useFetchUserByUsername } from "@sublay/react-js";
import { useEffect, useState } from "react";
import { User } from "@sublay/core";
function ProfilePage({ username }: { username: string }) {
const fetchUser = useFetchUserByUsername();
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser({ username }).then(setUser);
}, [username]);
if (!user) return null;
return
{user.name}
;
}
```
## Parameters
The hook returns a function. That function accepts:
The username to look up.
Optional. Pass `"files"` to populate `avatarFile` and `bannerFile` with full `File` objects.
## Returns
The user's public profile. When `spaceReputation` is requested, it carries an added `spaceReputation` number. See [User data model](/data-models/user) and [Reputation](/data-models/reputation).
# Fetch user suggestions
Source: https://docs.sublay.io/v7/hooks/users/use-fetch-user-suggestions
Search for users by a query string
## Overview
`useFetchUserSuggestions` returns a function that searches for users by a query string. This is the underlying hook used by [`useUserMentions`](/hooks/users/use-user-mentions) for `@mention` autocomplete, but it can also be used for any user search or autocomplete UI.
This hook uses an authenticated request. The user must be signed in.
## Usage Example
```tsx theme={null}
import { useFetchUserSuggestions } from "@sublay/react-js";
import { useState } from "react";
function UserSearch() {
const fetchSuggestions = useFetchUserSuggestions();
const [results, setResults] = useState([]);
const handleSearch = async (query: string) => {
const users = await fetchSuggestions({ query });
setResults(users);
};
return (
handleSearch(e.target.value)}
placeholder="Search users..."
/>
);
}
```
## Parameters
The hook returns a function. That function accepts:
The search string. Matched against usernames and names.
## Returns
An array of matching users' public profiles. When `spaceReputation` is requested, each carries an added `spaceReputation` number. See [User data model](/data-models/user) and [Reputation](/data-models/reputation).
# Use user mentions
Source: https://docs.sublay.io/v7/hooks/users/use-user-mentions
Detect @mention triggers, fetch user suggestions, and track mentioned users in text input
## Overview
`useUserMentions` manages the full `@mention` flow inside a text input. It monitors the cursor position and content, detects when the user is typing an `@mention`, debounces a search call, and returns suggestions to display. When the user selects a suggestion, it inserts the username into the content and tracks the mention.
This hook is intended to be used alongside a controlled text input where you manage the content and cursor position.
## Usage Example
```tsx theme={null}
import { useUserMentions } from "@sublay/react-js";
import { useState, useRef } from "react";
function CommentInput() {
const [content, setContent] = useState("");
const [cursorPosition, setCursorPosition] = useState(0);
const [isSelectionActive, setIsSelectionActive] = useState(false);
const inputRef = useRef(null);
const {
isMentionActive,
loading,
mentionSuggestions,
handleMentionClick,
mentions,
} = useUserMentions({
content,
setContent,
focus: () => inputRef.current?.focus(),
cursorPosition,
isSelectionActive,
});
return (
);
}
```
## Props
The current text content of the input.
Setter for the content. Called when a mention is selected and the username is inserted.
A function that re-focuses the input. Called after a mention is selected.
The current cursor position (character index) within the content.
Whether the user has text selected. Mention detection is suppressed while a selection is active.
The character that activates mention mode. Defaults to `"@"`.
Minimum characters after the trigger before fetching suggestions. Defaults to `3`.
Milliseconds to wait after the user stops typing before fetching suggestions. Defaults to `1000`.
Regex pattern (as a string) that the text after the trigger must match. Defaults to `"[\\w.]+"`.
## Return Values
`true` when the cursor is inside a valid mention trigger sequence.
`true` while user suggestions are being fetched.
The list of matching users to display as suggestions.
Call this when the user selects a suggestion. Replaces the trigger text in `content` with the user's username, adds the user to `mentions`, and closes the suggestion list.
Accumulated list of confirmed mentions in the current content. Each entry has `id`, `foreignId`, `username`, and `type: "user"`.
Manually adds a user to the `mentions` list. Used internally by `handleMentionClick`.
Clears the `mentions` list and resets all mention state. Call this after the content is submitted.
# Introduction
Source: https://docs.sublay.io/v7/index
Sublay is infrastructure for user-powered applications — auth, social graph, content, chat, community spaces, and more, pre-built and ready to integrate.
Every product eventually needs users to *do* something — comment on a post, react to content, follow a topic, send a message, join a community. Sometimes that's the core of what you're building. Sometimes it's just one part: a blog, a support page, a changelog with reactions.
The typical answer is a third-party tool. But third-party tools mean your users create *another* account, in someone else's UI, disconnected from your product. The experience fragments — and you lose control.
Sublay is the alternative. It gives you the complete set of social primitives — feeds, comments, chat, notifications, and more — as infrastructure you own. Bring your existing users via a signed JWT, or let Sublay handle auth entirely. Either way: one unified user identity, your design, whether social features are the heart of your product or just one corner of it.
## What's Included
Sublay ships with every building block a user-powered application needs:
Built-in email/password auth, OAuth (Google, GitHub, and more), external JWT integration for bringing your own auth system, and multi-account support.
The central content unit — posts, articles, listings, anything your users create. An optional bundle (`comments` and `collections` build on it). Comes with votes, reactions, view tracking, file attachments, drafts, and publishing workflows.
Threaded discussions on any entity. Nested replies, votes, emoji reactions, GIF support, @mentions, and moderation built in.
Hierarchical community spaces with membership, role-based permissions, moderation tools, rules, and space-level chat.
Real-time 1:1 and group conversations with message reactions, threaded replies, read state, typing indicators, and moderation.
Unidirectional follows and bidirectional connection requests (friend-style). Full follower/following counts, status checks, and bulk queries.
User-owned bookmarking and folder system for saving entities. Sub-collections, entity management, and saved-state checks.
In-app notification system for users. Delivered via webhook to your server for push bridging, email relay, or any downstream action.
Automatic content embedding for entities, comments, messages, users, and spaces. Enables semantic search across all content types and an AI-powered ask endpoint that synthesizes answers from your data.
File and image uploads with server-side processing. Used for entity attachments, user avatars, banner images, and chat attachments.
Report flows for entities, comments, and chat messages. Space-level moderation actions, ban management, and report resolution tools.
Emoji reactions on entities and comments. Six reaction types out of the box (like, love, wow, sad, angry, funny), with counts and per-user state.
## How It Works
Every Sublay project gets a hosted API endpoint. Your application talks to that endpoint — either through the React/React Native SDK (which handles tokens, state, and real-time automatically) or directly via the REST API.
All data is scoped to your project. Users, entities, conversations, and spaces exist only within your project's namespace. You can have multiple projects — a staging environment, separate apps, or white-labeled products — each fully isolated.
Sublay uses a project-scoped architecture. All API paths include a `projectId`. Your project ID is available in the dashboard.
## Auth Flexibility
Sublay does not force you into one authentication model. You choose how users are identified:
* **Built-in auth** — Sublay manages the full user identity: email/password sign-up, password reset, OAuth providers. No external auth system needed.
* **External auth** — Your backend issues a signed JWT, and Sublay automatically creates or matches a user from it. Keep your existing auth stack and add Sublay alongside it.
* **OAuth** — Sign in with Google, GitHub, or other configured providers. Supports linking multiple OAuth identities to a single account.
See [Authentication](/authentication) for a full conceptual overview.
## Get Started
Install `@sublay/react-js` or `@sublay/react-native` and get your first feature running in minutes.
Learn about the SDK, raw REST API, and Redux integration options — and choose the right path for your project.
Built-in, external, and OAuth auth models explained. Understand how user identity flows through Sublay.
Complete REST API documentation — every endpoint, every field.
TypeScript interfaces for every object your app works with: users, entities, comments, conversations, and more.
Pre-built, fully customizable comment sections and notification UI installed directly into your project via CLI.
# Integration Options
Source: https://docs.sublay.io/v7/integration-options
The different ways to integrate Sublay — SDK, raw REST API, and Redux store integration.
Sublay exposes its infrastructure through a REST API. On top of that, the React and React Native SDK packages provide a higher-level integration layer that handles token management, state, real-time subscriptions, and type safety automatically.
This page explains your options so you can choose the path that fits your project.
When using Sublay from a web application in production, whitelist your domain in the project dashboard. Requests from non-whitelisted origins will be rejected.
## Option 1: React / React Native SDK (Recommended)
If your application uses React or React Native, the SDK is the recommended integration path. It wraps the REST API in a set of hooks and context providers that do the heavy lifting:
* **Token lifecycle** — Access tokens are requested, stored, and refreshed automatically. You never manually manage auth headers.
* **Real-time** — The chat system connects via WebSocket automatically when `ChatProvider` is mounted. Message delivery, read state, and typing indicators are handled for you.
* **Optimistic state** — Actions like sending a message or toggling a reaction update local state immediately and sync with the server in the background.
* **Typed interfaces** — Every object your hooks return is typed against the interfaces in `@sublay/core`. You get full autocomplete and type safety across entities, users, conversations, comments, and more.
* **Context providers** — Features like the entity subtree, comment section, conversation, and space are scoped via React context providers, so any component in the subtree can access the full feature state with a single hook call.
Install `@sublay/react-js`. Works with Next.js, Vite, Create React App, or any React web setup.
Install `@sublay/react-native` (or `@sublay/expo` for Expo-managed workflows with secure token storage).
### SDK Package Reference
| Package | Platform | Notes |
| ---------------------- | ------------ | ----------------------------------------------------------------------- |
| `@sublay/react-js` | React web | Full SDK for browser environments |
| `@sublay/react-native` | React Native | Full SDK with AsyncStorage token storage |
| `@sublay/expo` | Expo | Same as React Native but uses SecureStore |
| `@sublay/core` | Shared | Platform-agnostic hooks and types — not imported directly in most cases |
## Option 2: Raw REST API
If your application does not use React, or you need to call Sublay from a server-side context, the REST API is available directly.
All endpoints are under `/v7/:projectId/`. Every request that requires a user identity must include a valid access token in the `Authorization: Bearer ` header.
Use cases for direct API access:
* Vue, Angular, Svelte, or other non-React frameworks
* Server-to-server operations (e.g., creating entities on behalf of users, webhook handlers, scheduled jobs)
* Custom SDKs or thin client wrappers
* Testing or exploration with tools like Postman or curl
### Service API Key (Recommended for Server-to-Server)
For server-to-server calls, using a **service API key** is almost always the right approach rather than managing individual user access tokens.
A service API key is generated once from the project dashboard and stored securely on your server. When included in a request, it grants elevated access to the API:
* **No user token required** — the service key bypasses user authentication entirely. You do not need to sign in as a user or manage token refresh on the server.
* **Act on behalf of any user** — many endpoints accept a `userId` body parameter when the request is authenticated with a service key, allowing you to perform actions on behalf of a specific user.
* **Elevated permissions** — the service key bypasses ownership checks, so it can update or delete any content, access draft entities, and read private user fields regardless of which user owns the resource.
Requests with a service key require two headers:
```http theme={null}
Authorization: Bearer
x-sublay-project-id:
```
The service API key has full administrative access to your project's data. Never expose it in client-side code, browser environments, or mobile apps. Only use it from your own server.
When using the API from a client-side (browser/mobile) context without a service key, you are responsible for requesting and refreshing user access tokens, maintaining auth state, and handling real-time features (WebSockets) yourself if needed.
See [API Reference → Getting Started](/api-reference/getting-started) for authentication details and base URL configuration.
## Option 3: Redux Store Integration
If your React application already has its own Redux store, you can integrate Sublay's state directly into it rather than using the default built-in store.
By default, `SublayProvider` creates an internal Redux store for Sublay's state. If you have an existing store, using two separate Redux stores in one app is not ideal. `SublayIntegrationProvider` lets you pass your own Redux store configuration and mount Sublay's reducers and middleware alongside your own.
This requires:
* Adding `sublayReducers` and `sublayApiReducer` to your `combineReducers` call
* Adding `sublayMiddleware` to your middleware chain
* Using `SublayIntegrationProvider` instead of `SublayProvider`
See [SDK Reference → Redux Integration](/sdk/redux-integration) for the full setup guide.
## Choosing the Right Path
| Scenario | Recommended |
| ------------------------------------------------ | ----------------------------------------------------- |
| Building with React or React Native | SDK (`@sublay/react-js` or `@sublay/react-native`) |
| Expo project | `@sublay/expo` |
| Already have a Redux store in React | SDK + Redux integration (`SublayIntegrationProvider`) |
| Vue, Angular, Svelte, or other frameworks | REST API with user access tokens |
| Server-side / backend operations | REST API with service API key |
| Webhook handlers, scheduled jobs, data pipelines | REST API with service API key |
| Mobile app without React Native | REST API with user access tokens |
# Interest Matching
Source: https://docs.sublay.io/v7/interest-matching
Match people by what they actually engage with — activity-derived interest facets, decayed over time, matched passively or by topic.
Interest matching connects people by their **activity-derived interests**. As users create and engage with content, Sublay folds that activity into a small set of **interest facets** per user — clusters of related topics, each with a recency-weighted "hotness". The [`POST /match/users`](/v7/api-reference/match/match-users) endpoint then ranks other users against an asker, either passively ("who is like me?") or by an explicit topic ("who is into biotech?").
Unlike a static interests list, facets are built from behavior and **decay over time**, so a match reflects what someone is engaged with *now*, not what they once filled into a profile field.
Interest matching requires a **paid plan**. Free-tier projects cannot use embedding-based features.
***
## Prerequisites
Interest matching builds on the semantic-search embedding pipeline, so it has an ordered set of prerequisites:
1. **Paid plan** — embeddings are a paid feature.
2. **`ai-search` bundle** — provides `ContentEmbeddings`, the source of facet building. Interest matching reads and annotates this table (it adds a `facetProcessedAt` marker column), so `ai-search` must be installed **first**. Installing `interest-matching` without it fails fast with `database/bundle-prerequisite-missing`.
3. **`interest-matching` bundle** — provisions the `UserInterestFacets` table (with its vector + btree indexes). Install it from the dashboard Database section, or via the bundle install API.
4. **`interestMatching.enabled`** — the project setting that turns folding and matching on.
***
## How Facets Are Built
Facet building runs inside the existing embedding cron (every few minutes), so there is nothing extra to schedule:
* **Fold-in.** Each newly-embedded, source-clean record is folded as **one unit of mass** into the author's nearest facet (or spawns a new facet if it is far from all existing ones). Multi-chunk content still counts once — long posts do not get extra weight.
* **Decay.** A facet's mass decays with a long half-life (interests move slowly), so recent activity dominates. The current, decayed mass is a facet's **hotness**.
* **Low-signal skip.** Trivial content ("lol", "same") is skipped so it never pollutes a facet.
* **Pruning.** Facets that never reach a significance threshold are garbage-collected after a grace window; a facet that *was* significant is kept even when it later goes cold.
Building is **moderation-, soft-delete-, and DM-safe**: an embedding row surviving admin removal is not treated as live content, and private-DM content never feeds a facet.
***
## Project Settings
Configure the feature through the `interestMatching` block on the project settings (dashboard Settings → project settings update). Every field is optional and paid-plan-guarded; the tuning knobs fall back to sensible platform defaults when unset.
Master switch. Turns on both cron fold-in and the `/match/users` endpoint.
**Gate 1** of the two-gate sample exposure. When on, callers *may* request raw sample content per matched facet by also passing `includeSampleContent: true` (Gate 2) on the request. Both gates must be on for samples to be returned. When off, requesting samples returns `403 match/sample-content-disabled`. Changing this setting invalidates the project cache so the gate takes effect immediately.
Half-life (hours) for facet mass/hotness decay. This is a **distinct** knob from the entity `scoring.halfLifeHours` — interests move far more slowly than trending content, so the default here is much longer (\~60 days).
Cosine distance under which a new record folds into an existing facet instead of spawning a new one. Larger values produce fewer, broader facets.
A facet whose lifetime-peak hotness never reaches this value is treated as never-significant and eligible for pruning.
Grace window (hours) after a facet is created during which it is never pruned, so a fresh facet has time to accumulate before garbage collection.
Threshold for the low-signal filter — content below it is skipped from folding (but still marked, so it never re-drains).
### Two-gate sample exposure
Sample content — the raw text that illustrates why two people overlap — is exposed only when **both** gates are satisfied:
| Gate 1 (`exposeSampleContent`) | Gate 2 (`includeSampleContent`) | Result |
| ------------------------------ | ------------------------------- | ------------------------------------------------------------ |
| off | off | No samples |
| off | on | `403 match/sample-content-disabled` |
| on | off | No samples |
| on | on | Samples attached (minus removed / soft-deleted / DM content) |
Gate 1 is a project-owner-level consent; Gate 2 is a per-request opt-in. The always-returned match breakdown (scores, facet ids, hotness) never carries readable content on its own.
***
## Accepted Limitations
Interest matching is a lossy, eventually-consistent primitive. Know these v1 tradeoffs:
* **Gradual backfill latency.** Facets are built by the periodic cron, not synchronously on write. A brand-new project (or a burst of new content) takes several cron ticks to reflect fully in match results. Newly-created content is not blended into a live query at match time.
* **Cold-start asymmetry.** A cold user can *ask* (via the bio-vector fallback) but, in v1, is not surfaced as a *candidate* until they have real facets. A rare niche expert with little activity may therefore be hard to find until they accrue facets.
* **Deletion / edits do not retract mass.** Facet mass is additive and decays with time; deleting or editing content does **not** subtract the contribution it already made. Removed/soft-deleted/DM content is excluded from *building new* facets and from *samples*, but past mass stays until it decays. Facets do not carry stable identity across cron runs.
***
## SDKs
* **Node SDK** — [`search.matchUsers`](/v7/node-sdk/search)
* **JavaScript SDK** — [`search.matchUsers`](/v7/js-sdk/search)
* **React / React Native** — the [`useMatchUsers`](/v7/hooks/search/use-match-users) hook
# MCP Server
Source: https://docs.sublay.io/v7/mcp-server
Integrate Sublay documentation into AI tools like Claude Code, Cursor, and Windsurf using the Model Context Protocol.
The Sublay MCP (Model Context Protocol) server gives AI development tools direct access to the Sublay documentation. Once configured, your AI assistant can search and reference the complete docs in real-time — without you having to paste context manually.
## Server URL
```
https://docs.sublay.io/mcp
```
## Available Tools
### search\_sublay\_documentation
Searches across the full Sublay documentation knowledge base to find relevant pages, code examples, API references, and guides.
**Useful for:**
* Answering questions about how Sublay features work
* Finding specific API endpoints or hook signatures
* Understanding integration patterns and configuration options
* Locating code examples for any SDK feature
**Returns:** Contextual content from documentation pages, including titles, descriptions, and direct links.
***
## Installation
### Claude Code
Run `/mcp` in Claude Code or open the MCP configuration file.
Add the following entry:
```json theme={null}
{
"mcpServers": {
"sublay": {
"url": "https://docs.sublay.io/mcp"
}
}
}
```
The Sublay MCP server will be available in your next session.
### Cursor
Go to **Settings → MCP**.
Enter `https://docs.sublay.io/mcp` and save.
The integration activates on next launch.
### Windsurf
Navigate to the MCP integration section.
Enter `https://docs.sublay.io/mcp` and save.
The integration activates on next launch.
***
## Usage
Once installed, your AI assistant uses the MCP server automatically when you ask about Sublay. Example prompts:
* "How do I set up authentication with an external user system in Sublay?"
* "Show me how to use `useEntityList` with filters"
* "What fields does the Space data model have?"
* "How do I configure webhooks for push notifications?"
The assistant retrieves accurate, up-to-date answers directly from the official documentation rather than relying on training data.
***
## Benefits
The MCP server always reflects the live documentation — no stale training data.
Suggestions are grounded in actual Sublay patterns and API shapes.
Skip copying documentation into prompts — the assistant fetches what it needs automatically.
Any MCP-compatible AI tool can use the same server.
***
## Troubleshooting
If the integration is not working:
1. Verify the server URL is entered correctly: `https://docs.sublay.io/mcp`
2. Confirm your AI tool supports MCP (Claude Code, Cursor, and Windsurf all do)
3. Restart the tool after adding the server
4. Check that `https://docs.sublay.io/mcp` is reachable from your network
# Notification Preferences & Mute
Source: https://docs.sublay.io/v7/notification-preferences
Automatic push fan-out for in-app events, per-user type toggles, per-conversation mute, and developer-authored copy templates.
Beyond the manual [`push.send()`](/push-notifications) call, Sublay can **automatically** push in-app events (comments, mentions, follows, chat messages, …) to a user's registered devices. This page covers the surfaces that control that automatic delivery:
* **Per-user preferences** — each user can disable push for specific event types.
* **Per-conversation mute** — a user can silence a single conversation's push for a duration or forever.
* **Per-event copy templates & project-level toggles** — the developer authors the push title/body per event type and can disable an event project-wide, from the dashboard.
**These are per-event-type controls for one signed-in user, and they are not the same thing as turning push off for an account on a device.** A user with no disabled types still receives nothing on a device where their account has been silenced with [`useAccountPushToggle`](/hooks/push/use-account-push-toggle) — that decides whether the account is bound to the device at all, while everything on this page decides which event types a bound account receives. The two compose, and neither overwrites the other.
**Requires the `push` bundle.** Automatic push and the preference table live in the `push` bundle. Install it from your project's **Database** page in the [dashboard](https://dash.sublay.io). Chat-message push additionally requires the `chat` bundle; app-notification push requires the `notifications` bundle. See [Bundles](/bundles).
## The event palette
Every automatic push is keyed by an **event type**. These are the server's exact type names — the full app-notification set plus the chat `message` event (which is push-only and writes no in-app notification):
`entity-comment`, `comment-reply`, `entity-mention`, `comment-mention`, `entity-reaction`, `comment-reaction`, `entity-reaction-milestone-specific`, `entity-reaction-milestone-total`, `comment-reaction-milestone-specific`, `comment-reaction-milestone-total`, `new-follow`, `connection-request`, `connection-accepted`, `space-membership-approved`, `event-invite`, `event-updated`, `event-cancelled`, `reputation-grant`, `message`.
The same names are used everywhere: a user's `disabledTypes`, the project-level per-event toggle, and the template map all key on this exact list. The SDKs export the list as `PUSH_EVENT_TYPES` (`@sublay/core`, `@sublay/node`, `@sublay/js`).
## Preference & mute model
### Per-user preferences (`disabledTypes`)
Each user has at most one preference row, holding the set of event types they've **opted out** of for push. The model is:
* **Absence of a row = all-on.** A user with no preference row receives push for every enabled event type. Reading preferences for such a user returns `{ "disabledTypes": [] }`.
* **Updating replaces the set.** The update endpoint upserts the whole `disabledTypes` array — send the complete set you want stored, not a delta.
* **Only valid type names are accepted.** Unknown names are rejected server-side.
Disabling a type only suppresses **push** — the in-app [App Notification](/sdk/app-notifications/overview) is still written, so the bell/inbox is unaffected.
### Per-conversation mute (`mutedUntil` / `mutedForever`)
A user can mute a single conversation for a fixed duration or forever. Mute suppresses the `message` push for that conversation only; the message is still delivered, socket-emitted, and available in the conversation as normal.
The client sends a **duration choice**, never a raw timestamp — the server resolves the window from its own clock so client skew can't distort it:
| Choice | Meaning |
| --------- | --------------------------- |
| `8h` | Muted for 8 hours from now |
| `24h` | Muted for 24 hours from now |
| `1w` | Muted for 1 week from now |
| `forever` | Muted indefinitely |
| `null` | Clear the mute (unmute) |
Timed mutes expire lazily — once the window passes, push resumes with no user action.
**"Forever" is an explicit signal, not a magic date.** Internally the server stores "forever" as a far-future sentinel timestamp, but that value **never** reaches clients. At the API boundary the viewer's own member row carries `mutedForever: true` with `mutedUntil: null`. Read the boolean — do **not** string-match a far-future date. The SDKs mirror this exactly.
The viewer's own member row (`currentMember`) is serialized as:
| State | `mutedForever` | `mutedUntil` |
| ------------- | -------------- | ------------------ |
| Not muted | `false` | `null` |
| Timed mute | `false` | real ISO timestamp |
| Muted forever | `true` | `null` |
**Mute state is personal.** A member's `mutedUntil` / `mutedForever` is present **only** on that member's own row. Endpoints that return *other* members' rows (`listMembers`, `changeMemberRole`, `addMember`, the `member:joined` socket broadcast) omit both keys entirely — a user's mute state is never exposed to other participants.
## Gating precedence
When an event fires, whether a push is dispatched is decided in this order:
1. **Project-level event disable** (dashboard toggle) — if the developer disabled the event, no one is pushed.
2. **Conversation mute** (chat `message` only) — muted members are dropped.
3. **Per-user type toggle** — users who disabled the type are dropped.
4. **Send** to everyone remaining who has a registered device.
## Copy templates & project-level toggles (dashboard)
The developer authors the push **title/body copy** per event type and can **disable** an event project-wide, from **Settings → Push Notifications** in the [dashboard](https://dash.sublay.io), beside the provider configuration.
* Each event type has an editable template field and a project-level enable toggle.
* A template is a plain-text string with `{variableName}` placeholders. Substitution is plain-text only — no expressions, no markup execution. Unknown tokens render empty.
* If a project configures no template for an event, a shipped default is used. Template edits take effect on the next send.
### Template variable palette
Each event exposes a fixed set of variables its template may interpolate. Values come from the notification metadata (app-notification events) or the message context (`message`). A token that isn't in the event's list renders empty.
| Event type | Variables |
| ------------------------------------- | ------------------------------------------------------------------------------------- |
| `entity-comment` | `initiatorName`, `initiatorUsername`, `entityTitle`, `commentContent` |
| `comment-reply` | `initiatorName`, `initiatorUsername`, `entityTitle`, `replyContent` |
| `entity-mention` | `initiatorName`, `initiatorUsername`, `entityTitle` |
| `comment-mention` | `initiatorName`, `initiatorUsername`, `entityTitle`, `commentContent` |
| `entity-reaction` | `initiatorName`, `initiatorUsername`, `entityTitle`, `reactionType` |
| `comment-reaction` | `initiatorName`, `initiatorUsername`, `entityTitle`, `commentContent`, `reactionType` |
| `entity-reaction-milestone-specific` | `entityTitle`, `reactionType`, `milestoneCount` |
| `entity-reaction-milestone-total` | `entityTitle`, `milestoneCount` |
| `comment-reaction-milestone-specific` | `entityTitle`, `commentContent`, `reactionType`, `milestoneCount` |
| `comment-reaction-milestone-total` | `entityTitle`, `commentContent`, `milestoneCount` |
| `new-follow` | `initiatorName`, `initiatorUsername` |
| `connection-request` | `initiatorName`, `initiatorUsername` |
| `connection-accepted` | `initiatorName`, `initiatorUsername` |
| `space-membership-approved` | `spaceName` |
| `event-invite` | `initiatorName`, `initiatorUsername`, `eventTitle` |
| `event-updated` | `initiatorName`, `initiatorUsername`, `eventTitle` |
| `event-cancelled` | `initiatorName`, `initiatorUsername`, `eventTitle` |
| `reputation-grant` | `amount`, `granterName`, `granterUsername`, `note` |
| `message` | `senderName`, `senderUsername`, `conversationTitle`, `messageContent` |
Example template for `entity-comment`:
```
{initiatorName} commented on {entityTitle}: {commentContent}
```
## Privacy
Muted-conversation content and disabled-type content is **simply not dispatched** — when a mute or a preference suppresses a push, the notification's title/body (which for chat/comments contains a content snippet) is never sent to APNs, FCM, or Web Push at all. Suppression happens before any provider call, so muted/opted-out content never leaves Sublay's infrastructure to a third-party push provider.
## Building a settings UI
The SDKs expose the read/update primitives so you can build a notification-settings screen and per-conversation mute control:
Read + update the current user's disabled types (React / RN / Expo)
Set / clear the current user's conversation mute
`push.getNotificationPreferences` / `updateNotificationPreferences`, `chat.muteConversation`
`push.getNotificationPreferences` / `updateNotificationPreferences`, `chat.muteConversation`
### API endpoints
`POST /chat/conversations/:conversationId/mute`
`GET /push-notifications/preferences`
`PUT /push-notifications/preferences`
# Push Notifications
Source: https://docs.sublay.io/v7/push-notifications
Deliver native OS push notifications to your users on iOS, Android, and Web — from a single server-side call.
Push Notifications lets your app deliver native OS push notifications to users on iOS (via APNs), Android (via FCM), and the browser (via Web Push) — all from a single server-side call. Sublay stores each user's registered devices, fans a message out to every platform, and prunes dead tokens automatically — across every account bound to the device, since a token the provider rejects is dead for all of them.
**Requires the `push` bundle.** Install it from your project's **Database** page in the [dashboard](https://dash.sublay.io) before using any push feature. See [Bundles](/bundles) for details.
## How It Works
1. **Configure credentials** in the dashboard — paste your APNs `.p8` key, FCM service account JSON, or enable Web Push (keypair generated server-side).
2. **Register devices** — your client app calls `register()` from `usePushRegistration` at a moment that makes sense for your UX (settings screen, first-run prompt, etc.). The SDK handles permission, token retrieval, and server registration.
3. **Send notifications** — your backend calls `sublay.push.send({ userIds, title, body, data })`. Sublay fans the message out to every registered device for those users across all platforms.
## Dashboard Setup
### Installing the bundle
Open **Database → Bundles** in the [dashboard](https://dash.sublay.io) and install `push`. Once provisioning completes, the **Push Notifications** settings page becomes active.
### Configuring providers
Go to **Settings → Push Notifications** in the dashboard. Configure each platform you want to support:
**APNs (iOS)**
| Field | Description |
| ---------- | ----------------------------------------------------------------------- |
| Key ID | The 10-character key identifier from your Apple Developer account |
| Team ID | Your Apple Developer team ID |
| Bundle ID | Your app's bundle identifier (e.g. `com.example.myapp`) |
| .p8 key | Contents of the `.p8` private key file downloaded from Apple |
| Production | Toggle for sandbox vs. production APNs gateway (defaults to production) |
**FCM (Android)**
Upload or paste your Firebase service account JSON. This is the file downloaded from the Firebase console under **Project Settings → Service Accounts → Generate new private key**.
**Web Push**
No credentials to paste — click **Enable**. Sublay generates a VAPID EC keypair server-side and stores the private key encrypted. Only the public key is returned and displayed; it is also available via the unauthenticated [`GET /vapid-public-key`](/api-reference/push-notifications/get-vapid-public-key) endpoint for use in your service worker.
### Automatic push settings
Sublay can fan a push out **automatically** whenever an in-app notification is created (a new comment, reply, mention, reaction, follow, connection, RSVP-event change, space-membership approval) or a chat message is sent — no `push.send()` call required. The **Settings → Push Notifications** page lets you tune what those automatic pushes look like per event type:
| Setting | Scope | Description |
| --------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Body template** | Per event type | The notification body, with a `{variable}` palette specific to each event. Ships with sensible defaults. |
| **Title template** | Per event type | An optional title line, interpolated against the **same** `{variable}` palette as the body. **Ships empty** — an unset title renders as an empty string, so titles are opt-in. |
| **Default delivery** | Project-wide | A default `{ sound, channelId, priority }` applied to every automatic push. |
| **Delivery override** | Per event type | Overrides the project default per field for a single event type. A present field wins; an absent one inherits the default. |
These settings affect only the **automatic** path. The manual [`push.send()`](#server-sdk--sending-notifications) call already accepts `title`, `sound`, `channelId`, `priority`, and everything else directly.
See [Automatic Pushes](#automatic-pushes) below for the full delivery-config, title-template, and tap-routing contract.
### Test-send UI
The **Push Notifications** settings page includes a **Send Test Push** panel for firing a one-off notification at a single device — the fastest way to verify a provider's credentials before wiring up the SDK. Pick a platform, paste a device token (iOS/Android) or a web push subscription, set a title and body, and click **Send Test Push**.
The catch is getting the token/subscription to paste in. It isn't shown anywhere in the dashboard — it's produced on the receiving device. Here's how to obtain each.
**"Test push sent successfully" verifies *credentials*, not *delivery*.** A success result means the provider (APNs / FCM / Web Push) *accepted* the message — your keys are valid. It does **not** mean a notification appeared on screen. Rendering is a separate step that depends on the receiving device (a web service worker's `push` handler, and the OS notification settings). If the send is green but nothing shows, see [Troubleshooting](#troubleshooting-a-test-push) below — the problem is almost never the credentials at that point.
#### Web: generating a Subscription JSON
The **Subscription JSON** is created by a browser subscribing to the Push API. You generate it from the DevTools console of a page that (a) is served over HTTPS or `localhost` and (b) has a **service worker registered** (`await navigator.serviceWorker.ready` hangs forever otherwise).
In the dashboard, go to **Settings → Push Notifications → Push Providers**. The **Web Push** row shows your project's VAPID public key with a copy button. (It's also available programmatically from the unauthenticated [`GET /vapid-public-key`](/api-reference/push-notifications/get-vapid-public-key) endpoint.) The subscription **must** be created with this exact key, or the send fails.
Open DevTools → Console on your service-worker-enabled page and run:
```js theme={null}
// Paste the EXACT public key from Settings → Push Notifications → Push Providers
const VAPID_PUBLIC_KEY = 'PASTE_YOUR_VAPID_PUBLIC_KEY';
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
}
const reg = await navigator.serviceWorker.ready;
// Drop any stale subscription bound to a previous key first
const existing = await reg.pushManager.getSubscription();
if (existing) await existing.unsubscribe();
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
console.log(JSON.stringify(sub.toJSON(), null, 2));
```
The browser prompts for notification permission — click **Allow**. The snippet prints an object like:
```json theme={null}
{
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
"keys": { "p256dh": "...", "auth": "..." }
}
```
Copy the printed object into **Subscription JSON**, set Platform to **Web (Web Push)**, and send. (`expirationTime` is harmless if included; only `endpoint` and `keys` are read.)
This manual console flow is a **debugging convenience** for the test panel. In a real app you never do this by hand — the SDK's [`usePushRegistration`](/hooks/push/use-push-registration) creates and registers the subscription for you. See the [Web section of the SDK reference](/sdk/push-notifications/overview) for service-worker setup.
#### iOS / Android: obtaining a device token
Native device tokens come from the OS (APNs / FCM), surfaced by the SDK's push-token adapter. To capture one for a test, wrap the built-in adapter so it logs the identifier it produces, then register as usual:
```tsx theme={null}
import { usePushRegistration } from "@sublay/core";
import type { PushTokenAdapter } from "@sublay/core";
import { expoPushTokenAdapter } from "@sublay/expo"; // or reactNativePushTokenAdapter
// Wraps the built-in adapter to log the token it retrieves
const debugAdapter: PushTokenAdapter = {
requestPermission: expoPushTokenAdapter.requestPermission,
async getDeviceIdentifier(context) {
const id = await expoPushTokenAdapter.getDeviceIdentifier(context);
console.log("Push device identifier:", id); // { platform: "ios", token: "..." }
return id;
},
};
function DebugRegister() {
const { register } = usePushRegistration(debugAdapter);
return ;
}
```
Run the app on a **real device** (simulators/emulators don't receive push on iOS), trigger registration, copy the `token` from the logs, then paste it into the panel with Platform set to **iOS** or **Android**.
**APNs sandbox vs. production.** A development/debug iOS build talks to the APNs **sandbox** gateway; a TestFlight/App Store build talks to **production**. The **Production** toggle on your APNs provider config must match the build the token came from, or the send is rejected. This is the most common iOS test-push failure.
#### Troubleshooting a test push
"Sent successfully" only means Web Push accepted the message. Rendering happens in **your service worker's `push` handler** — Sublay ships no service worker. Check, in order:
1. **Does your SW render pushes?** Its `push` handler must call `registration.showNotification(...)`. Isolate the rendering step by running this in the page console — it calls `showNotification` directly, bypassing the network:
```js theme={null}
const reg = await navigator.serviceWorker.ready;
await reg.showNotification("Direct test", { body: "does the OS show this?" });
```
If **this** shows nothing, the problem is OS-level (next point), not your code. If it shows but real pushes don't, your `push` handler is missing or not calling `showNotification`.
2. **OS notification settings.** Even with `Notification.permission === "granted"`, the OS can suppress everything. On macOS: **System Settings → Notifications → \[your browser]** must be allowed (alert style not "None"), and **Focus / Do Not Disturb** must be off. This silently swallows notifications and is the single most common cause of "green but nothing."
3. **DevTools "Push" button throws a JSON error.** The DevTools Application → Service Workers "Push" tool sends *plain text*; a handler that calls `event.data.json()` will throw on it. That's a test-tool artifact — real Sublay pushes are JSON. Test with a real send instead.
The push service returned HTTP 410 (Gone). Usual causes:
* **VAPID key mismatch** — the subscription was created with a different public key than the project's. Re-copy the key from Settings → Push Providers and re-subscribe. Chrome caches the old subscription, so `unsubscribe()` first (the snippet above already does).
* **Truncated endpoint** — make sure you pasted the *full* `endpoint`, not an abbreviated `https://.../...` copy.
* **Stale subscription** — regenerate a fresh one if it was minted a while ago.
* Test on a **real device**, not a simulator/emulator.
* Confirm the **APNs Production toggle matches the build** (sandbox for dev builds, production for TestFlight/App Store).
* Verify the provider credentials: APNs `bundleId` must match the app exactly; FCM service account must belong to the same Firebase project as the app's `google-services.json` / config.
* Re-copy the token — device tokens rotate and can go stale.
## Client SDK — Registering Devices
Use `usePushRegistration` with the adapter for your platform. Call `register()` in response to a deliberate user action — not on mount — because requesting OS push permission is a one-shot prompt that users cannot undo.
Once per device is enough. The SDK persists the device identifier and, wherever `usePushRegistration` is mounted, handles an OS token rotation itself, so there is no "call it again on every launch to be safe" step — see [Device Lifecycle](#device-lifecycle).
```tsx theme={null}
// Expo
import { usePushRegistration } from "@sublay/core";
import { expoPushTokenAdapter } from "@sublay/expo";
function SettingsScreen() {
const { register, registering } = usePushRegistration(expoPushTokenAdapter);
return ;
}
```
```tsx theme={null}
// Bare React Native
import { usePushRegistration } from "@sublay/core";
import { reactNativePushTokenAdapter } from "@sublay/react-native";
const { register } = usePushRegistration(reactNativePushTokenAdapter);
```
```tsx theme={null}
// Web (browser)
import { usePushRegistration } from "@sublay/core";
import { webPushTokenAdapter } from "@sublay/react-js";
const { register, unregister } = usePushRegistration(webPushTokenAdapter);
```
The web adapter requires a service worker. Register one before calling `register()`. See [SDK Reference → Push Notifications](/sdk/push-notifications/overview) for per-platform setup and [`usePushRegistration`](/hooks/push/use-push-registration) for the full hook API.
## Server SDK — Sending Notifications
Call `client.push.send()` from your backend whenever you want to notify users:
```typescript theme={null}
import { SublayClient } from "@sublay/node";
const sublay = await SublayClient.init({
projectId: process.env.SUBLAY_PROJECT_ID!,
apiKey: process.env.SUBLAY_SERVICE_KEY!,
});
const result = await sublay.push.send({
userIds: ["usr_abc123", "usr_def456"],
title: "You have a new match!",
body: "Tap to see who liked your post.",
data: { screen: "matches" },
});
for (const [userId, devices] of Object.entries(result.results)) {
if (devices.length === 0) {
console.log(`${userId} has no registered devices`);
} else {
console.log(`${userId}:`, devices);
}
}
```
A single call fans the notification out to all platforms. Capped at **100 user IDs per request**. See [Node SDK — Push Notifications](/node-sdk/push-notifications) for the full module reference.
A common pattern is to bridge Sublay's in-app notifications to push: subscribe to the `notification.created` webhook and call `push.send()` when it fires. See [Webhooks → Push Notification Bridge](/webhooks#push-notification-bridge).
## Rich Notification Payloads
Beyond `title`, `body`, and `data`, `push.send()` accepts optional fields for sound, badges, images, grouping, priority, and lifetime. Each maps to the native APNs / FCM / Web Push capability and is **silently ignored on platforms that don't support it** — so you can set `subtitle` (iOS-only) and `channelId` (Android-only) in the same call without branching.
```typescript theme={null}
await sublay.push.send({
userIds: ["usr_abc123"],
title: "New message",
body: "Alice sent you a message",
data: { conversationId: "conv_xyz789" },
sound: "notification.wav", // custom sound
channelId: "messages", // Android channel (see below)
badge: 3, // iOS app-icon badge
tag: "conv_xyz789", // one entry per conversation, per account
priority: "high",
});
```
| Field | Platforms | Notes |
| ---------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sound` | iOS, Android, Web | Filename of a bundled sound. On Android 8+ the **channel** owns the sound — see below. |
| `badge` | iOS | App-icon badge count. Your backend supplies the number; Sublay tracks no unread state. |
| `channelId` | Android | Notification channel id. Created client-side. |
| `priority` | iOS, Android | `"high"` (default) wakes the device; `"normal"` is power-considerate. |
| `subtitle` | iOS | Line under the title. |
| `imageUrl` | iOS, Android, Web | Big-picture image. iOS needs a Notification Service Extension — see below. |
| `tag` | Android, Web | Display-replace key so notifications collapse instead of stacking. Scoped per recipient, so the string the device receives is the one you sent plus a short digest — don't compare it to what you sent. |
| `collapseId` | iOS, Android | Transport-level collapse — a newer push supersedes an undelivered one **for the same account**. Scoped per recipient, and folded to a digest when an over-length key would exceed the APNs 64-byte cap. |
| `threadId` | iOS | Notification grouping. |
| `ttl` | iOS, Android | Time-to-live in **seconds** for offline devices. |
| `mutableContent` | iOS | Enables your Notification Service Extension. Set automatically when `imageUrl` is present. |
### Client-side requirements
Three of these need setup in **your app** — Sublay forwards the field but cannot do this part for you:
On Android 8+ the **notification channel** owns the sound, importance, and vibration — the payload can't override it. Create the channel once in your app (with the bundled sound) and pass its id as `channelId`:
```ts theme={null}
import * as Notifications from "expo-notifications";
await Notifications.setNotificationChannelAsync("messages", {
name: "Messages",
importance: Notifications.AndroidImportance.HIGH,
sound: "notification.wav", // bundled in the app
});
```
Then `push.send({ ..., channelId: "messages", sound: "notification.wav" })`. The `sound` field alone is only a pre-Android-8 fallback. Sublay does **not** create channels for you.
iOS does not download remote images from the payload on its own. To show `imageUrl` on iOS, add a [Notification Service Extension](https://developer.apple.com/documentation/usernotifications/unnotificationserviceextension) to your app that reads the URL from the notification payload, downloads it, and attaches it. Sublay sets `mutable-content` automatically whenever `imageUrl` is present so the extension is allowed to run. Android and Web render `imageUrl` with no extra work.
Sublay ships no service worker — your app's own SW renders web notifications. The server forwards the web-renderable fields (`sound`, `image`, `tag`) in the push JSON; your `push` event handler decides how to display them via `registration.showNotification(...)`.
## Automatic Pushes
Whenever Sublay writes an in-app notification (a new comment, reply, mention, reaction, milestone, follow, connection request/accept, RSVP-event invite/update/cancel, or space-membership approval) or a chat message is sent, it can fan a native push out to the recipient's registered devices **automatically** — you don't call `push.send()` for these. What each automatic push looks like is configured per project on the **Settings → Push Notifications** page.
Everything in this section applies to the **automatic** path only. The manual [`push.send()`](#server-sdk--sending-notifications) call takes `title`, `body`, `data`, `sound`, `channelId`, `priority`, and the [rich fields](#rich-notification-payloads) directly and is unchanged.
### Title & body templates
Each event type has a **body template** (shipped with sensible defaults) and an optional **title template**. Both are interpolated against the same per-event `{variable}` palette shown next to each row in the dashboard.
The title template **ships empty** for every event type — an unset title renders as an empty string, which is the historical behavior. Set a title only for the events where you want one; leaving it blank is a no-op.
```
# Example (dashboard, entity-comment)
Title: New comment
Body: {initiatorUsername} commented on your post
```
### Delivery: sound, channel & priority
You can set a project-wide **default delivery** block and, optionally, a **per-event override**:
* **Default** — a single `{ sound, channelId, priority }` applied to every automatic push.
* **Override** — a per-event-type `{ sound, channelId, priority }`. Any field you set on the override wins for that event; any field you leave unset inherits the project default. Precedence is **per-event override → project default → none**.
| Field | Platforms | Notes |
| ----------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sound` | iOS, Android, Web | Filename of a bundled sound. On Android 8+ the notification **channel** owns the sound — see [Android sound → notification channels](#client-side-requirements). |
| `channelId` | Android | Notification channel id. Created client-side; omitted from the web/iOS payloads. |
| `priority` | iOS, Android | `"high"` wakes the device; `"normal"` is power-considerate. |
These are the same three fields `push.send()` accepts, wired to the automatic path. The other rich fields (`badge`, `imageUrl`, `tag`, etc.) are configurable on the manual path only.
### Tap-routing `data`
Every automatic push carries a flat `data` object of string identifiers so your app's tap handler can deep-link to the right screen. All `data` values are **flat top-level strings** (FCM coerces `data` values with `String()`, so nested objects are not used).
Three keys are on every automatic push:
| Key | Value |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | The event type, e.g. `entity-comment`, `new-follow`, `event-invite`, `message`. |
| `action` | A canonical routing hint — one of `open-comment`, `open-entity`, `open-profile`, `open-space`, `open-event`, `open-chat-message`, `open-conversation` (message path), or `do-nothing`. |
| `recipientUserId` | The id of the account this copy of the notification is for. Stamped per device at dispatch time — see below. |
Beyond those three keys, each push forwards **every identifier form** for each object the notification references, so you can route on whichever id your app uses (UUID, short id, foreign id, slug, or username). Per referenced object:
| Object | Keys forwarded |
| -------------------------------------- | --------------------------------------------------------------------------------- |
| Entity | `entityId`, `entityShortId`, `entityForeignId` |
| Comment | `commentId`, `commentForeignId` (comments have no short id) |
| Space | `spaceId`, `spaceShortId`, `spaceSlug` |
| RSVP event | `eventId`, `eventShortId` |
| Initiator (the acting user) | `initiatorId`, `initiatorUsername`, `initiatorForeignId` (users have no short id) |
| Chat message | `messageId`, `conversationId` (both are needed to place a message) |
| Granter (the user who gave reputation) | `granterId`, `granterUsername`, `granterForeignId` |
| Connection | `connectionId` |
Which of those apply depends on the event type:
| Event type(s) | `action` | `data` identifiers (beyond `type`/`action`) |
| --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
| `entity-comment`, `comment-reply`, `comment-mention`, `comment-reaction`, `comment-reaction-milestone-specific`, `comment-reaction-milestone-total` | `open-comment` | Entity + Comment + Space keys (+ Initiator, except on milestones) |
| `entity-mention`, `entity-reaction`, `entity-reaction-milestone-specific`, `entity-reaction-milestone-total` | `open-entity` | Entity + Space keys (+ Initiator, except on milestones) |
| `new-follow` | `open-profile` | Initiator keys |
| `connection-request`, `connection-accepted` | `open-profile` | `connectionId` + Initiator keys |
| `space-membership-approved` | `open-space` | Space keys |
| `event-invite`, `event-updated`, `event-cancelled` | `open-event` | RSVP-event + parent-Space keys + Initiator keys |
| `reputation-grant` | `open-entity` / `open-comment` / `open-chat-message` / `open-profile` / `do-nothing` | Granter keys + whichever target's keys apply (Entity, Comment, or Chat message) + Space keys |
| `message` (chat) | `open-conversation` | `conversationId`, `messageId` |
**Content and RSVP-event notifications also carry the parent space.** A notification *about* an Entity or Comment (a "content notification") or an RSVP-event notification additionally forwards the parent **Space** id set (`spaceId`, `spaceShortId`, `spaceSlug`) when the subject lives in a space, so you can route within a space. Don't confuse the `message` **event type** or an RSVP **Event** (the data model) with the generic idea of a "notification event."
**These `data` keys are additive and non-breaking.** Existing tap handlers that ignore unknown keys are unaffected — nothing that was present before was removed or renamed. A key whose underlying value is absent (e.g. a user with no `foreignId`, a space with no `slug`) is simply omitted rather than sent empty.
#### `recipientUserId` is a reserved key
One physical device can be signed into several accounts at once, so a single send can deliver the same notification to the same device more than once — once per recipient account. `recipientUserId` names the account each copy belongs to, which is what lets a tap handler switch to the right account before deep-linking.
* **It is stamped per device by the server**, on both the automatic paths and `push.send()`. A value you supply yourself under the same key is **overwritten**: a client routing accounts on it has to be able to trust it absolutely.
* **A device signed into two accounts shows two notifications** for a message both accounts receive — one per account, each naming its own recipient. This is intentional; suppressing the non-foregrounded account's copy is app-side work.
* **Dashboard test sends omit it.** A test send from the dashboard targets a token you paste, not a registered account, so there is no recipient to name.
The chat-message path builds its `data` from the send context rather than notification metadata, so beyond the per-device `recipientUserId` it carries exactly `{ type: "message", action: "open-conversation", conversationId, messageId }`.
## Device Lifecycle
* **Re-registration:** registering the same physical device again (same token or endpoint) **as the same user** updates the existing record instead of duplicating it.
* **Multiple accounts per device:** registering the same device as a *different* user adds a second binding rather than reassigning the first. A device signed into several accounts receives notifications for all of them, each copy carrying its own `recipientUserId`.
* **Stale token cleanup:** a token or subscription permanently rejected by APNs, FCM, or Web Push during a send is automatically deleted — and because a dead token is dead for the whole device, **every** account bound to that token is unbound, not just the one that was being sent to. No separate cleanup pass is needed.
* **Token rotation:** device tokens rotate (reinstall, OS refresh, backup restore), which kills every binding on the old token. The SDK detects this wherever `usePushRegistration` is mounted with an adapter that reports rotations: it records the new identifier, re-binds the **active** account immediately, and marks every other push-enabled account as needing a re-bind, repairing each on its next activation rather than spending its stored credential in the background. Rotation is reported live on Expo and on Android, and on the next page load on Web (which has no in-page rotation event). iOS APNs rotation under bare React Native is not reported by the OS event at all; it is picked up on the next launch instead, because on native the hook also reads the device's current identifier on mount whenever notification permission is already granted. See [Automatic token rotation](/sdk/push-notifications/overview#automatic-token-rotation).
* **Signing out unbinds automatically:** once this device has a stored push identifier, `signOut()`, `removeAccount()` and `signOutAll()` pass its identifier with the sign-out request, and the server deletes that account's push binding in the **same transaction** as the session teardown. If the server refuses the unbind nothing commits — the sign-out fails and the account keeps its credential so the user can retry, rather than being left receiving notifications from an account it can no longer reach. A device with no stored identifier sends none, has no unbind to protect, and signs out best-effort. Other accounts on the device are unaffected.
* **Per-account control:** `unregister()` silences the **active** account on this device as a durable preference, and [`useAccountPushToggle`](/hooks/push/use-account-push-toggle) does the same for any stored account without switching to it. Neither is a logout step — see the [warning on the SDK page](/sdk/push-notifications/overview#turning-push-off).
## References
Everything related to push notifications across the docs:
Per-platform client setup for Expo, React Native, and Web
Request permission, register, and unregister the current user's device
Per-account push control on a device holding several accounts
The `push.send()` server module reference
Forward `notification.created` events to push
### API Endpoints
`POST /push-notifications/devices`
`DELETE /push-notifications/devices`
`POST /push-notifications/send`
`GET /push-notifications/vapid-public-key`
# Notifications Hook
Source: https://docs.sublay.io/v7/sdk/app-notifications/hook
Fetch, paginate, and manage app notifications with useAppNotifications
`useAppNotifications` is the primary hook for building a notification feed. It loads notifications, tracks the unread count, handles pagination, and exposes actions for marking notifications as read.
## Basic Usage
```tsx theme={null}
import { useAppNotifications } from "@sublay/react-js";
function NotificationFeed() {
const {
appNotifications,
unreadAppNotificationsCount,
loading,
hasMore,
loadMore,
markNotificationAsRead,
markAllNotificationsAsRead,
} = useAppNotifications({ limit: 20 });
return (
);
}
```
## Parameters
Number of notifications to load per page. Defaults to `10`.
Optional map of display text templates for each notification type. See [Notification Templates](/sdk/app-notifications/notification-templates).
## Returns
The list of notification records. If `notificationTemplates` is provided, each notification is augmented with a `title` and/or `content` field derived from the template.
The total number of unread notifications for the current user.
`true` while a fetch is in progress.
`true` if there are more notifications to load beyond the currently loaded set.
Increments the internal page counter, triggering a fetch of the next page and appending results to `appNotifications`.
Marks a single notification as read. Applies an optimistic update immediately before the API call.
Marks all of the current user's notifications as read. Applies an optimistic update immediately.
Clears the current notification list and re-fetches from page 1. Useful for pull-to-refresh.
The hook is backed by Redux. Notifications are shared globally in the Redux store — multiple instances of the hook in different components will share the same state.
## Related
* [Notification Templates](/sdk/app-notifications/notification-templates) — customize display text per type
* [useAppNotifications hook reference](/hooks/app-notifications/use-app-notifications)
# Notification Templates
Source: https://docs.sublay.io/v7/sdk/app-notifications/notification-templates
Customize the display text shown for each notification type
Sublay stores notifications as structured records with typed `metadata` — not pre-rendered strings. Your app controls how each notification type is displayed using **notification templates**.
Templates are passed to `useAppNotifications` via the `notificationTemplates` prop. Each template can define a `title` and/or `content`. Each field accepts either:
* A **string** with `$variable` placeholders that are filled from the notification's metadata, or
* A **function** that receives a typed variables object and returns a string — useful when you need full control, such as translating specific values or applying conditional logic.
## String template example
```tsx theme={null}
import { useAppNotifications } from "@sublay/react-js";
const templates = {
entityComment: {
title: "New comment",
content: "$initiatorName commented on your post",
},
commentReply: {
title: "New reply",
content: "$initiatorName replied to your comment",
},
newFollow: {
title: "New follower",
content: "$initiatorName started following you",
},
connectionRequest: {
title: "Connection request",
content: "$initiatorName wants to connect",
},
};
function NotificationFeed() {
const { appNotifications } = useAppNotifications({ notificationTemplates: templates });
return (
{appNotifications.map((n) => (
{n.title}
{n.content}
))}
);
}
```
## Function template example
Function templates are useful when string interpolation isn't enough — for example, when you need to translate a reaction type value into another language, or apply conditional phrasing.
```tsx theme={null}
const templates = {
entityReaction: {
title: ({ initiatorName, reactionType }) => {
const reactionLabel =
reactionType === "upvote" ? "me gusta" :
reactionType === "heart" ? "corazón" : reactionType;
return `${initiatorName} reaccionó con ${reactionLabel} a tu publicación`;
},
},
commentReaction: {
// Mix string and function in the same template
title: ({ initiatorName, reactionType }) => {
const reactionLabel =
reactionType === "upvote" ? "me gusta" :
reactionType === "heart" ? "corazón" : reactionType;
return `${initiatorName} reaccionó con ${reactionLabel} a tu comentario`;
},
content: "$commentContent",
},
};
```
TypeScript will infer the exact variables available for each notification type, so you get autocompletion and type safety when destructuring the parameter.
## Template keys and available variables
Each key in `notificationTemplates` corresponds to one notification type. The variables available to both string placeholders and function templates are listed below.
| Template key | Notification type | Available variables |
| ---------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `entityComment` | `entity-comment` | `initiatorName`, `initiatorUsername`, `entityTitle`, `entityContent`, `commentContent` |
| `commentReply` | `comment-reply` | `initiatorName`, `initiatorUsername`, `entityTitle`, `entityContent`, `commentContent`, `replyContent` |
| `entityMention` | `entity-mention` | `initiatorName`, `initiatorUsername`, `entityTitle`, `entityContent` |
| `commentMention` | `comment-mention` | `initiatorName`, `initiatorUsername`, `entityTitle`, `entityContent`, `commentContent` |
| `entityUpvote` | `entity-upvote` | `initiatorName`, `initiatorUsername`, `entityTitle`, `entityContent` |
| `commentUpvote` | `comment-upvote` | `initiatorName`, `initiatorUsername`, `entityTitle`, `entityContent`, `commentContent` |
| `entityReaction` | `entity-reaction` | `initiatorName`, `initiatorUsername`, `entityTitle`, `entityContent`, `reactionType` |
| `commentReaction` | `comment-reaction` | `initiatorName`, `initiatorUsername`, `entityTitle`, `entityContent`, `commentContent`, `reactionType` |
| `entityReactionMilestoneSpecific` | `entity-reaction-milestone-specific` | `entityTitle`, `entityContent`, `reactionType`, `milestoneCount` |
| `entityReactionMilestoneTotal` | `entity-reaction-milestone-total` | `entityTitle`, `entityContent`, `milestoneCount` |
| `commentReactionMilestoneSpecific` | `comment-reaction-milestone-specific` | `entityTitle`, `entityContent`, `commentContent`, `reactionType`, `milestoneCount` |
| `commentReactionMilestoneTotal` | `comment-reaction-milestone-total` | `entityTitle`, `entityContent`, `commentContent`, `milestoneCount` |
| `newFollow` | `new-follow` | `initiatorName`, `initiatorUsername` |
| `connectionRequest` | `connection-request` | `initiatorName`, `initiatorUsername` |
| `connectionAccepted` | `connection-accepted` | `initiatorName`, `initiatorUsername` |
| `spaceMembershipApproved` | `space-membership-approved` | `spaceName`, `spaceShortId`, `spaceSlug` |
All template keys are optional. If a template is not provided for a given notification type, Sublay uses a built-in default string template for that type. You only need to supply the keys you want to customize.
## Accessing raw metadata
For building fully custom UIs, access the raw `metadata` directly on each notification. Every notification includes:
* `id` — the notification's unique ID
* `type` — the notification type string
* `isRead` — whether the user has read it
* `action` — a hint string for the action to take on tap (e.g., `"open-entity"`, `"open-comment"`, `"open-profile"`)
* `metadata` — type-specific fields (entity IDs, initiator info, etc.)
* `createdAt` — ISO timestamp
# Overview
Source: https://docs.sublay.io/v7/sdk/app-notifications/overview
In-app notification records triggered by user interactions and system events
App Notifications are persistent, database-backed records delivered to users when activity happens in your app — new comments, replies, mentions, reactions, follows, connection requests, and more. They are distinct from push notifications; they live in the Sublay database and are surfaced through the SDK.
**Requires the `notifications` bundle.** App notifications are only available when the `notifications` bundle is installed on your project. See [Bundles](/bundles) to add it.
## How It Works
Sublay automatically creates notification records when relevant events occur (e.g., someone comments on an entity, someone follows a user). Your app polls or fetches those records and displays them in a notification feed. When push delivery is also needed, a webhook fires at the same time so your backend can forward the event to FCM or APNs.
## In This Section
How to use `useAppNotifications` — fetch notifications, unread count, and mark as read
Customize the display text for each notification type
Forward notifications to push services via webhook
## Hooks
Full API reference for `useAppNotifications`
## Notification Types
Sublay generates notifications for the following events:
| Type | Trigger |
| ------------------------------------- | ------------------------------------------------------------- |
| `entity-comment` | Someone comments on your entity |
| `comment-reply` | Someone replies to your comment |
| `entity-mention` | You are mentioned in an entity |
| `comment-mention` | You are mentioned in a comment |
| `entity-upvote` | Someone upvotes your entity |
| `comment-upvote` | Someone upvotes your comment |
| `entity-reaction` | Someone reacts to your entity |
| `comment-reaction` | Someone reacts to your comment |
| `entity-reaction-milestone-specific` | Your entity reaches a milestone for a specific reaction type |
| `entity-reaction-milestone-total` | Your entity reaches a total reaction milestone |
| `comment-reaction-milestone-specific` | Your comment reaches a milestone for a specific reaction type |
| `comment-reaction-milestone-total` | Your comment reaches a total reaction milestone |
| `new-follow` | Someone follows you |
| `connection-request` | Someone sends you a connection request |
| `connection-accepted` | Someone accepts your connection request |
| `space-membership-approved` | A moderator approves your pending space membership request |
| `system` | A custom message sent from the dashboard |
# Webhook Integration
Source: https://docs.sublay.io/v7/sdk/app-notifications/webhook-integration
Forward app notification events to your backend for push delivery
App Notifications in Sublay are in-app records — they appear in a notification feed inside your app. To deliver push notifications (iOS APNs, Android FCM) when users are not actively using the app, you can configure a webhook that fires whenever a notification is created.
## How It Works
In your Sublay project dashboard, go to **Settings → Webhooks** and add a webhook URL pointing to your backend endpoint. Select the **App Notification Created** event.
When Sublay creates an app notification, it sends an HTTP POST to your webhook URL with a JSON payload describing the notification:
```json theme={null}
{
"type": "app-notification.created",
"projectId": "your-project-id",
"data": {
"projectId": "your-project-id",
"userId": "recipient-user-id",
"type": "entity-comment",
"action": "open-comment",
"metadata": {
"entityId": "...",
"commentId": "...",
"initiatorId": "...",
"initiatorName": "Alice"
}
}
}
```
Use `data.userId` to look up the FCM or APNs token stored in your own database for that user.
Use your push provider (Firebase Admin SDK, APNs, Expo Push, etc.) to send a push notification to the device. Build the message text using `data.type` and `data.metadata` from the webhook payload.
## Payload Fields
| Field | Type | Description |
| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------- |
| `type` | `string` | Always `"app-notification.created"` |
| `projectId` | `string` | The Sublay project ID |
| `data.projectId` | `string` | The Sublay project ID (repeated inside data) |
| `data.userId` | `string` | The recipient's user ID |
| `data.type` | `string` | The notification type (e.g., `"entity-comment"`) |
| `data.action` | `string` | Navigation hint for the push tap action |
| `data.metadata` | `object` | Type-specific data — see [Notification Templates](/sdk/app-notifications/notification-templates) for fields per type |
This page describes the **bring-your-own-provider** bridge: you store the push
tokens in your own backend and dispatch through your own provider. Sublay can
also do this for you — the [`push` bundle](/push-notifications) stores each
user's registered devices per account and dispatches to APNs, FCM and Web Push
itself, in which case no webhook bridge is needed.
# Built-in Auth
Source: https://docs.sublay.io/v7/sdk/authentication/built-in
Email/password sign-up, sign-in, and sign-out flows
Sublay's built-in authentication lets you register and sign in users with an email address and password without building your own auth backend. The SDK's `useAuth` hook exposes all the actions you need.
## Sign Up
Call `signUpWithEmailAndPassword` with at minimum an `email` and `password`. Additional profile fields are optional.
```tsx theme={null}
import { useAuth } from "@sublay/react-js";
function SignUpForm() {
const { signUpWithEmailAndPassword } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const form = e.currentTarget;
try {
await signUpWithEmailAndPassword({
email: form.email.value,
password: form.password.value,
name: form.name.value,
});
// User is now signed in
} catch (err) {
console.error(err);
}
};
return (
);
}
```
### Optional Profile Fields
You can pass additional profile data at registration time:
| Field | Type | Description |
| ---------------- | ----------------------------------------- | ------------------------------------------------- |
| `name` | `string` | Display name |
| `username` | `string` | Unique username within the project |
| `avatar` | `string` | URL of avatar image |
| `bio` | `string` | Short bio text |
| `location` | `{ latitude: number; longitude: number }` | Geographic location |
| `birthdate` | `Date` | Date of birth |
| `metadata` | `Record` | Public custom data |
| `secureMetadata` | `Record` | Private custom data (not returned to other users) |
| `avatarFile` | `File \| Blob` | Upload an avatar image directly |
| `bannerFile` | `File \| Blob` | Upload a profile banner image |
If you supply both `avatar` (URL) and `avatarFile` (file upload), the file
takes precedence and the URL is ignored.
## Sign In
```tsx theme={null}
import { useAuth } from "@sublay/react-js";
function SignInForm() {
const { signInWithEmailAndPassword } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const form = e.currentTarget;
try {
await signInWithEmailAndPassword({
email: form.email.value,
password: form.password.value,
});
} catch (err) {
console.error(err);
}
};
return (
);
}
```
## The Account Limit
A device stores at most **5 accounts** ([Multi-Account](/sdk/authentication/multi-account)). Both calls above refuse a sixth, throwing an `Error` whose message is exported as `ACCOUNT_LIMIT_MESSAGE`:
```tsx theme={null}
import { useAuth, ACCOUNT_LIMIT_MESSAGE } from "@sublay/react-js";
try {
await signInWithEmailAndPassword({ email, password });
} catch (err) {
if (err instanceof Error && err.message === ACCOUNT_LIMIT_MESSAGE) {
setError("You're signed in to 5 accounts. Sign out of one to continue.");
} else {
setError("Incorrect email or password.");
}
}
```
| | Sign-up | Sign-in |
| ---------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------ |
| Checked | Before the request — normally no account is created | After the request, on the account the server resolved |
| Already-stored account | n/a (a sign-up is always new) | **Succeeds** — re-authenticating an account you already store is never blocked |
Sign-in is deliberately checked *after* the network call. Comparing the typed email against the stored accounts first would be faster and wrong: it would refuse users whose stored email is out of date, absent, or capitalised differently, with no way through.
**One sign-up case does leave a user record behind.** The pre-request check can only use the accounts stored when the call left. If the fifth slot fills while your sign-up is in flight — another tab, or a second sign-in racing this one — the account has already been created on the server by the time the limit is discovered. The SDK signs that session straight back out, but the **user record persists**, so retrying the same email after freeing a slot fails with "email already in use". Sign in with it instead.
A refusal leaves the currently active account signed in, leaves the accounts map untouched, signs the server-side session created by the refused attempt back out, and sets `accountLimitReached` — see [Reaching the account limit](/sdk/authentication/multi-account#reaching-the-account-limit).
## Sign Out
`signOut` revokes the current session's entire token family on the server and clears local auth state. Once this device has a stored push identifier it also asks the server to unbind that account's push notifications, in the same transaction as the session teardown.
```tsx theme={null}
import { useAuth } from "@sublay/react-js";
function SignOutButton() {
const { signOut } = useAuth();
return (
);
}
```
`signOut` **rejects** when the server refuses that unbind, and in that case nothing was torn down on either side — the session survives so the user can retry. Its signature is `() => Promise`, so nothing warns you at compile time; without the `catch` the rejection is unhandled and your UI reports a sign-out that did not happen. Every other failure — no network, a throttled or otherwise rejected request, a device that never registered for push — still signs out locally, because a user must always be able to sign out. See [`useAuth`](/hooks/auth/use-auth).
## Change Password
Users who signed up with email/password can change their password while authenticated. The current password must be provided for verification.
**A password change ends every *other* session for that user**, so their other devices must sign in again with the new password. The session making the call survives — the server reads which session is asking off the access token the request already carries, so nothing extra is sent. It also removes that user's push bindings on every other device while keeping this one's, which the hook arranges by sending the device identifier it already holds. See [`useAuth`](/hooks/auth/use-auth) and [Change Password](/api-reference/auth/change-password).
```tsx theme={null}
import { useAuth } from "@sublay/react-js";
function ChangePasswordForm() {
const { changePassword } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const form = e.currentTarget;
try {
await changePassword({
password: form.current.value,
newPassword: form.next.value,
});
} catch (err) {
console.error(err);
}
};
return (
);
}
```
## Password Reset
Users who forget their password can request a reset email. Sublay sends a link to the registered email address. The link expires after 1 hour.
Completing the reset revokes **every** session for that user and removes **every** push binding they hold, on every device — including the one they are standing on. They sign in again everywhere, and each device re-binds for push on its own once it is next opened. The reset itself is completed by the page the link points to, which collects the new password and calls the [Reset Password](/api-reference/auth/reset-password) endpoint — there is no React hook for that step.
```tsx theme={null}
import { useRequestPasswordReset } from "@sublay/react-js";
function ForgotPasswordForm() {
const requestPasswordReset = useRequestPasswordReset();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const form = e.currentTarget;
const result = await requestPasswordReset({ email: form.email.value });
console.log(result.message);
// Always returns a success message to prevent email enumeration
};
return (
);
}
```
The API always responds with a success message regardless of whether the email
address is registered, to prevent user enumeration.
## Auth State
Check whether the SDK has finished initializing and whether a user is currently signed in:
```tsx theme={null}
import { useAuth } from "@sublay/react-js";
function AuthGuard({ children }: { children: React.ReactNode }) {
const { initialized, accessToken } = useAuth();
if (!initialized) return
Loading...
;
if (!accessToken) return
Please sign in.
;
return <>{children}>;
}
```
## Email Verification
After sign-up, you can prompt users to verify their email address. Sublay sends a short-lived token (5 minutes) via email. There are two delivery modes: a **code** the user types back into your app, or a **link** they click to verify automatically.
### Send a verification email
`mode` is always required, and it determines what else you need to pass:
```tsx theme={null}
import { useSendVerificationEmail } from "@sublay/react-js";
function VerifyEmailPrompt() {
const sendVerificationEmail = useSendVerificationEmail();
return (
);
}
```
```tsx theme={null}
// 6-digit numeric code — easy for users to type
await sendVerificationEmail({ mode: "code", tokenFormat: "numeric", tokenLength: 6 });
// A code using the full hex charset — tokenLength isn't allowed here, since
// hex ignores it and always produces a fixed 64-character token
await sendVerificationEmail({ mode: "code", tokenFormat: "hex" });
// Clickable link — verifies automatically when opened. tokenFormat/tokenLength
// are optional for links, since the token is never read by a human
await sendVerificationEmail({
mode: "link",
redirectUrl: "https://yourapp.com/email-verified",
});
```
| Option | Type | Required | Description |
| ------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `mode` | `"code" \| "link"` | always | Whether to send a code to enter, or a link to click |
| `tokenFormat` | `"hex" \| "numeric" \| "alpha" \| "alphanumeric"` | when `mode: "code"` (optional, defaults to `"hex"`, for `mode: "link"`) | Character set for the token |
| `tokenLength` | `number` (4–12) | when `mode: "code"` and `tokenFormat` isn't `"hex"` (not allowed when it is `"hex"`; optional for `mode: "link"`, defaults to `6`) | Length of the token |
| `redirectUrl` | `string` | never | Where to redirect after link verification (only valid with `mode: "link"`) |
TypeScript enforces this combination at compile time — `sendVerificationEmail({ mode: "code" })` or `sendVerificationEmail({ mode: "code", tokenFormat: "numeric" })` (missing `tokenLength`) won't type-check.
### Verify with a code
When using `mode: "code"`, collect the token from the user and call `useVerifyEmail`:
```tsx theme={null}
import { useVerifyEmail } from "@sublay/react-js";
function VerifyCodeForm() {
const verifyEmail = useVerifyEmail();
const [code, setCode] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await verifyEmail({ token: code });
// user.isVerified is now true in the local Redux store
} catch (err) {
console.error(err);
}
};
return (
);
}
```
On success, `user.isVerified` is updated immediately in the local Redux store — no refetch needed.
### Link mode
When using `mode: "link"`, the email contains a button the user clicks. The server verifies the token and either:
* Redirects to the `redirectUrl` you provided with `?verified=true` appended, or
* Renders a hosted success page if no `redirectUrl` was given.
The `user.isVerified` field will be `true` the next time the user's session is loaded (e.g. on next sign-in or token refresh). There is no SDK callback for link-mode verification since it happens outside the app's JS context.
The verification token is valid for 5 minutes and can only be used once.
Calling `sendVerificationEmail` while a previous token is still valid will
generate a new token — the old one remains valid until it expires or the new
one is used.
## See Also
* [`useAuth` hook reference](/hooks/auth/use-auth)
* [`useRequestPasswordReset` hook reference](/hooks/auth/use-request-password-reset)
* [`useSendVerificationEmail` hook reference](/hooks/auth/use-send-verification-email)
* [`useVerifyEmail` hook reference](/hooks/auth/use-verify-email)
* [Sign Up API reference](/api-reference/auth/sign-up)
* [Sign In API reference](/api-reference/auth/sign-in)
# External Auth
Source: https://docs.sublay.io/v7/sdk/authentication/external
Integrating your own auth system via signed JWT
If you already have a user authentication system (such as Clerk, Auth0, Firebase Auth, or your own backend), you can integrate it with Sublay without requiring users to create a separate Sublay account. Your backend signs a JWT with a project-specific private key; the SDK exchanges that JWT for Sublay tokens.
## How It Works
After your own auth system verifies the user, your server signs a JWT using
your Sublay project's RSA private key. The token identifies the user by
their ID in your system.
Call `verifyExternalUser` (available via the `useAuth` hook or directly) with
the signed JWT. Sublay verifies the signature, looks up or creates the user,
and returns Sublay access and refresh tokens.
From this point, the user is signed into Sublay and all SDK hooks work
normally. Token refresh is handled automatically.
## JWT Requirements
Your backend must sign the JWT using **RS256** (RSA 256-bit) with your project's private key. The payload must include:
| Claim | Description |
| ---------- | ------------------------------------------------------------ |
| `sub` | The user's ID in your system (becomes `foreignId` in Sublay) |
| `iss` | Your Sublay project ID |
| `userData` | Object with optional profile fields (see below) |
### `userData` fields
All fields in `userData` are optional. If a user already exists and any field has changed, Sublay updates the stored value.
| Field | Type | Description |
| ---------------- | -------- | ---------------------------------- |
| `email` | `string` | User's email address |
| `name` | `string` | Display name |
| `username` | `string` | Unique username within the project |
| `avatar` | `string` | URL of avatar image |
| `bio` | `string` | Short bio text |
| `location` | object | `{ latitude, longitude }` |
| `birthdate` | `string` | ISO 8601 date string |
| `metadata` | object | Public custom data |
| `secureMetadata` | object | Private custom data |
## Backend Example (Node.js)
```ts theme={null}
import jwt from "jsonwebtoken";
import fs from "fs";
const privateKey = fs.readFileSync("sublay-private-key.pem");
function createSublayJwt(userId: string, userData: object) {
return jwt.sign(
{
sub: userId,
iss: "your-sublay-project-id",
userData,
},
privateKey,
{ algorithm: "RS256", expiresIn: "5m" }
);
}
```
Keep your RSA private key on your server. Never expose it to the client.
The JWT should be generated server-side and passed to the frontend at
sign-in time.
## Frontend Integration
Pass the signed JWT directly to `SublayProvider` (or `SublayIntegrationProvider` if you manage your own Redux store) via the `signedToken` prop. The SDK exchanges the token automatically on initialization — no manual API calls required.
```tsx Standard setup theme={null}
import { SublayProvider } from "@sublay/react-js";
function App() {
// Fetch or derive the signed JWT from your own auth system
const signedToken = useMyAuthSystem().sublayJwt;
return (
);
}
```
```tsx Integration mode (own Redux store) theme={null}
import { SublayIntegrationProvider } from "@sublay/react-js";
function App() {
// Fetch or derive the signed JWT from your own auth system
const signedToken = useMyAuthSystem().sublayJwt;
return (
);
}
```
Whenever `signedToken` changes (e.g., after a new user signs in), the SDK re-initializes auth with the updated token automatically.
The `verify-external-user` exchange happens inside the provider — you never
need to call the endpoint manually. The provider creates the user if they do
not exist, updates their profile if any `userData` fields have changed, and
handles token refresh from that point forward.
## The Account Limit
A device stores at most **5 accounts** ([Multi-Account](/sdk/authentication/multi-account)). If the verified token identifies a **sixth**, distinct user, the exchange is refused: the session Sublay just created is signed back out, the stored accounts are left untouched, and the app renders signed-out with `accountLimitReached` set.
```tsx theme={null}
import { useAccounts } from "@sublay/react-js";
function AccountLimitBanner() {
const { accountLimitReached, accounts } = useAccounts();
if (!accountLimitReached) return null;
return
This device already holds {accounts.length} accounts. Sign out of one to continue.
;
}
```
Two consequences specific to this integration:
* **A `signedToken` for an already-stored user is never refused.** The check is keyed on the user id Sublay resolves from the token, not on anything in `userData`, so returning users sign in normally at the limit.
* **The refusal is not silent, and it does not fall back.** Because the provider exchanges the token on every launch, a refused exchange would otherwise repeat invisibly forever — or quietly restore some *other* stored account instead. Instead the SDK surfaces the reason and sets `accountLimitReached`. The auth gate still opens, so the app renders normally in its signed-out state rather than hanging.
* **The refusal is not recorded as a sign-out.** Nobody signed out — an admission was refused — so the selection is left exactly where it was and `signedOut` is not set. The next launch therefore behaves as it would have without the refused attempt, rather than reproducing the same dead end forever. See [Reaching the account limit](/sdk/authentication/multi-account#reaching-the-account-limit).
Apps in integration mode that never store multiple accounts cannot hit this: the limit is only reached once five distinct users have been admitted on the same device.
## User Identity
When a user signs in via external auth, Sublay stores the `sub` claim as `foreignId` on the user record and creates a `UserIdentity` entry with `provider: "external"`. On subsequent sign-ins, the user is looked up by identity first, then by `foreignId` as a fallback, then by email.
## See Also
* [Verify External User API reference](/api-reference/auth/verify-external-user)
* [Authentication overview](/sdk/authentication/overview)
# Multi-Account
Source: https://docs.sublay.io/v7/sdk/authentication/multi-account
Managing multiple user accounts in a single app instance
Sublay stores up to **5 accounts at once** in a single app instance. Users can add new accounts, switch between them, and remove individual accounts without losing session data for the others.
## Architecture
Each signed-in account is stored in the Redux `accounts` map, keyed by user ID. The `activeAccountId` field tracks which account is currently active, and the map is persisted by the `AccountManager` your platform package mounts — see [Where accounts are stored](#where-accounts-are-stored).
When the user switches accounts, the SDK exchanges the target account's stored refresh token for a live session **before** it tears the current one down. If that fails — an expired or revoked stored token, for example — the switch **rejects** and the account you were using stays signed in; see [When a transition fails](#when-a-transition-fails).
## Hooks
| Hook | Purpose |
| ------------------------------------------------------------- | ------------------------------------------------------------ |
| `useAccounts` | Read the list of accounts and the active account |
| `useAddAccount` | Clear current auth state to allow signing into a new account |
| `useSwitchAccount` | Switch the active session to a different stored account |
| `useRemoveAccount` | Sign out and remove one account from the stored list |
| `useSignOutAll` | Sign out all accounts simultaneously |
| [`useAccountPushToggle`](/hooks/push/use-account-push-toggle) | Turn push on or off per account on this device |
## Reading Account State
```tsx theme={null}
import { useAccounts } from "@sublay/react-js";
function AccountList() {
const { accounts, activeAccount, accountCount } = useAccounts();
return (
Active: {activeAccount?.name ?? "None"}
Total accounts: {accountCount}
{accounts.map((account) => (
{account.name ?? account.email}
))}
);
}
```
Each account in `accounts` is a `StoredAccount`: the profile summary — `id`, `name`, `username`, `email`, `avatar` — plus two credential markers, `tokenExpiresAt` and `needsReauth`, and one notification marker, `needsPushRebind`.
`username`, `email` and `avatar` may be `null`, and `username` may be absent entirely on an entry stored by an older SDK version — absent means *unknown*, not "the user has no username".
### Knowing which stored accounts still work
A stored account can stop working while it sits in the switcher. The two markers let you show that before the user taps it, and you need both:
| Marker | Kind | What it catches |
| ---------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tokenExpiresAt` | Proactive — decoded from the stored refresh token's `exp` claim, no network call | The token simply aged out. `0` means the expiry could not be read from the token, and sorts as already-expired |
| `needsReauth` | Reactive — set when a transition into that account is actually refused, by a switch or by the restore at app launch | Everything expiry cannot see: reuse detection, a password change, a remote sign-out-all, an admin revocation. All of those kill the token family while `exp` is still far in the future. It is also set without a network call when the stored entry carries no usable credential at all |
```tsx theme={null}
const needsSignIn = (account) =>
account.needsReauth || account.tokenExpiresAt <= Date.now();
```
`needsReauth` clears the moment the account is successfully activated again — by a switch that works, or by signing into it afresh. A switch that failed because the request never reached the server does **not** set it: a flaky network is not a dead account.
A third marker, `needsPushRebind`, is about notifications rather than credentials — see [Push Notifications Per Account](#push-notifications-per-account).
Neither marker is a guarantee. `needsReauth: false` with a future expiry means *nothing has gone wrong that the SDK knows of*, so a switch can still fail — and when it does, the entry stays in the map precisely so you can render a re-auth affordance for it. See [When a transition fails](#when-a-transition-fails).
## Where Accounts Are Stored
Persistence is owned by an `AccountManager` component that each platform package mounts inside its provider. It carries that platform's storage adapter, which is why `@sublay/core`'s own providers cannot supply one.
| Platform package | Backing store |
| ---------------------- | ---------------------------------------------------------------------------- |
| `@sublay/react-js` | `localStorage` |
| `@sublay/react-native` | Keychain (`react-native-keychain`) |
| `@sublay/expo` | `expo-secure-store`, one encrypted value **per account** plus an index value |
### The five-account limit is a real limit, not an approximation
Expo's SecureStore documents a **2048-byte ceiling per value**, and a whole account map does not reliably fit inside one. `@sublay/expo` therefore writes one value per account behind a small index value, so five accounts fit comfortably with each value well inside the limit.
If a single account entry ever did exceed the budget, the adapter sheds `avatar` first, then `email`, and logs what it dropped — those are the two unbounded fields. `id`, `name`, `username`, the refresh token and the two re-auth markers are never shed: an entry missing those would load but be unusable, or would silently claim to be healthy.
**Upgrading keeps your users signed in.** A value written by a previous release — the whole map as one value — is converted to the new layout the first time the app loads it, with the accounts and the active selection intact. The conversion runs once and is invisible: later launches read the new layout directly. A stored value that is *not* a recognizable account map (corrupt bytes, a truncated write) is still read as signed-out rather than guessed at, so those rare installs sign in again once. React Native (Keychain) and web (`localStorage`) have no comparable limit, never changed layout, and are unaffected.
### Integration mode persists too
Apps that bring their own Redux store use `SublayIntegrationProvider`. **Import it from your platform package, not from `@sublay/core`** — core's version has no `AccountManager` and therefore no storage, so the account map would live only in memory and multi-account would not survive a relaunch. See [Redux Integration](/sdk/redux-integration).
## Adding a New Account
Calling `addAccount` clears the current auth state, which causes the app to display the sign-in UI. The previously stored accounts remain in the accounts map and are not affected. After the user signs in to the new account, it is added to the map automatically.
It clears the **session** and nothing else: the account the user came from stays selected, and `signedOut` is untouched. So abandoning the flow — backing out and quitting — leaves them where they were, and the next launch restores that account. On the web this also means opening the flow says nothing to the app's other tabs, which keep their sessions.
```tsx theme={null}
import { useAddAccount } from "@sublay/react-js";
function AddAccountButton() {
const { addAccount, canAddAccount } = useAddAccount();
return (
);
}
```
`canAddAccount` is `false` when 5 accounts are already stored.
## Switching Accounts
```tsx theme={null}
import { useAccounts, useSwitchAccount } from "@sublay/react-js";
function AccountSwitcher() {
const { accounts, activeAccount } = useAccounts();
const { switchAccount, isSwitching, error } = useSwitchAccount();
return (
);
}
```
Switching is asynchronous, and the order is deliberate: the SDK exchanges the target account's stored refresh token for a live session **first**, and only once that succeeds does it clear the current session, select the target and install the new tokens. A target whose credential is dead therefore costs you nothing — the switch rejects and the account you were using is still signed in.
## Removing an Account
```tsx theme={null}
import { useAccounts, useRemoveAccount } from "@sublay/react-js";
function RemoveAccountButton({ userId }: { userId: string }) {
const { removeAccount, isRemoving, error } = useRemoveAccount();
return (
<>
{error &&
{error}
}
>
);
}
```
When the removed account is the currently active one, **the session ends and no account is left active** — the remaining accounts stay in the map, but the SDK does not sign the user into one of them. Render your own next screen (an account picker, or your sign-in UI) when `activeAccount` is `null`.
Removing, signing out and deleting an account all leave `activeAccount` as `null` rather than landing the user inside a different identity, so your app owns what comes next.
Removing an account sends a sign-out request that revokes the refresh token family **and** unbinds that account's push notifications on this device, in a single server transaction.
**Removal is atomic once push is involved.** When this device has a stored push identifier, the request asks for an unbind — and if the server refuses it, **nothing is torn down**. The account keeps its entry and its credential, `removeAccount` **rejects**, and `error` carries the reason so the user can retry. `await removeAccount(...)` therefore needs a `catch`.
The strictness is **scoped to a server refusal of the unbind itself** — `auth/device-deregistration-failed` or `auth/sign-out-failed`. Anything that never reached the sign-out handler is best-effort: no network, a throttled request, a body the server rejected. An offline user can still remove an account locally, and a device with no stored push identifier has nothing for the guarantee to protect. A `200` carrying `auth/push-unbind-status-unknown` is a success, not a refusal: the server signed the account out without attempting an unbind because it could not determine whether the project has push devices, so the removal completes and the SDK warns.
## Signing Out All Accounts
```tsx theme={null}
import { useSignOutAll } from "@sublay/react-js";
function SignOutAllButton() {
const { signOutAll } = useSignOutAll();
const handleClick = () =>
signOutAll().catch((err) => {
// Some accounts were kept — see the warning below.
showError(err);
});
return ;
}
```
`signOutAll` sends a sign-out request for every stored account's refresh token — each one revoking that account's token family and unbinding its push on this device — and then clears local auth state.
**A partial failure keeps the accounts it could not sign out.** When the requests carry this device's push identifier, accounts whose request **succeeded** are removed and accounts whose unbind the server **refused** are kept with their credentials intact; the call then **rejects** with how many failed, so the user can retry. The live session ends either way — an access token is transient, not the credential the guarantee is about.
Same scoping as removal: a transport failure is not a refusal, so an offline `signOutAll` still clears local state and resolves, and neither is a `200` reporting a skipped unbind. With no push identifier on the device nothing is at stake either.
## When a Transition Fails
Every path that establishes a session for a stored account — `switchAccount`, and the automatic restore at app launch — has to exchange that account's stored refresh token for a live access token. That exchange can fail: the token expired, the server revoked it, or the stored entry is unusable.
**`switchAccount` rejects, and nothing else changes.** The target's credential is proven out of band *before* the current session is torn down, so a failure is a complete no-op against what you were using:
* The previously active account is **still active, with a live session** — same tokens, same user, same cached data. Nothing was signed out.
* **Both account entries survive.** The account that failed to restore stays in the map, which is what lets you render a "session expired — sign in again" affordance for it, and its `needsReauth` marker is set when the server was the one that refused.
* If **nothing was active** when the transition was attempted — the picker state the user lands in after signing out — nothing is selected and the app is marked signed out, so the next launch does not silently activate a different account.
```tsx theme={null}
import { AccountTransitionError } from "@sublay/react-js";
try {
await switchAccount({ userId });
} catch (err) {
// The switch did not happen and the account you were using is still signed
// in. `error` is also set on the hook.
if (err instanceof AccountTransitionError && err.credentialRejected) {
promptSignIn(userId); // the credential is dead
} else {
showRetry(); // the request never got an answer
}
}
```
### Telling a dead account from a dead network
`AccountTransitionError` is the typed error an account-transition failure rejects with — from
`switchAccount`, `removeAccount` and `activateStoredAccount` alike, and from
[`setAccountPushEnabled`](/hooks/push/use-account-push-toggle) for the one failure it shares
(`accountNotFound`). It extends `Error`, so
`message` and `instanceof Error` work as usual, and it carries **two independent boolean
discriminants**, because the three failures it covers call for three different responses.
Check with `instanceof` before reading those flags, because not every rejection is one:
`removeAccount` rejects with the **underlying error, unchanged**, when the server refuses to unbind
the account's push notifications — that is the atomic-removal refusal described under
[Removing an Account](#removing-an-account), and it is reported as the server sent it.
`switchAccount` and `removeAccount` also throw a plain `Error` when no `projectId` is configured.
`true` when the **server refused** the stored credential — expired, revoked, reuse-detected,
invalidated by a password change or a remote sign-out-all — or when the stored entry carries no
usable credential at all. This is the only case in which `needsReauth` is set on that account.
`false` when the exchange never got an answer, or the rotated successor could not be persisted.
Nothing is marked, and the same account may well work on the next attempt.
`true` when the operation named an **account that is not in the stored map at all** — a stale id,
typically from a switcher rendered off a snapshot the map has since moved past.
Nothing was attempted and nothing was marked: no network call went out, no session was touched,
`signedOut` is left exactly as it was, and re-authenticating would not help, because there is no
entry to authenticate. Refresh your account list from `useAccounts()` rather than prompting a
sign-in.
The two flags are never both `true` — an account that is not stored has no credential to have
been refused — and both are `false` for a transport failure.
The server's reason when it gave one, otherwise `ACCOUNT_TRANSITION_FAILED_MESSAGE`. Both the
class and that constant are exported, so you never have to match on a string you do not own.
Prompting a re-auth on every rejection is the mistake these discriminators exist to prevent: it
tells a user on a train that their account is dead, and it does so every time they lose signal.
```tsx theme={null}
try {
await switchAccount({ userId });
} catch (err) {
if (err instanceof AccountTransitionError) {
if (err.accountNotFound) refreshAccountList(); // stale id — nothing happened
else if (err.credentialRejected) promptSignIn(userId); // the credential is dead
else showRetry(); // the request never got an answer
}
}
```
A switch still costs exactly **one** token exchange. The refresh endpoint
rotates the token presented to it, so validating first replaces the old
post-swap refresh rather than adding a second call.
**`removeAccount` rejects** on the same terms, and so do `signOutAll` and `useAuth().requestNewAccessToken`. None of the four signatures marks them as fallible, so nothing warns you at compile time — `await` them inside a `try`/`catch`, or an unhandled rejection surfaces as a React Native redbox or a browser `unhandledrejection`.
**At app launch**, the two failures part company:
* **The server refused the stored credential.** The app lands **signed out with the account entries intact** — never silently switched into a different account — and that account is marked `needsReauth`, so the switcher can label it without the user having to discover it by tapping.
* **The server was never reached.** Nothing is torn down, nothing is marked, and the account stays selected. Opening the app on a train does not sign anyone out; the session is restored on the next launch, or sooner by the automatic refresh on the first request that takes a `401`/`403`.
See [Durable signed-out state](#durable-signed-out-state).
## Durable Signed-Out State
"No account is active" has two meanings, and the SDK tells them apart across relaunches:
| Situation | `activeAccount` | `signedOut` | What happens on the next launch |
| -------------------------------------------------------------------------------------------------------------------------- | --------------- | ----------- | ---------------------------------------------------------------------------------------------- |
| Nothing has ever been selected | `null` | `false` | The first stored account is selected and restored |
| The user deliberately signed out, removed the active account, deleted it, or a stored credential was **refused** at launch | `null` | `true` | **Nothing is auto-selected.** The app renders signed-out, and the user picks from the switcher |
| A launch could not reach the server | unchanged | unchanged | The stored account stays selected and its session is restored |
`signedOut` is read from `useAccounts()` and is persisted alongside the account map, so it survives a relaunch. It clears the moment any account is successfully activated.
```tsx theme={null}
const { accounts, activeAccount, signedOut } = useAccounts();
if (!activeAccount && signedOut && accounts.length > 0) {
return ;
}
```
## Push Notifications Per Account
Every signed-in account on a device is bound to that device **separately**, so a device holding four accounts receives push for all four — and a message that reaches two of them arrives as **two notifications**, each stamped with its own `recipientUserId`. Read that key in your tap handler before routing, or a tap lands in the wrong session; see [the SDK push page](/sdk/push-notifications/overview#the-notification-data-payload).
### Each account has its own switch
```tsx theme={null}
import { useAccountPushToggle } from "@sublay/react-js";
const { setAccountPushEnabled, isAccountPushEnabled } = useAccountPushToggle();
// Works on a background account — no switching required.
await setAccountPushEnabled({ userId: "other-account-id", enabled: false });
```
The preference is **durable**. An account silenced on this device stays silenced across switches and relaunches — including through the refresh-token rotation that rebuilds its stored entry on every launch — until it is turned back on. It is stored per account in the account map, alongside the credential, and merged rather than overwritten every time that entry is rebuilt. See [`useAccountPushToggle`](/hooks/push/use-account-push-toggle).
**`register()` turns push on for every stored account that has never expressed a preference.** Permission is a device-level grant, so the call records what it enabled for the whole device: an account you deliberately silenced keeps its `false`, but the four other accounts that were never asked all become enabled. If your UI implies per-account opt-in, follow the first `register()` with explicit toggles.
The account the call was made from is bound immediately. The others are marked as needing a re-bind and are bound the next time the user switches into each — see [When notifications are paused](#when-notifications-are-paused). Nothing is ever bound for an account that has *not* expressed a preference: a plain sign-in on a device that already holds a push identifier creates no binding.
### Signing out unbinds automatically
Once this device has a stored push identifier, `signOut()`, `removeAccount()` and `signOutAll()` each send it with their sign-out request, and the server removes that account's push binding in the same transaction as the session teardown. Either both happen or neither does, and "neither" is reported rather than swallowed — see the warnings above. Devices with no stored identifier send none, have no unbind to protect, and sign out best-effort.
The identifier is **device** state and does not imply that the signing-out account ever opted into push. On native, `usePushRegistration` records one on mount for any device that already holds OS notification permission — that is what lets an app upgrading from an older SDK release still unbind the bindings it created back then. When the account had no binding the unbind removes nothing, and the sign-out is indistinguishable from one that carried no identifier at all. A device whose notification permission was never granted stores nothing and sends nothing.
The permission check is bypassed **exactly once per device**, on the first mount that reads an identifier, and only on a device that already has at least one stored account and no identifier yet. Turning notifications off in system settings does not invalidate a push token or remove a binding — the provider still accepts the notification, the OS simply does not show it, and the server prunes a token only when it reports the app uninstalled. So a device that registered on an older SDK release and has since revoked permission would otherwise hold a binding that nothing could reach: no identifier means no unbind on sign-out, and re-enabling notifications later would deliver that account's notifications to a device nobody is signed into. The one-time read closes that. It cannot prompt (only adapters that read a value the OS already holds participate — web never does), and the SDK records that it has run, so it does not repeat on later launches. A device whose account storage was empty the first time the SDK ran is never eligible: it cannot be carrying a binding from an older release.
Do **not** call `usePushRegistration().unregister()` in a logout flow. It is a durable per-account preference, so using it as cleanup permanently silences that account's next session, and sign-out already unbinds. See [Turning push off](/sdk/push-notifications/overview#turning-push-off).
### When notifications are paused
When the OS rotates the device's push token, every binding on the old token is dead. As long as `usePushRegistration` is mounted with an adapter that reports rotations, the SDK notices and records the new identifier. The **active** account is re-bound on the spot.
Mount that hook **at your app root**, not on the settings screen that calls `register()`: rotation handling lives exactly as long as the hook is mounted, so a screen-scoped mount only covers the seconds that screen is open and a rotation anywhere else in the app is missed. See [`usePushRegistration`](/hooks/push/use-push-registration). Every other push-enabled account is **marked** as needing a re-bind and repaired the next time the user switches into it, using the live session that switch establishes.
The marking is deliberate rather than a shortcut. Re-binding a background account would mean spending its stored refresh token on a one-time-use exchange, in the background, for an account the user is not looking at — and an interruption in the middle of that exchange locks the account out permanently. The cost is that a push-enabled account nobody opens goes quiet after a rotation until it is next opened, which is what the marker is for:
```tsx theme={null}
const { accounts } = useAccounts();
accounts.map((account) => (
));
```
`needsPushRebind` clears as soon as the account is re-bound. It is **not** `needsReauth` and the two should not read the same in your UI: `needsReauth` means the credential is dead and the user must sign in again, while `needsPushRebind` means the account works perfectly and is merely quiet. It is only ever raised for accounts that explicitly enabled push — an account that never asked for notifications has none to pause.
See [Automatic token rotation](/hooks/push/use-push-registration#automatic-token-rotation) for which platforms report a rotation at all.
## Reaching the Account Limit
Signing in a **sixth** account is refused, with an error that names the cause: this device already remembers the maximum number of accounts, and signing out of one is what frees a slot. The limit is never raised and no account is evicted to make room — a map that fills up means users are being signed in without ever being signed out.
A refusal at the cap does **not** persist a signed-out state. The selection is left exactly where it was, so the next launch is unaffected by the refused attempt and the user is never parked in a state they cannot get out of. `accountLimitReached` stays raised for your UI to read, and switching back into an account that already has an entry works even when the current selection has no live session.
### How the refusal reaches you
Every sign-in path is covered, but they cannot all report the same way:
| Entry point | How the limit surfaces | When |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `signUpWithEmailAndPassword` | The call **rejects** | Before any network call — a sign-up is a new account by definition. If the map fills *while the request is in flight*, the refusal comes after it instead, and the created user record persists (see below) |
| `signInWithEmailAndPassword` | The call **rejects** | After authentication, once the server has said which account this is |
| External auth (`signedToken`) | The app **renders** signed-out with the flag set — the session is gone, but the selection stands and `signedOut` is not written; the reason rides the rejected `auth/initialize` action | After the token is verified |
| OAuth (web and Expo) | `accountLimitReached` only — **nothing throws** | After the provider returns |
The rejecting calls throw an `Error` whose message is exported as `ACCOUNT_LIMIT_MESSAGE`, so you can recognise the case without matching on a string you do not own:
```tsx theme={null}
import { useAuth, ACCOUNT_LIMIT_MESSAGE } from "@sublay/react-js";
const { signInWithEmailAndPassword } = useAuth();
try {
await signInWithEmailAndPassword({ email, password });
} catch (err) {
if (err instanceof Error && err.message === ACCOUNT_LIMIT_MESSAGE) {
// Offer to sign out of one of the stored accounts.
}
}
```
Every path also sets `accountLimitReached`, so a single piece of UI can cover all four.
**Signing back in to an account you already store always works, even at the limit.** The check is keyed on the account id the server resolves, not on the email you typed — so an account whose stored email is out of date, absent (accounts admitted through external auth may have no email), or capitalised differently signs in normally. This is also why email sign-in is checked *after* the network call rather than before it: the alternative would lock people out of their own accounts.
### Why OAuth cannot throw
`handleOAuthRedirect` — the synchronous core both `@sublay/react-js` and `@sublay/expo` drive their OAuth return through — takes the tokens straight from the redirect. (On the web, `handleOAuthCallback` parses the callback page's URL and hands off to it; on Expo the redirect is resolved inline and calls it directly, so that package's `handleOAuthCallback` is only a no-op compatibility shim.) It returns as soon as the redirect's tokens are read — before your app knows *whose* they are — and on the web the page that called `initiateOAuth` no longer exists by then. There is no promise left to reject, so both OAuth paths report the limit through `accountLimitReached` instead. The hook's own `error` string stays reserved for failures the provider reported.
### What is left behind
No session, ever: a refused sign-in really did create one on the server, and the SDK signs it back out immediately. Nothing in the map either — the stored accounts are untouched and the active account never points at an account that is not stored.
**One exception, and it is a user record rather than a session.** A sign-up refused *before* the request creates nothing at all. A sign-up whose fifth slot filled *while the request was in flight* is refused after it, so the account exists on the server: its session is destroyed, but the user record stays and the email is taken. Retrying that email after freeing a slot fails as "already in use" — sign in with it instead.
What changes locally depends on the path:
* **Sign-up, email sign-in and external verification** are refused *before* the SDK touches your session, so whatever account was active stays signed in.
* **OAuth** cannot be: the redirect hands over tokens before identity is known, so the previous session is already gone by the time the limit is discovered. The **selection is left exactly as it was** — nothing writes `activeAccountId` on this path — while the session behind it is **not** re-established. So whichever account the map already named stays named, the app renders signed-out with that account selected, and the user signs in again from the picker. This holds whether or not the flow was started from `addAccount()`, because that call leaves the selection alone too. If nothing was selected before the flow, nothing is selected after it; the refusal neither creates a selection nor clears one.
### Three signals for the account cap
Three related but distinct values answer three different questions. None replaces another:
| Value | Where from | Kind | Question it answers |
| -------------------------------------------- | ----------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `canAddAccount` | `useAddAccount()` | Predicate | **Is there room right now?** Derived from the map size on every render, and it knows no user id. Use it to enable or disable an "Add account" button. |
| `wouldExceedAccountLimit(accounts, userId?)` | Bare function export | Predicate | **Would admitting *this id* be a sixth account?** The exact rule the SDK's own refusal gates use, over a raw accounts map. |
| `accountLimitReached` | `useAccounts()` / `useAddAccount()` | Event | **Did an admission just get refused?** Latched until the next successful admission or any removal. Use it to render the error after the fact. |
Use `canAddAccount` to keep the user from starting a sign-in that cannot succeed, and `accountLimitReached` to cover the sign-ins that start anyway — a direct sign-in that never went through `addAccount()`, an OAuth return, or a map that filled in another tab while the request was in flight.
`true` when admitting `userId` into `accounts` would exceed the 5-account
limit. Pure and read-only — it takes the raw map (from `selectAccounts`, or
from your own store in [integration mode](/sdk/redux-integration)) and
computes; it dispatches nothing.
**An id already in the map is never an admission**, so this returns `false`
for it even at the cap. That is the whole reason it exists alongside
`canAddAccount`: re-authenticating an account this device already stores has
to work at the limit, or a user with five accounts could never sign back into
any of them. `canAddAccount` cannot express that — it knows no id.
Omit `userId` for the sign-up case, where the answer is unconditional: a
sign-up creates a person who by definition is not in the map.
```tsx theme={null}
import { useSublaySelector, selectAccounts, wouldExceedAccountLimit } from "@sublay/react-js";
const accounts = useSublaySelector(selectAccounts);
// A "sign back in" row in your own picker, for an account you already store:
const blocked = wouldExceedAccountLimit(accounts, account.id); // false — always allowed
```
### When `accountLimitReached` clears
It clears on the next successful admission and on any removal — but **not synchronously**. The clear rides the effect that records a successful admission into the account map, so it lands **one render after** the call that triggered it resolves.
That makes it correct to *render* and wrong to *sample*:
```tsx theme={null}
// ⛔ Reads the value from before the effect flushed — possibly a `true` left
// over from an earlier, unrelated refusal.
await signInWithEmailAndPassword({ email, password });
if (accountLimitReached) showCapError();
// ✅ The next render has the settled value.
if (accountLimitReached) return ;
```
The rule is general: **never read this flag synchronously right after triggering an action — read it through the hook's normal render cycle.** It is not only the clear that settles late. The flag is *set* at three different moments depending on the entry point: synchronously at the pre-flight gate, after the network round trip at the post-authentication gate, and — on the OAuth path — after a round trip the SDK does not await, because `handleOAuthRedirect` dispatches its completion thunk without one and returns before the cap has been evaluated. So an eager read can observe a stale value from either side.
Re-authenticating into an account the device already stores is one example worth naming, because it looks exempt: that path never trips the cap check, so nothing sets the flag on it — yet a stale `true` from an earlier refusal survives until that re-auth's admission lands, one render later. Rendering from the flag gets the settled value in every one of these cases; sampling it gets whichever moment you happened to land in.
## Controlling SDK Error Logging
The SDK logs handled failures with `console.error`. Apps whose crash reporters treat `console.error` as a signal — or that simply want a quieter console — can change the level:
```ts theme={null}
import { setSublayLogLevel } from "@sublay/react-js";
setSublayLogLevel("silent"); // "error" (default) | "warn" | "silent"
```
This is a **bare setter, not a provider prop**, deliberately: the setting is process-global, and a prop would be silently last-mount-wins in apps that mount two providers. It is also coarse — it silences *all* SDK logging, unexpected failures included. Errors are still returned to and surfaced by the calling hooks; only the console output changes.
## Composing Your Own Account UI
If the built-in hooks do not fit, the transition primitives are exported directly:
| Export | Purpose |
| ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `activateStoredAccount` | The full transition: validate a stored account's credential, then tear down and swap the session over. A plain function, callable outside React. Takes `dispatch` **and `getState`** — it reads the target's stored entry and writes the rotated successor back through the same persist path every other rotation uses. Rejects without touching anything when the credential is dead. |
| `resetAccountScopedState` | Redux action that returns every account-scoped feature slice to its initial state. Subscribe your own slices to it. |
| `resetAuth` / `clearUser` | The auth and user teardown actions. |
| `selectAccounts`, `selectActiveAccountId`, `selectSignedOut`, `selectAccountLimitReached`, `selectDeviceIdentifier` | Selectors over the accounts slice. |
| `wouldExceedAccountLimit` | The cap predicate for a *specific* user id, over a raw accounts map — `false` for an id already stored, because signing back in is not an admission. See [Three signals for the account cap](#three-signals-for-the-account-cap). |
| `setSignedOut` | Records that ending the session was deliberate. The three actions above clear the *session*; none of them touches the accounts slice, so a hand-rolled teardown that omits `setSignedOut(true)` leaves `signedOut: false` with an account still selected — and the next launch reads that as "nothing has ever been picked", restores the account and signs the app back in. `setActiveAccount` clears it again on the next activation, so nothing has to unset it by hand. |
| `AccountTransitionError`, `ACCOUNT_TRANSITION_FAILED_MESSAGE` | What a failed transition rejects with, and its default message. `credentialRejected` separates a dead account from a failed request, and `accountNotFound` separates both from a stale id that names no stored account — see [Telling a dead account from a dead network](#telling-a-dead-account-from-a-dead-network). |
The raw `setActiveAccount`, `upsertAccount` and `removeAccount` reducers are deliberately **not** exported. They only mutate local state: dispatching `setActiveAccount` can point the active id at an account that is not in the map, and dispatching `removeAccount` drops an account without signing it out server-side. Use `activateStoredAccount` and the `useRemoveAccount` hook instead — they perform the whole flow.
`accountNeedsReauth(entry)`, `accountNeedsPushRebind(entry)` and `isAccountPushEnabled(entry)` are exported for reading those fields off a raw `AccountEntry`, since absent and `false` do not always mean the same thing there. `isAccountPushEnabled` is **reported state** — the value a per-account switch renders as `checked`, with an absent preference reading as enabled. It is not the rule the SDK uses to decide whether to create a binding; that one requires an explicit opt-in and is internal, so no binding is ever created for an account that never asked.
## One Project Per App
One Sublay project per app is the supported shape. Account storage, the auth gate and the credential slot are one set of module-level singletons, so a second provider mounted for a *different* project id does not get its own world — it takes over the shared one, and the non-last project's account switching stops working while a rotated refresh token can be written under the wrong project's key, locking that account out permanently.
Rather than let that happen quietly, mounting a second provider for a different project **throws immediately**, with a message naming both project ids. Remounting the *same* project — a hot reload, a provider that unmounts and mounts again, two providers for one project — is fine and is not affected.
If you need to talk to two projects, run two apps, or drive the second project through [`@sublay/js`](/js-sdk/overview) or your own backend rather than mounting a second provider.
## See Also
* [`useAccounts` hook reference](/hooks/auth/use-accounts)
* [`useAddAccount` hook reference](/hooks/auth/use-add-account)
* [`useSwitchAccount` hook reference](/hooks/auth/use-switch-account)
* [`useRemoveAccount` hook reference](/hooks/auth/use-remove-account)
* [`useSignOutAll` hook reference](/hooks/auth/use-sign-out-all)
* [`useAccountPushToggle` hook reference](/hooks/push/use-account-push-toggle)
# OAuth
Source: https://docs.sublay.io/v7/sdk/authentication/oauth
Google, GitHub, Apple, and Facebook OAuth provider integration
Sublay supports OAuth 2.0 sign-in with Google, GitHub, Apple, and Facebook. The flow is redirect-based: the user is sent to the provider's authorization page and returns to a callback URL in your app with tokens embedded in the URL fragment.
OAuth providers must be configured in the Sublay dashboard before use. Each
provider requires a client ID, client secret, and a list of allowed redirect
URIs.
## Web Integration
The `useOAuthSignIn` hook (from `@sublay/react-js`) handles the full web OAuth flow.
```tsx theme={null}
import { useOAuthSignIn } from "@sublay/react-js";
```
### Initiating Sign-In
Call `initiateOAuth` with the provider name and the URL your app will redirect back to after authentication.
```tsx theme={null}
import { useOAuthSignIn } from "@sublay/react-js";
function SignInWithGoogle() {
const { initiateOAuth, isLoading, error } = useOAuthSignIn();
const handleClick = async () => {
// The user will be redirected to Google's authorization page.
// isLoading stays true during the redirect.
await initiateOAuth("google", "https://yourapp.com/auth/callback");
};
return (
);
}
```
Supported provider values: `"google"`, `"github"`, `"apple"`, `"facebook"`.
### Handling the Callback
On the page your app redirects back to, call `handleOAuthCallback` once on mount. It reads the tokens from the URL fragment, stores them in the SDK, and cleans the URL.
```tsx theme={null}
import { useEffect } from "react";
import { useOAuthSignIn } from "@sublay/react-js";
import { useNavigate } from "react-router-dom";
function AuthCallbackPage() {
const { handleOAuthCallback, error } = useOAuthSignIn();
const navigate = useNavigate();
useEffect(() => {
const success = handleOAuthCallback();
if (success) {
navigate("/dashboard");
}
}, []);
if (error) return
Authentication failed: {error}
;
return
Authenticating...
;
}
```
`handleOAuthCallback` returns `true` if tokens were found in the URL fragment and `false` otherwise. If the provider returned an error (for example, the user denied access), the error is surfaced through the `error` field.
### Linking an Additional Provider
Authenticated users can link additional OAuth providers to their account using `linkOAuthProvider`. This requires the user to already be signed in.
```tsx theme={null}
import { useOAuthSignIn } from "@sublay/react-js";
function LinkGitHubButton() {
const { linkOAuthProvider } = useOAuthSignIn();
return (
);
}
```
The callback page logic is identical for both sign-in and link flows — `handleOAuthCallback` handles both cases.
## Expo Integration
`@sublay/expo` ships its own `useOAuthSignIn` hook with the **same API** as the web hook (`initiateOAuth`, `linkOAuthProvider`, `handleOAuthCallback`, `isLoading`, `error`). Instead of a full-page redirect, it opens the Sublay-brokered consent screen in the system browser and returns to your app through a custom-scheme deep link.
Deep links don't work reliably in **Expo Go**. You need a [custom dev client](https://docs.expo.dev/develop/development-builds/introduction/) (`npx expo run:ios` / `npx expo run:android`) or an [EAS Build](https://docs.expo.dev/build/introduction/).
### Setup
The Expo hook relies on `expo-web-browser` (to open the auth session) and `expo-linking` (to parse the return URL).
```bash theme={null}
npx expo install expo-web-browser expo-linking
```
Add a custom `scheme` to your `app.json` (or `app.config.js`). This is the scheme your `redirectAfterAuth` deep link uses.
```json app.json theme={null}
{
"expo": {
"scheme": "myapp"
}
}
```
In your Sublay project's OAuth provider settings, add the exact deep link you'll pass as `redirectAfterAuth` (e.g. `myapp://auth/callback`) to the provider's **Allowed Redirect URIs**.
### Initiating Sign-In
`redirectAfterAuth` is **required** on mobile — there is no `window.location` to fall back to. Pass the deep link you registered above.
```tsx theme={null}
import { useOAuthSignIn } from "@sublay/expo";
function SignInWithGoogle() {
const { initiateOAuth, isLoading, error } = useOAuthSignIn();
const handlePress = () =>
initiateOAuth({
provider: "google",
redirectAfterAuth: "myapp://auth/callback",
});
return (
);
}
```
On a successful return, tokens are parsed from the deep link and stored automatically. There is **no separate callback page** — the result is resolved inline. `handleOAuthCallback` exists for API parity but is a no-op that always returns `false`, so you never need to call it on mobile.
If the user cancels or dismisses the browser, the flow resolves quietly: `isLoading` resets to `false` and `error` stays `null`. `error` is only set on a server failure, a provider `error` in the redirect, or when no tokens are returned.
### Linking an Additional Provider
`linkOAuthProvider` works exactly like the web flow and requires the user to already be signed in.
```tsx theme={null}
import { useOAuthSignIn } from "@sublay/expo";
function LinkGitHubButton() {
const { linkOAuthProvider } = useOAuthSignIn();
return (