mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Merge b7a1d2cc25 into 7ed89a2a9c
This commit is contained in:
commit
0ac2fb8f47
@ -137,7 +137,7 @@ export default function PageClient({ storeId }: PageClientProps) {
|
||||
|
||||
const serverExample = deindent`
|
||||
// In your .env file or environment variables:
|
||||
// STACK_DATA_VAULT_SECRET=insert-a-randomly-generated-secret-here
|
||||
// HEXCLAVE_DATA_VAULT_SECRET=insert-a-randomly-generated-secret-here
|
||||
|
||||
const store = await hexclaveServerApp.getDataVaultStore(${JSON.stringify(storeId)});
|
||||
|
||||
@ -146,12 +146,12 @@ export default function PageClient({ storeId }: PageClientProps) {
|
||||
|
||||
// Get a value for a specific key
|
||||
const value = await store.getValue(key, {
|
||||
secret: process.env.STACK_DATA_VAULT_SECRET,
|
||||
secret: process.env.HEXCLAVE_DATA_VAULT_SECRET,
|
||||
});
|
||||
|
||||
// Set a value for a specific key
|
||||
await store.setValue(key, "my-value", {
|
||||
secret: process.env.STACK_DATA_VAULT_SECRET,
|
||||
secret: process.env.HEXCLAVE_DATA_VAULT_SECRET,
|
||||
});
|
||||
`;
|
||||
|
||||
|
||||
@ -77,6 +77,7 @@
|
||||
"pages": [
|
||||
"guides/going-further/cli",
|
||||
"guides/going-further/local-vs-cloud-dashboard",
|
||||
"guides/going-further/hosted-vs-handler",
|
||||
"guides/going-further/hexclave-config"
|
||||
]
|
||||
},
|
||||
@ -93,6 +94,7 @@
|
||||
"guides/apps/authentication/connected-accounts",
|
||||
"guides/apps/authentication/jwts",
|
||||
"guides/apps/authentication/sign-up-rules",
|
||||
"guides/apps/authentication/fraud-protection",
|
||||
"guides/apps/authentication/cli-authentication",
|
||||
{
|
||||
"group": "All Auth Providers",
|
||||
@ -107,18 +109,50 @@
|
||||
"guides/apps/authentication/auth-providers/google",
|
||||
"guides/apps/authentication/auth-providers/linkedin",
|
||||
"guides/apps/authentication/auth-providers/microsoft",
|
||||
"guides/apps/authentication/auth-providers/passkey",
|
||||
"guides/apps/authentication/auth-providers/spotify",
|
||||
"guides/apps/authentication/auth-providers/twitch",
|
||||
"guides/apps/authentication/auth-providers/x-twitter",
|
||||
"guides/apps/authentication/auth-providers/passkey",
|
||||
"guides/apps/authentication/auth-providers/two-factor-auth",
|
||||
"guides/apps/authentication/auth-providers/x-twitter"
|
||||
"guides/apps/authentication/auth-providers/custom-oidc"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"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/guide",
|
||||
"guides/apps/emails/templates-and-themes",
|
||||
"guides/apps/emails/drafts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Payments",
|
||||
"icon": "/images/app-icons/payments.svg",
|
||||
"pages": [
|
||||
"guides/apps/payments/overview",
|
||||
"guides/apps/payments/setup",
|
||||
"guides/apps/payments/products-and-pricing",
|
||||
"guides/apps/payments/items-and-entitlements",
|
||||
"guides/apps/payments/checkout",
|
||||
"guides/apps/payments/subscriptions",
|
||||
"guides/apps/payments/billing-and-invoices",
|
||||
"guides/apps/payments/granting-products",
|
||||
"guides/apps/payments/customers"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Analytics",
|
||||
"icon": "/images/app-icons/analytics.svg",
|
||||
"pages": [
|
||||
"guides/apps/analytics/overview",
|
||||
"guides/apps/analytics/queries-and-tables",
|
||||
"guides/apps/analytics/replays-and-clickmaps"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Teams",
|
||||
"icon": "/images/app-icons/teams.svg",
|
||||
@ -127,7 +161,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",
|
||||
@ -244,6 +277,74 @@
|
||||
"customScripts": ["/apps-sidebar-filter.js", "/code-language-labels.js"]
|
||||
},
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/sdk/objects/stack-app",
|
||||
"destination": "/sdk/objects/hexclave-app"
|
||||
},
|
||||
{
|
||||
"source": "/sdk/hooks/use-stack-app",
|
||||
"destination": "/sdk/hooks/use-hexclave-app"
|
||||
},
|
||||
{
|
||||
"source": "/rest-api/overview",
|
||||
"destination": "/api/overview"
|
||||
},
|
||||
{
|
||||
"source": "/getting-started/setup",
|
||||
"destination": "/guides/getting-started/setup"
|
||||
},
|
||||
{
|
||||
"source": "/docs/getting-started/setup",
|
||||
"destination": "/guides/getting-started/setup"
|
||||
},
|
||||
{
|
||||
"source": "/docs/next/getting-started/setup",
|
||||
"destination": "/guides/getting-started/setup"
|
||||
},
|
||||
{
|
||||
"source": "/docs/sdk",
|
||||
"destination": "/sdk/overview"
|
||||
},
|
||||
{
|
||||
"source": "/docs/apps/analytics",
|
||||
"destination": "/guides/apps/analytics/overview"
|
||||
},
|
||||
{
|
||||
"source": "/docs/apps/api-keys",
|
||||
"destination": "/guides/apps/api-keys/overview"
|
||||
},
|
||||
{
|
||||
"source": "/docs/others/convex",
|
||||
"destination": "/guides/integrations/convex/overview"
|
||||
},
|
||||
{
|
||||
"source": "/docs/concepts/teams",
|
||||
"destination": "/guides/apps/teams/overview"
|
||||
},
|
||||
{
|
||||
"source": "/docs/concepts/custom-user-data",
|
||||
"destination": "/guides/getting-started/user-fundamentals#custom-metadata"
|
||||
},
|
||||
{
|
||||
"source": "/guides/going-further/user-metadata",
|
||||
"destination": "/guides/getting-started/user-fundamentals#custom-metadata"
|
||||
},
|
||||
{
|
||||
"source": "/others/js-client",
|
||||
"destination": "/sdk/objects/hexclave-app"
|
||||
},
|
||||
{
|
||||
"source": "/guides/going-further/stack-app",
|
||||
"destination": "/sdk/objects/hexclave-app"
|
||||
},
|
||||
{
|
||||
"source": "/guides/apps/fraud-protection/overview",
|
||||
"destination": "/guides/apps/authentication/fraud-protection"
|
||||
},
|
||||
{
|
||||
"source": "/guides/apps/payments/guide",
|
||||
"destination": "/guides/apps/payments/setup"
|
||||
},
|
||||
{
|
||||
"source": "/guides/going-further/backend-integration",
|
||||
"destination": "/guides/going-further/local-vs-cloud-dashboard"
|
||||
|
||||
@ -1,28 +1,32 @@
|
||||
---
|
||||
title: "Analytics"
|
||||
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 Hexclave. You can inspect raw event tables, run ClickHouse SQL queries, and watch session replays to debug real user behavior.
|
||||
|
||||
## Overview
|
||||
|
||||
Analytics is organized into three areas in the dashboard:
|
||||
Analytics is organized into four areas in the dashboard:
|
||||
|
||||
- **Tables**: Browse event rows with sorting, search, and incremental loading
|
||||
- **Tables**: Browse rows across tables (events, users, contact channels, and more) 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
|
||||
- **Clickmaps**: See which elements get clicks on your pages
|
||||
|
||||
See [Replays & Clickmaps](./replays-and-clickmaps) for the full guide to session replays and clickmaps.
|
||||
|
||||
## How Analytics Works
|
||||
|
||||
Stack records analytics events and replay chunks, then exposes them through the Analytics app for read-only querying and investigation.
|
||||
Hexclave 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 Hexclave event ingestion, which stores data in ClickHouse. This data powers the Tables view, the SQL query runner, and the Session replay UI.
|
||||
|
||||
Analytics isn't only client events. Hexclave also exposes product data from the rest of your project — users, contact channels, teams, permissions, email outbox, and more — in the same read-only ClickHouse dataset, so you can join product state with behavior in one place. See [Queries & Tables](./queries-and-tables) for the full table list.
|
||||
|
||||
### What Gets Tracked
|
||||
|
||||
Stack collects both client-side and server-side analytics events:
|
||||
Hexclave 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`
|
||||
@ -39,20 +43,22 @@ To use analytics in your project:
|
||||
## Quick Start
|
||||
|
||||
1. Enable Analytics in your Hexclave dashboard (**Apps -> Analytics**)
|
||||
2. Initialize Hexclave on your frontend with `HexclaveClientApp`/`HexclaveProvider`
|
||||
2. Initialize Hexclave on your frontend with `HexclaveClientApp`/`HexclaveProvider` and a persistent `tokenStore` (e.g. `"cookie"`, or `"nextjs-cookie"` in Next.js)
|
||||
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.
|
||||
SDK analytics capture and session replay recording are **on by default** once the Analytics app is enabled — you do not need to set `analytics.enabled` or `analytics.replays.enabled` unless you want to opt out (pass `analytics: { enabled: false }` or `analytics: { replays: { enabled: false } }`).
|
||||
|
||||
If you want replay recordings, also enable `analytics.replays.enabled` in your client app config.
|
||||
After setup, Hexclave automatically captures client-side `$page-view` and `$click` events.
|
||||
|
||||
See [Replays & Clickmaps](./replays-and-clickmaps) to tune replay privacy or opt out.
|
||||
|
||||
## Tables
|
||||
|
||||
The **Tables** screen is the fastest way to inspect recent analytics records.
|
||||
|
||||
- Currently shows the `events` table
|
||||
- Opens on `events` by default; pick other tables from the sidebar
|
||||
- Built-in ordering and client-side search
|
||||
- Relative/absolute timestamp display toggle
|
||||
- Row detail dialog for inspecting full JSON payloads
|
||||
@ -81,9 +87,9 @@ The **Replays** screen helps you move from "an event happened" to "what the user
|
||||
|
||||
Use replays when metrics alone are not enough to explain user behavior.
|
||||
|
||||
### Enabling Replay Recording in the SDK
|
||||
### 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.
|
||||
Session replay recording is **enabled by default** once Analytics is on and your client app uses a persistent token store. You can tune privacy or opt out via the `analytics.replays` options:
|
||||
|
||||
```ts
|
||||
import { HexclaveClientApp } from "@hexclave/js";
|
||||
@ -93,6 +99,7 @@ export const hexclaveClientApp = new HexclaveClientApp({
|
||||
tokenStore: "cookie", // use "nextjs-cookie" in Next.js
|
||||
analytics: {
|
||||
replays: {
|
||||
// Optional. Defaults to true when the Analytics app is enabled; set to false to opt out.
|
||||
enabled: true,
|
||||
// Optional. Defaults to true.
|
||||
maskAllInputs: true,
|
||||
@ -101,7 +108,7 @@ export const hexclaveClientApp = new HexclaveClientApp({
|
||||
});
|
||||
```
|
||||
|
||||
`maskAllInputs` defaults to `true`, so form fields are masked unless you explicitly disable it.
|
||||
`maskAllInputs` defaults to `true`, so form fields are masked unless you explicitly disable it. For the full set of privacy controls, playback, and clickmaps, see [Replays & Clickmaps](./replays-and-clickmaps).
|
||||
|
||||
### Disabling Analytics Capture in the SDK
|
||||
|
||||
@ -121,7 +128,8 @@ This stops the SDK from sending `$page-view` and `$click` events. If you'd rathe
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Tables for quick incident triage**: the Tables UI is the fastest way to inspect recent `events` rows without writing SQL.
|
||||
1. **Use Tables for quick incident triage**: the Tables UI is the fastest way to inspect recent rows (events, users, and more) 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.
|
||||
4. **Use Clickmaps for UI friction and attention**: overlay click counts on your live pages to see whether a flow takes unnecessary clicks, whether a control looks interactable, or how variants compare in an A/B test. Clicks are tied to DOM elements (not pixel coordinates), so a marker in the middle of a button does not mean the user clicked the middle of that button. See [Replays & Clickmaps](./replays-and-clickmaps).
|
||||
5. **Keep replay privacy defaults on**: leave `maskAllInputs` enabled unless you have a specific reason and a data-handling policy for unmasked inputs.
|
||||
|
||||
291
docs-mintlify/guides/apps/analytics/queries-and-tables.mdx
Normal file
291
docs-mintlify/guides/apps/analytics/queries-and-tables.mdx
Normal file
@ -0,0 +1,291 @@
|
||||
---
|
||||
title: "Queries & Tables"
|
||||
description: "Inspect raw analytics rows and run read-only ClickHouse SQL against your project's dataset"
|
||||
sidebarTitle: "Queries & Tables"
|
||||
---
|
||||
|
||||
The Analytics app stores your project's data in [ClickHouse](https://clickhouse.com/) and gives you two ways to explore it:
|
||||
|
||||
- **Tables** - a point-and-click grid for browsing raw rows. Best for "what just happened?" triage.
|
||||
- **Queries** - a read-only ClickHouse SQL workspace for aggregation, joins, and reusable analysis.
|
||||
|
||||
Both read from the same dataset and are automatically scoped to the current project and branch, so you never see another tenant's data and you never have to filter by `project_id` yourself.
|
||||
|
||||
<Note>
|
||||
This guide covers how to *use* Tables and Queries. For enabling the app and capturing events from the SDK, see the [Analytics overview](./overview).
|
||||
</Note>
|
||||
|
||||
## What you can query
|
||||
|
||||
Your dataset is exposed as a set of read-only views. Reference them directly by name (e.g. `events`) or fully-qualified (`default.events`) - both work.
|
||||
|
||||
| Table | What it holds |
|
||||
|---|---|
|
||||
| `events` | Every analytics event (page views, clicks, token refreshes, sign-up rule triggers) |
|
||||
| `users` | One row per user, with profile, metadata, and restriction state |
|
||||
| `contact_channels` | Emails and other contact channels per user |
|
||||
| `teams` | Teams in your project |
|
||||
| `team_member_profiles` | Team membership profiles |
|
||||
| `team_permissions` | Team-scoped permissions granted to users |
|
||||
| `team_invitations` | Outstanding and historical team invites |
|
||||
| `project_permissions` | Project-scoped permissions granted to users |
|
||||
| `connected_accounts` | OAuth accounts linked to users |
|
||||
| `refresh_tokens` | Active and historical refresh tokens |
|
||||
| `email_outboxes` | Emails queued/sent by your project |
|
||||
| `notification_preferences` | Per-user notification category opt-in/out |
|
||||
|
||||
<Tip>
|
||||
Run `SHOW TABLES` or `DESCRIBE events` in the Queries workspace to discover columns at any time - both are allowed.
|
||||
</Tip>
|
||||
|
||||
### The `events` table
|
||||
|
||||
`events` is the table you'll use most. Its columns are:
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `event_type` | `LowCardinality(String)` | The event name, e.g. `$page-view`, `$click`, `$token-refresh`, `$sign-up-rule-trigger` |
|
||||
| `event_at` | `DateTime64(3, 'UTC')` | When the event occurred (UTC, millisecond precision) |
|
||||
| `data` | `JSON` | Event-specific payload (see [Working with the `data` payload](#working-with-the-data-payload)) |
|
||||
| `user_id` | `Nullable(String)` | The acting user, when authenticated |
|
||||
| `team_id` | `Nullable(String)` | Reserved; currently always `NULL` |
|
||||
| `refresh_token_id` | `Nullable(String)` | Session's refresh token, when applicable |
|
||||
| `session_replay_id` | `Nullable(String)` | Linked [session replay](./overview#session-replays), if one exists |
|
||||
| `session_replay_segment_id` | `Nullable(String)` | Replay segment that produced the event |
|
||||
| `project_id` / `branch_id` | `String` | Tenant identity - filtered automatically; you rarely need these |
|
||||
| `created_at` | `DateTime64(3, 'UTC')` | Ingestion time (usually a few ms after `event_at`) |
|
||||
|
||||
<Warning>
|
||||
`team_id` on `events` is reserved and currently always `NULL`. Don't build team-level analytics on it - join to `team_member_profiles` via `user_id` instead.
|
||||
</Warning>
|
||||
|
||||
### Event types and their payloads
|
||||
|
||||
The shape of `data` depends on `event_type`. The built-in event types are:
|
||||
|
||||
| `event_type` | Captured by | Key `data` fields |
|
||||
|---|---|---|
|
||||
| `$page-view` | Client SDK | `path`, `url`, `referrer`, `title`, `entry_type`, `viewport_width`, `viewport_height`, `user_agent` |
|
||||
| `$click` | Client SDK | `tag_name`, `text`, `href`, `selector`, `url`, `path`, `x`/`y` (and scaled variants) |
|
||||
| `$token-refresh` | Server | `refresh_token_id`, `is_anonymous`, `ip_info` (`{ ip, is_trusted, country_code, region_code, city_name, ... }`) |
|
||||
| `$sign-up-rule-trigger` | Server | `rule_id`, `action` (`allow` / `reject` / `restrict` / `log`), `email`, `auth_method`, `oauth_provider` |
|
||||
|
||||
## Tables
|
||||
|
||||
Open **Analytics → Tables**, then pick a table from the sidebar (it opens on `events` by default). The grid shows every column the table returns, newest rows first.
|
||||
|
||||
What you can do here:
|
||||
|
||||
- **Search** - type in the filter to match text across all columns at once.
|
||||
- **Sort** - click any column header. Each table has a sensible default (e.g. `events` sorts by `event_at` descending).
|
||||
- **Toggle timestamps** - switch any date/time column between relative ("3 minutes ago") and absolute display from the **Columns** menu.
|
||||
- **Show/hide columns** - trim the grid to what you care about.
|
||||
- **Inspect a row** - click a row to open a detail dialog with every column and a pretty-printed view of the JSON `data` payload.
|
||||
- **Export** - download the current result set as CSV.
|
||||
|
||||
Rows load incrementally as you scroll (50 at a time), so large tables stay responsive. Use Tables for fast incident triage; switch to Queries when you need to aggregate or correlate across rows.
|
||||
|
||||
## Queries
|
||||
|
||||
Open **Analytics → Queries** to get a SQL editor. Write a query, run it, and the results appear in the same grid (with the same sorting, search, and CSV export as Tables).
|
||||
|
||||
A minimal starting point:
|
||||
|
||||
```sql
|
||||
SELECT *
|
||||
FROM events
|
||||
ORDER BY event_at DESC
|
||||
LIMIT 100
|
||||
```
|
||||
|
||||
### Automatic project scoping
|
||||
|
||||
Every query runs against your current project and branch only. Row-level security injects the tenant filter for you, so this:
|
||||
|
||||
```sql
|
||||
SELECT event_type, count() AS c
|
||||
FROM events
|
||||
GROUP BY event_type
|
||||
ORDER BY c DESC
|
||||
```
|
||||
|
||||
returns only *your* events - no `WHERE project_id = ...` required. Adding explicit tenant filters is harmless but unnecessary, and you cannot override the scoping to read other tenants' data.
|
||||
|
||||
### What's allowed
|
||||
|
||||
The workspace is strictly read-only. You can run:
|
||||
|
||||
- `SELECT` and `WITH` (CTEs)
|
||||
- `SHOW TABLES`, `SHOW GRANTS`, `DESCRIBE`, `EXPLAIN`
|
||||
|
||||
Anything that writes or reaches outside the dataset is blocked, including `INSERT`, `UPDATE`, `DELETE`, `ALTER`, `CREATE`, `DROP`, `TRUNCATE`, multi-statement scripts, and table functions like `file()`, `url()`, `remote()`, and `s3()`. Most `system.*` tables are off-limits too (table/column metadata is the exception).
|
||||
|
||||
### Limits
|
||||
|
||||
Queries run inside a budget so a single query can't overload the dataset:
|
||||
|
||||
| Limit | Value |
|
||||
|---|---|
|
||||
| Max result rows | 10,000 |
|
||||
| Max result size | 10 MiB |
|
||||
| Timeout | Up to your plan's cap (Free 10s · Team 60s · Growth 300s) |
|
||||
|
||||
If you hit the row or byte cap the query fails rather than returning a partial result, so always scope with `WHERE`, aggregate, or add a `LIMIT`. If a query times out, narrow the time range (`event_at >= now() - INTERVAL 1 DAY`) or pre-aggregate.
|
||||
|
||||
### Parameterized queries
|
||||
|
||||
Use ClickHouse's `{name:Type}` placeholders to keep values out of your SQL string and avoid escaping issues:
|
||||
|
||||
```sql
|
||||
SELECT event_at, user_id, data.path
|
||||
FROM events
|
||||
WHERE event_type = {event_type:String}
|
||||
AND event_at >= now() - INTERVAL {days:UInt32} DAY
|
||||
ORDER BY event_at DESC
|
||||
LIMIT 100
|
||||
```
|
||||
|
||||
### Working with the `data` payload
|
||||
|
||||
`data` is a real ClickHouse `JSON` column, so you can reach into it with dot notation and cast as needed:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
event_at,
|
||||
data.path AS path,
|
||||
data.referrer AS referrer
|
||||
FROM events
|
||||
WHERE event_type = '$page-view'
|
||||
AND data.path = '/pricing'
|
||||
ORDER BY event_at DESC
|
||||
LIMIT 50
|
||||
```
|
||||
|
||||
For values you want to treat as a specific type, cast explicitly:
|
||||
|
||||
```sql
|
||||
SELECT CAST(data.email, 'Nullable(String)') AS email
|
||||
FROM events
|
||||
WHERE event_type = '$sign-up-rule-trigger'
|
||||
LIMIT 20
|
||||
```
|
||||
|
||||
To discover which keys exist in a payload, expand them:
|
||||
|
||||
```sql
|
||||
SELECT arrayJoin(JSONExtractKeys(toString(data))) AS key, count() AS c
|
||||
FROM events
|
||||
WHERE event_type = '$click'
|
||||
GROUP BY key
|
||||
ORDER BY c DESC
|
||||
```
|
||||
|
||||
### Saving queries
|
||||
|
||||
Save a query to reuse it later: queries live in **folders** in the sidebar. You can **Save** a new query, **Save As** to copy one, or overwrite the selected query after editing it. Selecting a saved query loads its SQL and runs it immediately. Deleting a folder removes the queries inside it.
|
||||
|
||||
## Examples
|
||||
|
||||
### Daily active users (last 7 days)
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
toDate(event_at) AS day,
|
||||
uniqExact(user_id) AS active_users
|
||||
FROM events
|
||||
WHERE event_at >= today() - INTERVAL 7 DAY
|
||||
AND user_id IS NOT NULL
|
||||
GROUP BY day
|
||||
ORDER BY day
|
||||
```
|
||||
|
||||
### Top pages by views (last 24 hours)
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
data.path AS path,
|
||||
count() AS views
|
||||
FROM events
|
||||
WHERE event_type = '$page-view'
|
||||
AND event_at >= now() - INTERVAL 1 DAY
|
||||
GROUP BY path
|
||||
ORDER BY views DESC
|
||||
LIMIT 20
|
||||
```
|
||||
|
||||
### Event volume per hour
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
toStartOfHour(event_at) AS hour,
|
||||
event_type,
|
||||
count() AS events
|
||||
FROM events
|
||||
WHERE event_at >= now() - INTERVAL 1 DAY
|
||||
GROUP BY hour, event_type
|
||||
ORDER BY hour
|
||||
```
|
||||
|
||||
### Most-clicked elements
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
data.selector AS selector,
|
||||
data.text AS label,
|
||||
count() AS clicks
|
||||
FROM events
|
||||
WHERE event_type = '$click'
|
||||
AND event_at >= now() - INTERVAL 7 DAY
|
||||
GROUP BY selector, label
|
||||
ORDER BY clicks DESC
|
||||
LIMIT 25
|
||||
```
|
||||
|
||||
### Recent sign-up rule rejections
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
event_at AS triggered_at,
|
||||
CAST(data.email, 'Nullable(String)') AS email,
|
||||
CAST(data.rule_id, 'Nullable(String)') AS rule_id
|
||||
FROM events
|
||||
WHERE event_type = '$sign-up-rule-trigger'
|
||||
AND CAST(data.action, 'Nullable(String)') = 'reject'
|
||||
ORDER BY event_at DESC
|
||||
LIMIT 100
|
||||
```
|
||||
|
||||
### New users this week
|
||||
|
||||
```sql
|
||||
SELECT id, primary_email, signed_up_at
|
||||
FROM users
|
||||
WHERE signed_up_at >= today() - INTERVAL 7 DAY
|
||||
ORDER BY signed_up_at DESC
|
||||
```
|
||||
|
||||
### Token refreshes by country
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
CAST(data.ip_info.country_code, 'Nullable(String)') AS country,
|
||||
count() AS refreshes
|
||||
FROM events
|
||||
WHERE event_type = '$token-refresh'
|
||||
AND event_at >= now() - INTERVAL 30 DAY
|
||||
GROUP BY country
|
||||
ORDER BY refreshes DESC
|
||||
```
|
||||
|
||||
## Tips & gotchas
|
||||
|
||||
- **Events are eventually consistent.** New events are ingested asynchronously and can take a few seconds to appear. If a fresh event is missing, wait and re-run.
|
||||
- **Always bound your time range.** Filtering on `event_at` keeps queries fast and well under the result limits.
|
||||
- **Query the views, not internals.** Stick to the table names listed above (the `default.*` views). Internal physical tables aren't granted to the query runner and bypass the tenant safety policies.
|
||||
- **Metadata columns are strings.** On `users`, fields like `client_metadata` and `server_metadata` are stored as JSON-encoded `String`, so parse them with `JSONExtract*` functions rather than dot notation.
|
||||
- **Legacy rows may use camelCase keys.** Older `$sign-up-rule-trigger` rows can carry `ruleId` instead of `rule_id`; use `COALESCE` over both if you query far back in history.
|
||||
|
||||
## Related
|
||||
|
||||
- [Analytics Overview](./overview) - enabling the app and capturing events from the SDK.
|
||||
- [Sign-up Rules](../authentication/sign-up-rules) - the source of `$sign-up-rule-trigger` events.
|
||||
131
docs-mintlify/guides/apps/analytics/replays-and-clickmaps.mdx
Normal file
131
docs-mintlify/guides/apps/analytics/replays-and-clickmaps.mdx
Normal file
@ -0,0 +1,131 @@
|
||||
---
|
||||
title: "Replays & Clickmaps"
|
||||
description: "Record real user sessions and visualize where users click"
|
||||
---
|
||||
|
||||
Beyond raw events and SQL, Analytics ships two visual debugging tools: **Session Replays** (watch a real user's session play back) and **Clickmaps** (see where users actually click on a page). Both are sub-apps of Analytics - they turn on with the Analytics app and are captured automatically by the Hexclave client SDK, so there's no separate script tag to install.
|
||||
|
||||
<Info>
|
||||
A **Clickmap** aggregates how many times each on-page element was clicked (with dead-click detection). Clicks are tied to DOM elements, not pixel coordinates.
|
||||
</Info>
|
||||
|
||||
## Requirements
|
||||
|
||||
Replays and clickmaps both depend on the SDK's analytics capture, which runs when **all** of the following are true:
|
||||
|
||||
1. The **Analytics** app is enabled in your dashboard (**Apps -> Analytics**). The Session Replays and Clickmaps sub-apps inherit this - you don't enable them separately.
|
||||
2. Your client app uses a **persistent `tokenStore`** (e.g. `"cookie"`, or `"nextjs-cookie"` in Next.js). Without one, the SDK does not start capture.
|
||||
3. The visitor has a Hexclave **session with a refresh token** (a signed-in user, or an anonymous session created by the SDK). If there is no refresh token, the client may still attempt to send events, but the server will reject them and nothing is stored.
|
||||
|
||||
When those hold, the SDK captures `$page-view` and `$click` events and (for replays) records the session - no manual setup required.
|
||||
|
||||
## Session Replays
|
||||
|
||||
Session replays let you move from "an event happened" to "what the user actually saw." The SDK captures DOM snapshots and mutations, mouse interactions, and page state over time, then plays them back as a reconstruction of the session.
|
||||
|
||||
### Enabling and disabling
|
||||
|
||||
Replay recording is **on by default** once the requirements above are met. You can tune or opt out of it through the `analytics` option when you create your client app:
|
||||
|
||||
```ts
|
||||
import { HexclaveClientApp } from "@hexclave/js"; // replace `js` with the correct framework SDK package
|
||||
|
||||
export const hexclaveClientApp = new HexclaveClientApp({
|
||||
// ...your existing client app options
|
||||
tokenStore: "cookie", // use "nextjs-cookie" in Next.js
|
||||
analytics: {
|
||||
replays: {
|
||||
// Recording is enabled by default; set to false to opt out.
|
||||
enabled: true,
|
||||
// Privacy controls (see below)
|
||||
maskAllInputs: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
To turn replays off entirely, pass `analytics: { replays: { enabled: false } }`. To stop **all** analytics capture (events and replays), pass `analytics: { enabled: false }`.
|
||||
|
||||
### Privacy controls
|
||||
|
||||
Replays are masked by default to keep sensitive content out of recordings:
|
||||
|
||||
| Option | Default | Effect |
|
||||
| --- | --- | --- |
|
||||
| `maskAllInputs` | `true` | Masks the contents of `<input>` fields so typed values aren't recorded |
|
||||
| `blockClass` | — | Block elements matching a CSS class name or `RegExp` from being recorded |
|
||||
| `blockSelector` | — | Block elements matching a CSS selector from being recorded |
|
||||
|
||||
```ts
|
||||
analytics: {
|
||||
replays: {
|
||||
maskAllInputs: true,
|
||||
blockClass: "hx-private",
|
||||
blockSelector: "[data-private]",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Leave `maskAllInputs` on unless you have a specific reason and a data-handling policy for unmasked input. Disabling it records exactly what users type into forms.
|
||||
</Warning>
|
||||
|
||||
### Viewing replays
|
||||
|
||||
Replays live under **Analytics -> Replays** in the dashboard. The list shows each session's user, start and last-activity time, duration, and event/click counts. You can filter by:
|
||||
|
||||
- User and team
|
||||
- Duration (min/max)
|
||||
- Last active (24h / 7d / 30d)
|
||||
- Minimum click count
|
||||
|
||||
Open a replay to play it back. The player supports:
|
||||
|
||||
- Play / pause and scrubbing
|
||||
- Playback speeds of **0.5×, 1×, 2×, and 4×**
|
||||
- **Skip inactivity** (on by default) to jump past idle gaps
|
||||
- Timeline markers for clicks and page views
|
||||
- Multi-tab playback, with an optional "follow active tab"
|
||||
- A shareable deep link to the exact replay
|
||||
|
||||
You can also see a user's replays directly from their profile under the **Session Replays** tab on the user detail page.
|
||||
|
||||
### Storage and limits
|
||||
|
||||
Recorded replay data is stored privately (the DOM recording is gzipped and kept in object storage), with session metadata in the database. A session groups activity from the same login, and is closed after **3 minutes** of inactivity or **12 hours** total. Plans include a monthly allowance of new replays; once the allowance is exhausted, new recordings are skipped until the next cycle.
|
||||
|
||||
## Clickmaps
|
||||
|
||||
A clickmap overlays your live site with the **number of clicks each element received**, so you can see what users interact with - and spot "dead clicks" on things that look clickable but aren't. Clicks are tied to DOM elements (not pixel coordinates): a marker appearing in the middle of an element does not mean the user clicked that exact point. Click data comes from the same `$click` events the SDK already captures, so there's nothing extra to instrument.
|
||||
|
||||
### Enabling
|
||||
|
||||
Clickmaps need only the Analytics app enabled and the SDK capturing clicks (the [requirements](#requirements) above). Unlike replays, the clickmap is rendered **on your own site** as an overlay, not inside the dashboard. To activate it:
|
||||
|
||||
1. Go to **Analytics -> Clickmaps** in the dashboard.
|
||||
2. Add your site's origin as a trusted domain and generate an overlay token (valid for **24 hours**).
|
||||
3. Paste the provided console snippet into your browser's dev console while on your site.
|
||||
|
||||
The overlay then appears on the page, reading aggregated click data for the current URL.
|
||||
|
||||
### Controls
|
||||
|
||||
From the overlay you can adjust:
|
||||
|
||||
- **Date range** - 24h, 7d, or 30d (queries span at most 31 days)
|
||||
- **Device / viewport** - all, mobile, tablet, laptop, desktop, widescreen, or TV
|
||||
- **URL pattern** - target specific pages with `*` wildcards
|
||||
- **Element search** - find a specific element
|
||||
- **Dead clicks** - show or hide clicks that produced no page response
|
||||
|
||||
## How this relates to your events
|
||||
|
||||
These features build on the same analytics pipeline described in [Queries & Tables](./queries-and-tables):
|
||||
|
||||
- **`$click` and `$page-view` events** land in the `events` table (ClickHouse). Clickmaps read from a derived table populated automatically from `$click` events.
|
||||
- **Session replay recordings** are stored separately (object storage + database), not in ClickHouse. Your analytics events carry a `session_replay_id` so you can connect an event row back to the replay it occurred in.
|
||||
|
||||
## Related
|
||||
|
||||
- [Analytics overview](./overview) - enabling Analytics and what gets tracked
|
||||
- [Queries & Tables](./queries-and-tables) - the event schema, SQL runner, and Tables view
|
||||
File diff suppressed because it is too large
Load Diff
@ -8,11 +8,12 @@ Hexclave supports a variety of authentication providers to give your users flexi
|
||||
|
||||
## Overview
|
||||
|
||||
Authentication providers determine how users can sign in to your application. Stack supports the following provider types:
|
||||
Authentication providers determine how users can sign in to your application. Hexclave supports the following provider types:
|
||||
|
||||
- **Email/Password**: Traditional email and password authentication
|
||||
- **Magic Link**: Passwordless authentication via email links
|
||||
- **OAuth Providers**: Third-party sign-in with providers like Google, GitHub, Facebook, Microsoft, and more
|
||||
- **Custom OIDC**: Bring any OpenID Connect-compliant identity provider (Okta, Auth0, Keycloak, and others)
|
||||
- **Passkeys**: WebAuthn-based passwordless authentication
|
||||
|
||||
## Configuring Providers
|
||||
@ -28,16 +29,18 @@ Authentication providers determine how users can sign in to your application. St
|
||||
Toggle the providers you want to enable for your application.
|
||||
</Step>
|
||||
<Step title="Configure OAuth providers">
|
||||
For OAuth providers, you can use Stack's shared keys for development or configure your own OAuth client ID and client secret for production.
|
||||
For OAuth providers, you can use Hexclave's shared keys for development or configure your own OAuth client ID and client secret for production.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Shared vs. Custom OAuth Keys
|
||||
|
||||
<Info>
|
||||
For development and testing, Stack provides shared OAuth keys that work out of the box. For production, you should set up your own OAuth client credentials.
|
||||
For development and testing, Hexclave provides shared OAuth keys that work out of the box. For production, you should set up your own OAuth client credentials. Custom OIDC providers always use your own credentials.
|
||||
</Info>
|
||||
|
||||
A [development environment](/guides/going-further/local-vs-cloud-dashboard) typically uses shared keys. Configure your own client ID and secret in the [cloud dashboard](https://app.hexclave.com) before you launch.
|
||||
|
||||
### Shared Keys
|
||||
|
||||
Shared keys allow you to quickly get started without needing to register your application with each OAuth provider. These are suitable for development only.
|
||||
@ -93,6 +96,8 @@ For production use, configure your own OAuth client ID and client secret for eac
|
||||
|
||||
## Other Authentication Methods
|
||||
|
||||
These aren't OAuth providers, but round out how users sign in and secure their accounts.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Passkey" icon="key" href="/guides/apps/authentication/auth-providers/passkey">
|
||||
WebAuthn-based passwordless authentication
|
||||
@ -100,6 +105,9 @@ For production use, configure your own OAuth client ID and client secret for eac
|
||||
<Card title="Two-Factor Auth (2FA)" icon="shield-check" href="/guides/apps/authentication/auth-providers/two-factor-auth">
|
||||
TOTP-based two-factor authentication
|
||||
</Card>
|
||||
<Card title="Custom OIDC" icon="globe" href="/guides/apps/authentication/auth-providers/custom-oidc">
|
||||
Bring any OpenID Connect identity provider (Okta, Auth0, Keycloak, and more). Team plan or above.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,100 @@
|
||||
---
|
||||
title: "Custom OIDC"
|
||||
description: "Connect any OpenID Connect identity provider to Hexclave"
|
||||
---
|
||||
|
||||
Custom OIDC lets you bring **any OpenID Connect-compliant identity provider** - Okta, Auth0, Keycloak, Microsoft Entra ID, Ping, Zitadel, or your own - as a sign-in option, even if it isn't one of Hexclave's built-in providers. Hexclave handles the OAuth flow, OIDC discovery, and account linking; you supply the issuer URL and client credentials.
|
||||
|
||||
<Info>
|
||||
Custom OIDC providers require a **Team plan or above**. Because you're always using your own credentials, there are no shared development keys for custom OIDC.
|
||||
</Info>
|
||||
|
||||
## How it works
|
||||
|
||||
You give Hexclave an **issuer URL**, and Hexclave fetches the provider's configuration from its OIDC discovery document (`<issuer>/.well-known/openid-configuration`) to find the authorization, token, and userinfo endpoints. User profiles are mapped from standard OIDC claims (`sub`, `name` / `preferred_username`, `email`, `email_verified`, `picture`).
|
||||
|
||||
You can add **multiple** custom OIDC providers, each identified by a unique **provider ID** that you choose.
|
||||
|
||||
## Integration Steps
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Choose a provider ID
|
||||
|
||||
Pick a unique ID for this provider, for example `my-okta`. You'll use it in the callback URL and in your sign-in code.
|
||||
|
||||
Provider IDs may contain **lowercase letters, numbers, hyphens, and underscores** only, and can't match a built-in provider name (like `google` or `github`).
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Create an OIDC app with your identity provider
|
||||
|
||||
In your identity provider's admin console, create a new OIDC / OAuth2 web application and set its redirect (callback) URL to:
|
||||
|
||||
```
|
||||
https://api.hexclave.com/api/v1/auth/oauth/callback/YOUR_PROVIDER_ID
|
||||
```
|
||||
|
||||
Replace `YOUR_PROVIDER_ID` with the ID you chose in the previous step. For local development, use `http://localhost:8102/api/v1/auth/oauth/callback/YOUR_PROVIDER_ID`.
|
||||
|
||||
Then collect:
|
||||
|
||||
- **Issuer URL** - the base URL of your provider (e.g. `https://your-idp.example.com`). It must support OIDC discovery.
|
||||
- **Client ID** and **Client Secret** from the app you just created.
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Add the provider in Hexclave
|
||||
|
||||
1. On the Hexclave dashboard, select **Auth Methods** in the left sidebar.
|
||||
2. Click **Add Custom OIDC**.
|
||||
3. Fill in the form:
|
||||
- **Provider ID** - the ID you chose (e.g. `my-okta`)
|
||||
- **Display Name** - a human-readable label (e.g. `My Identity Provider`)
|
||||
- **Issuer URL** - your provider's issuer URL
|
||||
- **Client ID** and **Client Secret** - from your provider
|
||||
- **Scopes** (optional) - space-separated OAuth scopes. Defaults to `openid email profile`.
|
||||
4. Click **Add Provider**.
|
||||
|
||||
After it's created, choose **Configure** on the provider's row to view the exact **Redirect URL** Hexclave generated, and confirm it matches what you registered with your identity provider.
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Trigger sign-in from your app
|
||||
|
||||
Custom OIDC providers are not rendered automatically by the prebuilt sign-in buttons, so start the flow yourself with `signInWithOAuth`, passing your provider ID:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
import { useHexclaveApp } from "@hexclave/next"; // replace `next` with the correct framework SDK package
|
||||
|
||||
export function OktaSignInButton() {
|
||||
const app = useHexclaveApp();
|
||||
return (
|
||||
<button onClick={async () => await app.signInWithOAuth("my-okta")}>
|
||||
Sign in with My Identity Provider
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Note>
|
||||
Custom OIDC providers don't appear in the default `<SignIn />` / `<SignUp />` provider buttons. Add your own button that calls `signInWithOAuth("<your-provider-id>")` (or build a fully custom sign-in UI).
|
||||
</Note>
|
||||
|
||||
## Connecting accounts
|
||||
|
||||
Custom OIDC also works as a [connected account](/guides/apps/authentication/connected-accounts). To link a custom OIDC provider to an already signed-in user, call `linkConnectedAccount` with the same provider ID:
|
||||
|
||||
```tsx
|
||||
await user.linkConnectedAccount("my-okta");
|
||||
```
|
||||
|
||||
For non-shared providers like custom OIDC, you can also request additional scopes and retrieve access tokens to call your provider's APIs on the user's behalf.
|
||||
|
||||
## Need More Help?
|
||||
|
||||
* Read about [OpenID Connect discovery](https://openid.net/specs/openid-connect-discovery-1_0.html)
|
||||
* Join our [Discord](https://discord.hexclave.com)
|
||||
126
docs-mintlify/guides/apps/authentication/fraud-protection.mdx
Normal file
126
docs-mintlify/guides/apps/authentication/fraud-protection.mdx
Normal file
@ -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, and country), and the conditions you can write against them in [Sign-up Rules](./sign-up-rules).
|
||||
|
||||
In the dashboard, Fraud Protection appears as its own tile (grouped under the Authentication category) with a **Go to Authentication** button that takes you straight to **Authentication → Sign-up Rules**. There's nothing to configure on a separate screen - everything happens in the rule builder.
|
||||
|
||||
<Note>
|
||||
Since Authentication is always on, Fraud Protection is on. There is no independent toggle.
|
||||
</Note>
|
||||
|
||||
## 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`. The **Operators** column lists the choices shown in the builder's dropdown; under the hood each rule is stored as a CEL expression, so `equals` becomes `==`, `in_list` becomes `in [...]`, `greater_or_equal` becomes `>=`, and so on:
|
||||
|
||||
| Field | Type | Operators | Description |
|
||||
|---|---|---|---|
|
||||
| `countryCode` | string (ISO-3166-1 alpha-2) | `equals`, `not_equals`, `in_list` | Country associated with the sign-up attempt (e.g. `"US"`, `"DE"`, `"NG"`). Empty if unavailable. |
|
||||
| `riskScores.bot` | number (0-100) | `equals`, `not_equals`, `greater_than`, `greater_or_equal`, `less_than`, `less_or_equal` | Bot risk score. Higher means more likely automated. |
|
||||
| `riskScores.free_trial_abuse` | number (0-100) | same as `riskScores.bot` | Free-trial abuse risk score. Higher means more likely abuse. |
|
||||
|
||||
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**
|
||||
|
||||
**What Restrict means:** the account is created, but marked restricted (`isRestricted`). Hexclave treats restricted users like unauthenticated users in most SDK calls until an admin clears the restriction or they finish whatever verification is required. See [Restricted Users](./restricted-users) for SDK handling and JWT claims, and [Sign-up Rules → Restrict](./sign-up-rules#restrict) for how the action behaves in the rule engine.
|
||||
|
||||
### 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 ["CN", "RU"] && riskScores.free_trial_abuse >= 40` → **Restrict**
|
||||
4. Default: **Allow**
|
||||
|
||||
Remember rules are evaluated **top-to-bottom by priority**. A matching **Allow** or **Reject** is terminal - it stops evaluation and decides the outcome. A matching **Restrict** or **Log** is recorded but evaluation *continues* down the list (only the first matching `Restrict` rule is attributed to the user). 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.
|
||||
|
||||
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 → \<user\>**, switch to the **Authentication** tab, 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 (<reason>)`. 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 **Restrict user** button sits in the section header (and the action also lives in the user's top-right `⋮` menu). It's red (destructive) while the user has no manual restriction, and switches to an outline style once one is in place. 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`. The action label reflects the user's current state: **Restrict user** when they're unrestricted, **Add manual restriction** when they're already restricted for another reason (e.g. unverified email), and **Edit or remove manual restriction** once a manual restriction exists - in which case the dialog also 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.
|
||||
- [Restricted Users](./restricted-users) - what restricted accounts can and can't do.
|
||||
- [JWT Tokens](./jwts) - how the `Restrict` action surfaces in user tokens.
|
||||
@ -1,7 +1,91 @@
|
||||
---
|
||||
title: Authentication
|
||||
description: Integrate Hexclave with AI-powered tools and services
|
||||
sidebarTitle: Overview
|
||||
title: "Authentication"
|
||||
description: "Production-ready sign-in, a real user directory, and session verification - without building auth yourself"
|
||||
sidebarTitle: "Overview"
|
||||
---
|
||||
|
||||
TODO stub
|
||||
<Visibility for="agents">
|
||||
<Note>
|
||||
**For agents/LLMs:** This is a high-level *marketing* overview of the Authentication app, not an implementation reference. To actually build auth, use [Setup](/guides/getting-started/setup), [Hosted vs. Handler](/guides/going-further/hosted-vs-handler), [Auth providers](./auth-providers), [JWTs & session verification](./jwts), [Sign-up rules](./sign-up-rules), and [Connected accounts](./connected-accounts).
|
||||
</Note>
|
||||
</Visibility>
|
||||
|
||||
import { SignInSkeleton, AuthMethodsSkeleton, UserDirectorySkeleton } from "/snippets/skeletons/auth-skeletons.jsx";
|
||||
|
||||
Authentication is the foundation every other Hexclave app builds on. You get hosted sign-in pages, every common login method, and a complete user directory - wired up in minutes, not weeks. Below are the questions developers actually ask, and the honest answers.
|
||||
|
||||
## Can I add sign-in without building forms?
|
||||
|
||||
Yes. Prefer **hosted components** (`urls: { default: { type: "hosted" } }` in [Setup](/guides/getting-started/setup)) — Hexclave hosts the sign-in UI and keeps it updated. Or mount `<HexclaveHandler />` on your own `/handler/[...]` route if you want auth pages on your domain. See [Hosted vs. Handler](/guides/going-further/hosted-vs-handler).
|
||||
|
||||
```tsx title="app/handler/[...hexclave]/page.tsx"
|
||||
import { HexclaveHandler } from "@hexclave/next"; // replace `next` with your framework SDK
|
||||
|
||||
export default function Handler() {
|
||||
return <HexclaveHandler fullPage />;
|
||||
}
|
||||
```
|
||||
|
||||
<SignInSkeleton />
|
||||
|
||||
You never write a form, manage a redirect, or hand-roll a reset flow. Want it inside your own layout? Drop in the prebuilt components - `<SignIn />`, `<SignUp />`, `<AccountSettings />` - and tune them with props. Want a fully custom UI? Build your own against the SDK's auth methods.
|
||||
|
||||
## Can I offer every login method?
|
||||
|
||||
Yes - and you flip each one on or off from the dashboard, no redeploy required.
|
||||
|
||||
- **Email & password** with secure reset
|
||||
- **Magic links / OTP** for passwordless sign-in
|
||||
- **Passkeys** (WebAuthn) for phishing-resistant login
|
||||
- **12 OAuth providers** - Google, GitHub, Microsoft, Apple, Discord, Facebook, LinkedIn, Twitch, Spotify, GitLab, Bitbucket, and X - plus your own OpenID Connect provider
|
||||
- **Two-factor authentication** (TOTP)
|
||||
|
||||
<AuthMethodsSkeleton />
|
||||
|
||||
Shared Hexclave keys work out of the box for Google, GitHub, Microsoft, and Spotify — best for a [development environment](/guides/going-further/local-vs-cloud-dashboard). Swap in your own client ID and secret for production. See [Auth Providers → Shared vs. Custom OAuth Keys](./auth-providers#shared-vs-custom-oauth-keys).
|
||||
|
||||
## Can I get the current user anywhere in my app?
|
||||
|
||||
Yes. The same user object is available on the client (as a hook) and the server (as an async call), with full TypeScript types.
|
||||
|
||||
```tsx
|
||||
// Client component - re-renders when the user changes
|
||||
const user = useUser();
|
||||
|
||||
// Server component / route handler / action
|
||||
const user = await hexclaveServerApp.getUser();
|
||||
```
|
||||
|
||||
Need to gate a page? Pass `{ or: "redirect" }` and unauthenticated visitors are sent to sign-in automatically:
|
||||
|
||||
```tsx
|
||||
const user = useUser({ or: "redirect" });
|
||||
```
|
||||
|
||||
## Can I manage my users?
|
||||
|
||||
Yes. Every sign-up creates a real profile - connected accounts, auth methods, metadata, and activity - not just a token. Search, filter, edit, and export from the dashboard, or do the same from code with the server SDK.
|
||||
|
||||
<UserDirectorySkeleton />
|
||||
|
||||
Store your own data on a user with `clientMetadata`, `clientReadOnlyMetadata`, and `serverMetadata`, so you rarely need a separate users table of your own.
|
||||
|
||||
## Can I verify sessions on my backend?
|
||||
|
||||
Yes. Hexclave issues standard JWTs you can verify locally against a JWKS endpoint - no round-trip to Hexclave on every request - so auth checks stay fast even in middleware and edge functions. See [JWTs & session verification](./jwts).
|
||||
|
||||
## Can I control who gets in?
|
||||
|
||||
Yes. Write [sign-up rules](./sign-up-rules) to allow, reject, or restrict accounts by email domain, country, auth method, or built-in [fraud-protection](./fraud-protection) risk scores. Suspicious accounts can be held in a [restricted](./restricted-users) state for review instead of blocked outright.
|
||||
|
||||
## Can I own my data and self-host?
|
||||
|
||||
Yes. Hexclave is open source (MIT client, AGPLv3 server). Run it fully self-hosted, export your users from the dashboard whenever you want, and avoid lock-in. The same SDK and APIs work on the managed service and self-hosted. A [development environment](/guides/going-further/local-vs-cloud-dashboard) is for building against Hexclave locally — some production-only setup (custom OAuth keys, custom email, payments) lives in the [cloud dashboard](https://app.hexclave.com).
|
||||
|
||||
## Start here
|
||||
|
||||
1. [Set up Hexclave](/guides/getting-started/setup) in your project (a few minutes) — prefer hosted components; see [Hosted vs. Handler](/guides/going-further/hosted-vs-handler).
|
||||
2. Turn on the auth methods you want (and mount `<HexclaveHandler />` only if you chose the own-handler path).
|
||||
3. Use `useUser()` / `getUser()` to read the session and protect routes.
|
||||
|
||||
Everything else - [teams](../teams/overview), [payments](../payments/overview), [emails](../emails/overview), [analytics](../analytics/overview) - keys off this same user directory.
|
||||
|
||||
@ -74,7 +74,7 @@ Next, we can create a hook/function to check if the user has completed onboardin
|
||||
</Tab>
|
||||
<Tab title="Server Component">
|
||||
```jsx
|
||||
import { hexclaveServerApp } from '@/stack/server';
|
||||
import { hexclaveServerApp } from '@/hexclave/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export async function ensureOnboarded() {
|
||||
@ -108,7 +108,7 @@ You can then use these functions wherever onboarding is required:
|
||||
<Tab title="Server Component">
|
||||
```jsx
|
||||
import { ensureOnboarding } from '@/app/onboarding-functions';
|
||||
import { hexclaveServerApp } from '@/stack/server';
|
||||
import { hexclaveServerApp } from '@/hexclave/server';
|
||||
|
||||
export default async function HomePage() {
|
||||
await ensureOnboarding();
|
||||
|
||||
@ -21,16 +21,44 @@ 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 Hexclave 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 [Hexclave dashboard](https://app.hexclave.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 [Hexclave dashboard](https://app.hexclave.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 <id>` 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.<storeId>` 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 Hexclave cannot recover it.
|
||||
|
||||
<Warning>
|
||||
Deleting a store cannot be undone. Make sure no production traffic references the store ID before removing it.
|
||||
</Warning>
|
||||
|
||||
### 2. Generate a secret
|
||||
|
||||
Your secret can be any string, but for strong security it should be at least 32 characters long and provide 256 bits of entropy. Store it as an environment variable:
|
||||
|
||||
```bash title=".env"
|
||||
STACK_DATA_VAULT_SECRET=your-randomly-generated-secret-here
|
||||
HEXCLAVE_DATA_VAULT_SECRET=your-randomly-generated-secret-here
|
||||
```
|
||||
|
||||
### 3. Use the SDK
|
||||
@ -38,18 +66,20 @@ STACK_DATA_VAULT_SECRET=your-randomly-generated-secret-here
|
||||
Data Vault is accessed through the **server app** only — it requires your secret server key.
|
||||
|
||||
```typescript title="server-example.ts"
|
||||
import { hexclaveServerApp } from "@/hexclave/server";
|
||||
|
||||
const store = await hexclaveServerApp.getDataVaultStore("my-store-id");
|
||||
|
||||
const key = user.id;
|
||||
|
||||
// Store a value
|
||||
await store.setValue(key, "my-sensitive-value", {
|
||||
secret: process.env.STACK_DATA_VAULT_SECRET,
|
||||
secret: process.env.HEXCLAVE_DATA_VAULT_SECRET,
|
||||
});
|
||||
|
||||
// Retrieve a value
|
||||
const value = await store.getValue(key, {
|
||||
secret: process.env.STACK_DATA_VAULT_SECRET,
|
||||
secret: process.env.HEXCLAVE_DATA_VAULT_SECRET,
|
||||
});
|
||||
// value is the decrypted string, or null if the key doesn't exist
|
||||
```
|
||||
@ -70,7 +100,7 @@ Retrieves the decrypted value for the given key, or `null` if the key doesn't ex
|
||||
|
||||
```typescript
|
||||
const value = await store.getValue("some-key", {
|
||||
secret: process.env.STACK_DATA_VAULT_SECRET,
|
||||
secret: process.env.HEXCLAVE_DATA_VAULT_SECRET,
|
||||
});
|
||||
```
|
||||
|
||||
@ -80,7 +110,7 @@ Stores an encrypted value for the given key. If the key already exists, it is ov
|
||||
|
||||
```typescript
|
||||
await store.setValue("some-key", "some-value", {
|
||||
secret: process.env.STACK_DATA_VAULT_SECRET,
|
||||
secret: process.env.HEXCLAVE_DATA_VAULT_SECRET,
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
36
docs-mintlify/guides/apps/emails/drafts.mdx
Normal file
36
docs-mintlify/guides/apps/emails/drafts.mdx
Normal file
@ -0,0 +1,36 @@
|
||||
---
|
||||
title: "Drafts"
|
||||
description: "Compose emails in the dashboard, then send from the UI or with draftId."
|
||||
sidebarTitle: "Drafts"
|
||||
---
|
||||
|
||||
Drafts are emails you compose in the Hexclave dashboard — not TSX files like [templates and themes](./templates-and-themes). Use them for one-off or campaign-style sends when you don't want to ship a template in code.
|
||||
|
||||
## Create a draft
|
||||
|
||||
Open **Emails → Drafts** in the dashboard:
|
||||
|
||||
1. Create a blank draft, or **clone a template into a draft** to start from existing content.
|
||||
2. Edit the body (including TSX source with live preview), pick a theme, choose recipients, and optionally schedule a send time.
|
||||
3. Save. Active drafts stay editable until you send them; sent drafts move to history and can be tracked in the outbox.
|
||||
|
||||
You can create and edit drafts while on the shared email server. On Shared, programmatic sends still go out wrapped as Hexclave dev emails; for production sender identity, configure SMTP, Resend, or Managed in the [cloud dashboard](https://app.hexclave.com). The dashboard may ask you to configure a custom server before sending manual mail from the UI.
|
||||
|
||||
## Send a draft
|
||||
|
||||
Send from the dashboard when you're ready, or trigger the same draft from your backend:
|
||||
|
||||
```typescript
|
||||
await hexclaveServerApp.sendEmail({
|
||||
userIds: ["user-id"],
|
||||
draftId: "your-draft-id",
|
||||
});
|
||||
```
|
||||
|
||||
`draftId` is one of the three content options on `sendEmail` (alongside `html` and `templateId`). Recipients, scheduling, and theme overrides work the same as any other send — see the [Emails guide](./guide).
|
||||
|
||||
## Related
|
||||
|
||||
- [Templates & themes](./templates-and-themes) - reusable TSX content and branding wrappers.
|
||||
- [Emails guide](./guide) - full `sendEmail` options, delivery, and server configuration.
|
||||
- [Emails overview](./overview) - high-level product overview.
|
||||
263
docs-mintlify/guides/apps/emails/guide.mdx
Normal file
263
docs-mintlify/guides/apps/emails/guide.mdx
Normal file
@ -0,0 +1,263 @@
|
||||
---
|
||||
title: "Emails"
|
||||
description: "Implement Hexclave emails from start to finish — server, templates, sending, and delivery."
|
||||
sidebarTitle: "Guide"
|
||||
---
|
||||
|
||||
This guide walks through implementing emails in your app end to end: connect a server, brand your mail, create a template, send from your backend, then watch delivery. For a quick "can I do this?" checklist, see the [Emails overview](./overview).
|
||||
|
||||
You'll need Hexclave set up with a server app (`hexclaveServerApp`). If you don't have that yet, follow [Setup](/guides/getting-started/setup) first, then enable **Emails** in the dashboard.
|
||||
|
||||
## 1. Connect an email server
|
||||
|
||||
Open **Emails → Email Settings** in the dashboard. Hexclave needs somewhere to send from.
|
||||
|
||||
**While developing**, leave **Shared** selected. Built-in auth mail (verification, password reset, magic link) already works. Custom `sendEmail` calls also go out on shared — Hexclave wraps them as "dev emails" so recipients know they aren't from your domain yet.
|
||||
|
||||
A [development environment](/guides/going-further/local-vs-cloud-dashboard) can only use Shared. Configure the other providers in the [cloud dashboard](https://app.hexclave.com).
|
||||
|
||||
**Before production**, switch to one of:
|
||||
|
||||
| Provider | What you do |
|
||||
|---|---|
|
||||
| **Custom SMTP** | Enter host, port, username, password, sender name, and sender email (SendGrid, Postmark, SES, etc.). |
|
||||
| **Resend** | Paste your [Resend](https://resend.com) API key. |
|
||||
| **Managed** | Pick a subdomain (e.g. `mail.yourapp.com`) and sender local part. Hexclave shows DNS records during onboarding — you add them at your DNS provider, then verify. Hexclave handles signing and deliverability. |
|
||||
|
||||
Saving a custom provider triggers a test email from the dashboard so you know the config works.
|
||||
|
||||
## 2. Pick transactional vs marketing
|
||||
|
||||
Every send is one of two categories:
|
||||
|
||||
- **Transactional** — required for the product (verification, receipts, password reset). Users cannot opt out.
|
||||
- **Marketing** — promotional or informational. Users can unsubscribe; Hexclave appends an unsubscribe link.
|
||||
|
||||
<Warning>
|
||||
Never send marketing content as transactional mail. That can get your domain blacklisted.
|
||||
</Warning>
|
||||
|
||||
Set the category when you send (`notificationCategoryName`) or inside the template / draft with `<NotificationCategory>`. If you omit it everywhere, the category stays undefined and **unsubscribe preferences are not applied** — prefer setting it explicitly.
|
||||
|
||||
## 3. Brand with a theme
|
||||
|
||||
Themes wrap every email (header, footer, logo, background). Hexclave ships **Default Light**, **Default Dark**, and **Default Colorful**. Set a project default under **Emails → Email Settings → Themes**, or skip this step and use the default.
|
||||
|
||||
To author your own theme as TSX:
|
||||
|
||||
```tsx
|
||||
import { Html, Head, Tailwind, Body, Container } from "@react-email/components";
|
||||
import { ThemeProps, ProjectLogo } from "@hexclave/emails";
|
||||
|
||||
export function EmailTheme({ children, unsubscribeLink, projectLogos }: ThemeProps) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Tailwind>
|
||||
<Body className="bg-white font-sans m-0 p-0">
|
||||
<Container className="max-w-[600px] mx-auto p-8">
|
||||
<ProjectLogo data={projectLogos} mode="light" />
|
||||
{children}
|
||||
</Container>
|
||||
{unsubscribeLink && (
|
||||
<p className="text-center text-xs opacity-60">
|
||||
<a href={unsubscribeLink}>Unsubscribe</a>
|
||||
</p>
|
||||
)}
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Per-send overrides: `themeId: "your-theme-id"`, `themeId: null` for the project default, or `themeId: false` for no theme. Full theme API: [Templates & themes](./templates-and-themes).
|
||||
|
||||
## 4. Create a template
|
||||
|
||||
Built-in templates already cover verification, password reset, magic link, invitations, and payment receipts — Hexclave sends those automatically when those flows run. Customize them under **Emails → Templates**.
|
||||
|
||||
For your own product email, create a React Email template in the dashboard (or start from a clone). A minimal template looks like this:
|
||||
|
||||
```tsx
|
||||
import { type } from "arktype";
|
||||
import { Container } from "@react-email/components";
|
||||
import { Subject, NotificationCategory, Props } from "@hexclave/emails";
|
||||
|
||||
export const variablesSchema = type({
|
||||
featureName: "string",
|
||||
});
|
||||
|
||||
export function EmailTemplate({
|
||||
user,
|
||||
variables,
|
||||
}: Props<typeof variablesSchema.infer>) {
|
||||
return (
|
||||
<Container>
|
||||
<Subject value={`New feature: ${variables.featureName}`} />
|
||||
<NotificationCategory value="Transactional" />
|
||||
<p>Hi {user.displayName}, check out {variables.featureName}!</p>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
EmailTemplate.PreviewVariables = {
|
||||
featureName: "Dark mode",
|
||||
} satisfies typeof variablesSchema.infer;
|
||||
```
|
||||
|
||||
- `variablesSchema` validates the variables you pass at send time.
|
||||
- `<Subject>` and `<NotificationCategory>` live in the template so the content owns its subject and category.
|
||||
- Saving custom templates on Shared requires a custom email server; you can still edit and preview.
|
||||
|
||||
Don't want a reusable template yet? You can send raw `html` in the next step, or compose a [Draft](./drafts) in the dashboard instead.
|
||||
|
||||
## 5. Send your first email
|
||||
|
||||
From your server, call `hexclaveServerApp.sendEmail()`. Exactly one recipient selector and exactly one content source are required.
|
||||
|
||||
**HTML (fastest smoke test):**
|
||||
|
||||
```typescript
|
||||
import { hexclaveServerApp } from "@hexclave/next"; // replace `next` with your framework SDK
|
||||
|
||||
await hexclaveServerApp.sendEmail({
|
||||
userIds: ["user-id"],
|
||||
subject: "Welcome aboard!",
|
||||
html: "<h1>Welcome!</h1><p>Thanks for joining us.</p>",
|
||||
notificationCategoryName: "Transactional",
|
||||
});
|
||||
```
|
||||
|
||||
**Template with variables:**
|
||||
|
||||
```typescript
|
||||
await hexclaveServerApp.sendEmail({
|
||||
userIds: ["user-id"],
|
||||
templateId: "your-template-id",
|
||||
variables: { featureName: "Dark mode" },
|
||||
// subject / category can come from the template; override here if needed
|
||||
});
|
||||
```
|
||||
|
||||
**Everyone in the project:**
|
||||
|
||||
```typescript
|
||||
await hexclaveServerApp.sendEmail({
|
||||
allUsers: true,
|
||||
templateId: "your-template-id",
|
||||
subject: "We just shipped a big update",
|
||||
variables: { featureName: "Dark mode" },
|
||||
notificationCategoryName: "Marketing",
|
||||
});
|
||||
```
|
||||
|
||||
**Dashboard draft:**
|
||||
|
||||
```typescript
|
||||
await hexclaveServerApp.sendEmail({
|
||||
userIds: ["user-id"],
|
||||
draftId: "your-draft-id",
|
||||
});
|
||||
```
|
||||
|
||||
### Options reference
|
||||
|
||||
```typescript
|
||||
type SendEmailOptions =
|
||||
& {
|
||||
subject?: string;
|
||||
themeId?: string | null | false;
|
||||
notificationCategoryName?: string;
|
||||
variables?: Record<string, unknown>;
|
||||
scheduledAt?: Date;
|
||||
}
|
||||
& ({ userIds: string[] } | { allUsers: true })
|
||||
& ({ html: string } | { templateId: string } | { draftId: string });
|
||||
```
|
||||
|
||||
`sendEmail` resolves to `void` and **throws** on failure. Branch on stable `errorCode` values when present:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await hexclaveServerApp.sendEmail({
|
||||
userIds: ["user-id"],
|
||||
html: "<p>Hello!</p>",
|
||||
subject: "Test Email",
|
||||
notificationCategoryName: "Transactional",
|
||||
});
|
||||
} catch (error) {
|
||||
const errorCode = (error as { errorCode?: string }).errorCode;
|
||||
switch (errorCode) {
|
||||
case "USER_ID_DOES_NOT_EXIST":
|
||||
// One or more user IDs do not exist
|
||||
break;
|
||||
case "SCHEMA_ERROR":
|
||||
// Invalid email data provided
|
||||
break;
|
||||
default:
|
||||
throw error; // rethrow anything you didn't explicitly handle
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Unknown `templateId` or `draftId` values fail the request immediately (HTTP 400) — nothing is enqueued. Those errors typically have no `errorCode` in the switch above, so they fall through to `default`.
|
||||
|
||||
## 6. Schedule a send (optional)
|
||||
|
||||
Pass `scheduledAt` to enqueue now and deliver later. Omit it to send as soon as the pipeline allows.
|
||||
|
||||
```typescript
|
||||
await hexclaveServerApp.sendEmail({
|
||||
userIds: ["user-id"],
|
||||
html: "<p>Happy New Year!</p>",
|
||||
subject: "Happy New Year!",
|
||||
notificationCategoryName: "Marketing",
|
||||
scheduledAt: new Date("2027-01-01T00:00:00Z"),
|
||||
});
|
||||
```
|
||||
|
||||
## 7. Watch delivery
|
||||
|
||||
After `sendEmail` returns, the message shows up under **Emails → Sent** with a status such as Preparing, Rendering, Scheduled, Queued, Sending, Sent, Skipped, Render Error, or Server Error. Under the hood, Hexclave enqueues, renders, queues (respecting capacity and `scheduledAt`), sends (honoring unsubscribes), and tracks delivery.
|
||||
|
||||
From code:
|
||||
|
||||
```typescript
|
||||
const info = await hexclaveServerApp.getEmailDeliveryStats();
|
||||
// info.stats.day.sent, info.stats.day.bounced, info.stats.day.marked_as_spam
|
||||
// info.capacity.rate_per_second, info.capacity.is_boost_active, ...
|
||||
```
|
||||
|
||||
Stats cover hour, day, week, and month windows for **sent**, **bounced**, and **marked as spam**.
|
||||
|
||||
If you need a short-term throughput increase, call it from your server:
|
||||
|
||||
```typescript
|
||||
await hexclaveServerApp.activateEmailCapacityBoost();
|
||||
```
|
||||
|
||||
Or increase capacity from the dashboard: open **Emails → Sent**, find the **Domain Reputation** card, and click **Temporarily increase capacity**.
|
||||
|
||||
A boost raises hourly capacity for a limited time (about 4× for 4 hours). It still counts against your overall monthly sending capacity.
|
||||
|
||||
## 8. Optional: compose without a code template
|
||||
|
||||
For one-off or campaign mail, open **Emails → Drafts**, create a blank draft or clone a template, edit with live preview, pick a theme and recipients, then send from the UI or with `draftId`. Details: [Drafts](./drafts).
|
||||
|
||||
## What you should have now
|
||||
|
||||
1. An email server (Shared for development, custom for production)
|
||||
2. A theme (built-in or your own)
|
||||
3. A template, HTML body, or draft
|
||||
4. At least one successful `sendEmail` from your backend
|
||||
5. Visibility into delivery in **Emails → Sent** / `getEmailDeliveryStats`
|
||||
|
||||
Built-in auth and payment emails continue to send automatically when those flows run — you only call `sendEmail` for your own product mail.
|
||||
|
||||
## Related
|
||||
|
||||
- [Emails overview](./overview) — capability FAQ
|
||||
- [Templates & themes](./templates-and-themes) — full template and theme APIs
|
||||
- [Drafts](./drafts) — dashboard composition and `draftId`
|
||||
- [Local vs cloud dashboard](/guides/going-further/local-vs-cloud-dashboard) — where Shared vs custom providers apply
|
||||
@ -1,173 +1,41 @@
|
||||
---
|
||||
title: "Emails"
|
||||
description: "Send custom emails, manage templates, and track delivery - all from Hexclave."
|
||||
icon: "/images/app-icons/emails.svg"
|
||||
description: "Send branded transactional and marketing email - rendering, delivery, and tracking handled for you"
|
||||
sidebarTitle: "Overview"
|
||||
---
|
||||
|
||||
Hexclave 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.
|
||||
<Visibility for="agents">
|
||||
<Note>
|
||||
**For agents/LLMs:** This is a high-level *marketing* overview of the Emails app, not an implementation reference. To actually build with email, use [Setup](/guides/getting-started/setup) and the [Emails guide](./guide) (sending, templates, themes, delivery, and server configuration).
|
||||
</Note>
|
||||
</Visibility>
|
||||
|
||||
## Email types
|
||||
import { EmailPreviewSkeleton, EmailTemplatesSkeleton, DeliveryStatsSkeleton } from "/snippets/skeletons/email-skeletons.jsx";
|
||||
|
||||
There are two categories of email:
|
||||
The Emails app covers Hexclave's built-in mail (verification, password resets, receipts) and your own transactional or marketing sends — templates, themes, scheduling, unsubscribes, and delivery tracking included. Below are the questions developers actually ask, and the honest answers.
|
||||
|
||||
- **Transactional** - Required for your app to function (verification, password reset, receipts). Users cannot opt out.
|
||||
- **Marketing** - Promotional or informational. Always includes an unsubscribe link. Users can opt out.
|
||||
## Can I send an email from my backend?
|
||||
|
||||
<Warning>
|
||||
Never send marketing content as transactional emails. Doing so can get your domain blacklisted by spam filters.
|
||||
</Warning>
|
||||
|
||||
## Sending emails
|
||||
|
||||
Emails are sent from your server using `hexclaveServerApp.sendEmail()`. You must provide the content (HTML, a template, or a draft) and the recipients.
|
||||
|
||||
### Send to specific users
|
||||
Yes. One call from your server, addressed to specific users or everyone in your project. No SMTP wiring, no render step to manage.
|
||||
|
||||
```typescript
|
||||
await hexclaveServerApp.sendEmail({
|
||||
userIds: ['user-id-1', 'user-id-2'],
|
||||
subject: 'Welcome to our platform!',
|
||||
html: '<h1>Welcome!</h1><p>Thanks for joining us.</p>',
|
||||
userIds: ["user-id"],
|
||||
subject: "Welcome aboard!",
|
||||
html: "<h1>Welcome!</h1><p>Thanks for joining us.</p>",
|
||||
});
|
||||
```
|
||||
|
||||
### Send to all users
|
||||
<EmailPreviewSkeleton />
|
||||
|
||||
```typescript
|
||||
await hexclaveServerApp.sendEmail({
|
||||
allUsers: true,
|
||||
templateId: 'your-template-id',
|
||||
subject: 'We just shipped a big update',
|
||||
variables: {
|
||||
featureName: 'Dark mode',
|
||||
},
|
||||
});
|
||||
```
|
||||
Send raw HTML, a template with variables, or a draft you composed in the dashboard. And failures are never silent - `sendEmail` throws on error with a stable `errorCode` (like `REQUIRES_CUSTOM_EMAIL_SERVER` or `USER_ID_DOES_NOT_EXIST`) so you can handle exactly the cases you care about.
|
||||
|
||||
### Send from a dashboard draft
|
||||
## Can I use my own templates?
|
||||
|
||||
If you've composed an email in the dashboard's draft editor, you can trigger it programmatically:
|
||||
|
||||
```typescript
|
||||
await hexclaveServerApp.sendEmail({
|
||||
userIds: ['user-id'],
|
||||
draftId: 'your-draft-id',
|
||||
});
|
||||
```
|
||||
|
||||
### Full options
|
||||
|
||||
```typescript
|
||||
await hexclaveServerApp.sendEmail({
|
||||
// Recipients - exactly one of these is required:
|
||||
userIds: ['user-id-1'], // specific users
|
||||
// allUsers: true, // or all users in your project
|
||||
|
||||
// Content - exactly one of these is required:
|
||||
html: '<p>Hello!</p>', // raw HTML
|
||||
// templateId: 'template-id', // or a template with variables
|
||||
// draftId: 'draft-id', // or a dashboard draft
|
||||
|
||||
// Optional:
|
||||
subject: 'Hello!',
|
||||
variables: { key: 'value' },
|
||||
themeId: 'theme-id', // apply a specific theme
|
||||
// themeId: null, // use the default theme
|
||||
// themeId: false, // send with no theme at all
|
||||
notificationCategoryName: 'Marketing',
|
||||
scheduledAt: new Date('2025-12-25T00:00:00Z'),
|
||||
});
|
||||
```
|
||||
|
||||
### `SendEmailOptions` type shape
|
||||
|
||||
```typescript
|
||||
type SendEmailOptions = {
|
||||
userIds: string[]; // users to send to
|
||||
themeId?: string | null | false; // theme override
|
||||
subject?: string; // subject line
|
||||
notificationCategoryName?: string; // preference category
|
||||
html?: string; // raw HTML body
|
||||
templateId?: string; // template ID
|
||||
variables?: Record<string, any>; // template variables
|
||||
};
|
||||
```
|
||||
|
||||
<Info>
|
||||
`sendEmail` requires a custom email server (SMTP, Resend, or Managed). It cannot be used with the shared development server.
|
||||
</Info>
|
||||
|
||||
### Error handling
|
||||
|
||||
`sendEmail` returns a result object. Handle failures explicitly:
|
||||
|
||||
```typescript
|
||||
const result = await hexclaveServerApp.sendEmail({
|
||||
userIds: ['user-id'],
|
||||
html: '<p>Hello!</p>',
|
||||
subject: 'Test Email',
|
||||
});
|
||||
|
||||
if (result.status === 'error') {
|
||||
switch (result.error.code) {
|
||||
case 'REQUIRES_CUSTOM_EMAIL_SERVER':
|
||||
console.error('Please configure a custom email server');
|
||||
break;
|
||||
case 'SCHEMA_ERROR':
|
||||
console.error('Invalid email data provided');
|
||||
break;
|
||||
case 'USER_ID_DOES_NOT_EXIST':
|
||||
console.error('One or more user IDs do not exist');
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Scheduling
|
||||
|
||||
Pass a `scheduledAt` date to delay delivery. The email enters the pipeline immediately but won't be sent until the scheduled time.
|
||||
|
||||
```typescript
|
||||
await hexclaveServerApp.sendEmail({
|
||||
userIds: ['user-id'],
|
||||
html: '<p>Happy New Year!</p>',
|
||||
subject: 'Happy New Year!',
|
||||
scheduledAt: new Date('2026-01-01T00:00:00Z'),
|
||||
});
|
||||
```
|
||||
|
||||
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.
|
||||
Yes. Templates are [React Email](https://react.email) components in TSX, with typed variables validated at render time and a live preview in the dashboard editor.
|
||||
|
||||
```tsx
|
||||
import { type } from "arktype";
|
||||
import { Container } from "@react-email/components";
|
||||
import { Subject, NotificationCategory, Props } from "@hexclave/emails";
|
||||
|
||||
export const variablesSchema = type({
|
||||
featureName: "string",
|
||||
});
|
||||
|
||||
export function EmailTemplate({
|
||||
user,
|
||||
project,
|
||||
variables,
|
||||
}: Props<typeof variablesSchema.infer>) {
|
||||
export function EmailTemplate({ user, variables }: Props<typeof variablesSchema.infer>) {
|
||||
return (
|
||||
<Container>
|
||||
<Subject value={`New feature: ${variables.featureName}`} />
|
||||
@ -176,175 +44,81 @@ export function EmailTemplate({
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
EmailTemplate.PreviewVariables = {
|
||||
featureName: "Dark mode",
|
||||
} satisfies typeof variablesSchema.infer;
|
||||
```
|
||||
|
||||
Key concepts:
|
||||
<EmailTemplatesSkeleton />
|
||||
|
||||
- **`variablesSchema`** - Define the shape of your template variables using [arktype](https://arktype.io). Hexclave validates variables against this schema at render time.
|
||||
- **`<Subject>`** - Sets the email subject line from inside the template.
|
||||
- **`<NotificationCategory>`** - Declares whether this is a `"Transactional"` or `"Marketing"` email.
|
||||
- **`PreviewVariables`** - Sample data used for the live preview in the dashboard editor.
|
||||
Hexclave also ships ready-made templates for the common flows - email verification, password reset, magic link, team invitations, and payment receipts - wired up automatically and customizable from the dashboard.
|
||||
|
||||
### Built-in templates
|
||||
## Can I match my brand?
|
||||
|
||||
Hexclave 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. Hexclave 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:
|
||||
Yes. Themes wrap every email in a consistent layout - header, footer, logo, background. Use the built-in Light, Dark, and Colorful themes, or write your own as a TSX component:
|
||||
|
||||
```tsx
|
||||
import { Html, Head, Tailwind, Body, Container } from "@react-email/components";
|
||||
import { ThemeProps, ProjectLogo } from "@hexclave/emails";
|
||||
|
||||
export function EmailTheme({ children, unsubscribeLink, projectLogos }: ThemeProps) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Tailwind>
|
||||
<Body className="bg-white font-sans m-0 p-0">
|
||||
<Container className="max-w-[600px] mx-auto p-8">
|
||||
<ProjectLogo data={projectLogos} mode="light" />
|
||||
{children}
|
||||
</Container>
|
||||
{unsubscribeLink && (
|
||||
<p className="text-center text-xs opacity-60">
|
||||
<a href={unsubscribeLink}>Unsubscribe</a>
|
||||
</p>
|
||||
)}
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
<>
|
||||
<ProjectLogo data={projectLogos} mode="light" />
|
||||
{children}
|
||||
{unsubscribeLink && <a href={unsubscribeLink}>Unsubscribe</a>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
Set a project default, override per-email with `themeId`, or send with no theme at all. See [Templates & themes](./templates-and-themes) for the full theme API and built-in themes.
|
||||
|
||||
## Notification preferences
|
||||
## Can I respect unsubscribes and 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:
|
||||
Yes - and it's automatic. Every email is **Transactional** (always delivered) or **Marketing** (users can opt out). Mark the category when you send, and Hexclave skips users who've unsubscribed and appends an unsubscribe link to marketing mail for you.
|
||||
|
||||
```typescript
|
||||
await hexclaveServerApp.sendEmail({
|
||||
userIds: ['user-id'],
|
||||
html: '<p>Check out our new feature!</p>',
|
||||
subject: 'Product Updates',
|
||||
notificationCategoryName: 'Marketing',
|
||||
userIds: ["user-id"],
|
||||
subject: "Product updates",
|
||||
html: "<p>Check out what's new!</p>",
|
||||
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.
|
||||
## Can I use my own email provider?
|
||||
|
||||
## React components integration
|
||||
Yes. Connect **custom SMTP**, plug in **Resend** with an API key, or let Hexclave run a **Managed** domain — you add the DNS records from onboarding; Hexclave handles signing and deliverability. Your built-in auth emails - verification, password resets, magic links - already work on Hexclave's **shared** development server out of the box; connect one of the custom providers when you're ready to send your own email and ship to production. Custom SMTP, Resend, and Managed can only be configured in the [cloud dashboard](https://app.hexclave.com). A [development environment](/guides/going-further/local-vs-cloud-dashboard) is limited to the shared server.
|
||||
|
||||
Emails integrate with Hexclave UI components automatically (for example verification, password reset, and magic-link flows).
|
||||
For custom flows, trigger `sendEmail` from your server code:
|
||||
## Can I schedule and send in bulk?
|
||||
|
||||
Yes. Pass `scheduledAt` to send later, and `allUsers: true` to reach your whole project. Delivery runs through an async pipeline that respects your sending capacity, so large sends don't tank your reputation.
|
||||
|
||||
```typescript
|
||||
import { hexclaveServerApp } from '@hexclave/next'; // replace `next` with the correct framework SDK package
|
||||
|
||||
export async function inviteUser(userId: string) {
|
||||
const result = await hexclaveServerApp.sendEmail({
|
||||
userIds: [userId],
|
||||
templateId: 'invitation-template',
|
||||
subject: "You're invited!",
|
||||
variables: {
|
||||
inviteUrl: 'https://yourapp.com/invite/token123',
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
await hexclaveServerApp.sendEmail({
|
||||
allUsers: true,
|
||||
templateId: "product-update",
|
||||
subject: "We just shipped a big update",
|
||||
scheduledAt: new Date("2027-01-01T00:00:00Z"),
|
||||
});
|
||||
```
|
||||
|
||||
## Email server configuration
|
||||
## Can I see what happened after I hit send?
|
||||
|
||||
Configure your email server in the dashboard under **Emails → Email Settings**. There are four options:
|
||||
Yes. Every email's status is tracked - sent, bounced, marked as spam - across hourly, daily, weekly, and monthly windows, in the dashboard and from code.
|
||||
|
||||
### Shared (development only)
|
||||
|
||||
The default for new projects. Emails are sent from `noreply@sent-with-hexclave.com` using Hexclave'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. Hexclave configures the SMTP connection automatically.
|
||||
|
||||
### Managed
|
||||
|
||||
Let Hexclave manage your email domain. Hexclave handles DNS configuration and deliverability for you. Set up requires:
|
||||
|
||||
1. Choose a subdomain (e.g. `mail.yourapp.com`)
|
||||
2. Add the DNS records Hexclave provides
|
||||
3. Verify the domain in the dashboard
|
||||
|
||||
<Info>
|
||||
The dashboard tests your email configuration automatically when you save it by sending a test email.
|
||||
</Info>
|
||||
|
||||
## Delivery stats
|
||||
|
||||
Hexclave 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:
|
||||
<DeliveryStatsSkeleton />
|
||||
|
||||
```typescript
|
||||
const info = await hexclaveServerApp.getEmailDeliveryStats();
|
||||
// info.stats.day.sent, info.stats.day.bounced, etc.
|
||||
// info.capacity.rate_per_second, info.capacity.is_boost_active, etc.
|
||||
// info.stats.day.sent, info.stats.day.bounced, ...
|
||||
```
|
||||
|
||||
Delivery capacity is managed automatically based on your sending reputation. If you need to temporarily increase throughput, you can activate a capacity boost:
|
||||
## Can I compose without writing code?
|
||||
|
||||
```typescript
|
||||
await hexclaveServerApp.activateEmailCapacityBoost();
|
||||
```
|
||||
Yes. The dashboard has a full draft editor with live preview, theme selection, a recipient picker, and scheduling. You can then either send the draft from the dashboard or programmatically using the `draftId`. See [Drafts](./drafts).
|
||||
|
||||
## Drafts
|
||||
## Start here
|
||||
|
||||
The dashboard includes a full draft editor where you can compose emails visually before sending. Drafts support:
|
||||
1. [Set up Hexclave](/guides/getting-started/setup), then enable **Emails** in the dashboard.
|
||||
2. Connect an email server under **Emails → Email Settings** (SMTP, Resend, or Managed) - the shared server already powers your auth emails in development.
|
||||
3. Call `hexclaveServerApp.sendEmail(...)` from your backend.
|
||||
|
||||
- TSX source editing with live preview
|
||||
- Theme selection
|
||||
- Recipient picker (specific users or all users)
|
||||
- Scheduling
|
||||
- Send history per draft
|
||||
|
||||
Once a draft is sent, it's marked as sent and its delivery can be tracked in the outbox.
|
||||
Ready for a start-to-finish walkthrough — server setup, templates, sending, and delivery? Read the [Emails guide](./guide).
|
||||
|
||||
110
docs-mintlify/guides/apps/emails/templates-and-themes.mdx
Normal file
110
docs-mintlify/guides/apps/emails/templates-and-themes.mdx
Normal file
@ -0,0 +1,110 @@
|
||||
---
|
||||
title: "Templates & Themes"
|
||||
description: "Build email bodies with React Email templates, then wrap them in reusable branded themes."
|
||||
sidebarTitle: "Templates & Themes"
|
||||
---
|
||||
|
||||
Every email Hexclave sends is built from two pieces:
|
||||
|
||||
- A **template** - your content (the body, subject, and notification category), written as a React Email component.
|
||||
- A **theme** - the shared layout and branding that wraps that content (header, footer, background, logo, unsubscribe link).
|
||||
|
||||
Templates and themes are independent: one template renders consistently across any theme, and changing a theme restyles every email at once. Both ship with sensible built-ins and can be customized or replaced from the dashboard.
|
||||
|
||||
## 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 "@hexclave/emails";
|
||||
|
||||
export const variablesSchema = type({
|
||||
featureName: "string",
|
||||
});
|
||||
|
||||
export function EmailTemplate({
|
||||
user,
|
||||
project,
|
||||
variables,
|
||||
}: Props<typeof variablesSchema.infer>) {
|
||||
return (
|
||||
<Container>
|
||||
<Subject value={`New feature: ${variables.featureName}`} />
|
||||
<NotificationCategory value="Transactional" />
|
||||
<p>Hi {user.displayName}, check out {variables.featureName}!</p>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
EmailTemplate.PreviewVariables = {
|
||||
featureName: "Dark mode",
|
||||
} satisfies typeof variablesSchema.infer;
|
||||
```
|
||||
|
||||
Key concepts:
|
||||
|
||||
- **`variablesSchema`** - Define the shape of your template variables using [arktype](https://arktype.io). Hexclave validates variables against this schema at render time.
|
||||
- **`<Subject>`** - Sets the email subject line from inside the template.
|
||||
- **`<NotificationCategory>`** - Declares whether this is a `"Transactional"` or `"Marketing"` email.
|
||||
- **`PreviewVariables`** - Sample data used for the live preview in the dashboard editor.
|
||||
|
||||
### Built-in templates
|
||||
|
||||
Hexclave 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. Hexclave 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 "@hexclave/emails";
|
||||
|
||||
export function EmailTheme({ children, unsubscribeLink, projectLogos }: ThemeProps) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Tailwind>
|
||||
<Body className="bg-white font-sans m-0 p-0">
|
||||
<Container className="max-w-[600px] mx-auto p-8">
|
||||
<ProjectLogo data={projectLogos} mode="light" />
|
||||
{children}
|
||||
</Container>
|
||||
{unsubscribeLink && (
|
||||
<p className="text-center text-xs opacity-60">
|
||||
<a href={unsubscribeLink}>Unsubscribe</a>
|
||||
</p>
|
||||
)}
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Related
|
||||
|
||||
- [Drafts](./drafts) - compose in the dashboard and send with `draftId` (not TSX templates).
|
||||
- [Emails guide](./guide) - start-to-finish implementation: server, templates, sending, and delivery.
|
||||
@ -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)
|
||||
@ -1,20 +1,20 @@
|
||||
---
|
||||
title: "Launch Checklist"
|
||||
description: "Steps to prepare Stack for production use"
|
||||
description: "Steps to prepare Hexclave for production use"
|
||||
icon: "/images/app-icons/launch-checklist.svg"
|
||||
---
|
||||
|
||||
Stack makes development easy with various default settings, but these settings need to be optimized for security and user experience when moving to production. Here's a checklist of things you need to do before switching to production mode:
|
||||
Hexclave makes development easy with various default settings, but these settings need to be optimized for security and user experience when moving to production. Here's a checklist of things you need to do before switching to production mode:
|
||||
|
||||
## Domains
|
||||
|
||||
By default, Stack allows all localhost paths as valid callback URLs. This is convenient for development but poses a security risk in production because attackers could use their own domains as callback URLs to intercept sensitive information. Therefore, in production, Stack must know your domain (e.g., `https://your-website.com`) and only allow callbacks from those domains.
|
||||
By default, Hexclave allows all localhost paths as valid callback URLs. This is convenient for development but poses a security risk in production because attackers could use their own domains as callback URLs to intercept sensitive information. Therefore, in production, Hexclave must know your domain (e.g., `https://your-website.com`) and only allow callbacks from those domains.
|
||||
|
||||
Follow these steps when you're ready to push your application to production:
|
||||
|
||||
<Steps>
|
||||
<Step title="Add Your Domain">
|
||||
Navigate to the `Domain & Handlers` tab in the Stack dashboard. If you haven't configured your handler, you can leave it as the default. (Learn more about handlers [here](/sdk/objects/hexclave-app)).
|
||||
Navigate to the `Domain & Handlers` tab in the Hexclave dashboard. If you haven't configured your handler, you can leave it as the default. (Learn more about handlers [here](/sdk/objects/hexclave-app)).
|
||||
</Step>
|
||||
<Step title="Disable Localhost Callbacks">
|
||||
For enhanced security, disable the `Allow all localhost callbacks for development` option.
|
||||
@ -23,13 +23,13 @@ Follow these steps when you're ready to push your application to production:
|
||||
|
||||
## OAuth providers
|
||||
|
||||
Stack uses shared OAuth keys for development to simplify setup when using "Sign in with Google/GitHub/etc." However, this isn't secure for production as it displays "Stack Development" on the providers' consent screens, making it unclear to users if the OAuth request is genuinely from your site. Thus, you should configure your own OAuth keys with the providers and connect them to Stack.
|
||||
Hexclave uses shared OAuth keys for development to simplify setup when using "Sign in with Google/GitHub/etc." However, this isn't secure for production as it displays "Hexclave Development" on the providers' consent screens, making it unclear to users if the OAuth request is genuinely from your site. Thus, you should configure your own OAuth keys with the providers and connect them to Hexclave.
|
||||
|
||||
To use your own OAuth provider setups in production, follow these steps for each provider you use:
|
||||
|
||||
<Steps>
|
||||
<Step title="Create an OAuth App">
|
||||
On the provider's website, create an OAuth app and set the callback URL to the corresponding Stack callback URL. Copy the client ID and client secret.
|
||||
On the provider's website, create an OAuth app and set the callback URL to the corresponding Hexclave callback URL. Copy the client ID and client secret.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Google">
|
||||
@ -116,19 +116,19 @@ To use your own OAuth provider setups in production, follow these steps for each
|
||||
</Tabs>
|
||||
</Step>
|
||||
<Step title="Enter OAuth Credentials">
|
||||
Go to the `Auth Methods` section in the Stack dashboard, open the provider's settings, switch from shared keys to custom keys, and enter the client ID and client secret.
|
||||
Go to the `Auth Methods` section in the Hexclave dashboard, open the provider's settings, switch from shared keys to custom keys, and enter the client ID and client secret.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Email server
|
||||
|
||||
For development, Stack uses a shared email server, which sends emails from Stack's domain. This is not ideal for production as users may not trust emails from an unfamiliar domain. You should set up an email server connected to your own domain.
|
||||
For development, Hexclave uses a shared email server, which sends emails from Hexclave's domain. This is not ideal for production as users may not trust emails from an unfamiliar domain. You should set up an email server connected to your own domain.
|
||||
|
||||
Steps to connect your own email server with Stack:
|
||||
Steps to connect your own email server with Hexclave:
|
||||
|
||||
1. **Setup Email Server**: Configure your own email server and connect it to your domain (this step is beyond Stack's documentation scope).
|
||||
2. **Configure Stack's Email Settings**: Navigate to the `Emails` section in the Stack dashboard, click `Edit` in the `Email Server` section, switch from `Shared` to `Custom SMTP server`, enter your SMTP configurations, and save.
|
||||
1. **Setup Email Server**: Configure your own email server and connect it to your domain (this step is beyond Hexclave's documentation scope).
|
||||
2. **Configure Hexclave's Email Settings**: Navigate to the `Emails` section in the Hexclave dashboard, click `Edit` in the `Email Server` section, switch from `Shared` to `Custom SMTP server`, enter your SMTP configurations, and save.
|
||||
|
||||
## Enabling production mode
|
||||
|
||||
After completing the steps above, you can enable production mode on the `Project Settings` tab in the Stack dashboard, ensuring that your website runs securely with Stack in a production environment.
|
||||
After completing the steps above, you can enable production mode on the `Project Settings` tab in the Hexclave dashboard, ensuring that your website runs securely with Hexclave in a production environment.
|
||||
|
||||
67
docs-mintlify/guides/apps/payments/billing-and-invoices.mdx
Normal file
67
docs-mintlify/guides/apps/payments/billing-and-invoices.mdx
Normal file
@ -0,0 +1,67 @@
|
||||
---
|
||||
title: "Billing & Invoices"
|
||||
description: "Save payment methods and display a customer's billing history"
|
||||
---
|
||||
|
||||
## Payment methods
|
||||
|
||||
Customers can save a payment method for future purchases and plan switches. This is built on a secure setup-intent flow.
|
||||
|
||||
```typescript
|
||||
// Create a setup intent
|
||||
const setupIntent = await user.createPaymentMethodSetupIntent();
|
||||
// setupIntent.clientSecret - use with the embedded card form to collect card details
|
||||
|
||||
// After the user completes the card form:
|
||||
const paymentMethod = await user.setDefaultPaymentMethodFromSetupIntent(
|
||||
setupIntentId
|
||||
);
|
||||
// paymentMethod contains: id, brand, last4, exp_month, exp_year
|
||||
// (it may be null, and the card fields can individually be null)
|
||||
```
|
||||
|
||||
To check if a customer has a payment method saved:
|
||||
|
||||
```typescript
|
||||
// Client component (hook)
|
||||
const billing = user.useBilling();
|
||||
|
||||
// Server component
|
||||
const billing = await user.getBilling();
|
||||
|
||||
// billing.hasCustomer - whether a billing customer exists
|
||||
// billing.defaultPaymentMethod - card details or null
|
||||
```
|
||||
|
||||
## Invoices
|
||||
|
||||
List a customer's invoices for displaying billing history:
|
||||
|
||||
```typescript
|
||||
// Client component (hook)
|
||||
const invoices = user.useInvoices();
|
||||
|
||||
// Server component
|
||||
const invoices = await user.listInvoices();
|
||||
```
|
||||
|
||||
Each invoice includes `createdAt`, `status` (`"draft"`, `"open"`, `"paid"`, `"uncollectible"`, `"void"`, or `null`), `amountTotal` (in cents), and `hostedInvoiceUrl` (a link to the hosted invoice page, or `null`).
|
||||
|
||||
Invoices support pagination:
|
||||
|
||||
```typescript
|
||||
const firstPage = await user.listInvoices({ limit: 10 });
|
||||
const secondPage = await user.listInvoices({
|
||||
limit: 10,
|
||||
cursor: firstPage.nextCursor,
|
||||
});
|
||||
```
|
||||
|
||||
<Info>
|
||||
Billing and invoices are available for user and team customers only, not custom customers (see [Customer Types](./customers)).
|
||||
</Info>
|
||||
|
||||
## Related
|
||||
|
||||
- [Subscriptions](./subscriptions) - switching plans requires a saved payment method
|
||||
- [Checkout & Purchases](./checkout) - sell a product
|
||||
85
docs-mintlify/guides/apps/payments/checkout.mdx
Normal file
85
docs-mintlify/guides/apps/payments/checkout.mdx
Normal file
@ -0,0 +1,85 @@
|
||||
---
|
||||
title: "Checkout & Purchases"
|
||||
description: "Sell a product with a checkout URL and read what a customer owns"
|
||||
---
|
||||
|
||||
To sell a product, generate a checkout URL and redirect the customer to it. Hexclave runs the hosted checkout, receives the payment confirmation, and grants the product - you never write a webhook handler.
|
||||
|
||||
## Selling a product
|
||||
|
||||
The `createCheckoutUrl` method is available on both user and team objects.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Client Component">
|
||||
```typescript title="app/components/purchase-button.tsx"
|
||||
"use client";
|
||||
import { useUser } from "@hexclave/next"; // replace `next` with the correct framework SDK package
|
||||
|
||||
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 <button onClick={handlePurchase}>Purchase</button>;
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Server Component">
|
||||
```typescript title="app/purchase/page.tsx"
|
||||
import { hexclaveServerApp } from "@/hexclave/server";
|
||||
|
||||
export default async function PurchasePage() {
|
||||
const user = await hexclaveServerApp.getUser({ or: 'redirect' });
|
||||
|
||||
const checkoutUrl = await user.createCheckoutUrl({
|
||||
productId: "prod_premium_monthly",
|
||||
});
|
||||
|
||||
return <a href={checkoutUrl}>Upgrade to Premium</a>;
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
For team purchases, call `createCheckoutUrl` on the team object instead:
|
||||
|
||||
```typescript
|
||||
const team = user.useTeam(teamId);
|
||||
const checkoutUrl = await team.createCheckoutUrl({ productId });
|
||||
```
|
||||
|
||||
<Info>
|
||||
If you're using a non-JS backend (Python, Go, etc.), call the REST API directly: `POST /api/v1/payments/purchases/create-purchase-url` with `customer_type`, `customer_id`, and `product_id`. See the [REST API overview](/api/overview) for details.
|
||||
</Info>
|
||||
|
||||
## Checking what a customer owns
|
||||
|
||||
After a purchase, you'll want to know what the customer has. Check their product list, or check a specific [item balance](./items-and-entitlements).
|
||||
|
||||
```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)
|
||||
- `subscription` - `null` for one-time products, or an object with `subscriptionId`, `currentPeriodEnd`, `cancelAtPeriodEnd`, and `isCancelable` for subscriptions
|
||||
- `switchOptions` - Other products in the same product line the customer could switch to
|
||||
|
||||
## Related
|
||||
|
||||
- [Items & Entitlements](./items-and-entitlements) - read and consume credits, seats, and quota
|
||||
- [Subscriptions](./subscriptions) - manage recurring plans after purchase
|
||||
- [Granting Products](./granting-products) - give access without checkout
|
||||
18
docs-mintlify/guides/apps/payments/customers.mdx
Normal file
18
docs-mintlify/guides/apps/payments/customers.mdx
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
title: "Customer Types"
|
||||
description: "Bill users, teams, and custom external customers"
|
||||
---
|
||||
|
||||
A **customer** is whoever owns a purchase. Hexclave supports three types:
|
||||
|
||||
- **Users** - Individual user accounts. Users can manage their own purchases, billing, and invoices from the client SDK.
|
||||
- **Teams** - Team or organization accounts. Team admins can create checkouts, switch plans, and cancel subscriptions for their team.
|
||||
- **Custom customers** - External entities identified by an arbitrary string ID. Useful for integrations with external systems. Custom customers are managed only via the server SDK and do not support billing or invoices.
|
||||
|
||||
## Where each method lives
|
||||
|
||||
The full billing surface - [`createCheckoutUrl`](./checkout), [`useProducts`](./checkout#checking-what-a-customer-owns), [`useItem`](./items-and-entitlements), [`switchSubscription`](./subscriptions), [`useBilling` and `useInvoices`](./billing-and-invoices) - is available on both **user** and **team** objects.
|
||||
|
||||
For **custom customers**, use the top-level `hexclaveServerApp` methods with `customCustomerId` (for example [`grantProduct`](./granting-products), `getItem`, and `listProducts`). Billing, invoices, and subscription switching are not available for custom customers.
|
||||
|
||||
Every product declares a **customer type** when you [define it](./products-and-pricing#defining-products), which determines who can purchase it.
|
||||
61
docs-mintlify/guides/apps/payments/granting-products.mdx
Normal file
61
docs-mintlify/guides/apps/payments/granting-products.mdx
Normal file
@ -0,0 +1,61 @@
|
||||
---
|
||||
title: "Granting Products"
|
||||
description: "Give a customer a product without going through checkout"
|
||||
---
|
||||
|
||||
Sometimes you need to give a customer a product without charging them - free trials, admin grants, promotional offers, support credits. Use `grantProduct` on the server.
|
||||
|
||||
## Granting a configured product
|
||||
|
||||
```typescript
|
||||
import { hexclaveServerApp } from "@/hexclave/server";
|
||||
|
||||
// Grant a pre-configured product to a user
|
||||
await hexclaveServerApp.grantProduct({
|
||||
userId: "user-id",
|
||||
productId: "prod_premium",
|
||||
});
|
||||
|
||||
// Grant to a team
|
||||
await hexclaveServerApp.grantProduct({
|
||||
teamId: "team-id",
|
||||
productId: "prod_team_plan",
|
||||
});
|
||||
|
||||
// Grant to a custom customer
|
||||
await hexclaveServerApp.grantProduct({
|
||||
customCustomerId: "external-org-123",
|
||||
productId: "prod_enterprise",
|
||||
});
|
||||
```
|
||||
|
||||
If you already have a reference to a **server** user or team object (for example from `hexclaveServerApp.getUser(userId)`), you can also call `user.grantProduct({ productId })` directly. This method is server-only.
|
||||
|
||||
## Inline products
|
||||
|
||||
You can also grant products with an **inline definition** - no pre-configured product needed. This is useful for one-off grants like bonus credits:
|
||||
|
||||
```typescript
|
||||
import { hexclaveServerApp } from "@/hexclave/server";
|
||||
|
||||
await hexclaveServerApp.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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [Items & Entitlements](./items-and-entitlements) - the credits and seats a grant hands out
|
||||
- [Customer Types](./customers) - grant to users, teams, or custom customers
|
||||
@ -0,0 +1,83 @@
|
||||
---
|
||||
title: "Items & Entitlements"
|
||||
description: "Track and consume credits, seats, and API quota with race-safe item balances"
|
||||
---
|
||||
|
||||
Items are the building blocks of entitlements. Instead of just recording that a customer "bought the Pro plan," Hexclave tracks the quantifiable things that plan grants - **credits**, **seats**, **API calls** - as item balances on the customer, and keeps them in sync as customers buy, consume, renew, and churn.
|
||||
|
||||
When a product includes items like "100 credits" or "5 seats", those quantities are granted on purchase. Each included item is configured with:
|
||||
|
||||
- **Quantity** - How much to grant
|
||||
- **Repeat** - An optional refresh interval (e.g. grant again every month), or `never`
|
||||
- **Expires** - When the grant expires: `never`, `when-purchase-expires`, or `when-repeated`
|
||||
|
||||
## Checking item balances
|
||||
|
||||
```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
|
||||
|
||||
Here's a practical example - showing a credits counter:
|
||||
|
||||
```typescript title="app/components/credits-widget.tsx"
|
||||
"use client";
|
||||
import { useUser } from "@hexclave/next"; // replace `next` with the correct framework SDK package
|
||||
|
||||
export default function CreditsWidget() {
|
||||
const user = useUser({ or: 'redirect' });
|
||||
const credits = user.useItem("credits");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Available Credits</h3>
|
||||
<p>{credits.nonNegativeQuantity}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Consuming credits (server-side)
|
||||
|
||||
When your app needs to consume credits (e.g. when a user sends an AI request), use `tryDecreaseQuantity` on the server. It's a single transactional operation - it returns `false` and does nothing if the balance would go negative, so concurrent requests can't overspend.
|
||||
|
||||
```typescript title="lib/credits.ts"
|
||||
import { hexclaveServerApp } from "@/hexclave/server";
|
||||
|
||||
export async function consumeCredits(userId: string, amount: number) {
|
||||
const user = await hexclaveServerApp.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 };
|
||||
}
|
||||
```
|
||||
|
||||
<Info>
|
||||
Always use `tryDecreaseQuantity()` instead of checking the balance and then decreasing. This prevents race conditions where multiple requests could consume more credits than available.
|
||||
</Info>
|
||||
|
||||
You can also increase a balance with `credits.increaseQuantity(amount)` or decrease without the safety check using `credits.decreaseQuantity(amount)`. These consumption methods are **server-only** - client-side `useItem` returns a read-only balance.
|
||||
|
||||
## Adjusting balances from the dashboard
|
||||
|
||||
In **Payments -> Customers** you can view item balances per customer and manually adjust quantities (with an optional expiration) - useful for support credits, comps, or corrections.
|
||||
|
||||
## Related
|
||||
|
||||
- [Products & Pricing](./products-and-pricing) - attach items to a product
|
||||
- [Granting Products](./granting-products) - grant a one-off bundle of items
|
||||
@ -1,419 +1,111 @@
|
||||
---
|
||||
title: "Payments"
|
||||
description: "Accept payments and manage billing with Hexclave's Stripe integration"
|
||||
icon: "/images/app-icons/payments.svg"
|
||||
description: "Sell subscriptions and one-time products, and manage entitlements like credits and seats - billing without the plumbing"
|
||||
sidebarTitle: "Overview"
|
||||
---
|
||||
|
||||
import { PaymentsConcepts } from "/snippets/payments-concepts.jsx";
|
||||
<Visibility for="agents">
|
||||
<Note>
|
||||
**For agents/LLMs:** This is a high-level *marketing* overview of the Payments app, not an implementation reference. To actually build with payments, start at [Payments setup](./setup), then use the concept pages: [Products & Pricing](./products-and-pricing), [Items & Entitlements](./items-and-entitlements), [Checkout & Purchases](./checkout), [Subscriptions](./subscriptions), [Billing & Invoices](./billing-and-invoices), [Granting Products](./granting-products), and [Customer Types](./customers).
|
||||
</Note>
|
||||
</Visibility>
|
||||
|
||||
Hexclave includes a Payments app that handles billing, subscriptions, and one-time purchases through Stripe. Instead of building your own billing system, you define products in the dashboard and Hexclave takes care of checkout, entitlement tracking, subscription lifecycle, and invoicing.
|
||||
import { PricingSkeleton, EntitlementsSkeleton, TransactionsSkeleton } from "/snippets/skeletons/payments-skeletons.jsx";
|
||||
|
||||
This guide walks through how to set up payments, sell products, manage subscriptions, and work with item-based entitlements like credits or seats.
|
||||
Charging the card is the easy part. Everything *after* that - turning a payment into access, keeping it in sync, handling upgrades, refunds, and renewals - is the part you'd normally build and maintain yourself. The Payments app owns that layer. You define products in the dashboard; Hexclave runs checkout, grants and revokes entitlements, tracks subscriptions, and keeps your billing state correct. Below are the questions developers actually ask, and the honest answers.
|
||||
|
||||
## Getting started
|
||||
## Can I sell a subscription or one-time product?
|
||||
|
||||
<Steps>
|
||||
<Step title="Enable Payments">
|
||||
Go to the **Apps** section in your dashboard, find **Payments**, and enable it.
|
||||
</Step>
|
||||
<Step title="Connect Stripe">
|
||||
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.
|
||||
</Step>
|
||||
<Step title="Turn on test mode">
|
||||
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.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Info>
|
||||
Hexclave Payments is currently only available for US-based businesses. Support for other countries is coming soon.
|
||||
</Info>
|
||||
|
||||
## Core concepts
|
||||
|
||||
Before writing any code, it helps to understand how the pieces fit together.
|
||||
|
||||
A **product** is something you sell - a subscription plan, a one-time purchase, or a credit pack. Products can have one or more **prices** (one-time or recurring, in multiple currencies), and they can include **items** - quantifiable entitlements like credits, seats, or API calls.
|
||||
|
||||
A **product line** groups products that are mutually exclusive. For example, a "Plan" product line might contain Free, Pro, and Enterprise - a customer can only have one at a time. When they upgrade, the old plan is replaced.
|
||||
|
||||
A **customer** is whoever owns the purchase. This can be a user, a team, or a custom external entity.
|
||||
|
||||
Here's how these pieces look in practice:
|
||||
|
||||
<PaymentsConcepts />
|
||||
|
||||
And the typical flow to make it all work:
|
||||
|
||||
1. You define products and items in the dashboard
|
||||
2. Your app generates a checkout URL and redirects the user to Stripe
|
||||
3. The user pays, Stripe notifies Hexclave, and the product is granted
|
||||
4. Your app reads the customer's products and item balances to control access
|
||||
|
||||
## Defining products
|
||||
|
||||
Configure your products in **Payments -> Products & Items**. Each product has:
|
||||
|
||||
- **Display name** - What the customer sees
|
||||
- **Customer type** - Whether this product is for users, teams, or custom customers
|
||||
- **Prices** - One or more prices, each with a currency amount and an optional billing interval (day, week, month, or year). Currency amounts are **decimal strings** like `"9.99"` or `"1000"` — not cent integers. Supported currencies: USD, EUR, GBP, JPY, INR, AUD, CAD.
|
||||
- **Included items** - Items granted when the product is purchased, with configurable quantity, repeat schedule, and expiration behavior
|
||||
|
||||
A few additional options:
|
||||
|
||||
- **Product lines** - Assign products to a product line to make them mutually exclusive (e.g. plan tiers). Configure lines in **Payments -> Product Lines**.
|
||||
- **Add-ons** - Set **isAddOnTo** to require the customer to already own a specific base product before purchasing this one.
|
||||
- **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.
|
||||
- **Stackable** - Allow multiple purchases of the same product (default is one per customer).
|
||||
|
||||
## 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.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Client Component">
|
||||
```typescript title="app/components/purchase-button.tsx"
|
||||
"use client";
|
||||
import { useUser } from "@hexclave/next"; // replace `next` with the correct framework SDK package
|
||||
|
||||
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 <button onClick={handlePurchase}>Purchase</button>;
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Server Component">
|
||||
```typescript title="app/purchase/page.tsx"
|
||||
import { hexclaveServerApp } from "@/stack/server";
|
||||
|
||||
export default async function PurchasePage() {
|
||||
const user = await hexclaveServerApp.getUser({ or: 'redirect' });
|
||||
|
||||
const checkoutUrl = await user.createCheckoutUrl({
|
||||
productId: "prod_premium_monthly",
|
||||
});
|
||||
|
||||
return <a href={checkoutUrl}>Upgrade to Premium</a>;
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
For team purchases, call `createCheckoutUrl` on the team object instead:
|
||||
Yes. Define products and prices in the dashboard, then generate a checkout URL and redirect. Hexclave handles the checkout session, the webhook, and granting access on success - you never write a webhook handler.
|
||||
|
||||
```typescript
|
||||
const team = user.useTeam(teamId);
|
||||
const checkoutUrl = await team.createCheckoutUrl({ productId });
|
||||
const checkoutUrl = await user.createCheckoutUrl({
|
||||
productId: "pro_monthly",
|
||||
returnUrl: window.location.href,
|
||||
});
|
||||
window.location.href = checkoutUrl;
|
||||
```
|
||||
|
||||
<Info>
|
||||
If you're using a non-JS backend (Python, Go, etc.), call the REST API directly: `POST /api/v1/payments/purchases/create-purchase-url` with `customer_type`, `customer_id`, and `product_id`. See the [REST API overview](/api/overview) for details.
|
||||
</Info>
|
||||
<PricingSkeleton />
|
||||
|
||||
## Checking what a customer owns
|
||||
Group products into a **product line** to make them mutually exclusive - Free, Pro, and Enterprise where a customer can only hold one at a time. Upgrades, downgrades, and proration on plan switches are handled for you.
|
||||
|
||||
After a purchase, you'll want to know what the customer has. There are two ways to do this: check their product list, or check a specific item balance.
|
||||
## Can I sell credits, seats, or API quota?
|
||||
|
||||
### Listing products
|
||||
Yes - and this is the real point. Products include **items**: quantifiable entitlements like credits, seats, or API calls, with their own quantity, refresh schedule, and expiration. When a customer buys, the items are granted. When their plan ends, they're revoked. You don't track any of it in your own database.
|
||||
|
||||
<EntitlementsSkeleton />
|
||||
|
||||
Read a balance anywhere with a hook that stays in sync, and check access without a billing round-trip:
|
||||
|
||||
```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 (period end, cancel state) if applicable
|
||||
- `switchOptions` - Other products in the same product line the customer could switch to
|
||||
|
||||
### Checking item balances
|
||||
|
||||
Items are the building blocks of entitlements. When a product includes items like "100 credits" or "5 seats", those are tracked as item balances on the customer.
|
||||
|
||||
```typescript
|
||||
// Client component (hook - re-renders on changes)
|
||||
// Client - re-renders when the balance changes
|
||||
const credits = user.useItem("credits");
|
||||
return <span>{credits.nonNegativeQuantity} credits left</span>;
|
||||
```
|
||||
|
||||
// Server component
|
||||
## Can I consume credits safely under load?
|
||||
|
||||
Yes. Consume on the server with `tryDecreaseQuantity` - a single transactional operation that returns `false` instead of letting a balance go negative. No read-then-write race, no double-spend when two requests land at once.
|
||||
|
||||
```typescript
|
||||
const credits = await user.getItem("credits");
|
||||
const ok = await credits.tryDecreaseQuantity(1);
|
||||
if (!ok) throw new Error("Out of credits");
|
||||
// ...do the work
|
||||
```
|
||||
|
||||
An item has two quantity fields:
|
||||
Grant more with `increaseQuantity`, or adjust manually from the dashboard. This is the consumption layer you'd otherwise build by hand with a ledger table and a pile of webhooks.
|
||||
|
||||
- `quantity` - The raw balance (can be negative if you've consumed more than granted)
|
||||
- `nonNegativeQuantity` - `Math.max(0, quantity)` for display purposes
|
||||
## What exactly does Hexclave own for me?
|
||||
|
||||
Here's a practical example - showing a credits counter:
|
||||
With a raw payment processor you build and maintain the glue: webhook handlers, granting and revoking access on payment, proration on upgrades, refunds, subscription endings, and keeping your own database in sync with all of it. Hexclave owns that layer:
|
||||
|
||||
```typescript title="app/components/credits-widget.tsx"
|
||||
"use client";
|
||||
import { useUser } from "@hexclave/next"; // replace `next` with the correct framework SDK package
|
||||
- **Webhooks** - received and reconciled for you; there's nothing to host
|
||||
- **Grants & revokes** - products and items are applied on payment and removed when a subscription ends or a refund is issued
|
||||
- **Subscription lifecycle** - status, renewals, cancellations, and period ends tracked automatically
|
||||
- **Proration** - handled automatically on plan switches, triggered through one SDK call
|
||||
- **Refunds** - issued from the dashboard, with entitlements revoked in step
|
||||
- **Sync** - entitlement and subscription state stays consistent without a database to babysit
|
||||
|
||||
export default function CreditsWidget() {
|
||||
const user = useUser({ or: 'redirect' });
|
||||
const credits = user.useItem("credits");
|
||||
<TransactionsSkeleton />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Available Credits</h3>
|
||||
<p>{credits.nonNegativeQuantity}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
## Can I bill teams and external customers, not just users?
|
||||
|
||||
### Consuming credits (server-side)
|
||||
Yes. Every customer is a **user**, a **team**, or a **custom customer** (any external entity you key by ID). The billing surface - `createCheckoutUrl`, `useProducts`, `useItem`, `switchSubscription` - lives on user and team objects; custom customers are managed from the server SDK for items, products, and grants.
|
||||
|
||||
When your app needs to consume credits (e.g. when a user sends an AI request), use `tryDecreaseQuantity` on the server. It's atomic and race-condition-safe - it will return `false` and do nothing if the balance would go negative.
|
||||
## Can I manage subscriptions in code?
|
||||
|
||||
```typescript title="lib/credits.ts"
|
||||
import { hexclaveServerApp } from "@/stack/server";
|
||||
|
||||
export async function consumeCredits(userId: string, amount: number) {
|
||||
const user = await hexclaveServerApp.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 };
|
||||
}
|
||||
```
|
||||
|
||||
<Info>
|
||||
Always use `tryDecreaseQuantity()` instead of checking the balance and then decreasing. This prevents race conditions where multiple requests could consume more credits than available.
|
||||
</Info>
|
||||
|
||||
You can also increase a balance with `credits.increaseQuantity(amount)` or decrease without the safety check using `credits.decreaseQuantity(amount)`.
|
||||
|
||||
## Managing subscriptions
|
||||
|
||||
### Switching plans
|
||||
|
||||
When products belong to the same product line, customers can switch between them. For example, upgrading from Pro to Enterprise:
|
||||
|
||||
```typescript title="app/components/upgrade-button.tsx"
|
||||
"use client";
|
||||
import { useUser } from "@hexclave/next"; // replace `next` with the correct framework SDK package
|
||||
|
||||
export default function UpgradeButton() {
|
||||
const user = useUser({ or: 'redirect' });
|
||||
|
||||
return (
|
||||
<button onClick={async () => {
|
||||
await user.switchSubscription({
|
||||
fromProductId: "prod_pro",
|
||||
toProductId: "prod_enterprise",
|
||||
});
|
||||
}}>
|
||||
Upgrade to Enterprise
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
<Info>
|
||||
Switching plans requires the customer to have a default payment method saved. The switch happens immediately and Stripe prorates the charge automatically.
|
||||
</Info>
|
||||
|
||||
### Canceling a subscription
|
||||
|
||||
`cancelSubscription` is called on the app instance rather than the user object:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Client Component">
|
||||
```typescript
|
||||
"use client";
|
||||
import { useHexclaveApp } from "@hexclave/next"; // replace `next` with the correct framework SDK package
|
||||
|
||||
export default function CancelButton({ productId }: { productId: string }) {
|
||||
const app = useHexclaveApp();
|
||||
return (
|
||||
<button onClick={async () => {
|
||||
await app.cancelSubscription({ productId });
|
||||
}}>
|
||||
Cancel Subscription
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Server">
|
||||
```typescript
|
||||
// Cancel for the current user
|
||||
await hexclaveServerApp.cancelSubscription({ productId: "prod_pro" });
|
||||
|
||||
// Cancel for a team
|
||||
await hexclaveServerApp.cancelSubscription({
|
||||
productId: "prod_team_plan",
|
||||
teamId: "team-id",
|
||||
});
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Billing and payment methods
|
||||
|
||||
Customers can save a payment method for future purchases and plan switches. This is built on Stripe's SetupIntents.
|
||||
Yes. Switch a customer between plans in the same product line, or cancel, with a single call - proration and timing handled for you.
|
||||
|
||||
```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 if a customer has a payment method saved:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
## Invoices
|
||||
|
||||
List a customer's invoices for displaying billing history:
|
||||
|
||||
```typescript
|
||||
// Client component (hook)
|
||||
const invoices = user.useInvoices();
|
||||
|
||||
// Server component
|
||||
const invoices = await user.listInvoices();
|
||||
```
|
||||
|
||||
Each invoice includes `createdAt`, `status` (`"draft"`, `"open"`, `"paid"`, `"uncollectible"`, `"void"`), `amountTotal` (in cents), and `hostedInvoiceUrl` (a link to Stripe's hosted invoice page).
|
||||
|
||||
Invoices support pagination:
|
||||
|
||||
```typescript
|
||||
const firstPage = await user.listInvoices({ limit: 10 });
|
||||
const secondPage = await user.listInvoices({
|
||||
limit: 10,
|
||||
cursor: firstPage.nextCursor,
|
||||
});
|
||||
```
|
||||
|
||||
<Info>
|
||||
Invoices are available for user and team customers only, not custom customers.
|
||||
</Info>
|
||||
|
||||
## Granting products programmatically
|
||||
|
||||
Sometimes you need to give a customer a product without going through checkout - free trials, admin grants, promotional offers, etc. Use `grantProduct` on the server:
|
||||
|
||||
```typescript
|
||||
// Grant a pre-configured product to a user
|
||||
await hexclaveServerApp.grantProduct({
|
||||
userId: "user-id",
|
||||
productId: "prod_premium",
|
||||
await user.switchSubscription({
|
||||
fromProductId: "pro_monthly",
|
||||
toProductId: "enterprise_monthly",
|
||||
});
|
||||
|
||||
// Grant to a team
|
||||
await hexclaveServerApp.grantProduct({
|
||||
teamId: "team-id",
|
||||
productId: "prod_team_plan",
|
||||
});
|
||||
|
||||
// Grant to a custom customer
|
||||
await hexclaveServerApp.grantProduct({
|
||||
customCustomerId: "external-org-123",
|
||||
productId: "prod_enterprise",
|
||||
});
|
||||
await hexclaveServerApp.cancelSubscription({ productId: "pro_monthly" });
|
||||
```
|
||||
|
||||
You can also grant products with an **inline definition** - no pre-configured product needed. This is useful for one-off grants like bonus credits:
|
||||
You can also `grantProduct` directly - for trials, comps, or admin grants - with a pre-defined product or an inline one-off definition.
|
||||
|
||||
```typescript
|
||||
await hexclaveServerApp.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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
## Can I build and test before going live?
|
||||
|
||||
If you have a reference to the user object already, you can also call `user.grantProduct({ productId })` directly.
|
||||
Yes. Flip on **test mode** and purchases are granted instantly for free - no real charge, no live round-trip - so you can wire up entitlements end to end before connecting a real account.
|
||||
|
||||
## Customer types
|
||||
## What it costs, and what it doesn't do
|
||||
|
||||
Hexclave supports three types of payment customers:
|
||||
Straight answers so there are no surprises:
|
||||
|
||||
- **Users** - Individual user accounts. Users can manage their own purchases, billing, and invoices from the client SDK.
|
||||
- **Teams** - Team or organization accounts. Team admins can create checkouts, switch plans, and cancel subscriptions for their team.
|
||||
- **Custom customers** - External entities identified by an arbitrary string ID. Useful for integrations with external systems. Custom customers can only be managed via the server SDK and do not support billing or invoices.
|
||||
- **Platform fee** - Hexclave takes a **0.9%** fee on charges, on top of standard payment processing fees.
|
||||
- **Currency** - payments are processed in **USD** today.
|
||||
- **Not a marketplace** - Payments run through a single connected payment account per project. It isn't built for marketplace-style payouts to many sellers.
|
||||
- **Not locked in** - this is a convenience layer, not a cage. Use it because it saves you the billing plumbing, not because you have to.
|
||||
|
||||
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 `hexclaveServerApp` methods with `customCustomerId`.
|
||||
## Start here
|
||||
|
||||
## Dashboard
|
||||
1. [Set up Hexclave](/guides/getting-started/setup), then enable **Payments** in the dashboard.
|
||||
2. Connect your payment account under **Payments → Settings**, and turn on **test mode** while you build.
|
||||
3. Define a product (with items, if you want entitlements), then call `user.createCheckoutUrl(...)`.
|
||||
|
||||
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
|
||||
|
||||
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**.
|
||||
|
||||
## 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.
|
||||
|
||||
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:
|
||||
|
||||
- **Success**: `4242 4242 4242 4242`
|
||||
- **Decline**: `4000 0000 0000 0002`
|
||||
- **Insufficient Funds**: `4000 0000 0000 9995`
|
||||
|
||||
See [Stripe's testing documentation](https://stripe.com/docs/testing) for more test scenarios.
|
||||
Ready for the details - products, items, subscriptions, billing, invoices, and grants? Start with [Payments setup](./setup).
|
||||
|
||||
37
docs-mintlify/guides/apps/payments/products-and-pricing.mdx
Normal file
37
docs-mintlify/guides/apps/payments/products-and-pricing.mdx
Normal file
@ -0,0 +1,37 @@
|
||||
---
|
||||
title: "Products & Pricing"
|
||||
description: "Define what you sell - products, prices, product lines, add-ons, and trials"
|
||||
---
|
||||
|
||||
A **product** is anything a customer can buy: a subscription plan, a one-time purchase, or a credit pack. Configure your products in **Payments -> Products & Items**.
|
||||
|
||||
## Defining products
|
||||
|
||||
Each product has:
|
||||
|
||||
- **Display name** - What the customer sees
|
||||
- **Customer type** - Whether this product is for users, teams, or custom customers (see [Customer Types](./customers))
|
||||
- **Prices** - One or more prices, each with a currency amount and an optional billing interval (day, week, month, or year). Amounts are **decimal strings** like `"9.99"` or `"1000"` — not cent integers. Payments are processed in **USD**.
|
||||
- **Included items** - Items granted when the product is purchased, with configurable quantity, repeat schedule, and expiration behavior (see [Items & Entitlements](./items-and-entitlements))
|
||||
|
||||
A few additional options:
|
||||
|
||||
- **Free trial** - Give customers a trial period before charging. Can be set on the product or on individual prices.
|
||||
- **Add-ons** - Set **isAddOnTo** to require the customer to already own a specific base product before purchasing this one.
|
||||
- **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).
|
||||
|
||||
## Product lines
|
||||
|
||||
A **product line** groups products that are mutually exclusive. For example, a "Plan" product line might contain Free, Pro, and Enterprise - a customer can only hold one at a time. When they upgrade or downgrade, the old product is replaced.
|
||||
|
||||
Assign products to a product line to power plan tiers and enable [subscription switching](./subscriptions). Configure lines in **Payments -> Product Lines**.
|
||||
|
||||
<Info>
|
||||
Add-ons are exempt from product-line exclusivity - a customer can own an add-on alongside their base product in the same line.
|
||||
</Info>
|
||||
|
||||
## Related
|
||||
|
||||
- [Items & Entitlements](./items-and-entitlements) - attach credits, seats, and quota to a product
|
||||
- [Checkout & Purchases](./checkout) - sell a product once it's defined
|
||||
87
docs-mintlify/guides/apps/payments/setup.mdx
Normal file
87
docs-mintlify/guides/apps/payments/setup.mdx
Normal file
@ -0,0 +1,87 @@
|
||||
---
|
||||
title: "Setup"
|
||||
description: "Enable Payments, connect a payment account, and understand how the pieces fit together"
|
||||
---
|
||||
|
||||
import { PaymentsConcepts } from "/snippets/payments-concepts.jsx";
|
||||
|
||||
Hexclave includes a Payments app that handles billing, subscriptions, and one-time purchases. Instead of building your own billing system, you define products in the dashboard and Hexclave takes care of checkout, entitlement tracking, subscription lifecycle, and invoicing.
|
||||
|
||||
## Getting started
|
||||
|
||||
<Steps>
|
||||
<Step title="Enable Payments">
|
||||
Go to the **Apps** section in your dashboard, find **Payments**, and enable it.
|
||||
</Step>
|
||||
<Step title="Connect your payment account">
|
||||
Open **Payments -> Settings** and follow the onboarding flow. You'll be asked for business details, bank info, and identity verification. Once approved, payments are live.
|
||||
</Step>
|
||||
<Step title="Turn on test mode">
|
||||
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.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Info>
|
||||
Hexclave Payments is currently only available for US-based businesses, and processes payments in **USD**. Support for other countries and currencies is coming soon.
|
||||
</Info>
|
||||
|
||||
## Core concepts
|
||||
|
||||
Before writing any code, it helps to understand how the pieces fit together.
|
||||
|
||||
A **product** is something you sell - a subscription plan, a one-time purchase, or a credit pack. Products can have one or more **prices** (one-time or recurring), and they can include **items** - quantifiable entitlements like credits, seats, or API calls.
|
||||
|
||||
A **product line** groups products that are mutually exclusive. For example, a "Plan" product line might contain Free, Pro, and Enterprise - a customer can only have one at a time. When they upgrade, the old plan is replaced.
|
||||
|
||||
A **customer** is whoever owns the purchase. This can be a user, a team, or a custom external entity.
|
||||
|
||||
Here's how these pieces look in practice:
|
||||
|
||||
<PaymentsConcepts />
|
||||
|
||||
And the typical flow to make it all work:
|
||||
|
||||
1. You define products and items in the dashboard
|
||||
2. Your app generates a checkout URL and redirects the user to the hosted checkout page
|
||||
3. The user pays, Hexclave is notified, and the product is granted
|
||||
4. Your app reads the customer's products and item balances to control access
|
||||
|
||||
Each step has its own guide:
|
||||
|
||||
- [Products & Pricing](./products-and-pricing) - define what you sell
|
||||
- [Items & Entitlements](./items-and-entitlements) - credits, seats, and quota
|
||||
- [Checkout & Purchases](./checkout) - sell a product and read what a customer owns
|
||||
- [Subscriptions](./subscriptions) - switching plans and cancellation
|
||||
- [Billing & Invoices](./billing-and-invoices) - payment methods and billing history
|
||||
- [Granting Products](./granting-products) - give access without checkout
|
||||
- [Customer Types](./customers) - users, teams, and custom customers
|
||||
|
||||
## Test mode
|
||||
|
||||
While you're building, turn on **test mode** in **Payments -> Settings**. With test mode on, purchases skip the payment processor entirely: products and items are granted instantly, no card is collected, and no money moves. It's on by default in development environments.
|
||||
|
||||
This lets you wire up checkout, entitlements, and subscription logic end to end without touching real money. When you're confident everything works, turn test mode off - at which point purchases go through the payment processor and **charge real money**.
|
||||
|
||||
<Warning>
|
||||
There's no "live but free" middle ground. With test mode **off**, every purchase is a real, billable charge. Keep test mode on until you're ready to take real payments.
|
||||
</Warning>
|
||||
|
||||
## Dashboard
|
||||
|
||||
The dashboard gives you full visibility and control over your payments:
|
||||
|
||||
- **Product Lines** - Group products into mutually exclusive tiers. 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 your payment account, toggle test mode, configure payment methods, and block new purchases. In **Payments -> Settings**.
|
||||
|
||||
### 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** (see the [Emails guide](/guides/apps/emails/guide)).
|
||||
81
docs-mintlify/guides/apps/payments/subscriptions.mdx
Normal file
81
docs-mintlify/guides/apps/payments/subscriptions.mdx
Normal file
@ -0,0 +1,81 @@
|
||||
---
|
||||
title: "Subscriptions"
|
||||
description: "Switch plans with automatic proration, and cancel subscriptions"
|
||||
---
|
||||
|
||||
When products belong to the same [product line](./products-and-pricing#product-lines), customers can move between them, and Hexclave handles the proration and timing for you.
|
||||
|
||||
## Switching plans
|
||||
|
||||
For example, upgrading from Pro to Enterprise:
|
||||
|
||||
```typescript title="app/components/upgrade-button.tsx"
|
||||
"use client";
|
||||
import { useUser } from "@hexclave/next"; // replace `next` with the correct framework SDK package
|
||||
|
||||
export default function UpgradeButton() {
|
||||
const user = useUser({ or: 'redirect' });
|
||||
|
||||
return (
|
||||
<button onClick={async () => {
|
||||
await user.switchSubscription({
|
||||
fromProductId: "prod_pro",
|
||||
toProductId: "prod_enterprise",
|
||||
});
|
||||
}}>
|
||||
Upgrade to Enterprise
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
<Info>
|
||||
Switching plans requires the customer to have a default payment method saved (see [Billing & Invoices](./billing-and-invoices)). The switch happens immediately and the charge is prorated automatically.
|
||||
</Info>
|
||||
|
||||
## Canceling a subscription
|
||||
|
||||
`cancelSubscription` is called on the app instance rather than the user object:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Client Component">
|
||||
```typescript
|
||||
"use client";
|
||||
import { useHexclaveApp } from "@hexclave/next"; // replace `next` with the correct framework SDK package
|
||||
|
||||
export default function CancelButton({ productId }: { productId: string }) {
|
||||
const app = useHexclaveApp();
|
||||
return (
|
||||
<button onClick={async () => {
|
||||
await app.cancelSubscription({ productId });
|
||||
}}>
|
||||
Cancel Subscription
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Server">
|
||||
```typescript
|
||||
import { hexclaveServerApp } from "@/hexclave/server";
|
||||
|
||||
// Cancel for the current user
|
||||
await hexclaveServerApp.cancelSubscription({ productId: "prod_pro" });
|
||||
|
||||
// Cancel for a team
|
||||
await hexclaveServerApp.cancelSubscription({
|
||||
productId: "prod_team_plan",
|
||||
teamId: "team-id",
|
||||
});
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Reading subscription state
|
||||
|
||||
Each subscription product returned by [`useProducts` / `listProducts`](./checkout#checking-what-a-customer-owns) carries a `subscription` field with the current period end and cancellation state, plus `switchOptions` listing the other products in the same product line the customer can switch to.
|
||||
|
||||
## Related
|
||||
|
||||
- [Checkout & Purchases](./checkout) - start a subscription
|
||||
- [Billing & Invoices](./billing-and-invoices) - payment methods and billing history
|
||||
@ -5,40 +5,109 @@ 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. Hexclave 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:
|
||||
Hexclave 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 <permission-id>` 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
|
||||
|
||||
Hexclave 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.
|
||||
Team permissions control what a user can do within each team. You can create and assign permissions to team members from the Hexclave 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.
|
||||
|
||||
Permissions can be nested to create a hierarchical structure. For example, an `admin` permission can include both `moderator` and `user` permissions. We provide tools to help you verify whether a user has a permission directly or indirectly.
|
||||
|
||||
### 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 Hexclave 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
|
||||
|
||||
Stack comes with a few predefined team permissions known as system permissions. These permissions start with a dollar sign (`$`). While you can assign these permissions to members or include them within other permissions, you cannot modify them as they are integral to the Stack backend system.
|
||||
Hexclave comes with a few predefined team permissions known as system permissions. These permissions start with a dollar sign (`$`). While you can assign these permissions to members or include them within other permissions, you cannot modify them as they are integral to the Hexclave backend system.
|
||||
|
||||
### 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:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Client Component">
|
||||
```tsx title="Check user permission on the client"
|
||||
"use client";
|
||||
import { useUser } from "@hexclave/next"; // replace `next` with the correct framework SDK package
|
||||
import { useUser } from "@hexclave/next";
|
||||
|
||||
export function CheckUserPermission() {
|
||||
const user = useUser({ or: 'redirect' });
|
||||
@ -56,7 +125,7 @@ To check whether a user has a specific permission, use the `getPermission` metho
|
||||
</Tab>
|
||||
<Tab title="Server Component">
|
||||
```tsx title="Check user permission on the server"
|
||||
import { hexclaveServerApp } from "@/stack/server";
|
||||
import { hexclaveServerApp } from "@/hexclave/server";
|
||||
|
||||
export default async function CheckUserPermission() {
|
||||
const user = await hexclaveServerApp.getUser({ or: 'redirect' });
|
||||
@ -74,19 +143,38 @@ To check whether a user has a specific permission, use the `getPermission` metho
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
For authorization logic, prefer a boolean server-side check:
|
||||
|
||||
```tsx title="app/api/team-settings/route.ts"
|
||||
import { hexclaveServerApp } from "@/hexclave/server";
|
||||
|
||||
export async function POST() {
|
||||
const user = await hexclaveServerApp.getUser({ or: "redirect" });
|
||||
const team = await hexclaveServerApp.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:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Client Component">
|
||||
```tsx title="List user permissions on the client"
|
||||
"use client";
|
||||
import { useUser } from "@hexclave/next"; // replace `next` with the correct framework SDK package
|
||||
import { useUser } from "@hexclave/next";
|
||||
|
||||
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 (
|
||||
<div>
|
||||
@ -100,11 +188,12 @@ To get a list of all permissions a user has, use the `listPermissions` method or
|
||||
</Tab>
|
||||
<Tab title="Server Component">
|
||||
```tsx title="List user permissions on the server"
|
||||
import { hexclaveServerApp } from "@/stack/server";
|
||||
import { hexclaveServerApp } from "@/hexclave/server";
|
||||
|
||||
export default async function DisplayUserPermissions() {
|
||||
const user = await hexclaveServerApp.getUser({ or: 'redirect' });
|
||||
const permissions = await user.listPermissions();
|
||||
const team = await hexclaveServerApp.getTeam('some-team-id');
|
||||
const permissions = team ? await user.listPermissions(team) : [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -125,6 +214,7 @@ To grant a permission to a user, use the `grantPermission` method on the `Server
|
||||
```tsx
|
||||
const team = await hexclaveServerApp.getTeam('teamId');
|
||||
const user = await hexclaveServerApp.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 hexclaveServerApp.getTeam('teamId');
|
||||
const user = await hexclaveServerApp.getUser();
|
||||
if (!team || !user) throw new Error("Team or user not found");
|
||||
await user.revokePermission(team, 'read');
|
||||
```
|
||||
|
||||
@ -144,17 +235,17 @@ 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 Hexclave 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:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Client Component">
|
||||
```tsx title="Check user permission on the client"
|
||||
"use client";
|
||||
import { useUser } from "@hexclave/next"; // replace `next` with the correct framework SDK package
|
||||
import { useUser } from "@hexclave/next";
|
||||
|
||||
export function CheckGlobalPermission() {
|
||||
const user = useUser({ or: 'redirect' });
|
||||
@ -170,7 +261,7 @@ To check whether a user has a specific project permission, use the `getPermissio
|
||||
</Tab>
|
||||
<Tab title="Server Component">
|
||||
```tsx title="Check user permission on the server"
|
||||
import { hexclaveServerApp } from "@/stack/server";
|
||||
import { hexclaveServerApp } from "@/hexclave/server";
|
||||
|
||||
export default async function CheckGlobalPermission() {
|
||||
const user = await hexclaveServerApp.getUser({ or: 'redirect' });
|
||||
@ -186,15 +277,32 @@ To check whether a user has a specific project permission, use the `getPermissio
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
For authorization logic, prefer a server-side boolean check:
|
||||
|
||||
```tsx title="app/admin/page.tsx"
|
||||
import { hexclaveServerApp } from "@/hexclave/server";
|
||||
|
||||
export default async function AdminPage() {
|
||||
const user = await hexclaveServerApp.getUser({ or: "redirect" });
|
||||
const canAccessAdmin = await user.hasPermission("access_admin_dashboard");
|
||||
|
||||
if (!canAccessAdmin) {
|
||||
return <div>Access denied</div>;
|
||||
}
|
||||
|
||||
return <div>Admin dashboard</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### 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:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Client Component">
|
||||
```tsx title="List global permissions on the client"
|
||||
"use client";
|
||||
import { useUser } from "@hexclave/next"; // replace `next` with the correct framework SDK package
|
||||
import { useUser } from "@hexclave/next";
|
||||
|
||||
export function DisplayGlobalPermissions() {
|
||||
const user = useUser({ or: 'redirect' });
|
||||
@ -212,7 +320,7 @@ To get a list of all global permissions a user has, use the `listPermissions` me
|
||||
</Tab>
|
||||
<Tab title="Server Component">
|
||||
```tsx title="List global permissions on the server"
|
||||
import { hexclaveServerApp } from "@/stack/server";
|
||||
import { hexclaveServerApp } from "@/hexclave/server";
|
||||
|
||||
export default async function DisplayGlobalPermissions() {
|
||||
const user = await hexclaveServerApp.getUser({ or: 'redirect' });
|
||||
@ -230,12 +338,19 @@ To get a list of all global permissions a user has, use the `listPermissions` me
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
If you only want direct project permission grants, pass `{ recursive: false }`:
|
||||
|
||||
```tsx
|
||||
const directPermissions = await user.listPermissions({ recursive: false });
|
||||
```
|
||||
|
||||
### Granting a Project Permission
|
||||
|
||||
To grant a global permission to a user, use the `grantPermission` method:
|
||||
|
||||
```tsx
|
||||
const user = await hexclaveServerApp.getUser();
|
||||
if (!user) throw new Error("User not found");
|
||||
await user.grantPermission('access_admin_dashboard');
|
||||
```
|
||||
|
||||
@ -245,7 +360,25 @@ To revoke a global permission from a user, use the `revokePermission` method:
|
||||
|
||||
```tsx
|
||||
const user = await hexclaveServerApp.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`, Hexclave resolves contained permissions recursively so roles work as expected.
|
||||
|
||||
82
docs-mintlify/guides/going-further/hosted-vs-handler.mdx
Normal file
82
docs-mintlify/guides/going-further/hosted-vs-handler.mdx
Normal file
@ -0,0 +1,82 @@
|
||||
---
|
||||
title: "Hosted Components vs. Handler"
|
||||
description: "Choose where Hexclave auth pages live — hosted by Hexclave, or on your own domain with HexclaveHandler."
|
||||
sidebarTitle: "Hosted vs. Handler"
|
||||
---
|
||||
|
||||
Hexclave can render sign-in, sign-up, password reset, and the other auth pages in two ways. You pick the mode with the SDK `urls` option.
|
||||
|
||||
| Mode | Config | Where pages live | Best for |
|
||||
| --- | --- | --- | --- |
|
||||
| **Hosted components** (recommended) | `urls: { default: { type: "hosted" } }` | Hexclave-hosted URLs for your project | New projects, least maintenance |
|
||||
| **Own handler** | `urls: { default: { type: "handler-component" } }` plus `<HexclaveHandler />` | Routes on your domain (typically `/handler/...`) | Same-domain auth UI, frameworks that need a local catch-all |
|
||||
|
||||
You can also point individual keys (`signIn`, `accountSettings`, …) at a custom path or mix hosted and handler targets. See [Setup](/guides/getting-started/setup) for framework-specific wiring.
|
||||
|
||||
## Prefer hosted components
|
||||
|
||||
For new projects, set:
|
||||
|
||||
```ts
|
||||
export const hexclaveClientApp = new HexclaveClientApp({
|
||||
tokenStore: "cookie", // or "nextjs-cookie" on Next.js
|
||||
urls: {
|
||||
default: {
|
||||
type: "hosted",
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
With hosted components:
|
||||
|
||||
- Users land on Hexclave-hosted auth pages that stay up to date automatically.
|
||||
- You do **not** need a `/handler/[...]` catch-all in your app for the default sign-in flow.
|
||||
- Redirect helpers such as `redirectToSignIn()` send people to those hosted pages, then back to your app.
|
||||
|
||||
This is the path Setup and the CLI onboarding flow recommend.
|
||||
|
||||
## Own handler on your domain
|
||||
|
||||
Use a local handler when you want auth UI on your own origin (same cookies / branding constraints, or an older integration).
|
||||
|
||||
1. Point `urls.default` at the handler component (this is also the SDK default if you omit `urls.default`):
|
||||
|
||||
```ts
|
||||
urls: {
|
||||
default: {
|
||||
type: "handler-component",
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
2. Mount the catch-all route your framework SDK documents — for Next.js:
|
||||
|
||||
```tsx title="app/handler/[...hexclave]/page.tsx"
|
||||
import { HexclaveHandler } from "@hexclave/next";
|
||||
|
||||
export default function Handler() {
|
||||
return <HexclaveHandler fullPage />;
|
||||
}
|
||||
```
|
||||
|
||||
Auth URLs then look like `/handler/sign-in`, `/handler/sign-up`, and so on on **your** domain. The handler component is only available in some framework SDKs; hosted works everywhere the client SDK can redirect.
|
||||
|
||||
<Note>
|
||||
Older docs and reminders may say `type: "handler"`. The current target is `{ type: "handler-component" }`. Prefer `type: "hosted"` for new work.
|
||||
</Note>
|
||||
|
||||
## Mixing and custom pages
|
||||
|
||||
You do not have to pick one mode for every page. Examples:
|
||||
|
||||
- Keep `default: { type: "hosted" }`, but set `accountSettings: "/settings"` (or `{ type: "custom", url: "/settings", version: 0 }`) for a page you own.
|
||||
- Keep most pages on the handler, but send `signIn: { type: "hosted" }` if you want only sign-in hosted.
|
||||
|
||||
Whenever you add a custom auth page, update the matching `urls` key and any post-auth redirects (`afterSignIn`, `afterSignUp`, `afterSignOut`, `home`). Those keys are the source of truth for `redirectToSignIn()` and related helpers — if they still point at defaults after you customize routes, users can hit extra redirects or land on the wrong page.
|
||||
|
||||
## Related
|
||||
|
||||
- [Setup](/guides/getting-started/setup) — framework setup, including the hosted `urls` default.
|
||||
- [Authentication overview](/guides/apps/authentication/overview) — what the Authentication app covers.
|
||||
- [Local vs. Cloud Dashboard](/guides/going-further/local-vs-cloud-dashboard) — development environment vs cloud project.
|
||||
@ -17,7 +17,7 @@ If you have not installed Stack yet (handler routes, `HexclaveProvider`, environ
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Hexclave installed as in [Build a SaaS with Hexclave](/guides/other/tutorials/build-a-saas-with-hexclave) (Next.js App Router examples below assume `hexclaveServerApp` in `stack/server.ts` and `@hexclave/next` in the app).
|
||||
- Hexclave installed as in [Build a SaaS with Hexclave](/guides/other/tutorials/build-a-saas-with-hexclave) (Next.js App Router examples below assume `hexclaveServerApp` in `hexclave/server.ts` and `@hexclave/next` in the app).
|
||||
- A project in the [dashboard](https://app.hexclave.com/projects) where you can edit **Teams** and **Team permissions**.
|
||||
|
||||
<Note>
|
||||
@ -42,7 +42,7 @@ Use the **current user** as the scope: `listTeams` / `useTeams`, and `getTeam` /
|
||||
<Tab title="Server Component">
|
||||
```tsx title="app/team/[teamId]/page.tsx"
|
||||
import Link from "next/link";
|
||||
import { hexclaveServerApp } from "@/stack/server";
|
||||
import { hexclaveServerApp } from "@/hexclave/server";
|
||||
|
||||
type PageProps = { params: { teamId: string } };
|
||||
|
||||
@ -101,7 +101,7 @@ If your framework types route `params` as a `Promise` (newer Next.js), `await` t
|
||||
<Tab title="Server">
|
||||
```tsx title="app/team/page.tsx"
|
||||
import Link from "next/link";
|
||||
import { hexclaveServerApp } from "@/stack/server";
|
||||
import { hexclaveServerApp } from "@/hexclave/server";
|
||||
|
||||
export default async function TeamsIndexPage() {
|
||||
const user = await hexclaveServerApp.getUser({ or: "redirect" });
|
||||
@ -179,7 +179,7 @@ export function CreateWorkspaceButton() {
|
||||
For imports, support tools, or other **server** jobs, `hexclaveServerApp.createTeam` creates a team at project scope (see [Teams](/guides/apps/teams/overview)); wire membership separately if your flow requires it.
|
||||
|
||||
```tsx title="scripts/provision-team.example.ts"
|
||||
import { hexclaveServerApp } from "@/stack/server";
|
||||
import { hexclaveServerApp } from "@/hexclave/server";
|
||||
|
||||
export async function provisionEmptyTeam(displayName: string) {
|
||||
return await hexclaveServerApp.createTeam({ displayName });
|
||||
@ -226,7 +226,7 @@ Replace `export_reports` with a permission you created.
|
||||
<Tabs>
|
||||
<Tab title="Server Component">
|
||||
```tsx title="app/team/[teamId]/reports/page.tsx"
|
||||
import { hexclaveServerApp } from "@/stack/server";
|
||||
import { hexclaveServerApp } from "@/hexclave/server";
|
||||
|
||||
type PageProps = { params: { teamId: string } };
|
||||
|
||||
@ -289,7 +289,7 @@ For listing effective permissions, use `listPermissions` / `usePermissions` ([RB
|
||||
```tsx title="app/actions/reports.ts"
|
||||
"use server";
|
||||
|
||||
import { hexclaveServerApp } from "@/stack/server";
|
||||
import { hexclaveServerApp } from "@/hexclave/server";
|
||||
|
||||
export async function requestReportExportAction(teamId: string) {
|
||||
const user = await hexclaveServerApp.getUser({ or: "throw" });
|
||||
|
||||
@ -27,7 +27,7 @@ You typically combine **one or more** of:
|
||||
<Tab title="Middleware (route prefix)">
|
||||
```tsx title="middleware.ts"
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { hexclaveServerApp } from "@/stack/server";
|
||||
import { hexclaveServerApp } from "@/hexclave/server";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const user = await hexclaveServerApp.getUser();
|
||||
@ -48,7 +48,7 @@ You typically combine **one or more** of:
|
||||
</Tab>
|
||||
<Tab title="Server Component">
|
||||
```tsx title="app/app/dashboard/page.tsx"
|
||||
import { hexclaveServerApp } from "@/stack/server";
|
||||
import { hexclaveServerApp } from "@/hexclave/server";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
await hexclaveServerApp.getUser({ or: "redirect" });
|
||||
@ -76,7 +76,7 @@ You typically combine **one or more** of:
|
||||
- **`{ or: "throw" }`** — use in **server actions**, **route handlers**, and other places where redirecting would be wrong; map errors to `401`/`403` responses yourself.
|
||||
|
||||
```tsx title="app/api/me/route.ts"
|
||||
import { hexclaveServerApp } from "@/stack/server";
|
||||
import { hexclaveServerApp } from "@/hexclave/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
|
||||
@ -21,7 +21,6 @@ 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: "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" },
|
||||
|
||||
188
docs-mintlify/snippets/skeletons/auth-skeletons.jsx
Normal file
188
docs-mintlify/snippets/skeletons/auth-skeletons.jsx
Normal file
@ -0,0 +1,188 @@
|
||||
// Lightweight, decorative skeletons used to illustrate the Authentication app.
|
||||
// They are intentionally non-interactive and use neutral placeholders so they
|
||||
// read as "examples" rather than live UI.
|
||||
//
|
||||
// NOTE: Mintlify evaluates each exported component in isolation, so every
|
||||
// component must be fully self-contained — no shared module-level constants or
|
||||
// helper components. That's why ACCENT / Frame are redefined inside each one.
|
||||
|
||||
export const SignInSkeleton = () => {
|
||||
const ACCENT = "#6b5df7";
|
||||
|
||||
const Frame = ({ label, children }) => (
|
||||
<div className="overflow-hidden rounded-2xl border border-zinc-950/10 bg-white dark:border-white/10 dark:bg-zinc-900">
|
||||
<div className="flex items-center gap-2 border-b border-zinc-950/10 bg-zinc-950/[0.03] px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
</div>
|
||||
<span className="ml-1 text-[11px] font-medium text-zinc-400 dark:text-zinc-500">{label}</span>
|
||||
</div>
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const provider = (label) => (
|
||||
<div className="flex items-center justify-center gap-2 rounded-lg border border-zinc-200 bg-white px-3 py-2 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="h-3.5 w-3.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
<span className="text-[12px] font-medium text-zinc-500 dark:text-zinc-400">{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const field = (label) => (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[11px] font-medium text-zinc-400 dark:text-zinc-500">{label}</span>
|
||||
<div className="h-8 rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800/60" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="not-prose my-6">
|
||||
<Frame label="/handler/sign-in">
|
||||
<div className="mx-auto flex max-w-xs flex-col gap-3">
|
||||
<div className="mb-1 flex flex-col items-center gap-2">
|
||||
<div className="h-9 w-9 rounded-xl" style={{ backgroundColor: ACCENT }} />
|
||||
<div className="text-[13px] font-semibold text-zinc-700 dark:text-zinc-200">Sign in</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{provider("Google")}
|
||||
{provider("GitHub")}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 py-0.5">
|
||||
<div className="h-px flex-1 bg-zinc-200 dark:bg-zinc-700" />
|
||||
<span className="text-[10px] uppercase tracking-wide text-zinc-400 dark:text-zinc-500">or</span>
|
||||
<div className="h-px flex-1 bg-zinc-200 dark:bg-zinc-700" />
|
||||
</div>
|
||||
{field("Email")}
|
||||
{field("Password")}
|
||||
<div
|
||||
className="mt-1 flex h-8 items-center justify-center rounded-lg text-[12px] font-semibold text-white"
|
||||
style={{ backgroundColor: ACCENT }}
|
||||
>
|
||||
Continue
|
||||
</div>
|
||||
</div>
|
||||
</Frame>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AuthMethodsSkeleton = () => {
|
||||
const ACCENT = "#6b5df7";
|
||||
|
||||
const Frame = ({ label, children }) => (
|
||||
<div className="overflow-hidden rounded-2xl border border-zinc-950/10 bg-white dark:border-white/10 dark:bg-zinc-900">
|
||||
<div className="flex items-center gap-2 border-b border-zinc-950/10 bg-zinc-950/[0.03] px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
</div>
|
||||
<span className="ml-1 text-[11px] font-medium text-zinc-400 dark:text-zinc-500">{label}</span>
|
||||
</div>
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const toggle = (on) => (
|
||||
<div
|
||||
className={
|
||||
"flex h-4 w-7 items-center rounded-full px-0.5 " +
|
||||
(on ? "justify-end" : "justify-start bg-zinc-200 dark:bg-zinc-700")
|
||||
}
|
||||
style={on ? { backgroundColor: ACCENT } : undefined}
|
||||
>
|
||||
<div className="h-3 w-3 rounded-full bg-white" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const row = (label, on) => (
|
||||
<div className="flex items-center justify-between border-b border-zinc-950/[0.06] py-2.5 last:border-b-0 dark:border-white/[0.06]">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="h-5 w-5 rounded-md bg-zinc-200 dark:bg-zinc-700" />
|
||||
<span className="text-[12px] font-medium text-zinc-600 dark:text-zinc-300">{label}</span>
|
||||
</div>
|
||||
{toggle(on)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="not-prose my-6">
|
||||
<Frame label="Auth methods">
|
||||
<div className="flex flex-col">
|
||||
{row("Email & password", true)}
|
||||
{row("Magic link / OTP", true)}
|
||||
{row("Passkey", true)}
|
||||
{row("Google", true)}
|
||||
{row("GitHub", true)}
|
||||
{row("Microsoft", false)}
|
||||
</div>
|
||||
</Frame>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserDirectorySkeleton = () => {
|
||||
const Frame = ({ label, children }) => (
|
||||
<div className="overflow-hidden rounded-2xl border border-zinc-950/10 bg-white dark:border-white/10 dark:bg-zinc-900">
|
||||
<div className="flex items-center gap-2 border-b border-zinc-950/10 bg-zinc-950/[0.03] px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
</div>
|
||||
<span className="ml-1 text-[11px] font-medium text-zinc-400 dark:text-zinc-500">{label}</span>
|
||||
</div>
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const cell = (w) => <div className="h-2 rounded-full bg-zinc-200/90 dark:bg-zinc-700/80" style={{ width: w }} />;
|
||||
|
||||
const badge = (text, tone) => {
|
||||
const tones = {
|
||||
green: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400",
|
||||
zinc: "bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300",
|
||||
};
|
||||
return (
|
||||
<span className={"inline-block rounded-full px-2 py-0.5 text-[10px] font-medium " + tones[tone]}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const row = (initial, color, w1, w2, status) => (
|
||||
<div className="grid grid-cols-[1.4fr_1.6fr_0.8fr] items-center gap-3 border-b border-zinc-950/[0.06] px-3 py-2.5 last:border-b-0 dark:border-white/[0.06]">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-bold text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
{cell(w1)}
|
||||
</div>
|
||||
{cell(w2)}
|
||||
<div>{badge(status[0], status[1])}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="not-prose my-6">
|
||||
<Frame label="Users">
|
||||
<div className="overflow-hidden rounded-xl border border-zinc-950/[0.06] dark:border-white/[0.06]">
|
||||
<div className="grid grid-cols-[1.4fr_1.6fr_0.8fr] gap-3 border-b border-zinc-950/[0.06] bg-zinc-950/[0.02] px-3 py-2 text-[10px] font-semibold uppercase tracking-wide text-zinc-400 dark:border-white/[0.06] dark:bg-white/[0.02] dark:text-zinc-500">
|
||||
<span>User</span>
|
||||
<span>Email</span>
|
||||
<span>Status</span>
|
||||
</div>
|
||||
{row("A", "#6b5df7", "68px", "120px", ["Active", "green"])}
|
||||
{row("M", "#0ea5e9", "84px", "104px", ["Active", "green"])}
|
||||
{row("S", "#f59e0b", "56px", "132px", ["Invited", "zinc"])}
|
||||
{row("R", "#ef4444", "72px", "92px", ["Active", "green"])}
|
||||
</div>
|
||||
</Frame>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
153
docs-mintlify/snippets/skeletons/email-skeletons.jsx
Normal file
153
docs-mintlify/snippets/skeletons/email-skeletons.jsx
Normal file
@ -0,0 +1,153 @@
|
||||
// Lightweight, decorative skeletons used to illustrate the Emails app.
|
||||
// They are intentionally non-interactive and use neutral placeholders so they
|
||||
// read as "examples" rather than live UI.
|
||||
//
|
||||
// NOTE: Mintlify evaluates each exported component in isolation, so every
|
||||
// component must be fully self-contained — no shared module-level constants or
|
||||
// helper components. That's why ACCENT / Frame are redefined inside each one.
|
||||
|
||||
export const EmailPreviewSkeleton = () => {
|
||||
const ACCENT = "#6b5df7";
|
||||
|
||||
const Frame = ({ label, children }) => (
|
||||
<div className="overflow-hidden rounded-2xl border border-zinc-950/10 bg-white dark:border-white/10 dark:bg-zinc-900">
|
||||
<div className="flex items-center gap-2 border-b border-zinc-950/10 bg-zinc-950/[0.03] px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
</div>
|
||||
<span className="ml-1 text-[11px] font-medium text-zinc-400 dark:text-zinc-500">{label}</span>
|
||||
</div>
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const bodyLine = (w) => (
|
||||
<div className="h-2 rounded-full bg-zinc-200/90 dark:bg-zinc-700/80" style={{ width: w }} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="not-prose my-6">
|
||||
<Frame label="To: alice@example.com">
|
||||
<div className="mx-auto max-w-sm overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-700">
|
||||
<div className="flex items-center gap-2 border-b border-zinc-200 px-4 py-3 dark:border-zinc-700">
|
||||
<div className="h-6 w-6 rounded-lg" style={{ backgroundColor: ACCENT }} />
|
||||
<div className="h-2.5 w-20 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 px-4 py-5">
|
||||
<div className="h-3 w-40 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
{bodyLine("100%")}
|
||||
{bodyLine("92%")}
|
||||
{bodyLine("96%")}
|
||||
{bodyLine("60%")}
|
||||
</div>
|
||||
<div
|
||||
className="mt-2 flex h-8 w-32 items-center justify-center rounded-lg text-[12px] font-semibold text-white"
|
||||
style={{ backgroundColor: ACCENT }}
|
||||
>
|
||||
Get started
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-zinc-200 px-4 py-3 dark:border-zinc-700">
|
||||
<div className="mx-auto h-1.5 w-24 rounded-full bg-zinc-200 dark:bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
</Frame>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EmailTemplatesSkeleton = () => {
|
||||
const ACCENT = "#6b5df7";
|
||||
|
||||
const Frame = ({ label, children }) => (
|
||||
<div className="overflow-hidden rounded-2xl border border-zinc-950/10 bg-white dark:border-white/10 dark:bg-zinc-900">
|
||||
<div className="flex items-center gap-2 border-b border-zinc-950/10 bg-zinc-950/[0.03] px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
</div>
|
||||
<span className="ml-1 text-[11px] font-medium text-zinc-400 dark:text-zinc-500">{label}</span>
|
||||
</div>
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const row = (label, tag) => (
|
||||
<div className="flex items-center justify-between border-b border-zinc-950/[0.06] py-2.5 last:border-b-0 dark:border-white/[0.06]">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-zinc-100 dark:bg-zinc-800">
|
||||
<div className="h-3 w-3 rounded-[3px]" style={{ backgroundColor: ACCENT, opacity: 0.55 }} />
|
||||
</div>
|
||||
<span className="text-[12px] font-medium text-zinc-600 dark:text-zinc-300">{label}</span>
|
||||
</div>
|
||||
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[10px] font-medium text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
|
||||
{tag}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="not-prose my-6">
|
||||
<Frame label="Templates">
|
||||
<div className="flex flex-col">
|
||||
{row("Email verification", "Transactional")}
|
||||
{row("Password reset", "Transactional")}
|
||||
{row("Magic link / OTP", "Transactional")}
|
||||
{row("Team invitation", "Transactional")}
|
||||
{row("Product update", "Marketing")}
|
||||
</div>
|
||||
</Frame>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DeliveryStatsSkeleton = () => {
|
||||
const Frame = ({ label, children }) => (
|
||||
<div className="overflow-hidden rounded-2xl border border-zinc-950/10 bg-white dark:border-white/10 dark:bg-zinc-900">
|
||||
<div className="flex items-center gap-2 border-b border-zinc-950/10 bg-zinc-950/[0.03] px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
</div>
|
||||
<span className="ml-1 text-[11px] font-medium text-zinc-400 dark:text-zinc-500">{label}</span>
|
||||
</div>
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const tile = (label, value, dot) => (
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-zinc-950/[0.06] p-3 dark:border-white/[0.06]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: dot }} />
|
||||
<span className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400">{label}</span>
|
||||
</div>
|
||||
<span className="text-[18px] font-semibold text-zinc-800 dark:text-zinc-100">{value}</span>
|
||||
<div className="flex items-end gap-1">
|
||||
{[7, 11, 6, 13, 9, 14, 10].map((h, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1.5 rounded-sm bg-zinc-200 dark:bg-zinc-700"
|
||||
style={{ height: h + "px" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="not-prose my-6">
|
||||
<Frame label="Delivery">
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
{tile("Sent", "8,241", "#10b981")}
|
||||
{tile("Bounced", "37", "#f59e0b")}
|
||||
{tile("Spam", "4", "#ef4444")}
|
||||
</div>
|
||||
</Frame>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
165
docs-mintlify/snippets/skeletons/payments-skeletons.jsx
Normal file
165
docs-mintlify/snippets/skeletons/payments-skeletons.jsx
Normal file
@ -0,0 +1,165 @@
|
||||
// Lightweight, decorative skeletons used to illustrate the Payments app.
|
||||
// They are intentionally non-interactive and use neutral placeholders so they
|
||||
// read as "examples" rather than live UI.
|
||||
//
|
||||
// NOTE: Mintlify evaluates each exported component in isolation, so every
|
||||
// component must be fully self-contained — no shared module-level constants or
|
||||
// helper components. That's why ACCENT / Frame are redefined inside each one.
|
||||
|
||||
export const PricingSkeleton = () => {
|
||||
const ACCENT = "#10b981";
|
||||
|
||||
const Frame = ({ label, children }) => (
|
||||
<div className="overflow-hidden rounded-2xl border border-zinc-950/10 bg-white dark:border-white/10 dark:bg-zinc-900">
|
||||
<div className="flex items-center gap-2 border-b border-zinc-950/10 bg-zinc-950/[0.03] px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
</div>
|
||||
<span className="ml-1 text-[11px] font-medium text-zinc-400 dark:text-zinc-500">{label}</span>
|
||||
</div>
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const tier = (name, price, highlighted) => (
|
||||
<div
|
||||
className={
|
||||
"flex flex-col gap-3 rounded-xl border p-3 " +
|
||||
(highlighted
|
||||
? "border-transparent ring-2"
|
||||
: "border-zinc-950/[0.08] dark:border-white/[0.08]")
|
||||
}
|
||||
style={highlighted ? { boxShadow: `0 0 0 2px ${ACCENT}` } : undefined}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[12px] font-semibold text-zinc-700 dark:text-zinc-200">{name}</span>
|
||||
{highlighted && (
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[9px] font-semibold text-white"
|
||||
style={{ backgroundColor: ACCENT }}
|
||||
>
|
||||
Popular
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-[18px] font-bold text-zinc-800 dark:text-zinc-100">{price}</span>
|
||||
<span className="text-[10px] text-zinc-400 dark:text-zinc-500">/mo</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 pt-1">
|
||||
{["80%", "65%", "72%"].map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<div className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: ACCENT, opacity: 0.6 }} />
|
||||
<div className="h-2 rounded-full bg-zinc-200/90 dark:bg-zinc-700/80" style={{ width: w }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="not-prose my-6">
|
||||
<Frame label='Product line: "Plan"'>
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
{tier("Free", "$0", false)}
|
||||
{tier("Pro", "$20", true)}
|
||||
{tier("Enterprise", "$99", false)}
|
||||
</div>
|
||||
</Frame>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EntitlementsSkeleton = () => {
|
||||
const ACCENT = "#10b981";
|
||||
|
||||
const Frame = ({ label, children }) => (
|
||||
<div className="overflow-hidden rounded-2xl border border-zinc-950/10 bg-white dark:border-white/10 dark:bg-zinc-900">
|
||||
<div className="flex items-center gap-2 border-b border-zinc-950/10 bg-zinc-950/[0.03] px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
</div>
|
||||
<span className="ml-1 text-[11px] font-medium text-zinc-400 dark:text-zinc-500">{label}</span>
|
||||
</div>
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const meter = (label, value, pct) => (
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-zinc-950/[0.06] p-3 dark:border-white/[0.06]">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400">{label}</span>
|
||||
<span className="text-[13px] font-semibold text-zinc-800 dark:text-zinc-100">{value}</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-700">
|
||||
<div className="h-full rounded-full" style={{ width: pct, backgroundColor: ACCENT }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="not-prose my-6">
|
||||
<Frame label="user.useItem(…)">
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
{meter("Credits", "820", "82%")}
|
||||
{meter("Seats", "4 / 5", "80%")}
|
||||
{meter("API quota", "12k", "47%")}
|
||||
</div>
|
||||
</Frame>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TransactionsSkeleton = () => {
|
||||
const Frame = ({ label, children }) => (
|
||||
<div className="overflow-hidden rounded-2xl border border-zinc-950/10 bg-white dark:border-white/10 dark:bg-zinc-900">
|
||||
<div className="flex items-center gap-2 border-b border-zinc-950/10 bg-zinc-950/[0.03] px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
</div>
|
||||
<span className="ml-1 text-[11px] font-medium text-zinc-400 dark:text-zinc-500">{label}</span>
|
||||
</div>
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const row = (amount, status, color) => (
|
||||
<div className="flex items-center justify-between border-b border-zinc-950/[0.06] py-2.5 last:border-b-0 dark:border-white/[0.06]">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="h-6 w-6 rounded-md bg-zinc-100 dark:bg-zinc-800" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="h-2 w-24 rounded-full bg-zinc-300 dark:bg-zinc-600" />
|
||||
<div className="h-1.5 w-16 rounded-full bg-zinc-200 dark:bg-zinc-700" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[12px] font-semibold text-zinc-700 dark:text-zinc-200">{amount}</span>
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[10px] font-medium"
|
||||
style={{ backgroundColor: color + "22", color }}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="not-prose my-6">
|
||||
<Frame label="Transactions">
|
||||
<div className="flex flex-col">
|
||||
{row("$20.00", "Paid", "#10b981")}
|
||||
{row("$99.00", "Renewed", "#10b981")}
|
||||
{row("$20.00", "Refunded", "#f59e0b")}
|
||||
{row("$20.00", "Failed", "#ef4444")}
|
||||
</div>
|
||||
</Frame>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -79,6 +79,7 @@ const docsJson = {
|
||||
"pages": [
|
||||
"guides/going-further/cli",
|
||||
"guides/going-further/local-vs-cloud-dashboard",
|
||||
"guides/going-further/hosted-vs-handler",
|
||||
"guides/going-further/hexclave-config"
|
||||
]
|
||||
},
|
||||
@ -95,6 +96,7 @@ const docsJson = {
|
||||
"guides/apps/authentication/connected-accounts",
|
||||
"guides/apps/authentication/jwts",
|
||||
"guides/apps/authentication/sign-up-rules",
|
||||
"guides/apps/authentication/fraud-protection",
|
||||
"guides/apps/authentication/cli-authentication",
|
||||
{
|
||||
"group": "All Auth Providers",
|
||||
@ -109,18 +111,50 @@ const docsJson = {
|
||||
"guides/apps/authentication/auth-providers/google",
|
||||
"guides/apps/authentication/auth-providers/linkedin",
|
||||
"guides/apps/authentication/auth-providers/microsoft",
|
||||
"guides/apps/authentication/auth-providers/passkey",
|
||||
"guides/apps/authentication/auth-providers/spotify",
|
||||
"guides/apps/authentication/auth-providers/twitch",
|
||||
"guides/apps/authentication/auth-providers/x-twitter",
|
||||
"guides/apps/authentication/auth-providers/passkey",
|
||||
"guides/apps/authentication/auth-providers/two-factor-auth",
|
||||
"guides/apps/authentication/auth-providers/x-twitter"
|
||||
"guides/apps/authentication/auth-providers/custom-oidc"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"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/guide",
|
||||
"guides/apps/emails/templates-and-themes",
|
||||
"guides/apps/emails/drafts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Payments",
|
||||
"icon": "/images/app-icons/payments.svg",
|
||||
"pages": [
|
||||
"guides/apps/payments/overview",
|
||||
"guides/apps/payments/setup",
|
||||
"guides/apps/payments/products-and-pricing",
|
||||
"guides/apps/payments/items-and-entitlements",
|
||||
"guides/apps/payments/checkout",
|
||||
"guides/apps/payments/subscriptions",
|
||||
"guides/apps/payments/billing-and-invoices",
|
||||
"guides/apps/payments/granting-products",
|
||||
"guides/apps/payments/customers"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Analytics",
|
||||
"icon": "/images/app-icons/analytics.svg",
|
||||
"pages": [
|
||||
"guides/apps/analytics/overview",
|
||||
"guides/apps/analytics/queries-and-tables",
|
||||
"guides/apps/analytics/replays-and-clickmaps"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Teams",
|
||||
"icon": "/images/app-icons/teams.svg",
|
||||
@ -129,7 +163,6 @@ const docsJson = {
|
||||
"guides/apps/teams/team-selection"
|
||||
]
|
||||
},
|
||||
"guides/apps/fraud-protection/overview",
|
||||
"guides/apps/rbac/overview",
|
||||
"guides/apps/api-keys/overview",
|
||||
"guides/apps/data-vault/overview",
|
||||
@ -254,6 +287,74 @@ const docsJson = {
|
||||
]
|
||||
},
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/sdk/objects/stack-app",
|
||||
"destination": "/sdk/objects/hexclave-app"
|
||||
},
|
||||
{
|
||||
"source": "/sdk/hooks/use-stack-app",
|
||||
"destination": "/sdk/hooks/use-hexclave-app"
|
||||
},
|
||||
{
|
||||
"source": "/rest-api/overview",
|
||||
"destination": "/api/overview"
|
||||
},
|
||||
{
|
||||
"source": "/getting-started/setup",
|
||||
"destination": "/guides/getting-started/setup"
|
||||
},
|
||||
{
|
||||
"source": "/docs/getting-started/setup",
|
||||
"destination": "/guides/getting-started/setup"
|
||||
},
|
||||
{
|
||||
"source": "/docs/next/getting-started/setup",
|
||||
"destination": "/guides/getting-started/setup"
|
||||
},
|
||||
{
|
||||
"source": "/docs/sdk",
|
||||
"destination": "/sdk/overview"
|
||||
},
|
||||
{
|
||||
"source": "/docs/apps/analytics",
|
||||
"destination": "/guides/apps/analytics/overview"
|
||||
},
|
||||
{
|
||||
"source": "/docs/apps/api-keys",
|
||||
"destination": "/guides/apps/api-keys/overview"
|
||||
},
|
||||
{
|
||||
"source": "/docs/others/convex",
|
||||
"destination": "/guides/integrations/convex/overview"
|
||||
},
|
||||
{
|
||||
"source": "/docs/concepts/teams",
|
||||
"destination": "/guides/apps/teams/overview"
|
||||
},
|
||||
{
|
||||
"source": "/docs/concepts/custom-user-data",
|
||||
"destination": "/guides/getting-started/user-fundamentals#custom-metadata"
|
||||
},
|
||||
{
|
||||
"source": "/guides/going-further/user-metadata",
|
||||
"destination": "/guides/getting-started/user-fundamentals#custom-metadata"
|
||||
},
|
||||
{
|
||||
"source": "/others/js-client",
|
||||
"destination": "/sdk/objects/hexclave-app"
|
||||
},
|
||||
{
|
||||
"source": "/guides/going-further/stack-app",
|
||||
"destination": "/sdk/objects/hexclave-app"
|
||||
},
|
||||
{
|
||||
"source": "/guides/apps/fraud-protection/overview",
|
||||
"destination": "/guides/apps/authentication/fraud-protection"
|
||||
},
|
||||
{
|
||||
"source": "/guides/apps/payments/guide",
|
||||
"destination": "/guides/apps/payments/setup"
|
||||
},
|
||||
{
|
||||
"source": "/guides/going-further/backend-integration",
|
||||
"destination": "/guides/going-further/local-vs-cloud-dashboard"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user