diff --git a/docs-mintlify/docs.json b/docs-mintlify/docs.json index 7410dcd03e..c44010d938 100644 --- a/docs-mintlify/docs.json +++ b/docs-mintlify/docs.json @@ -79,6 +79,7 @@ "guides/apps/authentication/jwts", "guides/apps/authentication/sign-up-rules", "guides/apps/authentication/cli-authentication", + "guides/apps/authentication/fraud-protection", { "group": "All Auth Providers", "root": "guides/apps/authentication/auth-providers", @@ -101,9 +102,39 @@ } ] }, - "guides/apps/emails/overview", - "guides/apps/payments/overview", - "guides/apps/analytics/overview", + { + "group": "Emails", + "icon": "/images/app-icons/emails.svg", + "pages": [ + "guides/apps/emails/overview", + "guides/apps/emails/sent", + "guides/apps/emails/drafts", + "guides/apps/emails/templates", + "guides/apps/emails/email-settings" + ] + }, + { + "group": "Payments", + "icon": "/images/app-icons/payments.svg", + "pages": [ + "guides/apps/payments/overview", + "guides/apps/payments/product-lines", + "guides/apps/payments/products-and-items", + "guides/apps/payments/customers", + "guides/apps/payments/transactions", + "guides/apps/payments/settings" + ] + }, + { + "group": "Analytics", + "icon": "/images/app-icons/analytics.svg", + "pages": [ + "guides/apps/analytics/overview", + "guides/apps/analytics/tables", + "guides/apps/analytics/queries", + "guides/apps/analytics/replays" + ] + }, { "group": "Teams", "icon": "/images/app-icons/teams.svg", @@ -112,7 +143,6 @@ "guides/apps/teams/team-selection" ] }, - "guides/apps/fraud-protection/overview", "guides/apps/rbac/overview", "guides/apps/api-keys/overview", "guides/apps/data-vault/overview", @@ -230,5 +260,10 @@ "settings": { "customScripts": ["/apps-sidebar-filter.js", "/code-language-labels.js"] }, - "redirects": [] + "redirects": [ + { + "source": "/guides/apps/fraud-protection/overview", + "destination": "/guides/apps/authentication/fraud-protection" + } + ] } diff --git a/docs-mintlify/guides/apps/analytics/overview.mdx b/docs-mintlify/guides/apps/analytics/overview.mdx index 153d7e9326..afc645e30c 100644 --- a/docs-mintlify/guides/apps/analytics/overview.mdx +++ b/docs-mintlify/guides/apps/analytics/overview.mdx @@ -1,112 +1,77 @@ --- -title: "Analytics" +title: "Overview" description: "Explore events, session replays, and SQL queries in your project's analytics dataset" -icon: "/images/app-icons/analytics.svg" --- -The Analytics app gives you direct access to your project's analytics dataset in Stack Auth. You can inspect raw event tables, run ClickHouse SQL queries, and watch session replays to debug real user behavior. +The Analytics app gives you direct access to your project's analytics dataset in Stack Auth. You can inspect raw event tables, run ClickHouse SQL queries, ask an AI to write queries for you, and watch session replays to debug real user behavior. -## Overview +## Pages -Analytics is organized into three areas in the dashboard: +Analytics is organized into three pages in the dashboard, each documented separately: -- **Tables**: Browse event rows with sorting, search, and incremental loading -- **Queries**: Run and save reusable ClickHouse SQL queries -- **Replays**: Watch session replays and filter by user, team, duration, activity window, and click count + + + Browse rows from any of your project's analytics tables, with sorting, AI-assisted search, and incremental loading. + + + Write, run, and save reusable ClickHouse SQL queries in folders. + + + Watch session replays and filter by user, team, duration, last activity, and click count. + + ## How Analytics Works -Stack records analytics events and replay chunks, then exposes them through the Analytics app for read-only querying and investigation. - -User activity in your app flows into Stack event ingestion, which stores data in ClickHouse. This data powers the Tables view, the SQL query runner, and the Session replay UI. +User activity in your app flows into Stack's ingestion pipeline, which stores data in ClickHouse. This dataset powers all three pages. ### What Gets Tracked Stack collects both client-side and server-side analytics events: -- **Client-side events**: browser interaction events like `$page-view` and `$click` -- **Server-side events**: currently `$token-refresh` and `$sign-up-rule-trigger` +- **Client-side events** (captured automatically by `StackClientApp`): + - `$page-view` + - `$click` +- **Server-side events** (captured automatically by the backend): + - `$token-refresh` + - `$sign-up-rule-trigger` + +Session replays are recorded as separate `rrweb` chunks tied to the same session/user as the events. Replay recording is **opt-in** — see the [Replays page](/guides/apps/analytics/replays#enabling-replay-recording-in-the-sdk) for SDK setup. ## Enabling the Analytics App -To use analytics in your project: +To use Analytics in your project: 1. Open your Stack Auth dashboard 2. Go to **Apps** 3. Open **Analytics** 4. Click **Enable** +Once enabled, the Analytics app gains a sidebar entry with three sub-pages: **Tables**, **Queries**, and **Replays**. + ## Quick Start -1. Enable Analytics in your Stack Auth dashboard (**Apps -> Analytics**) -2. Initialize Stack Auth on your frontend with `StackClientApp`/`StackProvider` +1. Enable Analytics in your Stack Auth dashboard (**Apps → Analytics**) +2. Initialize Stack Auth on your frontend with `StackClientApp` / `StackProvider` 3. Sign in with a real user session -4. Open the app and navigate/click around -5. Check **Analytics -> Tables** to confirm events are arriving - -After setup, Stack automatically captures client-side `$page-view` and `$click` events. - -If you want replay recordings, also enable `analytics.replays.enabled` in your client app config. - -## Tables - -The **Tables** screen is the fastest way to inspect recent analytics records. - -- Currently shows the `events` table -- Built-in ordering and client-side search -- Relative/absolute timestamp display toggle -- Row detail dialog for inspecting full JSON payloads - -Use this view when you need to quickly answer "what just happened?" without writing SQL. - -## Queries - -The **Queries** screen is a ClickHouse SQL workspace for deeper analysis. - -- Run read-only SQL queries with a timeout budget -- Query the users and analytics tables -- Save reusable queries into folders -- Re-run saved queries with one click -- Edit and overwrite saved query definitions - -## Session Replays - -The **Replays** screen helps you move from "an event happened" to "what the user actually saw." - -- Filter sessions by user, team, duration, recency, and click count -- Play back multi-tab sessions -- Control playback speed -- Optionally skip inactive ranges -- Jump across click/page-view timeline markers - -Use replays when metrics alone are not enough to explain user behavior. +4. Open the app and navigate / click around +5. Check **Analytics → Tables** to confirm events are arriving -### Enabling Replay Recording in the SDK +After setup, Stack automatically captures `$page-view` and `$click` events from the browser. If you also want session replay recordings, enable `analytics.replays.enabled` in your client app config — see [Enabling Replay Recording](/guides/apps/analytics/replays#enabling-replay-recording-in-the-sdk). -Session replay recording is disabled by default. To enable it, pass `analytics.replays.enabled: true` when creating your client app. +## Usage and Quotas -```ts -import { StackClientApp } from "@stackframe/stack"; +Both analytics events and session replays count against your plan's monthly quotas. The Analytics pages show inline warning banners when you approach or hit your limits: -export const stackClientApp = new StackClientApp({ - projectId: process.env.NEXT_PUBLIC_STACK_PROJECT_ID!, - publishableClientKey: process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY!, - tokenStore: "nextjs-cookie", - analytics: { - replays: { - enabled: true, - // Optional. Defaults to true. - maskAllInputs: true, - }, - }, -}); -``` +- **Analytics events** — banner appears at ≥80% usage; turns into a destructive banner with no further events being tracked at 100%. Free and Team plans show an **Upgrade plan** button. +- **Session replays** — banner appears at ≥80% usage; new replays stop being recorded at 100%. Limits reset monthly. -`maskAllInputs` defaults to `true`, so form fields are masked unless you explicitly disable it. +Both banners disappear automatically when usage drops back below 80% (e.g. at the start of a new billing month). ## Best Practices -1. **Use Tables for quick incident triage**: the Tables UI is the fastest way to inspect recent `events` rows without writing SQL. -2. **Use Queries for repeatable analysis**: save important SQL in folders, and scope queries with filters/`LIMIT` so they stay within result and timeout limits. -3. **Use Replays for behavioral debugging**: start from an event pattern, then inspect matching session replays to understand what users actually did. -4. **Keep replay privacy defaults on**: leave `maskAllInputs` enabled unless you have a specific reason and a data-handling policy for unmasked inputs. +1. **Use Tables for quick triage** — pick a table from the sidebar and scan recent rows. Use AI search for one-off "what just happened?" questions. +2. **Use Queries for repeatable analysis** — save important SQL in folders with descriptions, and scope queries with `WHERE` / `LIMIT` so they stay within result and timeout limits. +3. **Use Replays for behavioral debugging** — start from an event pattern in Tables / Queries, then jump to matching session replays to see what users actually did. +4. **Keep replay privacy defaults on** — leave `maskAllInputs` enabled unless you have an explicit need for unmasked input recording and a data-handling policy to match. +5. **Watch the usage banners** — analytics events and replays are metered; if you see the 80% banner, either upgrade or scope down your recording (e.g. gate `analytics.replays.enabled` behind a sampling check). diff --git a/docs-mintlify/guides/apps/analytics/queries.mdx b/docs-mintlify/guides/apps/analytics/queries.mdx new file mode 100644 index 0000000000..f461ea339d --- /dev/null +++ b/docs-mintlify/guides/apps/analytics/queries.mdx @@ -0,0 +1,89 @@ +--- +title: "Queries" +description: "Write, run, and save reusable ClickHouse SQL queries" +sidebarTitle: "Queries" +--- + +The **Queries** page is a ClickHouse SQL workspace for deeper analysis and reusable reporting. It lives at **Analytics → Queries** in the dashboard sidebar. + +Use it when you need to write your own SQL, share named queries with your team, or build a library of repeatable reports. + +## Editor + +The right pane is a freeform SQL editor: + +- **Run** button — execute the current SQL (also bound to `Cmd+Enter` / `Ctrl+Enter`) +- **Save** — overwrite the currently selected saved query (preserves name and description) +- **Save As…** — save the current SQL as a new query into a folder +- **New Query** (sidebar) — clear the editor and selection to start a fresh query + +Queries run with a **30-second timeout** budget. Results stream back as a virtualized table; click any row for the same **Row Details** dialog as the [Tables](/guides/apps/analytics/tables) page. + +Any read-only ClickHouse SQL is accepted. You can query the same 12 tables documented on the [Tables page](/guides/apps/analytics/tables#tables-sidebar), all under the `default` schema (e.g. `default.events`, `default.users`). + +### Example + +```sql +SELECT * FROM default.events +ORDER BY event_at DESC +LIMIT 100 +``` + +## Saved queries + +The left sidebar contains folders of saved queries. Each saved query stores: + +- **Display name** +- **SQL** +- **Description** (optional) + +Click a saved query to load it into the editor and run it immediately. The selected query is highlighted in blue. + +### Folder management + +- **New folder** (`+` button next to **Folders**) — create a new folder by name +- **Delete folder** (trash icon, shown on hover) — delete a folder and all queries inside it (confirmation required) +- Folders are ordered by `sortOrder`, assigned automatically on creation + +### Query management + +- **Save As…** opens a dialog with: + - **Name** (required) + - **Folder** — pick an existing folder, or choose **Create new…** to open the folder creation dialog inline + - **Description** (optional) +- **Save** updates the SQL of the currently loaded query in-place. Use this after editing a previously saved query. +- **Delete query** (trash icon on hover) — delete a single saved query (confirmation required) + +Saved queries are stored in your project's **environment config** under `analytics.queryFolders..queries.` and persist across dashboard sessions. + +### Loading a query + +Clicking a saved query: + +1. Loads its SQL into the editor +2. Runs the query immediately +3. Marks it as the current selection so the **Save** button overwrites this query in place + +To stop editing a saved query and start fresh, click **New Query** in the sidebar. + +## Result states + +The right pane handles five distinct states: + +| State | When it shows | Notes | +| --------------- | -------------------------------------------- | ----------------------------------------------------- | +| **Empty** | No query has been run yet | Shows an example query | +| **Loading** | Query is in flight | Spinner with "Running query..." | +| **Error** | ClickHouse returned an error | Shows the error message and a **Retry** button | +| **No results** | Query ran successfully but returned 0 rows | Shows "Query executed successfully but returned no rows." | +| **Results** | Query returned ≥1 row | Virtualized table with row count and click-to-inspect rows | + +## Keyboard shortcuts + +| Shortcut | Action | +| ------------------------- | -------------------- | +| `Cmd+Enter` / `Ctrl+Enter` | Run the current SQL | + +## Limits + +The analytics events usage banner appears at the top of the Queries page when your project is at ≥80% of its monthly event quota — see [Usage and Quotas](/guides/apps/analytics/overview#usage-and-quotas). diff --git a/docs-mintlify/guides/apps/analytics/replays.mdx b/docs-mintlify/guides/apps/analytics/replays.mdx new file mode 100644 index 0000000000..0ef3a63315 --- /dev/null +++ b/docs-mintlify/guides/apps/analytics/replays.mdx @@ -0,0 +1,130 @@ +--- +title: "Session Replays" +description: "Watch session replays and filter by user, team, duration, last activity, and click count" +sidebarTitle: "Replays" +--- + +The **Replays** page lets you watch how real users interacted with your app, scoped to a single recording at a time. It lives at **Analytics → Replays** in the dashboard sidebar. + +Session replays are recorded by the SDK using [`rrweb`](https://www.rrweb.io) and grouped by session. Multi-tab sessions are stitched together into a single timeline. + + + Session replay recording is **opt-in**. Until you enable it in your client app config, no replays will be recorded. See [Enabling Replay Recording in the SDK](#enabling-replay-recording-in-the-sdk). + + +## Sessions list + +The left panel lists every session replay for your project, sorted by most recent activity. + +Each row shows: + +- The user's display name, email, or ID +- The session duration (e.g. `2m 14s`) +- A relative timestamp (e.g. `5 minutes ago`) +- A click count (when greater than 0) + +Scroll to the bottom of the list to load more replays. + +### Filters + +Click the **Filters** dropdown at the top of the list to filter the sessions list. An indicator badge shows the number of active filters. + +| Filter | Control | +| ------------- | ------------------------------------------------------------------ | +| **User** | Pick a specific user from a searchable user list | +| **Team** | Pick a specific team from a searchable team list | +| **Duration** | Minimum and maximum session duration in seconds | +| **Last active** | Preset windows: `Last 24 hours`, `Last 7 days`, `Last 30 days` | +| **Click count** | Minimum number of `$click` events in the session | + +Active filters appear as chips above the list. Click **clear** to remove all filters. + +When the Replays panel is embedded inline on a user's detail page, the **User** filter is locked to that user and isn't counted in the active filter badge. + +## Player + +Selecting a replay loads it into the player on the right. The header shows: + +- The user's name (linked to the user's page in the dashboard) +- A **copy link** button — copies a direct share link to the replay. The link works at `/projects//session-replays/` and opens the standalone replay viewer. +- A **gear icon** — opens [replay settings](#replay-settings) + +### Multi-tab playback + +If the session spans multiple browser tabs, the active tab fills the main player area. Up to **2 other tabs** appear as small previews on the right at any one time; click a preview to switch which tab is active. + +Tabs are labeled `Tab 1`, `Tab 2`, … based on the order of their first event in the session. + +### Timeline + +The bottom of the player shows a scrubbable timeline: + +- **Play / Pause** button +- **Current time** and **total time** counters (`mm:ss`) +- **Speed** selector — `0.5x`, `1x`, `2x`, `4x` +- **Event markers** above the timeline: + - **Blue ticks** — `$click` events; tooltip shows the clicked tag (e.g. *Clicked button*) + - **Green ticks** — `$page-view` events; tooltip shows the path + - Click a marker to jump to that moment +- **Progress bar** — clickable for arbitrary seeking + +When the replay finishes, the player overlays a **Replay from start** button. Click anywhere on the player to toggle play / pause. + +If [skip inactivity](#replay-settings) is on, a *Skipping inactivity* badge appears at the top of the player while fast-forwarding idle ranges. + +### Replay settings + +Open the gear icon to toggle: + +| Setting | Default | Description | +| -------------------- | ------- | --------------------------------------------------------------------------------- | +| **Skip inactivity** | **on** | Fast-forward idle ranges during playback. While active, a *Skipping inactivity* badge appears. | +| **Follow active tab** | **off** | Auto-switch the active tab to whichever tab has activity at the current playback time. | + +These settings are stored in `localStorage` and persist across sessions and recordings. + +## Standalone replay pages + +Each replay has a direct URL at `/projects//session-replays/`. Opening that URL skips the sessions list and loads the player full-width — useful for sharing a specific replay with a teammate via the **copy link** button. + +## Enabling Replay Recording in the SDK + +Session replay recording is **disabled by default**. To enable it, pass `analytics.replays.enabled: true` when creating your client app: + +```ts +import { StackClientApp } from "@stackframe/stack"; + +export const stackClientApp = new StackClientApp({ + projectId: process.env.NEXT_PUBLIC_STACK_PROJECT_ID!, + publishableClientKey: process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY!, + tokenStore: "nextjs-cookie", + analytics: { + replays: { + enabled: true, + // Optional. Defaults to true. + maskAllInputs: true, + }, + }, +}); +``` + +| Option | Type | Default | Description | +| --------------------------------- | --------- | ------- | --------------------------------------------------------------------------- | +| `analytics.replays.enabled` | `boolean` | `false` | Master switch for replay recording. | +| `analytics.replays.maskAllInputs` | `boolean` | `true` | Masks all `` values in recordings. Disable only with a privacy plan. | + +After enabling, sign in as a real user and interact with your app. Replays will start appearing in the Replays page within a few seconds of session activity. + +## Privacy + +By default, every `` element in your app is masked in replay recordings (`maskAllInputs: true`). This includes password fields, search boxes, form inputs, and any custom input components — values are replaced with `*` characters in the recorded events. + +Only disable `maskAllInputs` if: + +- You have a documented data-handling policy that covers user-typed text +- You've confirmed no sensitive fields (passwords, tokens, PII) will be captured +- You've considered using rrweb's `data-rrweb-no-record` / `data-rrweb-mask` attributes for finer-grained control on specific elements + +## Limits + +The session replay usage banner appears at the top of the Replays page when your project is at ≥80% of its monthly replay quota. At 100%, new replays stop being recorded until the next billing month — see [Usage and Quotas](/guides/apps/analytics/overview#usage-and-quotas). diff --git a/docs-mintlify/guides/apps/analytics/tables.mdx b/docs-mintlify/guides/apps/analytics/tables.mdx new file mode 100644 index 0000000000..27ab3d2702 --- /dev/null +++ b/docs-mintlify/guides/apps/analytics/tables.mdx @@ -0,0 +1,99 @@ +--- +title: "Tables" +description: "Browse and search rows from any analytics table in your project" +sidebarTitle: "Tables" +--- + +The **Tables** page is the fastest way to inspect rows from any of your project's analytics tables. No SQL required. + +It lives at **Analytics → Tables** in the dashboard sidebar. + +## Tables sidebar + +The left sidebar lists every table available for direct browsing: + +- `events` — every analytics event (client and server) +- `users` +- `contact_channels` +- `teams` +- `team_member_profiles` +- `team_permissions` +- `team_invitations` +- `email_outboxes` +- `project_permissions` +- `notification_preferences` +- `refresh_tokens` +- `connected_accounts` + +All tables live in the ClickHouse `default` schema (e.g. `default.events`). + +A **Queries** shortcut at the bottom of the sidebar jumps you to the [Queries](/guides/apps/analytics/queries) workspace. + +## Toolbar + +For the currently selected table, the toolbar provides: + +- **AI search bar** — type a natural-language question (see [AI search](#ai-search) below) +- **AI query builder** (eye icon) — opens a dedicated AI chat dialog +- **Clear AI query** (`x` button) — visible only while an AI-generated query is active +- **Refresh** — re-runs the current query +- **Row count** — shows `N rows` or `N+ rows` when more results are available +- **Columns** — show / hide columns +- **Export** — export the current view as CSV +- **Density** — adjust row height + +## Sorting + +- Click any column header to sort. +- Each table has a default sort by its primary timestamp column descending: + - `events` → `event_at DESC` + - `users` → `signed_up_at DESC` + - `notification_preferences` → `user_id DESC` + - All other tables → `created_at DESC` + +The default sort is removed while an AI query is active (the AI's own `ORDER BY` is preserved). + +## Quick search + +When no AI query is active, the search box applies an `ILIKE '%term%'` filter across discovered text columns on the server. This is the same input as the AI bar — typing a question routes to the AI; typing a literal substring filters rows. + +## Row details + +Click any row to open a **Row Details** dialog showing every column's full value, with JSON payloads pretty-printed. Use this to inspect large `data` blobs without writing SQL. + +## Incremental loading + +Tables load in pages of 50 rows. Scroll to the bottom to fetch the next page. There is no fixed row cap — keep scrolling to load more. + +The row counter shows `N+ rows` while more results are available and `N rows` once you've reached the end. + +## AI search + +Typing into the AI bar sends a prompt to Stack's AI, which writes a ClickHouse query against your dataset and runs it against the current table. Examples: + +- "daily signups over the last 30 days" +- "top 10 users by event count this week" +- "events from users on team `acme` in the last hour" + +While an AI query is active: + +- The bar is highlighted purple and switches to a **Refine with AI…** placeholder +- The grid shows the AI's result set instead of the raw table +- The default sort is removed (the AI's `ORDER BY` is preserved) +- Click the **x** button next to the bar to clear and return to the raw table + +### AI query builder dialog + +Click the eye icon next to the AI bar to open the **AI query builder** dialog. This provides: + +- **Current query** editor — view and edit the SQL that's currently driving the grid. `Cmd+Enter` (or blur) applies edits. +- **Copy SQL** — copy the current query to your clipboard +- **Apply query** — re-apply edits to the grid +- **Chat thread** — multi-turn conversation with the AI; each AI tool call shows the SQL it ran and exposes a **Use query** button to snap the grid to that query +- **Clear chat** (trash icon) — reset the conversation +- **Save query** (footer) — save the current SQL into a folder under the [Queries](/guides/apps/analytics/queries) page. If no folder exists, an `AI Queries` folder is created automatically. +- **Build dashboard** (footer) — open the dashboard builder pre-seeded with the current SQL so the AI generates a visual dashboard from it + +## Limits + +The analytics events usage banner appears at the top of the Tables page when your project is at ≥80% of its monthly event quota — see [Usage and Quotas](/guides/apps/analytics/overview#usage-and-quotas). diff --git a/docs-mintlify/guides/apps/api-keys/overview.mdx b/docs-mintlify/guides/apps/api-keys/overview.mdx index 8ceb4fc688..df1ae24505 100644 --- a/docs-mintlify/guides/apps/api-keys/overview.mdx +++ b/docs-mintlify/guides/apps/api-keys/overview.mdx @@ -4,17 +4,26 @@ description: "Create and manage API keys for users and teams" icon: "/images/app-icons/api-keys.svg" --- -The API Keys app enables your users to generate and manage API keys for programmatic access to your backend services. API keys provide a secure way to authenticate requests, allowing developers to associate API calls with specific users or teams. Stack Auth provides prebuilt UI components for users and teams to manage their own API keys. +The API Keys app enables your users to generate and manage API keys for programmatic access to your backend services. API keys provide a secure way to authenticate requests, allowing developers to associate API calls with specific **users** or **teams**. Stack Auth provides prebuilt UI components so users and teams can manage their own keys. -## Overview +## Concepts -API keys allow your users to access your backend services programmatically without interactive authentication. +### The authentication flow -The flow works as follows: a user or client sends an API request with an API key to your application server. Your server validates the API key with Stack Auth, which returns an authenticated User object. Your server then processes the request and returns the response. +A user or client sends an API request with an API key to your application server. Your server validates the API key with Stack Auth, which returns an authenticated `User` (or `Team`) object. Your server then processes the request and returns the response. -Stack Auth provides two types of API keys: +### Two kinds of API keys -### User API keys +Stack Auth supports two kinds of API keys: + +- **User API keys** — associated with an individual user; calls authenticated with this key act on behalf of that user. +- **Team API keys** — associated with a team; calls authenticated with this key act on behalf of that team. Only users with the `$manage_api_keys` permission for the team can create or revoke them. + + + **Don't confuse API Keys with Project Keys.** The **API Keys app** documented here lets your end users issue keys for *their* accounts and teams. If you want to create or rotate the publishable / secret keys that *your* Stack Auth project uses to call the Stack API, that lives in **Project Settings → Project Keys** instead. + + +#### User API keys User API keys are associated with individual users and allow them to authenticate with your API. @@ -177,7 +186,7 @@ User API keys are associated with individual users and allow them to authenticat -### Team API keys +#### Team API keys Team API keys are associated with teams and can be used to provide access to team resources over your API. @@ -353,52 +362,61 @@ Team API keys are associated with teams and can be used to provide access to tea ## Enabling the API Keys App -To use API keys in your application, you need to enable the API Keys app in your Stack Auth dashboard: +To use API keys in your application, you must enable the **API Keys** app in your Stack Auth dashboard: + +1. Open your Stack Auth dashboard +2. Go to **Apps** +3. Find and open **API Keys** +4. Click **Enable** + +### Dashboard settings -1. Navigate to your Stack Auth dashboard -2. Go to the **Apps** section -3. Find and click on **API Keys** in the app store -4. Click the **Enable** button +Once enabled, the API Keys app exposes exactly two toggles under **API Key Settings**: -Once enabled, you can configure User API Keys and Team API Keys in the app settings. The app will provide your users with a prebuilt UI to manage their own API keys. +| Setting | Config field | Description | +| ------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------- | +| **User API Keys** | `apiKeys.enabled.user` | Allow users to create API keys for their accounts. Enables the `user-api-keys` backend routes. | +| **Team API Keys** | `apiKeys.enabled.team` | Allow users to create API keys for their teams. Enables the `team-api-keys` backend routes. | + +Both are **disabled by default**. Changes require clicking **Save** before they take effect. + +Toggling **User API Keys** controls whether the `` component shows its API Keys tab. Toggling **Team API Keys** controls whether the team settings page shows its API Keys section to users with the `$manage_api_keys` permission. + +### Team permission requirement + +Creating, listing, and revoking **team** API keys requires the [`$manage_api_keys`](/guides/apps/rbac/overview) permission on the team. Make sure your team roles grant this permission to the right members (e.g. admins). ## Prebuilt UI Components -Stack Auth provides prebuilt UI components that allow your users to manage their own API keys without any additional code: +Stack Auth provides prebuilt UI components that let your users manage their own API keys without any additional code. ### User API Keys UI For frameworks that support React components, the `` component includes an API Keys tab where users can: - View all their active API keys -- Create new API keys with custom descriptions and expiration dates +- Create new API keys with a description and an expiration date - Revoke existing API keys -- See when each key was created and when it expires. +- See when each key was created and when it expires + +The tab is only shown when `apiKeys.enabled.user` is on for your project. - ```typescript title="app/src/account-page.tsx" + ```typescript title="app/account/page.tsx" import { AccountSettings } from '@stackframe/stack'; export default function MyAccountPage() { - return ( - - ); + return ; } ``` - ```typescript title="app/src/account-page.tsx" + ```typescript title="src/account-page.tsx" import { AccountSettings } from '@stackframe/react'; export default function MyAccountPage() { - return ( - - ); + return ; } ``` @@ -406,17 +424,44 @@ For frameworks that support React components, the `` component ### Team API Keys UI -For team API keys, the team settings page automatically includes an API Keys section when: +The team settings page automatically includes an **API Keys** section when **all** of the following are true: - The API Keys app is enabled -- `allowTeamApiKeys` is configured in your project settings -- The user has the `$manage_api_keys` permission for the team +- `apiKeys.enabled.team` is on for your project +- The current user has the `$manage_api_keys` permission on the team + +Users with the right permission can create, list, and revoke team API keys directly from the team settings interface — no extra code required. + +## The `ApiKey` object + +Both `user.listApiKeys()` and `team.listApiKeys()` return arrays of `ApiKey` objects. The same shape comes back from `user.createApiKey(...)` / `team.createApiKey(...)`, except the `value` is the full plaintext key only on the first view. + +| Field | Type | Description | +| ---------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `id` | `string` | Stable identifier for the key | +| `description` | `string` | Human-readable description set on creation | +| `createdAt` | `Date` | When the key was created | +| `expiresAt` | `Date \| undefined` | Optional expiration timestamp | +| `manuallyRevokedAt` | `Date \| null \| undefined` | Set when the key was explicitly revoked | +| `value` | `string` (first view) / `{ lastFour: string }` | Full key on first view only, then just the last 4 characters | +| `type` | `"user" \| "team"` | Which flavor of key this is | +| `userId` / `teamId` | `string` | The owning user (for `type: "user"`) or team (for `type: "team"`) | +| `update(options)` | `(options) => Promise` | Update `description`, `expiresAt`, or `revoked` | +| `revoke()` | `() => Promise` | Convenience for `update({ revoked: true })` | +| `isValid()` | `() => boolean` | `true` if the key is not expired and not manually revoked | +| `whyInvalid()` | `() => "manually-revoked" \| "expired" \| null` | Reason the key is invalid, or `null` if it's still valid | + + + The full plaintext value of an API key is **only returned once** — at creation time. After that, the SDK only ever exposes `value.lastFour`. Display, copy, or store the value immediately on creation; it cannot be retrieved later. + -Users with appropriate permissions can manage team API keys directly from the team settings interface. +### `isPublic` keys + +When creating a key, pass `isPublic: true` to exempt it from Stack Auth's secret scanner. The secret scanner automatically revokes API keys it detects in public places (e.g. exposed in a GitHub repo). Use `isPublic` only for keys that are intentionally exposed to clients (e.g. anonymous-style access tokens). ## Working with API Keys -### Creating User API Keys +### Creating a user API key @@ -430,7 +475,7 @@ Users with appropriate permissions can manage team API keys directly from the te const handleCreateKey = async () => { const apiKey = await user.createApiKey({ description: "My client application", - expiresAt: new Date(Date.now() + (90 * 24 * 60 * 60 * 1000)), // 90 days + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), // 90 days }); console.log("API Key created:", apiKey.value); @@ -449,7 +494,7 @@ Users with appropriate permissions can manage team API keys directly from the te const apiKey = await user.createApiKey({ description: "Admin-provisioned API key", - expiresAt: new Date(Date.now() + (30 * 24 * 60 * 60 * 1000)), // 30 days + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days }); return
API Key: {apiKey.value}
; @@ -467,7 +512,7 @@ Users with appropriate permissions can manage team API keys directly from the te const handleCreateKey = async () => { const apiKey = await user.createApiKey({ description: "My client application", - expiresAt: new Date(Date.now() + (90 * 24 * 60 * 60 * 1000)), // 90 days + expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), }); console.log("API Key created:", apiKey.value); @@ -480,6 +525,7 @@ Users with appropriate permissions can manage team API keys directly from the te ```python title="views.py" import requests + import time from django.http import JsonResponse def create_user_api_key(request): @@ -577,7 +623,9 @@ Users with appropriate permissions can manage team API keys directly from the te
-### Creating Team API Keys +### Creating a team API key + +Requires the `$manage_api_keys` team permission. @@ -594,7 +642,7 @@ Users with appropriate permissions can manage team API keys directly from the te const teamApiKey = await team.createApiKey({ description: "Team integration service", - expiresAt: new Date(Date.now() + (60 * 24 * 60 * 60 * 1000)), // 60 days + expiresAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000), // 60 days }); console.log("Team API Key created:", teamApiKey.value); @@ -610,14 +658,11 @@ Users with appropriate permissions can manage team API keys directly from the te export default async function CreateTeamApiKey({ teamId }: { teamId: string }) { const team = await stackServerApp.getTeam(teamId); - - if (!team) { - return
Team not found
; - } + if (!team) return
Team not found
; const teamApiKey = await team.createApiKey({ description: "Admin-provisioned team API key", - expiresAt: new Date(Date.now() + (30 * 24 * 60 * 60 * 1000)), // 30 days + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days }); return
Team API Key: {teamApiKey.value}
; @@ -638,7 +683,7 @@ Users with appropriate permissions can manage team API keys directly from the te const teamApiKey = await team.createApiKey({ description: "Team integration service", - expiresAt: new Date(Date.now() + (60 * 24 * 60 * 60 * 1000)), // 60 days + expiresAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000), // 60 days }); console.log("Team API Key created:", teamApiKey.value); @@ -749,7 +794,7 @@ Users with appropriate permissions can manage team API keys directly from the te
-### Listing API Keys +### Listing API keys @@ -906,69 +951,45 @@ Users with appropriate permissions can manage team API keys directly from the te -### Revoking API Keys - -API keys can be revoked when they are no longer needed or if they have been compromised. - - - - ```typescript title="app/components/revoke-api-key.tsx" - "use client"; - import { useUser } from "@stackframe/stack"; - - export default function RevokeApiKey({ apiKeyId }: { apiKeyId: string }) { - const user = useUser({ or: 'redirect' }); - const apiKeys = user.useApiKeys(); +The same pattern works for team API keys via `team.useApiKeys()` (client) or `team.listApiKeys()` (server), and the `/api/v1/team-api-keys` REST endpoint. - const handleRevoke = async () => { - const apiKeyToRevoke = apiKeys.find(key => key.id === apiKeyId); +### Validating an incoming API key on your server - if (apiKeyToRevoke) { - await apiKeyToRevoke.revoke(); - console.log("API Key revoked"); - } - }; +This is the core authentication flow: an incoming request includes an API key, and your server needs to know **which user or team** it represents. Pass the plaintext key directly to `stackServerApp.getUser({ apiKey })` or `stackServerApp.getTeam({ apiKey })` — Stack Auth validates it and returns the corresponding object, or `null` if the key is invalid, expired, or revoked. - return ; - } - ``` - + - ```typescript title="lib/api-keys.ts" + ```typescript title="app/api/protected/route.ts" import { stackServerApp } from "@/stack/server"; - export async function revokeApiKey(userId: string, apiKeyId: string) { - const user = await stackServerApp.getUser(userId); - if (!user) return; - - const apiKeys = await user.listApiKeys(); - const apiKeyToRevoke = apiKeys.find(key => key.id === apiKeyId); + export async function GET(request: Request) { + const auth = request.headers.get("authorization"); + const apiKey = auth?.replace(/^Bearer\s+/i, ""); + if (!apiKey) { + return new Response("Missing API key", { status: 401 }); + } - if (apiKeyToRevoke) { - await apiKeyToRevoke.revoke(); + const user = await stackServerApp.getUser({ apiKey }); + if (!user) { + return new Response("Invalid API key", { status: 401 }); } + + return Response.json({ userId: user.id, displayName: user.displayName }); } ``` - - ```typescript title="components/RevokeApiKey.tsx" - "use client"; - import { useUser } from "@stackframe/react"; - - export default function RevokeApiKey({ apiKeyId }: { apiKeyId: string }) { - const user = useUser({ or: 'redirect' }); - const apiKeys = user.useApiKeys(); + + ```typescript title="app/api/team-protected/route.ts" + import { stackServerApp } from "@/stack/server"; - const handleRevoke = async () => { - const apiKeyToRevoke = apiKeys.find(key => key.id === apiKeyId); + export async function GET(request: Request) { + const apiKey = request.headers.get("authorization")?.replace(/^Bearer\s+/i, ""); + if (!apiKey) return new Response("Missing API key", { status: 401 }); - if (apiKeyToRevoke) { - await apiKeyToRevoke.revoke(); - console.log("API Key revoked"); - } - }; + const team = await stackServerApp.getTeam({ apiKey }); + if (!team) return new Response("Invalid team API key", { status: 401 }); - return ; + return Response.json({ teamId: team.id, displayName: team.displayName }); } ``` @@ -977,58 +998,76 @@ API keys can be revoked when they are no longer needed or if they have been comp import requests from django.http import JsonResponse - def revoke_api_key(request, api_key_id): - # Get the current user's access token from session/cookie - access_token = request.COOKIES.get('stack-access-token') + def protected_view(request): + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return JsonResponse({'error': 'Missing API key'}, status=401) + api_key = auth_header[len('Bearer '):] - # Revoke API key via client API (update with revoked: true) - response = requests.patch( - f'https://api.stack-auth.com/api/v1/user-api-keys/{api_key_id}', + # Check the API key via server API + check = requests.post( + 'https://api.stack-auth.com/api/v1/user-api-keys/check', headers={ - 'x-stack-access-type': 'client', + 'x-stack-access-type': 'server', 'x-stack-project-id': stack_project_id, - 'x-stack-publishable-client-key': stack_publishable_client_key, - 'x-stack-access-token': access_token, + 'x-stack-secret-server-key': stack_secret_server_key, }, - json={ - 'revoked': True, - } + json={'api_key': api_key}, ) - if response.status_code != 200: - raise Exception(f"Failed to revoke API key: {response.text}") + if check.status_code != 200: + return JsonResponse({'error': 'Invalid API key'}, status=401) - return JsonResponse({'message': 'API key revoked successfully'}) + api_key_obj = check.json() + # Fetch the owning user + user_resp = requests.get( + f'https://api.stack-auth.com/api/v1/users/{api_key_obj["user_id"]}', + headers={ + 'x-stack-access-type': 'server', + 'x-stack-project-id': stack_project_id, + 'x-stack-secret-server-key': stack_secret_server_key, + }, + ) + user = user_resp.json() + return JsonResponse({'userId': user['id'], 'displayName': user.get('display_name')}) ``` ```python title="main.py" import requests - from fastapi import Cookie, HTTPException + from fastapi import Header, HTTPException - @app.delete("/api/user-api-keys/{api_key_id}") - async def revoke_api_key(api_key_id: str, stack_access_token: str = Cookie(None, alias="stack-access-token")): - if not stack_access_token: - raise HTTPException(status_code=401, detail="Not authenticated") + @app.get("/api/protected") + async def protected_view(authorization: str = Header(None)): + if not authorization or not authorization.startswith('Bearer '): + raise HTTPException(status_code=401, detail="Missing API key") + api_key = authorization[len('Bearer '):] - # Revoke API key via client API (update with revoked: true) - response = requests.patch( - f'https://api.stack-auth.com/api/v1/user-api-keys/{api_key_id}', + # Check the API key via server API + check = requests.post( + 'https://api.stack-auth.com/api/v1/user-api-keys/check', headers={ - 'x-stack-access-type': 'client', + 'x-stack-access-type': 'server', 'x-stack-project-id': stack_project_id, - 'x-stack-publishable-client-key': stack_publishable_client_key, - 'x-stack-access-token': stack_access_token, + 'x-stack-secret-server-key': stack_secret_server_key, }, - json={ - 'revoked': True, - } + json={'api_key': api_key}, ) - if response.status_code != 200: - raise HTTPException(status_code=response.status_code, detail=response.text) + if check.status_code != 200: + raise HTTPException(status_code=401, detail="Invalid API key") - return {"message": "API key revoked successfully"} + api_key_obj = check.json() + user_resp = requests.get( + f'https://api.stack-auth.com/api/v1/users/{api_key_obj["user_id"]}', + headers={ + 'x-stack-access-type': 'server', + 'x-stack-project-id': stack_project_id, + 'x-stack-secret-server-key': stack_secret_server_key, + }, + ) + user = user_resp.json() + return {'userId': user['id'], 'displayName': user.get('display_name')} ``` @@ -1036,37 +1075,45 @@ API keys can be revoked when they are no longer needed or if they have been comp import requests from flask import request, jsonify - @app.route('/api/user-api-keys/', methods=['DELETE']) - def revoke_api_key(api_key_id): - access_token = request.cookies.get('stack-access-token') - if not access_token: - return jsonify({'error': 'Not authenticated'}), 401 + @app.route('/api/protected', methods=['GET']) + def protected_view(): + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return jsonify({'error': 'Missing API key'}), 401 + api_key = auth_header[len('Bearer '):] - # Revoke API key via client API (update with revoked: true) - response = requests.patch( - f'https://api.stack-auth.com/api/v1/user-api-keys/{api_key_id}', + # Check the API key via server API + check = requests.post( + 'https://api.stack-auth.com/api/v1/user-api-keys/check', headers={ - 'x-stack-access-type': 'client', + 'x-stack-access-type': 'server', 'x-stack-project-id': stack_project_id, - 'x-stack-publishable-client-key': stack_publishable_client_key, - 'x-stack-access-token': access_token, + 'x-stack-secret-server-key': stack_secret_server_key, }, - json={ - 'revoked': True, - } + json={'api_key': api_key}, ) - if response.status_code != 200: - return jsonify({'error': response.text}), response.status_code + if check.status_code != 200: + return jsonify({'error': 'Invalid API key'}), 401 - return jsonify({'message': 'API key revoked successfully'}) + api_key_obj = check.json() + user_resp = requests.get( + f'https://api.stack-auth.com/api/v1/users/{api_key_obj["user_id"]}', + headers={ + 'x-stack-access-type': 'server', + 'x-stack-project-id': stack_project_id, + 'x-stack-secret-server-key': stack_secret_server_key, + }, + ) + user = user_resp.json() + return jsonify({'userId': user['id'], 'displayName': user.get('display_name')}) ``` -### Checking API Key Validity +### Checking an existing key's validity -You can check if an API key is still valid: +When you already hold an `ApiKey` object (e.g. from `useApiKeys()`), use its synchronous helpers `isValid()` and `whyInvalid()`. The latter returns `"manually-revoked"`, `"expired"`, or `null`. @@ -1283,3 +1330,170 @@ You can check if an API key is still valid: ``` + +### Revoking an API key + +API keys can be revoked when they are no longer needed or if they have been compromised. Revoking is irreversible: a revoked key's `manuallyRevokedAt` becomes set and `isValid()` returns `false` (`whyInvalid()` returns `"manually-revoked"`). + + + + ```typescript title="app/components/revoke-api-key.tsx" + "use client"; + import { useUser } from "@stackframe/stack"; + + export default function RevokeApiKey({ apiKeyId }: { apiKeyId: string }) { + const user = useUser({ or: 'redirect' }); + const apiKeys = user.useApiKeys(); + + const handleRevoke = async () => { + const apiKeyToRevoke = apiKeys.find(key => key.id === apiKeyId); + + if (apiKeyToRevoke) { + await apiKeyToRevoke.revoke(); + console.log("API Key revoked"); + } + }; + + return ; + } + ``` + + + ```typescript title="lib/api-keys.ts" + import { stackServerApp } from "@/stack/server"; + + export async function revokeApiKey(userId: string, apiKeyId: string) { + const user = await stackServerApp.getUser(userId); + if (!user) return; + + const apiKeys = await user.listApiKeys(); + const apiKeyToRevoke = apiKeys.find(key => key.id === apiKeyId); + + if (apiKeyToRevoke) { + await apiKeyToRevoke.revoke(); + } + } + ``` + + + ```typescript title="components/RevokeApiKey.tsx" + "use client"; + import { useUser } from "@stackframe/react"; + + export default function RevokeApiKey({ apiKeyId }: { apiKeyId: string }) { + const user = useUser({ or: 'redirect' }); + const apiKeys = user.useApiKeys(); + + const handleRevoke = async () => { + const apiKeyToRevoke = apiKeys.find(key => key.id === apiKeyId); + + if (apiKeyToRevoke) { + await apiKeyToRevoke.revoke(); + console.log("API Key revoked"); + } + }; + + return ; + } + ``` + + + ```python title="views.py" + import requests + from django.http import JsonResponse + + def revoke_api_key(request, api_key_id): + # Get the current user's access token from session/cookie + access_token = request.COOKIES.get('stack-access-token') + + # Revoke API key via client API (update with revoked: true) + response = requests.patch( + f'https://api.stack-auth.com/api/v1/user-api-keys/{api_key_id}', + headers={ + 'x-stack-access-type': 'client', + 'x-stack-project-id': stack_project_id, + 'x-stack-publishable-client-key': stack_publishable_client_key, + 'x-stack-access-token': access_token, + }, + json={ + 'revoked': True, + } + ) + + if response.status_code != 200: + raise Exception(f"Failed to revoke API key: {response.text}") + + return JsonResponse({'message': 'API key revoked successfully'}) + ``` + + + ```python title="main.py" + import requests + from fastapi import Cookie, HTTPException + + @app.delete("/api/user-api-keys/{api_key_id}") + async def revoke_api_key(api_key_id: str, stack_access_token: str = Cookie(None, alias="stack-access-token")): + if not stack_access_token: + raise HTTPException(status_code=401, detail="Not authenticated") + + # Revoke API key via client API (update with revoked: true) + response = requests.patch( + f'https://api.stack-auth.com/api/v1/user-api-keys/{api_key_id}', + headers={ + 'x-stack-access-type': 'client', + 'x-stack-project-id': stack_project_id, + 'x-stack-publishable-client-key': stack_publishable_client_key, + 'x-stack-access-token': stack_access_token, + }, + json={ + 'revoked': True, + } + ) + + if response.status_code != 200: + raise HTTPException(status_code=response.status_code, detail=response.text) + + return {"message": "API key revoked successfully"} + ``` + + + ```python title="app.py" + import requests + from flask import request, jsonify + + @app.route('/api/user-api-keys/', methods=['DELETE']) + def revoke_api_key(api_key_id): + access_token = request.cookies.get('stack-access-token') + if not access_token: + return jsonify({'error': 'Not authenticated'}), 401 + + # Revoke API key via client API (update with revoked: true) + response = requests.patch( + f'https://api.stack-auth.com/api/v1/user-api-keys/{api_key_id}', + headers={ + 'x-stack-access-type': 'client', + 'x-stack-project-id': stack_project_id, + 'x-stack-publishable-client-key': stack_publishable_client_key, + 'x-stack-access-token': access_token, + }, + json={ + 'revoked': True, + } + ) + + if response.status_code != 200: + return jsonify({'error': response.text}), response.status_code + + return jsonify({'message': 'API key revoked successfully'}) + ``` + + + +## Best Practices + +1. **Show the value once.** API key values are returned in plaintext only at creation. Always display, copy, or send them immediately — don't expect to fetch them again later. +2. **Set sensible expirations.** Long-lived keys are convenient but risky. Default to short expirations (30–90 days) and let users rotate. +3. **Don't share user keys across users.** A user API key acts as that exact user. If a service needs to act independently, prefer a team API key with a service-style role. +4. **Use `$manage_api_keys` deliberately.** Only grant this team permission to roles you'd trust to lock or unlock the entire team's programmatic access. +5. **Mark public keys with `isPublic: true`.** This opts them out of the secret scanner so your legitimately-public keys don't get auto-revoked. +6. **Use `getUser({ apiKey })` / `getTeam({ apiKey })` for validation.** Never try to parse or compare the plaintext value yourself — Stack Auth handles hashing, expiration, and revocation. diff --git a/docs-mintlify/guides/apps/authentication/fraud-protection.mdx b/docs-mintlify/guides/apps/authentication/fraud-protection.mdx new file mode 100644 index 0000000000..83eb87f7d7 --- /dev/null +++ b/docs-mintlify/guides/apps/authentication/fraud-protection.mdx @@ -0,0 +1,126 @@ +--- +title: "Fraud Protection" +description: "Detect bots, free-trial abuse, and other fraudulent sign-ups." +sidebarTitle: "Fraud Protection" +--- + +Fraud Protection is a sub-app of [Authentication](./overview). It isn't a separate page or a separate toggle - it's the name we give to the **risk signals** that the Authentication app already attaches to every sign-up attempt (bot score, free-trial abuse score, geo-IP country, and Cloudflare Turnstile result), and the conditions you can write against them in [Sign-up Rules](./sign-up-rules). + +In the dashboard, the Fraud Protection tile nests under Authentication and its **Open** button takes you straight to **Authentication → Sign-up Rules**. There's nothing to configure on a separate screen - everything happens in the rule builder. + + + Fraud Protection inherits its enabled state from its parent app, Authentication. If Authentication is on, Fraud Protection is on. There is no independent toggle. + + +## Available signals + +Every sign-up attempt is scored with the following fields. They're always available in the CEL condition builder alongside `email`, `emailDomain`, `authMethod`, and `oauthProvider`: + +| Field | Type | Operators | Description | +|---|---|---|---| +| `countryCode` | string (ISO-3166-1 alpha-2) | `equals`, `not_equals`, `in_list` | Geo-IP country of the request (e.g. `"US"`, `"DE"`, `"NG"`). Empty if it can't be resolved. | +| `riskScores.bot` | number (0-100) | `equals`, `not_equals`, `greater_than`, `greater_or_equal`, `less_than`, `less_or_equal` | Confidence that the sign-up is automated. Higher = more likely a bot. Stack Auth uses signals like the Cloudflare Turnstile verdict to compute this score. | +| `riskScores.free_trial_abuse` | number (0-100) | same as `riskScores.bot` | Confidence that the user is attempting to abuse a free-trial / new-account incentive (multi-accounting, disposable infra, etc.). | + +The rule tester additionally exposes a **Turnstile** override (`ok` / `invalid` / `error`) so you can simulate how a given Turnstile verdict affects the resulting bot score. + +These fields evaluate together with the rest of the sign-up context, so a single rule can combine them freely. + +## Using signals in rules + +Open **Authentication → Sign-up Rules → Add rule**. The rule builder is the same one documented in [Sign-up Rules](./sign-up-rules#creating-rules) - the fraud-specific fields just appear in the field dropdown. + +### Block obvious bots + +- Condition: `riskScores.bot >= 80` +- Action: **Reject** + +### Restrict suspected free-trial abuse for manual review + +Send borderline accounts to a restricted state instead of blocking outright, so support can review them: + +- Condition: `riskScores.free_trial_abuse >= 60` +- Action: **Restrict** - see [Sign-up Rules → Restrict](./sign-up-rules#restrict) for how restricted users appear in JWTs and the dashboard. + +### Combine signals with email and geo + +Allow sign-ups from a known corporate domain, but still hard-block anything that smells like automation: + +1. Rule 1: `emailDomain == "company.com" && riskScores.bot < 70` → **Allow** +2. Rule 2: `riskScores.bot >= 70` → **Reject** +3. Rule 3: `countryCode.in_list("CN", "RU") && riskScores.free_trial_abuse >= 40` → **Restrict** +4. Default: **Allow** + +Remember rules are evaluated **top-to-bottom by priority** - the first matching rule wins. Place explicit allows above broader blocks if you want allow-lists to short-circuit. + +### Log first, enforce later + +When you start tuning thresholds, set the action to **Log** instead of `Reject` / `Restrict`. The rule will trigger and show up in the per-rule sparkline + trigger history, but the sign-up flow is unaffected. Once you're confident, switch the action to `Reject` or `Restrict`. + +## Testing fraud signals + +The Sign-up Rules **rule tester** (button **Open tester** at the bottom of the page) has a dedicated **Risk overrides** section for the fraud fields: + +- **Country** - override the resolved country code (any ISO-3166-1 alpha-2). +- **Bot score** - 0–100. Must be provided together with **Free-trial abuse** or both must be blank. +- **Free-trial abuse** - 0–100. Same pairing rule as Bot score. +- **Turnstile** - `Default (real result)` / `OK` / `Invalid` / `Error`. Default uses whatever the live engine would compute. + +Click **Run test** to see how each rule evaluates against the simulated context. The result panel shows: + +- **Outcome** - allow / reject and whether it came from a rule or the default action. +- **Triggered rules** - which rules matched, plus which one was the decision. +- **Evaluation trace** - every rule's status (`Matched` / `No match` / `Disabled` / `No condition` / `Error`). +- **Normalized context** - the exact values the engine used, so you can sanity-check your overrides against the rendered context. + +This is the safest way to validate a new threshold before flipping it from **Log** to **Reject**. + +## Trigger history & analytics + +Each rule row on the Sign-up Rules page shows a **sparkline** with the count of triggers in the recent analytics window (typically last 48h). Click the sparkline to open the **trigger history** dialog, which shows: + +- All-time and recent counts. +- A per-rule activity chart. +- A paginated list of every individual trigger (timestamp + email when captured). + +For high-risk rules, this is where you'll watch volume in real time as you tune `riskScores.*` thresholds. + +## On the user page + +The fraud signals also surface per-user. Open any user from **Users → \** and scroll to the **Fraud** section. It shows a 2-column grid with four fields: + +| Field | Editable | Notes | +|---|---|---| +| **Manual restriction** | via dialog | Status text - `Not restricted` / `Restricted by admin` / `Not manually restricted ()`. The reason mirrors the underlying restriction (`anonymous`, `email_not_verified`, `restricted_by_administrator`). | +| **Risk score: bot** | inline | `user.riskScores.signUp.bot` (0-100). Click to override. | +| **Risk score: free trial abuse** | inline | `user.riskScores.signUp.freeTrialAbuse` (0-100). Click to override. | +| **Sign-up country code** | inline | `user.countryCode` (ISO-3166-1 alpha-2, uppercased). Empty if not resolved. | + +These are the same values the [Sign-up Rules engine](./sign-up-rules) saw at sign-up time. Editing them is useful for back-filling test data, fixing a miscalibrated score, or overriding the country before re-running a downstream flow. + +### Restricting a user + +A red **Restrict user** button sits in the section header (and the action also lives in the user's top-right `⋮` menu). It opens the **User Restriction** dialog with two fields: + +- **Public reason** - shown to the user when they try to access your app. +- **Private details** - admin-only notes (e.g. internal ticket links). + +Click **Save & restrict user** to mark the user as `restrictedByAdmin: true`. If the user is already manually restricted, the button label switches to **Edit or remove manual restriction**, and the dialog gains a **Remove manual restriction** action. + +A `Restrict` outcome from your sign-up rules also lands users here - but with `restrictedReason.type === "restricted_by_administrator"` you can tell the difference between a rule-driven restriction (which carries a rule ID in analytics) and a hand-picked manual one. + +### Restriction banner + +If a user is restricted for any reason, a destructive banner shows at the top of their page explaining why: + +- `anonymous` - Anonymous users must sign up with credentials to remove this restriction. +- `email_not_verified` - The user needs to verify their email address. +- `restricted_by_administrator` - Shows the public reason and private details if set. + +The banner also exposes the same restriction action button so you can manage it without scrolling. + +## Related + +- [Authentication Overview](./overview) - parent app. +- [Sign-up Rules](./sign-up-rules) - the enforcement layer that consumes these signals. +- [JWT Tokens](./jwts) - how the `Restrict` action surfaces in user tokens. diff --git a/docs-mintlify/guides/apps/authentication/sign-up-rules.mdx b/docs-mintlify/guides/apps/authentication/sign-up-rules.mdx index 865ebb9556..ac212cca4b 100644 --- a/docs-mintlify/guides/apps/authentication/sign-up-rules.mdx +++ b/docs-mintlify/guides/apps/authentication/sign-up-rules.mdx @@ -23,12 +23,15 @@ To add a rule: When building rule conditions, you have access to these context variables: -| Variable | Type | Description | -| --------------- | ------ | --------------------------------------------------------------------------------------- | -| `email` | string | The user's email address (normalized to lowercase) | -| `emailDomain` | string | The domain part of the email (after @) | -| `authMethod` | string | The authentication method: `password`, `otp`, `oauth`, or `passkey` | -| `oauthProvider` | string | The OAuth provider ID if using OAuth (e.g., `google`, `github`), empty string otherwise | +| Variable | Type | Description | +| --- | --- | --- | +| `email` | string | The user's email address (normalized to lowercase). | +| `emailDomain` | string | The domain part of the email (after `@`). | +| `authMethod` | string | The authentication method: `password`, `otp`, `oauth`, or `passkey`. | +| `oauthProvider` | string | The OAuth provider ID if using OAuth (e.g., `google`, `github`), empty string otherwise. | +| `countryCode` | string | Geo-IP ISO-3166-1 alpha-2 country code (e.g. `US`, `DE`). See [Fraud Protection](./fraud-protection). | +| `riskScores.bot` | number (0-100) | Bot-confidence score. See [Fraud Protection](./fraud-protection). | +| `riskScores.free_trial_abuse` | number (0-100) | Free-trial / multi-account abuse score. See [Fraud Protection](./fraud-protection). | The condition builder supports these operations on string values: @@ -38,6 +41,8 @@ The condition builder supports these operations on string values: * `matches("regex")` - Check if value matches a regular expression * `==` and `!=` - Exact equality comparisons +Numeric fields (the `riskScores.*` family) also support `<`, `<=`, `>`, and `>=`. Enum-style fields (`countryCode`, `authMethod`, `oauthProvider`) support `in_list("a", "b", ...)`. + You can combine multiple conditions using AND/OR logic. ## Actions diff --git a/docs-mintlify/guides/apps/data-vault/overview.mdx b/docs-mintlify/guides/apps/data-vault/overview.mdx index 0ba3fbf36f..1153d22d56 100644 --- a/docs-mintlify/guides/apps/data-vault/overview.mdx +++ b/docs-mintlify/guides/apps/data-vault/overview.mdx @@ -21,9 +21,37 @@ Because keys are hashed before storage, **you cannot list or enumerate keys** in ## Setup +### 0. Enable the Data Vault app + +Before you can create stores, you need to enable the Data Vault app for your project: + +1. Open your Stack Auth dashboard +2. Go to **Apps** +3. Find and open **Data Vault** +4. Click **Enable** + ### 1. Create a store -Go to your project's **Data Vault** page in the [Stack Auth dashboard](https://app.stack-auth.com) and create a new store. Each store has a unique ID that you'll reference in your code. +Go to your project's **Data Vault → Stores** page in the [Stack Auth dashboard](https://app.stack-auth.com) and click **Create Store**. Each project can have **multiple stores**, and each store is fully isolated from the others. + +When creating a store you'll be asked for: + +- **Store ID** (required) — the identifier you'll reference in your code. Must contain only letters, numbers, underscores, and hyphens, and cannot start with a hyphen. Store IDs are immutable once created. +- **Display Name** (optional) — a human-readable label shown in the dashboard. Defaults to `Store ` if left blank. Unlike the ID, the display name can be edited later from the store detail page. + +Stores are stored in your project config under `dataVault.stores.` and are part of your pushable configuration, so they propagate across branches like any other config setting. + +### Managing a store + +Click any store in the **Stores** list to open its detail page. From there you can: + +- **Copy the Store ID** — useful when wiring it into your code or environment +- **Rename the store** — edit the Display Name and click **Save** to persist +- **Delete the store** — click **Delete Store**, then type the store ID into the confirmation dialog to confirm. **Deletion is irreversible**: all encrypted data in the store is permanently deleted, and Stack Auth cannot recover it. + + + Deleting a store cannot be undone. Make sure no production traffic references the store ID before removing it. + ### 2. Generate a secret diff --git a/docs-mintlify/guides/apps/emails/drafts.mdx b/docs-mintlify/guides/apps/emails/drafts.mdx new file mode 100644 index 0000000000..c03f1dafad --- /dev/null +++ b/docs-mintlify/guides/apps/emails/drafts.mdx @@ -0,0 +1,110 @@ +--- +title: "Drafts" +description: "Compose, preview, and send one-off emails to selected recipients." +sidebarTitle: "Drafts" +--- + +The **Drafts** tab is your composer for one-off emails - launches, announcements, manual outreach, anything you'd write in Mailchimp. Each draft has a full TSX source, a live preview, an AI builder, a recipient picker, and an optional schedule. Drafts persist between sessions and can be sent later (or scheduled), and every send is logged with its delivery status. + + + Drafts can only be sent when you've configured a custom email server. On the shared server you can still create, edit, and preview drafts, but the **Send** action is disabled. See [Email Settings](/guides/apps/emails/email-settings). + + +## Drafts list + +The page is split into two sections: + +- **Active Drafts** - in-progress drafts that haven't been sent yet. +- **History** - drafts that have already been sent. Sent drafts are read-only but you can still inspect their content, recipients, and delivery results. + +Each draft card has a quick **Edit** button (revealed on hover) and a `⋮` menu with **Delete**. Deletion is confirmed in a dialog. Deleting a sent draft removes the draft record but does **not** un-send the emails already delivered. + +If you're on the shared email server, every card opens a warning dialog before you enter the editor - you can still open the draft, but the **Send** step at the end will be blocked. + +## Creating a new draft + +The **New Draft** dropdown in the top-right offers two starting points: + +### Create from scratch + +Opens a dialog that asks for a **Draft name**, then drops you into the editor with an empty template. + +### Create from template + +Lists every template in your project. Pick one and Stack Auth uses AI to rewrite the template's TSX into a one-off draft (preserving layout and styling so you can tweak the copy). You can rename the draft before it's created. + +If no templates exist yet, this option shows a placeholder pointing you to [Templates](/guides/apps/emails/templates). + +## The draft editor + +Once you open a draft you progress through four stages, shown as a progress bar at the top: + +1. **Draft** - write the email. +2. **Recipients** - decide who receives it. +3. **Schedule** - decide when. +4. **Send** - confirmation and delivery view. + +You can jump backward to any completed step by clicking on it in the progress bar. + +### 1. Draft + +This stage uses the same **vibe-coding** layout as Templates - a three-pane workspace with: + +- **AI Assistant** (left) - chat with the email builder to make edits, summarize content, restyle, add CTAs, etc. The assistant has full context of the current TSX and can iterate on it. +- **Preview** (center) - live render of the email. The viewport switcher lets you preview **Edit**, **Desktop**, **Tablet**, or **Phone**. In **Edit** mode you can WYSIWYG-edit directly in the preview, and the AI rewrites the source for you on commit. +- **Code Editor** (right) - the raw TSX. Edit by hand if you want full control. Templates use the same [`@stackframe/emails`](/guides/apps/emails/templates#authoring-templates) primitives - ``, ``, ``, and React Email components. + +The header has an **EmailThemeSelector** so you can swap themes mid-edit. **Save draft** persists your changes; **Undo** reverts to the last saved version. **Next: Recipients** advances the flow. + +If saving fails (for example, because of a TSX rendering error), an inline alert shows the rendering message so you can fix it. + +### 2. Recipients + +Pick who receives the email: + +- **All users** - send to every user in the project. +- **Select users…** - opens the user picker table. Add or remove users by toggling them in the table; selected users appear as removable badges at the top. + +You can't advance until at least one recipient is selected (or scope is set to "All users"). Use **Back** to return to the draft. + +### 3. Schedule + +Choose **Send immediately** or **Schedule for later**. The scheduled option exposes a date and time picker. The final **Send** button is disabled until a valid date and time are set. + +This stage submits the draft via: + +```typescript +await stackServerApp.sendEmail({ + draftId, + // either: + userIds: [...], + // or: + allUsers: true, + scheduledAt: new Date('2026-12-25T09:00:00Z'), +}); +``` + +If the configured email server can't handle the send (e.g. shared SMTP), you get an inline error and stay on the schedule step. + +### 4. Send + +The final stage shows the **Sent Emails** view scoped to this draft - the same email log you see in [Sent](/guides/apps/emails/sent), filtered to the draft you just sent. You can drill into individual recipients to inspect the rendered HTML and delivery status. + +## Sending a draft from code + +You don't have to send drafts from the dashboard. Once a draft exists, trigger it from your server using its ID: + +```typescript +await stackServerApp.sendEmail({ + userIds: ['user-id'], + draftId: 'your-draft-id', +}); +``` + +You can mix `draftId` with `variables`, `scheduledAt`, `notificationCategoryName`, and `themeId` just like with templates - see the [Overview](/guides/apps/emails/overview#sending-emails) for the full options shape. + +## Related + +- [Templates](/guides/apps/emails/templates) - reusable templates that can be turned into drafts. +- [Sent](/guides/apps/emails/sent) - inspect the delivery status of emails launched from a draft. +- [Email Settings](/guides/apps/emails/email-settings) - required setup before drafts can actually send. diff --git a/docs-mintlify/guides/apps/emails/email-settings.mdx b/docs-mintlify/guides/apps/emails/email-settings.mdx new file mode 100644 index 0000000000..93e7c7e27f --- /dev/null +++ b/docs-mintlify/guides/apps/emails/email-settings.mdx @@ -0,0 +1,99 @@ +--- +title: "Email Settings" +description: "Configure the email server, sender identity, and active theme." +sidebarTitle: "Email Settings" +--- + +The **Email Settings** tab is where you wire your project to an actual email provider and pick the theme every email is wrapped in. It has two cards: + +- **Theme Settings** - the layout, branding, and footer that wraps every email. +- **Email Server** - the provider that actually delivers the bytes (Shared, Managed Domain, Resend, or Custom SMTP). + +You need a non-shared provider configured before `sendEmail()`, [Drafts](/guides/apps/emails/drafts), and custom [Templates](/guides/apps/emails/templates) can actually send. + +## Theme Settings + +Themes wrap your email content in a consistent shell - header, footer, background, branding. Stack Auth includes three built-in themes (**Default Light**, **Default Dark**, **Default Colorful**) and lets you author as many custom themes as you want. + +For the underlying TSX shape (`children`, `unsubscribeLink`, `projectLogos`) and how to override a theme per-send with `themeId`, see [Templates → Themes](/guides/apps/emails/templates#themes). This card is purely about *managing* and *selecting* themes. + +The card itself shows a small carousel of three thumbnails: your current active theme in the center, flanked by two others. The active theme is marked with a check badge and labeled at the bottom of the showcase. + +Click **Manage Themes** in the header to open the full theme manager, where you can: + +- **Switch the active theme** - clicking a theme opens a confirmation dialog showing a preview at your selected viewport (Phone / Tablet / Desktop). Confirm to set it project-wide. +- **Create a new theme** with the **New Theme** button. +- **Delete a custom theme** from its overflow menu (built-in themes can't be deleted). +- **Edit a theme** by clicking it - opens the same vibe-coding editor used for [Templates](/guides/apps/emails/templates#the-template-editor), so you can iterate on the TSX with the AI assistant, the live preview, and viewport switching. + +## Email Server + +The Email Server card is where you pick your provider. The four options are shown as cards with a status badge: + +- **Current** (green) - the saved provider for your project. +- **Draft** (amber, dashed) - the provider you've selected but haven't saved yet. + +If you have unsaved changes, an amber banner appears with **Discard** and **Save changes** buttons. Saving any non-shared provider triggers a **test send** to `test-email-recipient@stackframe.co` before the config is persisted - if the test fails, the config is rolled back and the error is shown inline. + +You can send your own test email any time after saving with the **Send test email** button (top-right of the card, hidden for shared and managed providers). + +### Stack Shared + +The default for new projects. Emails are sent from `noreply@stackframe.co` using Stack Auth's shared infrastructure. Good for development - not suitable for production. + +While the shared server is active you can't: + +- Send manual emails or drafts. +- Save custom email templates. +- Set a custom sender identity. + +Default auth emails (verification, password reset, magic link) still work on the shared server. + +### Managed Domain + +Bring your own domain - you add DNS records, Stack Auth handles signing, sending reputation, and delivery. + +Click **Add domain** to open the three-step setup wizard: + +1. **Your domain** - enter a dedicated subdomain (e.g. `emails.example.com`, not your apex domain) and a **sender local part** (e.g. `updates`). The full sender becomes `updates@emails.example.com`. +2. **DNS records** - Stack Auth provisions an NS-record set. Add each record to your DNS provider. Use the **Copy** button next to each value. DNS changes typically propagate within 10 minutes but can take up to 48 hours. +3. **Verify** - click **Check verification** to poll your DNS. Once verified, the wizard advances to a success state. Click **Use this domain** to apply it project-wide. + +Below the wizard, the **Tracked managed domains** list shows every domain you've started setting up. Each entry shows: + +- The full sender (`local-part@subdomain`). +- A status badge: **Waiting for DNS** / **Verifying…** / **Verified** / **Active** / **Failed**. +- Whether the domain is currently in use for this project. +- **View DNS** to resume a pending setup, or **Use this domain** for a verified-but-unused domain. + +### Resend + +Connect your [Resend](https://resend.com) account by entering your API key. Stack Auth configures the SMTP connection automatically. + +Required fields: + +- **Sender Email** - must be a verified domain in your Resend account. +- **Sender Name**. +- **Resend API Key** - encrypted at rest. Create one with **Sending access** permissions in [resend.com/api-keys](https://resend.com/api-keys). + +### Custom SMTP + +Connect any SMTP provider (SendGrid, Postmark, Mailgun, AWS SES, etc.). Required fields: + +- **Host** - e.g. `smtp.sendgrid.net`. +- **Port** - common values: `587` (TLS / STARTTLS, recommended), `465` (SSL), `25` (unencrypted, not recommended). +- **Username** and **Password** - most providers require an app-specific password. +- **Sender Email** - must be authorized by your SMTP server. +- **Sender Name**. + +Credentials are encrypted at rest. + +## Development environments + +In a [development environment](/guides/going-further/local-development) (RDE or local emulator), the Email Server card is read-only - an alert explains that you need to update the production deployment instead. The local emulator also shows a **Mock Emails** card on the [Sent](/guides/apps/emails/sent) tab that links to your Inbucket inbox. + +## Related + +- [Sent](/guides/apps/emails/sent) - check delivery status and capacity once your server is wired up. +- [Templates](/guides/apps/emails/templates) - author templates that render with the active theme. +- [Drafts](/guides/apps/emails/drafts) - need a configured server to actually send. diff --git a/docs-mintlify/guides/apps/emails/overview.mdx b/docs-mintlify/guides/apps/emails/overview.mdx index 30b106e462..885fa14f7d 100644 --- a/docs-mintlify/guides/apps/emails/overview.mdx +++ b/docs-mintlify/guides/apps/emails/overview.mdx @@ -1,11 +1,46 @@ --- title: "Emails" description: "Send custom emails, manage templates, and track delivery - all from Stack Auth." -icon: "/images/app-icons/emails.svg" +sidebarTitle: "Overview" --- Stack Auth includes a full email system for sending transactional and marketing emails to your users. It handles rendering, delivery, scheduling, notification preferences, and tracking out of the box. +The dashboard splits the Emails app into four tabs: + + + + Live email log, delivery status, and domain reputation. + + + Compose, preview, and send one-off emails to selected recipients. + + + Author reusable TSX email templates with the AI builder. + + + Configure the email server, sender identity, and active theme. + + + +## How Emails Works + +Every email - whether it comes from a built-in auth flow, your server code, a draft, or a template - flows through the same asynchronous pipeline: + +1. **Enqueue** - The email is saved to the outbox with its template, recipients, theme, and scheduling metadata. +2. **Render** - The template TSX is compiled into HTML, subject, and plain text. +3. **Queue** - Rendered emails whose scheduled time has passed are queued for delivery, respecting your project's sending capacity. +4. **Send** - Emails are delivered, honoring notification preferences and skipping users who have unsubscribed. +5. **Track** - Delivery events (sent, opened, clicked, bounced, marked as spam) are recorded. + +You can monitor every email's status in the dashboard under [Emails → Sent](/guides/apps/emails/sent). + +## Enabling the Emails app + +Like every Stack Auth app, Emails has to be enabled before its dashboard tabs show up in your project. Open the **Apps** picker in the dashboard sidebar and toggle on **Emails**. + +If a feature in the docs assumes the Emails app is enabled and you don't see the corresponding UI, this is the first thing to check. + ## Email types There are two categories of email: @@ -17,6 +52,19 @@ There are two categories of email: Never send marketing content as transactional emails. Doing so can get your domain blacklisted by spam filters. +When sending, specify the category: + +```typescript +await stackServerApp.sendEmail({ + userIds: ['user-id'], + html: '

Check out our new feature!

', + subject: 'Product Updates', + notificationCategoryName: 'Marketing', +}); +``` + +If a user has unsubscribed from Marketing emails, the email will be automatically skipped during delivery. Marketing emails always include an unsubscribe link. + ## Sending emails Emails are sent from your server using `stackServerApp.sendEmail()`. You must provide the content (HTML, a template, or a draft) and the recipients. @@ -46,7 +94,7 @@ await stackServerApp.sendEmail({ ### Send from a dashboard draft -If you've composed an email in the dashboard's draft editor, you can trigger it programmatically: +If you've composed an email in the dashboard's draft editor, you can trigger it programmatically. See [Drafts](/guides/apps/emails/drafts) for the full workflow. ```typescript await stackServerApp.sendEmail({ @@ -94,7 +142,7 @@ type SendEmailOptions = { ``` - `sendEmail` requires a custom email server (SMTP, Resend, or Managed). It cannot be used with the shared development server. + `sendEmail` requires a custom email server (SMTP, Resend, or Managed). It cannot be used with the shared development server. Configure one in [Email Settings](/guides/apps/emails/email-settings). ### Error handling @@ -138,131 +186,10 @@ await stackServerApp.sendEmail({ If `scheduledAt` is omitted, the email is sent as soon as possible. -## Email pipeline - -Emails are processed asynchronously through a multi-stage pipeline: - -1. **Enqueue** - The email is saved to the outbox with its template, recipients, and scheduling metadata. -2. **Render** - The template TSX is compiled into HTML, subject, and plain text. -3. **Queue** - Rendered emails whose scheduled time has passed are queued for delivery, respecting your project's sending capacity. -4. **Send** - Emails are delivered, honoring notification preferences and skipping users who have unsubscribed. -5. **Track** - Delivery events (sent, opened, clicked, bounced, marked as spam) are recorded. - -You can monitor every email's status in the dashboard under **Emails → Sent**. - -## Templates - -Templates are React Email components written in TSX. Each template receives the current `user`, `project`, and any custom `variables` you pass when sending. - -```tsx -import { type } from "arktype"; -import { Container } from "@react-email/components"; -import { Subject, NotificationCategory, Props } from "@stackframe/emails"; - -export const variablesSchema = type({ - featureName: "string", -}); - -export function EmailTemplate({ - user, - project, - variables, -}: Props) { - return ( - - - -

Hi {user.displayName}, check out {variables.featureName}!

-
- ); -} - -EmailTemplate.PreviewVariables = { - featureName: "Dark mode", -} satisfies typeof variablesSchema.infer; -``` - -Key concepts: - -- **`variablesSchema`** - Define the shape of your template variables using [arktype](https://arktype.io). Stack Auth validates variables against this schema at render time. -- **``** - Sets the email subject line from inside the template. -- **``** - Declares whether this is a `"Transactional"` or `"Marketing"` email. -- **`PreviewVariables`** - Sample data used for the live preview in the dashboard editor. - -### Built-in templates - -Stack Auth ships with templates for common auth flows. These are used automatically by the built-in authentication components: - -| Template | Trigger | -|---|---| -| **Email Verification** | User signs up or changes their email | -| **Password Reset** | User requests a password reset | -| **Magic Link / OTP** | User signs in with magic link or one-time password | -| **Team Invitation** | User is invited to join a team | -| **Sign-in Invitation** | User is invited to create an account | -| **Payment Receipt** | A payment succeeds (one-time or subscription) | -| **Payment Failed** | A payment fails | - -You can customize any built-in template from the dashboard under **Emails → Templates**. - -## Themes - -Themes wrap your email content in a consistent layout - header, footer, background, branding. Stack Auth includes three built-in themes: - -- **Default Light** - Clean white background with subtle shadow -- **Default Dark** - Dark background with light text -- **Default Colorful** - Light purple background with an accent border - -You can create custom themes in the dashboard under **Emails → Email Settings → Themes**. Themes are also TSX components: - -```tsx -import { Html, Head, Tailwind, Body, Container } from "@react-email/components"; -import { ThemeProps, ProjectLogo } from "@stackframe/emails"; - -export function EmailTheme({ children, unsubscribeLink, projectLogos }: ThemeProps) { - return ( - - - - - - - {children} - - {unsubscribeLink && ( -

- Unsubscribe -

- )} - -
- - ); -} -``` - -Set a default theme for your project in the dashboard. You can also override the theme per-email with the `themeId` option, or pass `themeId: false` to send without any theme. - -## Notification preferences - -Emails are categorized as either **Transactional** or **Marketing**. Users can opt out of Marketing emails but not Transactional ones. - -When sending, specify the category: - -```typescript -await stackServerApp.sendEmail({ - userIds: ['user-id'], - html: '

Check out our new feature!

', - subject: 'Product Updates', - notificationCategoryName: 'Marketing', -}); -``` - -If a user has unsubscribed from Marketing emails, the email will be automatically skipped during delivery. Marketing emails always include an unsubscribe link. - ## React components integration -Emails integrate with Stack Auth UI components automatically (for example verification, password reset, and magic-link flows). +Emails integrate with Stack Auth UI components automatically (for example verification, password reset, and magic-link flows). Each of those flows uses a built-in template that you can customize in [Templates](/guides/apps/emails/templates). + For custom flows, trigger `sendEmail` from your server code: ```typescript @@ -282,69 +209,9 @@ export async function inviteUser(userId: string) { } ``` -## Email server configuration - -Configure your email server in the dashboard under **Emails → Email Settings**. There are four options: - -### Shared (development only) - -The default for new projects. Emails are sent from `noreply@stackframe.co` using Stack Auth's shared infrastructure. Good for development - not suitable for production. - -### Custom SMTP - -Connect any SMTP provider. Configure: - -- **Host** - e.g. `smtp.sendgrid.net` -- **Port** - typically 587 (STARTTLS) or 465 (implicit TLS) -- **Username** and **Password** -- **Sender email** and **Sender name** - -### Resend - -Connect your [Resend](https://resend.com) account by entering your API key. Stack Auth configures the SMTP connection automatically. - -### Managed - -Let Stack Auth manage your email domain. Stack Auth handles DNS configuration and deliverability for you. Set up requires: - -1. Choose a subdomain (e.g. `mail.yourapp.com`) -2. Add the DNS records Stack Auth provides -3. Verify the domain in the dashboard - - - The dashboard tests your email configuration automatically when you save it by sending a test email. - - -## Delivery stats - -Stack Auth tracks delivery metrics across multiple time windows (hour, day, week, month): - -- **Sent** - Successfully delivered -- **Bounced** - Rejected by the recipient's mail server -- **Marked as spam** - Recipient flagged the email - -Access these programmatically: - -```typescript -const info = await stackServerApp.getEmailDeliveryStats(); -// info.stats.day.sent, info.stats.day.bounced, etc. -// info.capacity.rate_per_second, info.capacity.is_boost_active, etc. -``` - -Delivery capacity is managed automatically based on your sending reputation. If you need to temporarily increase throughput, you can activate a capacity boost: - -```typescript -await stackServerApp.activateEmailCapacityBoost(); -``` - -## Drafts - -The dashboard includes a full draft editor where you can compose emails visually before sending. Drafts support: - -- TSX source editing with live preview -- Theme selection -- Recipient picker (specific users or all users) -- Scheduling -- Send history per draft +## Where to go next -Once a draft is sent, it's marked as sent and its delivery can be tracked in the outbox. +- [Sent](/guides/apps/emails/sent) - inspect the outbox, status, and domain reputation. +- [Drafts](/guides/apps/emails/drafts) - compose visually and ship one-off campaigns. +- [Templates](/guides/apps/emails/templates) - author reusable TSX templates with the AI builder. +- [Email Settings](/guides/apps/emails/email-settings) - configure your email server and active theme. diff --git a/docs-mintlify/guides/apps/emails/sent.mdx b/docs-mintlify/guides/apps/emails/sent.mdx new file mode 100644 index 0000000000..652bb3321e --- /dev/null +++ b/docs-mintlify/guides/apps/emails/sent.mdx @@ -0,0 +1,101 @@ +--- +title: "Sent" +description: "Inspect the outbox, delivery status, and domain reputation." +sidebarTitle: "Sent" +--- + +The **Sent** tab is your live email log. Every email that flows through the [pipeline](/guides/apps/emails/overview#how-emails-works) - whether triggered by built-in auth flows, a draft, a template, or `sendEmail()` - shows up here with its current status. The right rail shows your **Domain Reputation** so you always know how much sending capacity you have left. + +## Email log + +The log is a paginated grid sorted by time (newest first). It supports an **infinite scroll** to load more rows. + +### View modes + +The pill toggle at the top of the card switches between two views: + +- **List all** - Flat table of every outbox entry. +- **Group by template/draft** - Collapse rows under their parent template or draft so you can see how a given campaign performed at a glance. + +### Columns + +| Column | Notes | +|---|---| +| **Recipient** | The recipient's email or `User: ...` if the email was addressed by user ID. | +| **Subject** | The rendered subject line, or `(Not yet rendered)` if the email hasn't been processed yet. | +| **Time** | `deliveredAt` if available, otherwise the `scheduledAt` time. | +| **Status** | Color-coded badge - see the [status reference](#status-reference) below. | + +Click any row to open the **email viewer**, which shows the rendered HTML, recipient details, status timeline, and any rendering / delivery errors. + +### Status reference + +| Status | Badge | Meaning | +|---|---|---| +| Paused | cyan | The email is paused (e.g. domain reputation issues). | +| Preparing | cyan | Initial outbox record created. | +| Rendering | cyan | Template is being compiled. | +| Render Error | red | Template compilation failed. | +| Scheduled | cyan | Waiting for `scheduledAt`. | +| Queued | cyan | Ready to send, waiting for capacity. | +| Sending | cyan | Handed off to the SMTP provider. | +| Server Error | red | Provider rejected the request. | +| Skipped | cyan | Recipient unsubscribed or didn't qualify. | +| Bounced | red | Recipient's mail server rejected the message. | +| Delivery Delayed | cyan | Provider is retrying delivery. | +| Sent | green | Delivered successfully. | +| Opened | blue | Recipient opened the email. | +| Clicked | purple | Recipient clicked a tracked link. | +| Marked as Spam | orange | Recipient flagged the email as spam. | + +## Domain Reputation + +The right-hand card surfaces three signals that determine how much - and how fast - you can send. + +### Email Capacity + +Shows hourly sends versus your current cap (`emails sent / max per hour`). Stack Auth automatically buffers email as your domain warms up, so this number scales over time. + +If you need a temporary lift (e.g. you're launching a campaign), click **Temporarily increase capacity** to activate a **boost**. While a boost is active, the bar animates and a countdown timer shows when it expires. Boosts can also be triggered programmatically: + +```typescript +await stackServerApp.activateEmailCapacityBoost(); +``` + +### Bounce Rate + +Percentage of emails that couldn't be delivered, against a healthy ceiling (`5%`). High bounce rates hurt your sender reputation and deliverability. + +### Spam Complaint + +Percentage of recipients who marked your emails as spam, against the standard threshold (`0.1%`). Keep this low to stay deliverable. + +## Programmatic access + +Pull the same metrics that power Domain Reputation from your server: + +```typescript +const info = await stackServerApp.getEmailDeliveryStats(); + +info.stats.hour.sent; +info.stats.day.bounced; +info.stats.week.marked_as_spam; +info.stats.month.sent; + +info.capacity.rate_per_second; +info.capacity.is_boost_active; +info.capacity.boost_multiplier; +info.capacity.boost_expires_at; +``` + +Stats are bucketed by `hour`, `day`, `week`, and `month`, with counts for `sent`, `bounced`, `marked_as_spam`, and more. + +## Local emulator + +When you're running the local emulator, mock emails are captured by Inbucket instead of being delivered. Open the **Mock Emails → Open Inbox** card at the top of the Emails app to inspect them. + +## Related + +- [Drafts](/guides/apps/emails/drafts) - compose and send one-off emails. +- [Templates](/guides/apps/emails/templates) - author the templates that show up in the log. +- [Email Settings](/guides/apps/emails/email-settings) - configure the sender that emails are sent from. diff --git a/docs-mintlify/guides/apps/emails/templates.mdx b/docs-mintlify/guides/apps/emails/templates.mdx new file mode 100644 index 0000000000..b5b2a87045 --- /dev/null +++ b/docs-mintlify/guides/apps/emails/templates.mdx @@ -0,0 +1,175 @@ +--- +title: "Templates" +description: "Author reusable TSX email templates with the AI builder." +sidebarTitle: "Templates" +--- + +The **Templates** tab is where you author every reusable email in your project - from custom transactional flows to the built-in auth emails. Templates are React Email components written in TSX. Once defined, they can be rendered from your server via `sendEmail({ templateId, variables })`, embedded in [Drafts](/guides/apps/emails/drafts), or wired automatically into Stack Auth's auth flows. + + + Templates require a custom email server. On the shared server you can still open and read templates, but **Edit Template** opens a warning dialog and saves are rejected. Configure a provider in [Email Settings](/guides/apps/emails/email-settings) first. + + +## Templates list + +The list shows every template in your project as a card with: + +- The **template name**. +- An **Edit Template** button. +- A `⋮` overflow menu with **Delete Template**. + +Both the shared-server warning and the delete confirmation appear as modal dialogs - delete is destructive and cannot be undone. + +The page header has a **New Template** button that opens a dialog asking for a name, then immediately opens the editor. + +## Built-in templates + +Stack Auth ships with templates for common auth flows. These are pre-populated and used automatically by the built-in authentication components: + +| Template | Trigger | +|---|---| +| **Email Verification** | User signs up or changes their email | +| **Password Reset** | User requests a password reset | +| **Magic Link / OTP** | User signs in with magic link or one-time password | +| **Team Invitation** | User is invited to join a team | +| **Sign-in Invitation** | User is invited to create an account | +| **Payment Receipt** | A payment succeeds (one-time or subscription) | +| **Payment Failed** | A payment fails | + +Customize any built-in template by clicking **Edit Template** on its row. Your edits replace the default content for the matching auth flow. + +## The template editor + +The editor uses Stack Auth's **vibe-coding** layout - a three-pane workspace identical to the [Drafts editor](/guides/apps/emails/drafts#the-draft-editor): + +- **AI Assistant** (left) - chat with the email builder to refactor the template, swap copy, restyle, or wire in new variables. The assistant has the current TSX as context and edits it directly. +- **Preview** (center) - live render with viewport modes for **Edit**, **Desktop**, **Tablet**, and **Phone**. In **Edit** mode you can WYSIWYG-edit directly in the preview, and the assistant rewrites the source on commit. +- **Code Editor** (right) - the raw TSX. Edit by hand for full control. + +The header has an **EmailThemeSelector** so you can pair the template with a specific theme as you iterate. **Save template** persists changes; **Undo** reverts to the last saved version. If a save fails because the template can't render, an inline alert shows the exact `EmailRenderingError` message so you can fix it. + +## Authoring templates + +Templates are React Email components written in TSX. Each template receives the current `user`, `project`, and any custom `variables` you pass when sending. + +```tsx +import { type } from "arktype"; +import { Container } from "@react-email/components"; +import { Subject, NotificationCategory, Props } from "@stackframe/emails"; + +export const variablesSchema = type({ + featureName: "string", +}); + +export function EmailTemplate({ + user, + project, + variables, +}: Props) { + return ( + + + +

Hi {user.displayName}, check out {variables.featureName}!

+
+ ); +} + +EmailTemplate.PreviewVariables = { + featureName: "Dark mode", +} satisfies typeof variablesSchema.infer; +``` + +Key concepts: + +- **`variablesSchema`** - Define the shape of your template variables using [arktype](https://arktype.io). Stack Auth validates variables against this schema at render time. If a call to `sendEmail()` passes the wrong shape, it fails with a `SCHEMA_ERROR`. +- **``** - Sets the email subject line from inside the template. +- **``** - Declares whether this is a `"Transactional"` or `"Marketing"` email. Drives unsubscribe behavior and the unsubscribe link in the footer. +- **`PreviewVariables`** - Sample data used for the live preview in the dashboard editor and in [Email Settings → Theme Settings](/guides/apps/emails/email-settings#theme-settings). + +## Themes + +Templates render the *inside* of an email. **Themes** wrap that content in a consistent shell - header, footer, background, branding, and the unsubscribe link for marketing emails. Every template renders against the project's active theme by default, but you can swap themes per-send or even render with no theme at all. + +Stack Auth includes three built-in themes: + +- **Default Light** - clean white background with a subtle shadow. +- **Default Dark** - dark background with light text. +- **Default Colorful** - light purple background with an accent border. + +You can switch the active theme, create new themes, edit existing ones, or delete custom themes from the dashboard - see [Email Settings → Theme Settings](/guides/apps/emails/email-settings#theme-settings) for the full management UI. + +### Authoring themes + +Like templates, themes are TSX components. They receive the rendered template content as `children`, an `unsubscribeLink` (only present for marketing emails), and `projectLogos`: + +```tsx +import { Html, Head, Tailwind, Body, Container } from "@react-email/components"; +import { ThemeProps, ProjectLogo } from "@stackframe/emails"; + +export function EmailTheme({ children, unsubscribeLink, projectLogos }: ThemeProps) { + return ( + + + + + + + {children} + + {unsubscribeLink && ( +

+ Unsubscribe +

+ )} + +
+ + ); +} +``` + +Key points: + +- **`children`** - the rendered template body. Wrap it in whatever layout you want. +- **`unsubscribeLink`** - only populated for `Marketing` emails. Render it whenever it's present; omit the wrapper when it's `null` to keep transactional emails clean. +- **`projectLogos`** - your project's logo set. Pair with `` (or `"dark"`) and Stack Auth picks the right asset. +- **Styling** - use `` from `@react-email/components` for utility classes, or fall back to inline styles. Email clients are notoriously picky about CSS. + +### Choosing a theme at send time + +The project-wide active theme applies by default. Override it per-send with the `themeId` option: + +```typescript +await stackServerApp.sendEmail({ + userIds: ['user-id'], + templateId: 'welcome', + themeId: 'theme-id', // specific theme + // themeId: null, // use the project default theme + // themeId: false, // send with no theme at all +}); +``` + +In the template editor, the **EmailThemeSelector** in the header lets you preview your template against any theme as you iterate. + +## Sending a template from code + +Once a template exists, render it from any server-side handler with `templateId` and `variables`: + +```typescript +await stackServerApp.sendEmail({ + userIds: ['user-id-1'], + templateId: 'your-template-id', + variables: { + featureName: 'Dark mode', + }, +}); +``` + +Add `themeId` to override the active theme, or `allUsers: true` to broadcast. See [Overview → Full options](/guides/apps/emails/overview#full-options) for the complete shape. + +## Related + +- [Drafts](/guides/apps/emails/drafts) - turn a template into a one-off send with custom recipients. +- [Email Settings](/guides/apps/emails/email-settings) - configure the active theme that templates render with. +- [Sent](/guides/apps/emails/sent) - inspect the delivery status of emails rendered from your templates. diff --git a/docs-mintlify/guides/apps/fraud-protection/overview.mdx b/docs-mintlify/guides/apps/fraud-protection/overview.mdx deleted file mode 100644 index ae983c28b8..0000000000 --- a/docs-mintlify/guides/apps/fraud-protection/overview.mdx +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Fraud Protection -description: Protect your project from fraud and abuse -icon: "/images/app-icons/fraud-protection.svg" ---- - -Fraud Protection helps you block abusive sign-ups and enforce safer onboarding rules. - -Use the Authentication app docs below for the implementation details currently available: - -- [Sign-up Rules](../authentication/sign-up-rules) -- [Authentication Overview](../authentication/overview) diff --git a/docs-mintlify/guides/apps/payments/customers.mdx b/docs-mintlify/guides/apps/payments/customers.mdx new file mode 100644 index 0000000000..9f73270397 --- /dev/null +++ b/docs-mintlify/guides/apps/payments/customers.mdx @@ -0,0 +1,179 @@ +--- +title: "Customers" +description: "Inspect any customer's item balances and grant products or adjust quantities" +--- + +The **Customers** page (`Payments -> Customers`) is the per-customer admin surface for Payments. Pick a user, team, or custom customer, and you'll see every item that's relevant to that customer type with the customer's current balance — plus actions to grant products or adjust balances by hand. + +## Picking a customer + +The page opens with two controls on the same row: + +- **Customer type** dropdown — `User`, `Team`, or `Custom`. Changing the type clears the selected customer and refilters the items table to match. +- **Select customer** dropdown — opens a search dialog tailored to the chosen type: + - **User** — the user picker table with avatar, display name, primary email, and a **Select** button per row. + - **Team** — the team search table with avatar, display name, and a **Select** button. + - **Custom** — a free-text input for the custom customer identifier (e.g. `customer-123`). The **Use customer** button is disabled until the field is non-empty. + +The page actions at the top right are: + +- **Grant Product** — disabled until a customer is selected (tooltip explains why). +- **Create User/Team/Custom Item** — label changes based on the customer-type dropdown. + +## Items table + +Once both the customer type and a customer are selected, the page shows a DataGrid with every configured item of the matching customer type: + +- **Item** — display name + monospace ID. +- **Quantity** — the customer's current balance for that item. `0` for items the customer has never received. +- **Actions** — an **Adjust** button per row. + +The table supports the standard DataGrid features that are enabled for this view: column resizing, sorting, and state synced into the URL under the `customeritems` prefix so deep links survive reloads. + + + If you select a customer type with no configured items, the table is replaced with "No user/team/custom items are configured yet." Add items from [Products & Items](/guides/apps/payments/products-and-items) or via the **Create … Item** button at the top. + + +## Adjusting an item balance + +Click **Adjust** on any row to open the adjustment dialog. The fields are: + +- **Quantity change** — a positive integer to add to the balance, or a negative integer to subtract. The dialog rejects `0`. +- **Description** *(optional)* — a freeform note that appears alongside the resulting entry in [Transactions](/guides/apps/payments/transactions). Useful for audit context like "manual refund — chargeback dispute won". +- **Expires at** *(optional)* — a date after which the adjustment is rolled back automatically. Use this for time-limited grants like promotional credits. + +Submitting writes an entry of type **Manual Item Quantity Change** to the transactions log. Errors are surfaced as destructive toasts: + +- **Item not found** — the item no longer exists. +- **Customer/User/Team not found** — the customer was deleted between selection and submission. +- **Quantity too low** — the change would push the balance below zero. Use `tryDecreaseQuantity` semantics on the SDK side instead, or grant before decrementing. +- **Customer type mismatch** — the item belongs to a different customer type than the selected customer (shouldn't happen via the UI because items are filtered, but the backend validates anyway). + +## Granting a product + +The **Grant Product** button at the top of the page opens a focused dialog: + +- **Product** — a dropdown of every product whose customer type matches the selected customer. Disabled with a "No products are configured for this customer type yet" message when the catalogue is empty for that type. +- **Quantity** *(only for stackable products)* — defaults to `1`. The field only appears when the chosen product has the **Stackable** toggle on. + +Submitting calls `grantProduct` server-side and shows a "Product granted" toast on success. Common errors come back as destructive toasts: + +- **Product not found** +- **Customer type mismatch** — the product isn't available for the selected customer type. +- **Product already granted** — the customer already owns it and the product isn't stackable. +- **Customer not found** + +Once the grant succeeds, the items table refreshes the affected rows so any items the granted product includes show their new balances immediately. + +## Creating items inline + +The page also exposes a **Create User Item / Create Team Item / Create Custom Item** action that opens the same item dialog used in [Products & Items](/guides/apps/payments/products-and-items#items). The customer type is locked to whatever the customer-type dropdown is currently showing, so you can quickly add items without context-switching. + +## Programmatic equivalents + +The same customer-facing operations are available from the server SDK. Use these in your app when balances or grants are part of product behavior rather than one-off support work. + +### Reading item balances + +```typescript +// User customer +const user = await stackServerApp.getUser("user-id"); +if (!user) throw new Error("User not found"); +const userCredits = await user.getItem("credits"); + +// Team customer +const team = await stackServerApp.getTeam("team-id"); +if (!team) throw new Error("Team not found"); +const teamSeats = await team.getItem("seats"); + +// Custom customer +const customCredits = await stackServerApp.getItem({ + customCustomerId: "external-org-123", + itemId: "credits", +}); +``` + +### Adjusting balances + +Use `increaseQuantity` for grants, `decreaseQuantity` when a negative balance is acceptable, and `tryDecreaseQuantity` when you need a hard pre-paid limit. + +```typescript title="lib/credits.ts" +import { stackServerApp } from "@/stack/server"; + +export async function consumeCredits(userId: string, amount: number) { + const user = await stackServerApp.getUser(userId); + if (!user) throw new Error("User not found"); + + const credits = await user.getItem("credits"); + const success = await credits.tryDecreaseQuantity(amount); + + if (!success) { + throw new Error("Insufficient credits"); + } + + return { remaining: credits.quantity }; +} +``` + +To add credits instead: + +```typescript +const credits = await user.getItem("credits"); +await credits.increaseQuantity(100); +``` + +### Granting products + +Grant a configured product to any customer type from server code: + +```typescript +// Grant to a user +await stackServerApp.grantProduct({ + userId: "user-id", + productId: "prod_premium", +}); + +// Grant to a team +await stackServerApp.grantProduct({ + teamId: "team-id", + productId: "prod_team_plan", +}); + +// Grant to a custom customer +await stackServerApp.grantProduct({ + customCustomerId: "external-org-123", + productId: "prod_enterprise", +}); +``` + +Stackable products can be granted with an explicit quantity: + +```typescript +await stackServerApp.grantProduct({ + userId: "user-id", + productId: "prod_credit_pack", + quantity: 3, +}); +``` + +Inline product definitions are also supported for one-off grants: + +```typescript +await stackServerApp.grantProduct({ + userId: "user-id", + product: { + display_name: "Bonus Credits", + customer_type: "user", + server_only: true, + stackable: false, + prices: { + manual: { USD: "0" }, + }, + included_items: { + credits: { quantity: 100 }, + }, + }, +}); +``` + +Use the dashboard for one-off support work; use the SDK for behavior that needs to happen automatically, such as crediting users when they complete an in-app challenge. diff --git a/docs-mintlify/guides/apps/payments/overview.mdx b/docs-mintlify/guides/apps/payments/overview.mdx index 8dbf5d9e7c..f4cf8fb9e5 100644 --- a/docs-mintlify/guides/apps/payments/overview.mdx +++ b/docs-mintlify/guides/apps/payments/overview.mdx @@ -10,6 +10,28 @@ Stack Auth includes a Payments app that handles billing, subscriptions, and one- This guide walks through how to set up payments, sell products, manage subscriptions, and work with item-based entitlements like credits or seats. +## Dashboard pages + +The Payments app is split across five dashboard pages — one for shaping your catalogue, one for inspecting customers, and the rest for monitoring activity and configuring the integration. + + + + Group products into mutually exclusive tiers (Free, Pro, Enterprise) and arrange them visually with drag-and-drop. + + + Define products and items, set pricing, configure included items, and visualize how they connect. + + + Inspect any user, team, or custom customer — view item balances, adjust quantities, and grant products manually. + + + Browse every payment event, filter by type or customer, and issue partial or full refunds. + + + Connect Stripe, toggle test mode, pick which payment methods to accept, and block new purchases. + + + ## Getting started @@ -17,7 +39,7 @@ This guide walks through how to set up payments, sell products, manage subscript Go to the **Apps** section in your dashboard, find **Payments**, and enable it. - Open **Payments -> Settings** and follow the Stripe Connect onboarding flow. You'll be asked for business details, bank info, and identity verification. Once approved, payments are live. + Open **Payments -> Settings** and follow the Stripe Connect onboarding flow. You'll be asked for business details, bank info, and identity verification. Once approved, payments are live. See [Settings](/guides/apps/payments/settings) for the full connection flow. While building, enable **test mode** in **Payments -> Settings**. All purchases will be free - no real money is charged. You can switch to live when you're ready. @@ -66,6 +88,8 @@ A few additional options: - **Server-only** - Hide the product from client SDK responses. Useful for products that should only be granted programmatically. - **Stackable** - Allow multiple purchases of the same product (default is one per customer). +See [Products & Items](/guides/apps/payments/products-and-items) for the full editor walkthrough. + ## Selling a product To sell a product, generate a checkout URL and redirect the user to it. The `createCheckoutUrl` method is available on both user and team objects. @@ -376,6 +400,8 @@ await stackServerApp.grantProduct({ If you have a reference to the user object already, you can also call `user.grantProduct({ productId })` directly. +The same flow is available from the dashboard — see the [Customers](/guides/apps/payments/customers) page for the **Grant Product** dialog and per-customer item adjustments. + ## Customer types Stack Auth supports three types of payment customers: @@ -386,29 +412,24 @@ Stack Auth supports three types of payment customers: All payment methods (`createCheckoutUrl`, `useProducts`, `useItem`, `switchSubscription`, `useBilling`, `useInvoices`, etc.) are available on both user and team objects. For custom customers, use the top-level `stackServerApp` methods with `customCustomerId`. -## Dashboard - -The dashboard gives you full visibility and control over your payments: - -- **Product Lines** - Group products into mutually exclusive tiers. Configure in **Payments -> Product Lines**. -- **Products & Items** - Create and edit products, set pricing, and configure included items. In **Payments -> Products & Items**. -- **Customers** - View item balances per customer, manually adjust quantities (with optional expiration), and grant products directly. In **Payments -> Customers**. -- **Transactions** - See all payment activity, filter by type and customer, view details, and issue refunds. In **Payments -> Transactions**. -- **Payouts** - View payout information. In **Payments -> Payouts**. -- **Settings** - Connect Stripe, toggle test mode, configure payment methods, and block new purchases. In **Payments -> Settings**. - -### Payment emails +## Payment emails Email notifications are sent automatically on payment events: - **Payment Receipt** - Sent on successful payment with product details, amount, and receipt link - **Payment Failed** - Sent on failed payment with product name, amount, and failure reason -These apply to both one-time purchases and subscription renewals. Customize them in **Emails -> Templates**. +These apply to both one-time purchases and subscription renewals. Customize them in **Emails -> Templates** — see the [Templates guide](/guides/apps/emails/templates) for authoring details. + +## Payouts + +Payouts are handled by Stripe for the connected account you set up in **Payments -> Settings**. Stack Auth handles checkout, purchases, subscriptions, invoices, and entitlements; Stripe remains the source of truth for bank payouts, payout timing, and payout history. + +Use your Stripe dashboard for payout schedules, bank-account settings, and accounting exports. ## Test mode -During development, enable test mode in **Payments -> Settings**. All purchases will be free and no real money is charged - products are granted immediately without going through Stripe. +During development, enable **test mode** in [Payments -> Settings](/guides/apps/payments/settings). All purchases will be free and no real money is charged - products are granted immediately without going through Stripe. When you're ready to test with real Stripe flows (but still using test credentials), disable Stack's test mode and use Stripe's test card numbers: diff --git a/docs-mintlify/guides/apps/payments/product-lines.mdx b/docs-mintlify/guides/apps/payments/product-lines.mdx new file mode 100644 index 0000000000..2c59f146d2 --- /dev/null +++ b/docs-mintlify/guides/apps/payments/product-lines.mdx @@ -0,0 +1,135 @@ +--- +title: "Product Lines" +description: "Group products into mutually exclusive tiers and lay them out as a pricing table" +--- + +A **product line** is a set of products that a customer can only own one of at a time. Free, Pro, and Enterprise plans live in the same product line; upgrading to Pro automatically replaces the Free plan. Add-on products are the exception — they layer on top of a product line without competing with it. + +The **Product Lines** page (`Payments -> Product Lines`) is the visual home for your catalogue. Each product line is rendered as a pricing-table strip, and a final "No product line" strip captures any standalone products. + +## First-run experience + +When the app has no products or items yet, the page shows a four-slide onboarding carousel: + +1. **Welcome to Payments!** — entry-point illustration. +2. **Products** — plans, goods, or offers your customers buy. Each product can have multiple prices (e.g. monthly vs. yearly). +3. **Items** — what customers receive when they purchase a product (feature access, usage limits, credit balances). +4. **Pricing Table** — products are columns, items are rows; the same item can appear in many products. + +The final slide reveals a **Create Your First Product** button that jumps to the product editor. Once any product or item exists, the carousel is replaced by the live product-line view. + +## Layout + +Each product line is rendered as a horizontally-scrolling strip: + +- **Header** — the product line's display name, an editable customer-type badge (`user` / `team` / `custom`), and inline edit/delete buttons that appear on hover. +- **Pricing-table cluster** — non-add-on products in the line share a single rounded card with each product as a column. Pricing tables make it obvious which features are shared and which are tier-exclusive. +- **Add-on cards** — products with `isAddOnTo` set are rendered as separate cards alongside the table. +- **Add product** — a dashed placeholder at the end of every strip that links to the product editor pre-filled with the strip's product line and customer type. + +A separate **No product line** strip at the bottom catches every product that isn't grouped. Products in this strip are not mutually exclusive. + + + Product lines are sorted by customer-type priority: `user` first, then `team`, then `custom`. Products within a strip are sorted by customer type, then non-add-ons before add-ons, then by lowest USD price, then by ID. + + +## Creating a product line + +Click **New product line** at the bottom of the page and fill in the popover: + +- **Display Name** — shown to admins; suggested IDs are derived from this field. +- **Product Line ID** — lowercase, kebab-case, must be unique. Used in SDK calls and stable across renames. +- **Customer Type** — pick `user`, `team`, or `custom`. The line will only accept products of the same customer type going forward. + +The **Create Product Line** button stays disabled until both the display name and ID are non-empty. IDs are validated against Stack's user-specified-ID rules — duplicates are rejected with an alert. + +## Editing and deleting + +Hover the product line header to reveal the pencil and trash icons. + +- **Edit** opens a dialog with the display name field. The ID and customer type are immutable. +- **Delete** opens a destructive confirmation dialog. Deleting a line keeps every product inside it but moves them to **No product line** so existing customers and checkout URLs keep working. + +When the **last** product in a line is deleted, the line itself is auto-deleted to keep the page tidy. You'll see a "Product and empty product line deleted" toast confirming both changes. + +## Product cards + +Each card is a compact summary of one product, with shortcuts to the most common admin actions. + +- **Header** — customer-type badge, monospace product ID badge, and the display name. Add-ons show a small puzzle-piece icon next to the name. +- **Indicator row** — small badges that appear only when active: **Server only**, **Stackable**, and **Add-on to [base product]** (with a clickable link that jumps to the referenced product). +- **Pricing** — the product's prices stacked vertically with `OR` separators when multiple price options exist. +- **Items** — the included items list with each item's quantity and short repeat interval (e.g. `100 /mo`). +- **Footer actions** (only for `user` and `team` products): + - **Copy code** — copies a ready-to-paste `createCheckoutUrl` snippet for the product. + - **Copy prompt** — copies a comprehensive markdown prompt covering product configuration, pricing, items, SDK usage (server + client), and edge-case notes. Useful for handing context to an AI assistant. +- **Action menu** (top-right, visible on hover): + - **View Details** — open the dedicated product page. + - **Edit** — open the full product editor. + - **Duplicate** — clone the product into the create flow with a `" Copy"` suffix. + - **Delete** — destructive confirmation that removes the product (and the line, if it was the last product). + +Clicking anywhere on a card (outside the menu or buttons) navigates to the product's detail page. + +## Drag and drop between lines + +Cards expose a grab handle on the left edge when hovered. Drag a card to: + +- Any product line that has the **same** customer type as the dragged product, or +- The **No product line** strip (always valid). + +Compatible drop zones are outlined with a primary-coloured ring while a card is in flight; incompatible lines flash a "Cannot move product / customer type mismatch" message. After a successful drop, a "Product moved" toast confirms the new home; a global loading overlay covers the page while the config update runs. + +## Cross-reference + +- For the data model and product fields (display name, customer type, prices, items, add-ons, free trials, server-only, stackable), see [Products & Items](/guides/apps/payments/products-and-items). + +## SDK usage + +Product lines matter at runtime because they control plan switching. When a customer owns a product in a line, products in the same line show up as `switchOptions` on the owned product. + +```typescript +// Client component (hook - re-renders on changes) +const products = user.useProducts(); +const currentPlan = products.find((product) => product.id === "prod_pro"); +const availableSwitches = currentPlan?.switchOptions ?? []; + +// Server component +const serverProducts = await user.listProducts(); +``` + +To switch between two products in the same product line, call `switchSubscription` on the customer object: + +```typescript title="app/components/upgrade-button.tsx" +"use client"; +import { useUser } from "@stackframe/stack"; + +export default function UpgradeButton() { + const user = useUser({ or: "redirect" }); + + return ( + + ); +} +``` + + + Switching plans requires the customer to have a default payment method saved. The switch happens immediately and Stripe handles proration automatically. + + +Team plans work the same way when you call the methods on a team object: + +```typescript +const team = user.useTeam(teamId); +await team.switchSubscription({ + fromProductId: "prod_team_pro", + toProductId: "prod_team_enterprise", +}); +``` diff --git a/docs-mintlify/guides/apps/payments/products-and-items.mdx b/docs-mintlify/guides/apps/payments/products-and-items.mdx new file mode 100644 index 0000000000..f3aa77016b --- /dev/null +++ b/docs-mintlify/guides/apps/payments/products-and-items.mdx @@ -0,0 +1,244 @@ +--- +title: "Products & Items" +description: "Define what you sell and the entitlements it grants" +--- + +The **Products & Items** page (`Payments -> Products & Items`) is the home for your billable catalogue. **Products** are the things you sell (plans, packs, add-ons). **Items** are the underlying entitlements (credits, seats, API quotas). One product can include many items, and the same item can be referenced by many products — like a pricing-table row that crosses multiple columns. + +This page is also where you fix problems with existing entries. Validation alerts surface at the top of the page so you don't ship a broken checkout. + +## Layout + +### Desktop + +The page is a two-column split: + +- **Products** (left) — every product grouped by product line. The product line header is a sticky bar that scrolls with the section. +- **Items** (right) — every item, grouped by customer type (user, then team, then custom). + +Hovering a product highlights the items it includes, and a dashed bezier curve is drawn between the product and each connected item with a small `×` chip at the midpoint. Hovering an item works in reverse — every product that includes the item lights up. + +Each column has its own search box and a `+` action in the header for creating new entries. + +### Mobile + +Below the `lg` breakpoint, the columns collapse into a single panel with a tabbed switcher (**Products** / **Items**). The connection-line visualization is desktop-only. + +## Top-of-page alerts + +Two destructive alert banners surface above the lists when the catalogue is in a broken state: + +- **"N products have no prices configured"** — products previously set to "include by default" (no longer supported) lose their prices on migration. They're no longer purchasable and won't appear in upgrade flows. Existing owners keep their included items, but no new owners are granted them. The alert links to each broken product so you can either set a price or click **Make free** in the editor. +- **"N products have prices customers can't check out"** — prices Stripe will reject at checkout (e.g. `$0` one-time, or one-time charges below `$0.50`). The alert lists the offending price IDs per product and links to the editor. + +Both alerts go away automatically when the underlying issues are resolved. + +## Defining products + +Each product has: + +- **Display name** — what the customer sees on Stripe Checkout and in their billing portal. +- **Customer type** — `user`, `team`, or `custom`. Products of one type cannot be granted to customers of another. +- **Prices** — one or more prices, each with: + - A currency amount (USD, EUR, GBP, JPY, INR, AUD, CAD). + - An optional billing interval (`day`, `week`, `month`, `year`) — omit to make the price a one-time charge. + - An optional per-price free trial. + - An optional `server-only` flag that hides the price from client SDK responses (so it can only be checked out from server code). + - A special `"free"` price for $0 products and a `"manual"` price for products that can only be granted programmatically (never via checkout). +- **Included items** — items granted on purchase, each with: + - **Quantity** — how many units are granted (e.g. `100` credits, `5` seats). + - **Repeat** — when the grant repeats: + - **One-time only** — granted once on purchase. + - **Monthly / Yearly / Weekly / Daily** (or a **Custom** count + unit) — granted every N units, refreshing the balance on each renewal. + - **Expires** — when the items are removed from the customer: + - **Never** — customer keeps them forever, even after the subscription ends. + - **With subscription** — removed when the subscription ends or is cancelled. + - **Until next renewal** — items reset on each billing cycle (monthly credits that refresh, for example). + +A few additional toggles: + +- **Product lines** — assign products to a [product line](/guides/apps/payments/product-lines) to make them mutually exclusive (e.g. plan tiers). +- **Add-ons** — set **isAddOnTo** to require the customer to already own one of the listed base products before this one can be purchased. +- **Free trial** — give customers a trial period before charging. Can be set on the product or on individual prices. +- **Server-only** — hide the product from client SDK responses. Useful for products that should only be granted programmatically (e.g. promotional offers). +- **Stackable** — allow multiple purchases of the same product. Quantities accumulate instead of replacing. + +## Creating a product + +Click the `+` in the **Products** header to launch the dedicated product editor. The first step is a **Who will this product be for?** customer-type picker: + +- **User** — individual user accounts (the default). +- **Team** — disabled when the Teams app is off; the picker links you to the Teams settings. +- **Custom** — external entities you identify with your own IDs. + +Once a customer type is chosen, the editor opens with: + +- A **display name** and an auto-generated **product ID** (kebab-case, editable, must be unique). +- A **pricing section** that starts with a single free price; add as many price variants as you need with the **+ Add price** button. Each price has its own currency dropdown, amount, interval, and free-trial controls. +- An **included items** section. Existing items can be picked from a dropdown filtered to the matching customer type, or you can click **New Item** to launch the item dialog inline (the new item is auto-selected after save). +- A toggle row for **Server only**, **Stackable**, **Add-on**, and product-level **Free trial**. +- A **Product line** selector. If you picked an existing product line from the URL (via "Add product" on the Product Lines page), the field is pre-filled and the customer type is locked to match. +- A **live preview** card on the right that updates as you type, showing exactly how the product will appear in the Product Lines page. + +Saving redirects back to the product line where the product now lives. Errors (duplicate IDs, invalid currency amounts, missing prices) are surfaced inline. + + + The **Duplicate** action on the Product Lines page reuses this editor — the original product is pre-filled with a `" Copy"` suffix on the display name, leaving you to pick a new ID and tweak any details. + + +## Editing a product + +Two entry points lead to edits: + +- **Edit** in the action menu on a product card or row opens the full editor at `/payments/products//edit` — the same UI as the create flow, but pre-populated and saving in place. +- **View Details** on a product card opens a read-only page that lays out every field, price, and included item alongside a small "Used in" panel showing which products reference its items. + +The product-line view also supports lightweight inline edits — clicking on price rows or item rows opens a focused dialog so you don't have to leave the strip. + +## Items + +Items are far simpler than products: they just need an ID, a display name, and a customer type. They're created via: + +- The `+` in the **Items** column header. +- The **New Item** button inside the product editor's "Included items" picker. +- The `Create Item` action button on the [Customers](/guides/apps/payments/customers) page. + +The item dialog enforces unique IDs against existing items. Customer type is locked when the dialog is launched from a context that requires a specific type (e.g. inside a team product, or from the Customers page with the team tab selected). + +Items can be edited (display name only — ID and customer type are immutable) or deleted. Deletion does **not** cascade to products that reference the item; the reference becomes a dangling pointer until you clean it up in the product editor. + +## SDK usage + +Products and items are read from the customer object at runtime. Use them to build pricing pages, unlock features, and show balances. + +### Selling a product + +Generate a checkout URL from the user or team that will own the purchase: + + + + ```typescript title="app/components/purchase-button.tsx" + "use client"; + import { useUser } from "@stackframe/stack"; + + export default function PurchaseButton({ productId }: { productId: string }) { + const user = useUser({ or: "redirect" }); + + const handlePurchase = async () => { + const checkoutUrl = await user.createCheckoutUrl({ + productId, + returnUrl: window.location.href, + }); + window.location.href = checkoutUrl; + }; + + return ; + } + ``` + + + ```typescript title="app/purchase/page.tsx" + import { stackServerApp } from "@/stack/server"; + + export default async function PurchasePage() { + const user = await stackServerApp.getUser({ or: "redirect" }); + + const checkoutUrl = await user.createCheckoutUrl({ + productId: "prod_premium_monthly", + }); + + return Upgrade to Premium; + } + ``` + + + +For team purchases, call `createCheckoutUrl` on the team object: + +```typescript +const team = user.useTeam(teamId); +const checkoutUrl = await team.createCheckoutUrl({ productId: "prod_team_plan" }); +``` + + + If you're using a non-JS backend, call the public purchase URL endpoint directly: `POST /api/v1/payments/purchases/create-purchase-url` with `customer_type`, `customer_id`, and `product_id`. + + +### Listing products + +Use the product list to show what the customer owns and what they can switch to: + +```typescript +// Client component (hook - re-renders on changes) +const products = user.useProducts(); + +// Server component +const products = await user.listProducts(); +``` + +Each product in the list includes: + +- `id` - The product ID (or `null` for inline products) +- `displayName` - The product name +- `quantity` - How many the customer owns (relevant for stackable products) +- `type` - `"one_time"` or `"subscription"` +- `subscription` - Subscription details if applicable +- `switchOptions` - Other products in the same product line the customer could switch to + +### Reading item balances + +Use items to gate metered features like credits, seats, messages, or API calls: + +```typescript +// Client component (hook - re-renders on changes) +const credits = user.useItem("credits"); + +// Server component +const credits = await user.getItem("credits"); +``` + +An item has two quantity fields: + +- `quantity` - The raw balance (can be negative if you've consumed more than granted) +- `nonNegativeQuantity` - `Math.max(0, quantity)` for display purposes + +```typescript title="app/components/credits-widget.tsx" +"use client"; +import { useUser } from "@stackframe/stack"; + +export default function CreditsWidget() { + const user = useUser({ or: "redirect" }); + const credits = user.useItem("credits"); + + return ( +
+

Available Credits

+

{credits.nonNegativeQuantity}

+
+ ); +} +``` + +### Consuming items safely + +When a user spends credits, use `tryDecreaseQuantity` on the server. It is atomic and race-condition-safe: if the balance would go negative, it returns `false` and leaves the balance unchanged. + +```typescript title="lib/credits.ts" +import { stackServerApp } from "@/stack/server"; + +export async function consumeCredits(userId: string, amount: number) { + const user = await stackServerApp.getUser(userId); + if (!user) throw new Error("User not found"); + + const credits = await user.getItem("credits"); + const success = await credits.tryDecreaseQuantity(amount); + + if (!success) { + throw new Error("Insufficient credits"); + } + + return { remaining: credits.quantity }; +} +``` + +You can also increase a balance with `credits.increaseQuantity(amount)` or decrease without the safety check using `credits.decreaseQuantity(amount)`. diff --git a/docs-mintlify/guides/apps/payments/settings.mdx b/docs-mintlify/guides/apps/payments/settings.mdx new file mode 100644 index 0000000000..3e58236457 --- /dev/null +++ b/docs-mintlify/guides/apps/payments/settings.mdx @@ -0,0 +1,126 @@ +--- +title: "Settings" +description: "Connect Stripe, toggle test mode, pick payment methods, and block new purchases" +--- + +The **Settings** page (`Payments -> Settings`) is where Payments is wired up — Stripe connection, test-mode behavior, accepted payment methods, and a kill switch for new purchases. + +## Stripe connection + +The **Stripe Connection** card at the top of the page reflects the current state of the underlying Stripe Connect account. It shows one of three states: + +- **Not connected** *(red)* — no Stripe account is linked yet. A **Connect Stripe** button launches Stripe's hosted onboarding flow. After completing onboarding, you're redirected back to this page. +- **Setup incomplete** *(orange)* — Stripe started onboarding but hasn't finished. The card lists missing capabilities as badges (e.g. **Charge customers**, **Receive payouts**) and offers a **Continue setup** button that resumes the hosted flow. +- **Connected** *(green)* — Stripe is fully set up. The card lists the enabled capabilities as badges (typically **Charges enabled** and **Payouts enabled**). + +The state is read live via `useStripeAccountInfo()`. Reconnecting or onboarding always pushes you out to Stripe's hosted flow; Stack Auth never collects bank or identity information directly. + +## Test mode + +The **Test Mode** card has a single switch. Toggling it takes effect immediately for both the dashboard and SDKs. + +When **Test mode is active**, the card turns blue and surfaces three badges describing the runtime behavior: + +- **No credit card required** +- **Products granted instantly** +- **No Stripe transactions** + +In test mode, every checkout URL short-circuits — the product is granted directly to the customer without a redirect to Stripe and without any real money moving. This is the recommended mode for local development, end-to-end tests, and demos. + + + Test mode is independent of Stripe's own test-mode flag. If you want to exercise the full Stripe checkout flow with test cards instead of bypassing checkout entirely, leave Stack's test mode off and use Stripe's [test card numbers](https://stripe.com/docs/testing) (e.g. `4242 4242 4242 4242`). + + +## Payment methods + +The **Payment Methods** card lets you choose which methods Stripe Checkout offers your customers, organized by category in an expandable accordion: + +- **Cards** — credit and debit cards (Visa, Mastercard, Amex, etc.). +- **Wallets** — Apple Pay, Google Pay, Link, etc. +- **BNPL** — Klarna, Afterpay/Clearpay, Affirm. +- **Realtime** — instant bank transfers like Cash App Pay, WeChat Pay, Alipay. +- **Bank Debits** — ACH, SEPA, Bacs. +- **Bank Transfers** — wire-style transfers. +- **Vouchers** — Konbini, OXXO, Boleto, etc. +- **Other** — any method that doesn't fit the categories above. + +Each category header shows the number of available methods. Inside a category, every method row has its brand logo (or a category-fallback icon), the method name, and a switch. Toggling a switch tags the row as **Modified** until you commit the changes with the **Save Changes** button in the card header (or discard them with **Cancel**). + +Some methods have dependencies — Stack Auth will refuse to save if you enable a method that requires a prerequisite (e.g. Link requires Cards) without also enabling the prerequisite. The dialog explains what's missing and asks you to either enable the prerequisite or disable the dependent method. + + + Many payment methods only appear at checkout for customers in specific regions, currencies, or transaction types — Stripe filters automatically based on the buyer's locale and the price's currency. Toggling a method on doesn't force it to appear; it just allows it. + + +### Platform-managed methods + +Some methods are controlled by Stripe at the platform level and cannot be customized from your project. They show up below the configurable section in a muted **Platform-Managed Methods** card with read-only switches. If your project relies on one of them, you'll see it listed there with its current enabled/disabled state for reference. + +If Stripe hasn't finished onboarding yet, the card is replaced with: *"No payment methods are currently available. Complete Stripe onboarding to enable payment methods."* + +## Checkout controls + +The **Checkout Controls** card has a single toggle — **Block new purchases**. + +When enabled: + +- The card border turns orange to make the state highly visible. +- Every new checkout URL fails fast with a "checkout is paused" error. +- Existing subscriptions keep renewing and existing customers keep their entitlements — only *new* purchases are stopped. + +This is the lever to flip if you need to pause sales during a billing migration, a price change rollout, or a customer-support incident. Subscription cancellations and refunds remain available throughout. + +## SDK usage + +Settings change how customer SDK calls behave: + +- **Test mode** makes `createCheckoutUrl` grant products immediately instead of redirecting to Stripe Checkout. +- **Payment methods** determine which methods Stripe may offer inside Checkout or Stripe Elements. +- **Block new purchases** makes new checkout creation fail fast while leaving existing subscriptions and entitlements alone. + +### Creating checkout URLs + +```typescript +const checkoutUrl = await user.createCheckoutUrl({ + productId: "prod_premium_monthly", + returnUrl: window.location.href, +}); + +window.location.href = checkoutUrl; +``` + +If **Block new purchases** is enabled, this call fails instead of creating a new checkout session. Existing subscriptions can still renew, cancel, or switch depending on the action. + +### Saving payment methods + +Customers can save a default payment method for future purchases and plan switches. This is built on Stripe SetupIntents: + +```typescript +// Create a setup intent +const setupIntent = await user.createPaymentMethodSetupIntent(); +// setupIntent.clientSecret - use with Stripe Elements to collect card details +// setupIntent.stripeAccountId - the connected Stripe account ID + +// After the user completes Stripe's card form: +const paymentMethod = await user.setDefaultPaymentMethodFromSetupIntent( + setupIntentId +); +// paymentMethod contains: id, brand, last4, exp_month, exp_year +``` + +To check whether a customer already has a payment method: + +```typescript +// Client component (hook) +const billing = user.useBilling(); + +// Server component +const billing = await user.getBilling(); + +// billing.hasCustomer - whether a Stripe customer exists +// billing.defaultPaymentMethod - card details or null +``` + + + Switching subscriptions requires the customer to have a default payment method saved. If you offer plan upgrades in-app, make sure you collect a payment method before calling `switchSubscription`. + diff --git a/docs-mintlify/guides/apps/payments/transactions.mdx b/docs-mintlify/guides/apps/payments/transactions.mdx new file mode 100644 index 0000000000..656b108413 --- /dev/null +++ b/docs-mintlify/guides/apps/payments/transactions.mdx @@ -0,0 +1,107 @@ +--- +title: "Transactions" +description: "Audit every payment event and issue refunds" +--- + +The **Transactions** page (`Payments -> Transactions`) is the ledger of every payment event that has happened in your project: purchases, subscription renewals, cancellations, refunds, chargebacks, manual item adjustments, and product switches. It's the place to debug billing issues, reconcile against Stripe, and issue refunds. + +## Table layout + +Transactions are displayed in an infinite-scroll DataGrid. New rows are loaded a page at a time (25 per request) as you scroll. The columns are: + +- **Type** — a small icon button that tooltips the human-readable label on hover. Icons map 1:1 to transaction types: + - **Purchase** — shopping cart + - **Subscription Renewal** — clockwise arrow + - **Subscription Cancellation** — prohibit + - **Chargeback** — counter-clockwise arrow + - **Refund** — receipt-with-X + - **Manual Item Quantity Change** — gear + - **Product Change** — shuffle +- **Customer** — for `user` and `team` customers, a clickable avatar + name that links into the user or team detail page. For `custom`, the type and ID are shown inline. +- **Amount** — formatted as `$` for paid transactions. Test-mode transactions read **Test mode**; non-USD currencies read **Non USD amount**. +- **Details** — a contextual blurb: + - For product grants: `)`. + - For item quantity changes: ` (+)` or `(-)`. + - For refunds: **Product access revoked** if a product was revoked, otherwise **Refund**. + - A small **Refunded** badge is added inline when the transaction has subsequent adjustments. +- **Created** — date/time in the project timezone. +- **Actions** — overflow menu containing the **Refund** action (see below). + +## Filters + +The toolbar above the table has two dropdown filters: + +- **Type** — filter to a specific transaction type (Purchase, Subscription Renewal, Chargeback, Refund, etc.) or **All types**. +- **Customer type** — `User`, `Team`, `Custom`, or **All customers**. + +Both filters reset the cursor and re-fetch from the start. Quick search is hidden on this page because the backend doesn't yet index transactions by free text. + + + Sorting is disabled on the table — transactions always come back in reverse-chronological order. If you need a different sort, page through and load into your own analytics tool. + + +## Refunding a purchase + +Open the row's overflow menu and click **Refund** to open the refund dialog. The action is only enabled on `Purchase` rows that are tied to a subscription or one-time purchase. Refunding individual subscription renewals is not currently exposed from the dashboard. + +The dialog has two fields: + +- **Amount (USD)** — defaults to `0` if the transaction has already been partially refunded, otherwise the original charged amount. You can enter any value from `0` up to the remaining refundable balance. Decimal values are accepted. The field is disabled for transactions with nothing refundable (test mode, non-USD). +- **Subscription** / **Product** — what to do with the underlying entitlement: + - **End now** *(default)* — immediately ends the subscription or revokes the one-time purchase. + - **End at period end** *(subscriptions only)* — let the customer keep access until the end of the current billing period before cancellation. + - **No change** — issue the refund but leave the subscription or product access untouched. + +Inline validation catches: + +- Negative or non-numeric amounts. +- Refund amount greater than the charged amount. +- Refund amount of `0` combined with **No change** — the refund has to do something. +- Refund amount > `0` on a transaction with no money to refund (test mode or non-USD). + +Submitting the dialog sends the refund request and prevents double-submits while the request is in flight. On success the table reloads so the new refund row appears immediately above the original purchase; on failure, an inline error alert appears in the dialog without closing it. Partial refunds and separate revokes are both supported — keep refunding until the backend reports the cap is hit. + + + The refund icon stays available even after a partial refund — the backend computes the remaining refundable balance and rejects requests that exceed it. The original transaction gains a **Refunded** badge once any adjustment is recorded against it. + + +## SDK usage + +The Transactions page itself is an operator view, but most customer-facing billing history should be shown with invoices in your app. + +```typescript +// Client component (hook) +const invoices = user.useInvoices({ limit: 10 }); + +// Server component +const invoices = await user.listInvoices({ limit: 10 }); +``` + +Each invoice includes `createdAt`, `status` (`"draft"`, `"open"`, `"paid"`, `"uncollectible"`, `"void"`), `amountTotal` (in cents), and `hostedInvoiceUrl` for Stripe's hosted invoice page. + +Invoices are paginated: + +```typescript +const firstPage = await user.listInvoices({ limit: 10 }); +const secondPage = await user.listInvoices({ + limit: 10, + cursor: firstPage.nextCursor, +}); +``` + + + Invoices are available for user and team customers only, not custom customers. + + +For subscription lifecycle actions in your app, use the customer SDK instead of directing users to the operator dashboard: + +```typescript +// Cancel for the current user +await stackServerApp.cancelSubscription({ productId: "prod_pro" }); + +// Cancel for a team +await stackServerApp.cancelSubscription({ + productId: "prod_team_plan", + teamId: "team-id", +}); +``` diff --git a/docs-mintlify/guides/apps/rbac/overview.mdx b/docs-mintlify/guides/apps/rbac/overview.mdx index 2060876d35..09def31864 100644 --- a/docs-mintlify/guides/apps/rbac/overview.mdx +++ b/docs-mintlify/guides/apps/rbac/overview.mdx @@ -5,17 +5,86 @@ sidebarTitle: RBAC icon: "/images/app-icons/rbac.svg" --- -Permissions are a way to control what each user can do and access within your application. +Permissions are a way to control what each user can do and access within your application. Stack Auth RBAC lets you define reusable permission IDs in the dashboard, compose them into higher-level roles, assign team permissions to team members, and check permissions from the SDK. ## Permission Types Stack supports two types of permissions: 1. **Team Permissions**: Control what a user can do within a specific team -2. **User Permissions**: Control what a user can do globally, across the entire project +2. **Project Permissions**: Control what a user can do globally, across the entire project Both permission types can be managed from the dashboard, and both support arbitrary nesting. +## Dashboard + +The RBAC app adds two dashboard pages: + +- **Project Permissions** - Global permissions that apply outside of a team context. The dashboard page defines the permissions and their hierarchy. +- **Team Permissions** - Permissions scoped to a team. The dashboard page defines the permissions and their hierarchy, and team-member assignment happens from the Teams app. + +The RBAC pages define permission definitions. Team permission assignment is available from the Teams member table; project permission grants and revokes are done from server-side SDK code. + +### Permission table + +Both pages use the same permission table: + +- **ID** - The permission ID used in SDK calls, such as `access_admin_dashboard` or `team:billing:manage`. +- **Description** - Optional human-readable context for the permission. +- **Contained Permissions** - Directly contained permissions, shown as badges. This column intentionally shows only direct children, not the full recursive expansion. +- **Actions** - Edit and delete actions for custom permissions. + +The table has a **Filter** search box, infinite loading for larger team-permission sets, and URL-synced table state so filtered views can be shared or reloaded. + +### Creating a permission + +Click **Create Permission** from either RBAC page. The dialog contains: + +- **ID** - Required, unique across project and team permission definitions. IDs may contain lowercase letters, numbers, `_`, and `:` only. +- **Description** - Optional text shown in the dashboard table. +- **Contained Permission IDs** - A checklist of permissions of the same type. For example, a team permission can contain other team permissions, and a project permission can contain other project permissions. + +Contained permissions are recursive. If `admin` contains `moderator`, and `moderator` contains `read`, then a user with `admin` also has `read`. + +### Editing a permission + +Use the row action menu and choose **Edit**. The edit dialog keeps the same fields, with one important difference: **ID** is disabled. To rename a permission, create a new permission and migrate your checks/assignments. + +The contained-permissions checklist shows inherited permissions with a `from ` note, so you can tell whether a permission is selected directly or included through another selected permission. + +### Deleting a permission + +Use the row action menu and choose **Delete**. Deleting is destructive and requires confirming: + +```text +I understand this will remove the permission from all users and other permissions that contain it. +``` + +Deleting a permission removes the definition, removes it from users who had it directly, and removes it from other permissions that contained it. + +### System permissions + +Stack comes with predefined team permissions known as system permissions. These IDs start with `$`. + +System permissions: + +- Can be assigned to members +- Can be included inside custom permissions +- Cannot be edited or deleted from the dashboard + +The permission table marks system permissions with an info tooltip, and hides the edit/delete action menu for those rows. + +### Assigning team permissions + +Team permission definitions are created in **RBAC -> Team Permissions**, but assignments happen from the Teams app: + +1. Open **Teams**. +2. Select a team. +3. Open the members table. +4. Use the row action menu for a member and choose **Edit permissions**. + +The member permissions dialog shows the same nested permission checklist. The members table's **Permissions** column shows only direct permissions for each user. If the permission lookup fails, the row shows **Failed to load** and the edit action is disabled until the table is reloaded. + ## Team Permissions Team permissions control what a user can do within each team. You can create and assign permissions to team members from the Stack dashboard. These permissions could include actions like `create_post` or `read_secret_info`, or roles like `admin` or `moderator`. Within your app, you can verify if a user has a specific permission within a team. @@ -24,7 +93,7 @@ Permissions can be nested to create a hierarchical structure. For example, an `a ### Creating a Permission -To create a new permission, navigate to the `Team Permissions` section of the Stack dashboard. You can select the permissions that the new permission will contain. Any permissions included within these selected permissions will also be recursively included. +To create a new permission, navigate to **RBAC -> Team Permissions** in the Stack dashboard. Click **Create Permission**, set the permission ID, optionally add a description, and choose any contained permissions. Any permissions included within these selected permissions will also be recursively included. ### System Permissions @@ -32,7 +101,7 @@ Stack comes with a few predefined team permissions known as system permissions. ### Checking if a User has a Permission -To check whether a user has a specific permission, use the `getPermission` method or the `usePermission` hook on the `User` object. This returns the `Permission` object if the user has it; otherwise, it returns `null`. Always perform permission checks on the server side for business logic, as client-side checks can be bypassed. Here's an example: +To check whether a user has a specific permission within a team, use `hasPermission`, `getPermission`, or the `usePermission` hook on the `User` object. `getPermission` returns the `Permission` object if the user has it; otherwise, it returns `null`. Always perform permission checks on the server side for business logic, as client-side checks can be bypassed. Here's an example: @@ -74,9 +143,27 @@ To check whether a user has a specific permission, use the `getPermission` metho +For authorization logic, prefer a boolean server-side check: + +```tsx title="app/api/team-settings/route.ts" +import { stackServerApp } from "@/stack/server"; + +export async function POST() { + const user = await stackServerApp.getUser({ or: "redirect" }); + const team = await stackServerApp.getTeam("some-team-id"); + + if (!team || !(await user.hasPermission(team, "team:settings:update"))) { + return new Response("Forbidden", { status: 403 }); + } + + // Update team settings here. + return new Response("OK"); +} +``` + ### Listing All Permissions of a User -To get a list of all permissions a user has, use the `listPermissions` method or the `usePermissions` hook on the `User` object. This method retrieves both direct and indirect permissions. Here is an example: +To get a list of all permissions a user has in a team, use the `listPermissions` method or the `usePermissions` hook on the `User` object. By default, the list includes direct and indirect permissions. Pass `{ recursive: false }` if you only want direct assignments. Here is an example: @@ -86,7 +173,8 @@ To get a list of all permissions a user has, use the `listPermissions` method or export function DisplayUserPermissions() { const user = useUser({ or: 'redirect' }); - const permissions = user.usePermissions(); + const team = user.useTeam('some-team-id'); + const permissions = user.usePermissions(team); return (
@@ -104,7 +192,8 @@ To get a list of all permissions a user has, use the `listPermissions` method or export default async function DisplayUserPermissions() { const user = await stackServerApp.getUser({ or: 'redirect' }); - const permissions = await user.listPermissions(); + const team = await stackServerApp.getTeam('some-team-id'); + const permissions = team ? await user.listPermissions(team) : []; return (
@@ -125,6 +214,7 @@ To grant a permission to a user, use the `grantPermission` method on the `Server ```tsx const team = await stackServerApp.getTeam('teamId'); const user = await stackServerApp.getUser(); +if (!team || !user) throw new Error("Team or user not found"); await user.grantPermission(team, 'read'); ``` @@ -135,6 +225,7 @@ To revoke a permission from a user, use the `revokePermission` method on the `Se ```tsx const team = await stackServerApp.getTeam('teamId'); const user = await stackServerApp.getUser(); +if (!team || !user) throw new Error("Team or user not found"); await user.revokePermission(team, 'read'); ``` @@ -144,11 +235,11 @@ Project permissions are global permissions that apply to a user across the entir ### Creating a Project Permission -To create a new project permission, navigate to the `Project Permissions` section of the Stack dashboard. Similar to team permissions, you can select other permissions that the new permission will contain, creating a hierarchical structure. +To create a new project permission, navigate to **RBAC -> Project Permissions** in the Stack dashboard. Similar to team permissions, you can set an ID, add a description, and select other project permissions that the new permission contains. ### Checking if a User has a Project Permission -To check whether a user has a specific project permission, use the `getPermission` method or the `usePermission` hook. Here's an example: +To check whether a user has a specific project permission, use `hasPermission`, `getPermission`, or the `usePermission` hook. Here's an example: @@ -186,9 +277,26 @@ To check whether a user has a specific project permission, use the `getPermissio +For authorization logic, prefer a server-side boolean check: + +```tsx title="app/admin/page.tsx" +import { stackServerApp } from "@/stack/server"; + +export default async function AdminPage() { + const user = await stackServerApp.getUser({ or: "redirect" }); + const canAccessAdmin = await user.hasPermission("access_admin_dashboard"); + + if (!canAccessAdmin) { + return
Access denied
; + } + + return
Admin dashboard
; +} +``` + ### Listing All Project Permissions -To get a list of all global permissions a user has, use the `listPermissions` method or the `usePermissions` hook: +To get a list of all global permissions a user has, use the `listPermissions` method or the `usePermissions` hook. Pass `{ recursive: false }` if you only want direct grants: @@ -198,7 +306,7 @@ To get a list of all global permissions a user has, use the `listPermissions` me export function DisplayGlobalPermissions() { const user = useUser({ or: 'redirect' }); - const permissions = user.usePermissions(); + const permissions = user.usePermissions({ recursive: false }); return (
@@ -216,7 +324,7 @@ To get a list of all global permissions a user has, use the `listPermissions` me export default async function DisplayGlobalPermissions() { const user = await stackServerApp.getUser({ or: 'redirect' }); - const permissions = await user.listPermissions(); + const permissions = await user.listPermissions({ recursive: false }); return (
@@ -236,6 +344,7 @@ To grant a global permission to a user, use the `grantPermission` method: ```tsx const user = await stackServerApp.getUser(); +if (!user) throw new Error("User not found"); await user.grantPermission('access_admin_dashboard'); ``` @@ -245,7 +354,25 @@ To revoke a global permission from a user, use the `revokePermission` method: ```tsx const user = await stackServerApp.getUser(); +if (!user) throw new Error("User not found"); await user.revokePermission('access_admin_dashboard'); ``` -By following these guidelines, you can efficiently manage and verify both team and user permissions within your application. +## Direct vs. inherited permissions + +A permission can be present in two ways: + +- **Direct** - The user was explicitly granted that permission. +- **Inherited** - The user was granted a permission that contains it, directly or recursively. + +The dashboard definition tables show direct containment only. The SDK can return recursive or direct-only lists: + +```tsx +// Includes inherited permissions +const allPermissions = await user.listPermissions(team); + +// Direct assignments only +const directPermissions = await user.listPermissions(team, { recursive: false }); +``` + +For checks like `hasPermission` and `getPermission`, Stack resolves contained permissions recursively so roles work as expected. diff --git a/docs-mintlify/guides/apps/teams/overview.mdx b/docs-mintlify/guides/apps/teams/overview.mdx index b8d23370c9..afdf2f2fa0 100644 --- a/docs-mintlify/guides/apps/teams/overview.mdx +++ b/docs-mintlify/guides/apps/teams/overview.mdx @@ -4,6 +4,424 @@ description: "Manage teams and team members" sidebarTitle: "Overview" --- +Teams provide a structured way to group users and manage their permissions. Users can belong to multiple teams simultaneously, allowing them to represent departments, B2B customers, organizations, or projects. + +The server can perform all operations on a team. Client-side team and membership actions are gated by team permissions, so users can only perform the actions they are allowed to perform. + +## Dashboard + +The Teams app adds two dashboard pages: + +- **Teams** - List, create, edit, inspect, and delete teams. +- **Team Settings** - Configure how teams are created and which permissions are granted by default. + +### Teams page + +The **Teams** page shows: + +- **Create Team** button - Opens a dialog with a required **Display Name** field. +- **KPI cards** - New active teams, daily active teams, returning team rate, and total teams. +- **Teams table** - Infinite-scroll table with URL-synced state and a default sort by newest teams first. + +The teams table includes: + +- **ID** - Monospace team ID. +- **Display Name** - Team display name. +- **Created At** - Creation timestamp. +- **Actions** - Row actions for team-specific workflows. + +If no teams exist yet, the page may show an alert pointing project owners to project-level team settings if they were actually trying to invite someone to the project owner team. + +### Team row actions + +Each team row has an action menu: + +- **View Members** - Opens the team detail page. +- **Edit** - Opens the **Edit Team** dialog. The dialog shows the immutable team ID and lets you update **Display Name**. +- **Create Checkout** - Opens a product selector for team-scoped payment products, creates a temporary checkout URL, and shows it in a dialog. The URL expires after 24 hours. +- **Delete** - Opens a destructive confirmation dialog. The confirmation text is: + +```text +I understand that this action cannot be undone and all the team members will be also removed from the team. +``` + +Deleting a team removes its memberships as well. + + + **Create Checkout** only works with products whose customer type is `team`. If the selected product does not exist, has the wrong customer type, is already granted, or the team customer cannot be found, the dashboard shows an error toast. + + +### Team detail page + +Click a team row or **View Members** to open the team detail page. The header shows: + +- Team avatar +- Editable display name +- Team creation date + +The page has category tabs. Some tabs only appear when the related app is installed: + +- **Members** - Always available when Teams is enabled. +- **Payments** - Appears when Payments is installed. +- **Analytics** - Appears when Analytics is installed. +- **Metadata** - Always available. +- **Install apps** - Shortcut to the Apps page if you want to add related apps. + +The selected tab is synced to the URL using the `tab` query parameter. + +### Members tab + +The **Members** tab shows a team member table and an **Add a user** action. + +The **Add a user** dialog has two paths: + +- **Invite a new user** - Enter an email address and click **Invite**. The project must have at least one configured non-wildcard domain so the dashboard can build the team invitation callback URL. After a successful invite, the button changes to **Invited!**. +- **Add an existing user** - Search existing users and click **Add**. Users who are already members show **Added** and cannot be added again. + +The member table includes: + +- **User** - Avatar and display name, linking to the user detail page. +- **Email** +- **User ID** - Shortened ID with a copy-to-clipboard button. +- **Email Verified** - Hidden by default, available as a column. +- **Last active** - Default sort column, newest first. +- **Permissions** - Direct team permissions only, shown as badges. +- **Actions** - **Edit permissions** and **Remove from team**. + +**Edit permissions** opens a nested permission checklist built from the project's team permission definitions. It grants checked permissions and revokes unchecked permissions on save. The table shows only direct permissions; inherited permissions still apply at runtime through RBAC. If the permission fetch fails for a row, the table shows **Failed to load** and disables **Edit permissions** until the table is reloaded. + +**Remove from team** opens a destructive confirmation dialog: + +```text +I understand this will cause the user to lose access to the team. +``` + +### Payments tab + +The **Payments** tab summarizes team-scoped billing when the Payments app is installed. It shows: + +- **Active subscriptions** +- **Products owned** +- **Lifetime spend** or **Recent spend** if the transaction window is truncated +- **Products & subscriptions** table with product, type, quantity, and granted date +- **Transaction history** table with type, detail, date, amount, and status +- **Item balances** for team-scoped payment items + +Metrics are computed from recent team transactions. If the team has more than the dashboard's transaction cap, the page shows that older history is excluded from the metric cards. + +### Analytics tab + +The **Analytics** tab summarizes activity for the team's members when Analytics is installed. It scopes activity by member user IDs and shows: + +- **Members** +- **Active (7d)** +- **Active (30d)** +- **Total events** +- **Active users by hour of week** heatmap +- **Daily activity** chart +- **Top contributors** table + +If the team has no members or no activity, the charts show empty states. If analytics queries fail, the tab shows an **Analytics unavailable** card. + +### Metadata tab + +The **Metadata** tab lets you inspect and edit team metadata: + +- **Client metadata** - Readable and writable from client and server SDKs. +- **Client read-only metadata** - Readable from client and server SDKs, writable from the server and dashboard. +- **Server metadata** - Server-only metadata, editable from the dashboard. + +Use metadata for application-specific team data that belongs on the team object, such as billing identifiers, onboarding state, or workspace settings. + +### Team Settings page + +The **Team Settings** page controls team creation and default permissions. + +#### Team Creation + +The **Team Creation** card has two switches with deferred save/discard controls: + +- **Client Team Creation** - Controls whether users can create teams from account settings and the team switcher. This must be enabled before client-side `user.createTeam()` can be used. +- **Personal Team on Sign-up** - Creates a personal team for each new user at sign-up. This does not affect existing users. + +Changes are marked as modified until you click **Save** or **Discard**. + +#### Default permissions + +Two cards control permissions that are automatically granted: + +- **Team Creator Default Permissions** - Granted to the user who creates a team. +- **Team Member Default Permissions** - Granted to users when they join a team. + +Each card shows the selected permissions as badges, or **No default permissions set**. Click **Edit** to open a nested permission checklist. Inherited permissions are labeled with `from ` so you can see when a selected permission includes another permission indirectly. + +Default permissions are based on the team permission definitions from [RBAC](/guides/apps/rbac/overview). + +## Concepts + +### Team permissions + +If you attempt to perform an action without the necessary team permissions, the function will throw an error. Always check if the user has the required permission before performing an action. Learn more about permissions [here](../rbac/overview). + +Here is an example of how to check if a user has a specific permission on the client: + +```tsx +const user = useUser({ or: "redirect" }); +const team = user.useTeam("some-team-id"); + +if (!team) { + return
Team not found
; +} + +const hasPermission = user.usePermission(team, "$invite_members"); + +if (!hasPermission) { + return
No permission
; +} + +// Perform corresponding action like inviting a user +``` + +Always enforce authorization on the server for business logic: + +```tsx +const user = await stackServerApp.getUser({ or: "redirect" }); +const team = await user.getTeam("some-team-id"); + +if (!team || !(await user.hasPermission(team, "$invite_members"))) { + return new Response("Forbidden", { status: 403 }); +} +``` + +### Team profile + +A user can have a different profile for each team they belong to. This is separate from the user's personal profile. The team profile contains information like `displayName` and `profileImageUrl`. The team profile can be left empty and will automatically fall back to the user's personal profile information. + +The team profile is visible to other users in the team that have the `$read_members` permission. + +## Retrieving a user's teams + +You can list all teams a user belongs to using `listTeams` or `useTeams`, or fetch a specific team with `getTeam` or `useTeam`. These functions work on both clients and servers. + + + + ```tsx + "use client"; + import { useUser } from "@stackframe/stack"; + + export function TeamList() { + const user = useUser({ or: "redirect" }); + const allTeams = user.useTeams(); + const someTeam = user.useTeam("some-team-id"); + + return ( + <> +
+ {allTeams.map(team => ( +
{team.displayName}
+ ))} +
+
+ {someTeam ? someTeam.displayName : "Not a member of this team"} +
+ + ); + } + ``` +
+ + ```tsx + import { stackServerApp } from "@/stack/server"; + + export default async function TeamList() { + const user = await stackServerApp.getUser({ or: "redirect" }); + const allTeams = await user.listTeams(); + const someTeam = await user.getTeam("some-team-id"); + + return ( + <> +
+ {allTeams.map(team => ( +
{team.displayName}
+ ))} +
+
+ {someTeam ? someTeam.displayName : "Not a member of this team"} +
+ + ); + } + ``` +
+
+ +## Creating a team + +To create a team for the current user, use `createTeam` on the `User` object. The user is added to the team with the default team creator permissions configured in **Team Settings**. + +On the client, this requires **Client Team Creation** to be enabled in **Team Settings**. + +```tsx +const team = await user.createTeam({ + displayName: "New Team", +}); +``` + +To create a team on the server without adding a specific user, use `createTeam` on the server app: + +```tsx +const team = await stackServerApp.createTeam({ + displayName: "New Team", +}); +``` + +## Updating a team + +You can update a team with `update` on the `Team` object. On the client, the user must have the `$update_team` permission. + +```tsx +await team.update({ + displayName: "New Name", +}); +``` + +## Custom team metadata + +You can store custom metadata on a team object, similar to the user object. The metadata can be any JSON object. + +- `clientMetadata`: Can be read and updated on both the client and server sides. +- `serverMetadata`: Can only be read and updated on the server side. +- `clientReadOnlyMetadata`: Can be read on both the client and server sides, but can only be updated on the server side. + +```tsx +await team.update({ + clientMetadata: { + customField: "value", + }, +}); + +console.log(team.clientMetadata.customField); // "value" +``` + +## Listing users in a team + +You can list all users in a team with `listUsers` or `useUsers` on the `Team` object. To show a user's team profile, read `user.teamProfile`. + +On the client, the current user must have the `$read_members` permission. + + + + ```tsx + // ... retrieve the team and ensure user has the necessary permissions + const users = team.useUsers(); + + return ( +
+ {users.map(user => ( +
{user.teamProfile.displayName}
+ ))} +
+ ); + ``` +
+ + ```tsx + // ... retrieve the team + const users = await team.listUsers(); + + return ( +
+ {users.map(user => ( +
{user.teamProfile.displayName}
+ ))} +
+ ); + ``` +
+
+ +## Current user's team profile + +You can get the current user's team profile with `getTeamProfile` or `useTeamProfile` on the `User` object. + + + + ```tsx + const teamProfile = user.useTeamProfile(team); + ``` + + + ```tsx + const teamProfile = await user.getTeamProfile(team); + ``` + + + +## Inviting users + +You can invite a user to a team with `inviteUser` on the `Team` object. The user receives an email with a link to join the team. + +On the client, the current user must have the `$invite_members` permission. + +```tsx +await team.inviteUser({ + email: "person@example.com", +}); +``` + +If you need to specify where the invitation returns after acceptance, pass an object with `email` and `callbackUrl`: + +```tsx +await team.inviteUser({ + email: "person@example.com", + callbackUrl: "https://example.com/team-invitation", +}); +``` + +## Adding a user directly + +If you want to add a user to a team without sending an email, use `addUser` on the server-side `Team` object. + +```tsx +await team.addUser(user.id); +``` + +New members receive the default team member permissions configured in **Team Settings**. + +## Removing a user from a team + +You can remove a user from a team with `removeUser` on the `Team` object. On the client, the current user must have the `$remove_members` permission. + +```tsx +await team.removeUser(user.id); +``` + +## Leaving a team + +All users can leave a team without any permissions required. + +```tsx +const team = await user.getTeam("some-team-id"); +if (!team) throw new Error("Team not found"); + +await user.leaveTeam(team); +``` + +## Deleting a team + +You can delete a team with `delete` on the `Team` object. On the client, the current user must have the `$delete_team` permission. + +```tsx +await team.delete(); +``` + +Deleting a team removes all memberships for that team. Make sure your app treats deleted team IDs as invalid and refreshes any team switchers after deletion. +--- +title: "Teams" +description: "Manage teams and team members" +sidebarTitle: "Overview" +--- + Teams provide a structured way to group users and manage their permissions. Users can belong to multiple teams simultaneously, allowing them to represent departments, B2B customers, or projects. The server can perform all operations on a team, but the client can only carry out some actions if the user has the necessary permissions. This applies to all actions that can be performed on a server/client-side `User` object and a `Team` object. diff --git a/docs-mintlify/guides/apps/teams/team-selection.mdx b/docs-mintlify/guides/apps/teams/team-selection.mdx index e77d17582c..129fd279e1 100644 --- a/docs-mintlify/guides/apps/teams/team-selection.mdx +++ b/docs-mintlify/guides/apps/teams/team-selection.mdx @@ -25,32 +25,36 @@ To facilitate team selection, Stack provides a component that looks like this: TeamSwitcher -You can import and use the `SelectedTeamSwitcher` component for the "current team" method. It updates the `selectedTeam` when a user selects a team: +You can import and use the `SelectedTeamSwitcher` component for the "current team" method. Pass the user's current `selectedTeam` so the switcher shows the saved selection on first render. When a user selects a different team, the switcher calls `user.setSelectedTeam()`: -```jsx -import { SelectedTeamSwitcher } from "@stackframe/stack"; +```tsx +"use client"; + +import { SelectedTeamSwitcher, useUser } from "@stackframe/stack"; export function MyPage() { + const user = useUser({ or: "redirect" }); + return (
- +
); } ``` -To combine the switcher with the deep link method, you can pass in `urlMap` and `selectedTeam`. The `urlMap` is a function to generate a URL based on the team information, and `selectedTeam` is the team that the user is currently working on. This lets you implement "deep link" + "most recent team". The component will update the `user.selectedTeam` with the `selectedTeam` prop: +To combine the switcher with the deep link method, pass both `urlMap` and `selectedTeam`. The `urlMap` function generates a URL for each team, and `selectedTeam` is the team for the current page. This implements "deep link" + "most recent team": selecting a team navigates to that team's URL, and the component updates `user.selectedTeam` unless you disable that behavior. -```jsx +```tsx `/team/${team.id}`} selectedTeam={team} /> ``` -To implement the "deep link" + "default team" method, where you update the `selectedTeam` only when the user clicks "set to default team" or similar, pass `noUpdateSelectedTeam`: +To implement the "deep link" + "default team" method, where changing pages should not update the user's saved selected team, pass `noUpdateSelectedTeam`: -```jsx +```tsx `/team/${team.id}`} selectedTeam={team} @@ -58,17 +62,26 @@ To implement the "deep link" + "default team" method, where you update the `sele /> ``` +Other useful options: + +- `allowNull` - Allows the user to select "no team". +- `nullLabel` - Custom label for the null option. +- `onChange` - Called whenever a team is selected. +- `triggerClassName` - Adds classes to the switcher trigger. + ## Example: Deep Link + Most Recent Team -First, create a page at `/app/team/[teamId]/page.tsx` to display information about a specific team: +First, create a client page at `app/team/[teamId]/page.tsx` to display information about a specific team: -```jsx +```tsx "use client"; import { useUser, SelectedTeamSwitcher } from "@stackframe/stack"; +import { useParams } from "next/navigation"; -export default function TeamPage({ params }: { params: { teamId: string } }) { - const user = useUser({ or: 'redirect' }); +export default function TeamPage() { + const user = useUser({ or: "redirect" }); + const params = useParams<{ teamId: string }>(); const team = user.useTeam(params.teamId); if (!team) { @@ -89,26 +102,27 @@ export default function TeamPage({ params }: { params: { teamId: string } }) { } ``` -Next, create a page to display all teams at `/app/team/page.tsx`: +Next, create a page to display all teams at `app/team/page.tsx`: -```jsx +```tsx "use client"; import { useRouter } from "next/navigation"; import { useUser } from "@stackframe/stack"; export default function TeamsPage() { - const user = useUser({ or: 'redirect' }); + const user = useUser({ or: "redirect" }); const teams = user.useTeams(); const router = useRouter(); const selectedTeam = user.selectedTeam; return (
- {selectedTeam && + {selectedTeam && ( } + + )}

All Teams

{teams.map(team => ( @@ -122,3 +136,30 @@ export default function TeamsPage() { ``` Now, if you navigate to `http://localhost:3000/team`, you should be able to see and interact with the teams. + +## Example: Allow Personal Scope + +Some apps let users work either personally or inside a team. Use `allowNull` to add a null option: + +```tsx +"use client"; + +import { SelectedTeamSwitcher, useUser } from "@stackframe/stack"; + +export function ScopeSwitcher() { + const user = useUser({ or: "redirect" }); + + return ( + { + console.log(team ? `Selected ${team.id}` : "Selected personal scope"); + }} + /> + ); +} +``` + +When `allowNull` is enabled, the `onChange` callback can receive `null`. If `noUpdateSelectedTeam` is not set, selecting a real team updates `user.selectedTeam`; selecting the null option clears it. diff --git a/docs-mintlify/guides/apps/webhooks/overview.mdx b/docs-mintlify/guides/apps/webhooks/overview.mdx index 4a61ff22b7..b04d144aac 100644 --- a/docs-mintlify/guides/apps/webhooks/overview.mdx +++ b/docs-mintlify/guides/apps/webhooks/overview.mdx @@ -6,7 +6,7 @@ icon: "/images/app-icons/webhooks.svg" Webhooks are a powerful way to keep your backend in sync with Stack. They allow you to receive real-time updates when events occur in your Stack project, such as when a user or team is created, updated, or deleted. -For payload schemas and each webhook event, see the [webhook API reference](/api/webhooks/users/usercreated). +For payload schemas and generated webhook event references, see the [webhook API reference](/api/webhooks/users/usercreated). ## Setting up webhooks @@ -18,11 +18,94 @@ In the Stack dashboard, you can create a webhook endpoint in the "Webhooks" sect "data": { "id": "2209422a-eef7-4668-967d-be79409972c5", "display_name": "My Team", - ... + "profile_image_url": null, + "client_metadata": {}, + "client_read_only_metadata": {}, + "server_metadata": {}, + "created_at_millis": 1715360400000 } } ``` +## Dashboard + +The **Webhooks** dashboard page embeds the endpoint manager where you create endpoints, send test events, inspect delivery history, and copy the verification secret for each endpoint. + + + Webhooks are unavailable in preview mode. Preview deployments show an informational alert instead of the endpoint manager. + + +### Endpoints list + +The main Webhooks page shows your webhook endpoints. Each endpoint includes: + +- **Endpoint URL** - The URL Stack sends events to. +- **Description** - Optional label for your own reference. +- **Status** - **Active** or **Disabled**. +- **Actions** - View details, test, edit, or delete the endpoint. + +Use this list to find existing endpoints, open endpoint details, or start endpoint actions. + +### Creating an endpoint + +Click **Add new endpoint** to open the endpoint creation dialog. The dialog includes: + +- **URL** - Required, must be a valid URL. +- **Description** - Optional, useful for labeling environments like `Production`, `Staging`, or `Local tunnel`. + +The dialog warns you to use only URLs you control because webhook payloads can contain sensitive user and team data. If the URL starts with `http://`, the dashboard shows an additional warning: + +```text +Using HTTP endpoints is insecure. This can expose your user data to attackers. Only use HTTP endpoints in development environments. +``` + +After creation, the dashboard shows a success state with the endpoint URL and description, plus a **Test endpoint** button so you can immediately send a test event. + +### Testing an endpoint + +Endpoint rows have a **Test** action, and newly-created endpoints can be tested from the success state. The test dialog sends a `stack.test` event to the selected endpoint and shows the sample payload before sending: + +```json +{ + "type": "stack.test", + "data": { + "message": "Stack webhook test event triggered from the Stack dashboard.", + "endpointUrl": "https://example.com/api/webhooks/stack" + } +} +``` + +Click **Send test event** to send it. On success, the dashboard confirms that the event was sent and the endpoint is ready. On failure, it shows the delivery error returned by the webhook service. + +### Editing and deleting endpoints + +The endpoint action menu includes: + +- **View Details** - Opens the endpoint detail page. +- **Test** - Opens the test-event dialog. +- **Edit** - Lets you update the endpoint description. The endpoint URL is not edited in this dialog. +- **Delete** - Opens a destructive confirmation dialog. Deleted endpoints stop receiving events. + +### Endpoint details + +The endpoint detail page shows: + +- **URL** - The endpoint URL. +- **Description** - The saved description, or `-` when empty. +- **Verification Secret** - The endpoint signing secret, with a copy button. + +Use the verification secret as `STACK_WEBHOOK_SECRET` (or your own secret environment variable name) in your webhook receiver. Do not expose it to the browser. + +### Event history + +The endpoint detail page also shows recent message attempts for that endpoint with: + +- **ID** - Message attempt ID. +- **Status** - **Success**, **Pending**, **Fail**, **Sending**, or **Unknown**. +- **Timestamp** - When the attempt was created. + +Use the event history to confirm whether deliveries succeeded, are pending, or failed. If no events have been sent yet, the dashboard shows **No events sent yet.** + ## Testing webhooks locally You can use services like [Svix Playground](https://www.svix.com/play/) or [Webhook.site](https://webhook.site/) to test the receiving of webhooks or relay them to your local development environment. @@ -184,10 +267,13 @@ def stack_webhook(request): ``` ```python FastAPI +import os + from fastapi import FastAPI, Request from svix.webhooks import Webhook app = FastAPI() +STACK_WEBHOOK_SECRET = os.environ["STACK_WEBHOOK_SECRET"] @app.post("/api/webhooks/stack") @@ -207,10 +293,13 @@ async def stack_webhook(request: Request): ``` ```python Flask +import os + from flask import Flask, jsonify, request from svix.webhooks import Webhook app = Flask(__name__) +STACK_WEBHOOK_SECRET = os.environ["STACK_WEBHOOK_SECRET"] @app.post("/api/webhooks/stack") @@ -233,11 +322,13 @@ If you do not want to install the Svix client library or are using a language th ## Event types -These are the `type` values you may receive. Each links to its API reference page. +These are the `type` values you may receive. Linked events include generated API reference pages with their payload schemas. - [`user.created`](/api/webhooks/users/usercreated) - [`user.updated`](/api/webhooks/users/userupdated) - [`user.deleted`](/api/webhooks/users/userdeleted) +- `project_permission.created` - Triggered when a project-level permission is granted to a user. The payload includes `id` and `user_id`. +- `project_permission.deleted` - Triggered when a project-level permission is revoked from a user. The payload includes `id` and `user_id`. - [`team.created`](/api/webhooks/teams/teamcreated) - [`team.updated`](/api/webhooks/teams/teamupdated) - [`team.deleted`](/api/webhooks/teams/teamdeleted) @@ -248,4 +339,4 @@ These are the `type` values you may receive. Each links to its API reference pag ## Examples -Some members of the community have shared their webhook implementations. For example, [here is an example by Clark Gredona](https://gist.github.com/clarkg/56ffad44949826ae3efe0a431b6021c4) that validates the Webhook schema and update a database user. +Some members of the community have shared their webhook implementations. For example, [here is an example by Clark Gredona](https://gist.github.com/clarkg/56ffad44949826ae3efe0a431b6021c4) that validates the webhook schema and updates a database user. diff --git a/docs-mintlify/snippets/docs-apps-home-grid.jsx b/docs-mintlify/snippets/docs-apps-home-grid.jsx index 2339423c91..7ae4e8bc28 100644 --- a/docs-mintlify/snippets/docs-apps-home-grid.jsx +++ b/docs-mintlify/snippets/docs-apps-home-grid.jsx @@ -21,7 +21,7 @@ export const DocsAppsHomeGrid = () => { { name: "Payments", href: "/guides/apps/payments/overview", iconSrc: "/images/app-icons/payments.svg" }, { name: "Analytics", href: "/guides/apps/analytics/overview", iconSrc: "/images/app-icons/analytics.svg" }, { name: "Teams", href: "/guides/apps/teams/overview", iconSrc: "/images/app-icons/teams.svg" }, - { name: "Fraud Protection", href: "/guides/apps/fraud-protection/overview", iconSrc: "/images/app-icons/fraud-protection.svg" }, + { name: "Fraud Protection", href: "/guides/apps/authentication/fraud-protection", iconSrc: "/images/app-icons/fraud-protection.svg" }, { name: "RBAC", href: "/guides/apps/rbac/overview", iconSrc: "/images/app-icons/rbac.svg" }, { name: "API Keys", href: "/guides/apps/api-keys/overview", iconSrc: "/images/app-icons/api-keys.svg" }, { name: "Data Vault", href: "/guides/apps/data-vault/overview", iconSrc: "/images/app-icons/data-vault.svg" },