# CLI overview Source: https://docs.result.dev/cli/overview npx @resultdev/cli - setup and admin operations for your backend. ```bash theme={"dark"} npx @resultdev/cli init ``` The CLI handles everything that needs the admin key: tables, migrations, buckets, realtime channels, functions and secrets. App code never does these - it uses the [SDK](/sdk/database) with the publishable key. ## Credentials The CLI reads `.env.local` (then `.env`) in the current directory: ```bash theme={"dark"} NEXT_PUBLIC_BACKEND_URL=... # backend base URL BACKEND_ADMIN_KEY=... # admin key - server/terminal only ``` Both are on your app's page in the Result dashboard. To override them, pass the global flags **before** the command: `result --url --key `. ## `result init` Run this once per project, first. It: 1. Verifies the credentials against the live backend. 2. Writes the backend rules into `AGENTS.md` (creating or updating a marked section - safe to re-run). 3. Creates `CLAUDE.md` pointing at `AGENTS.md` if you don't have one. That persistence step is what keeps AI coding agents working correctly in future sessions - a fresh chat thread reads `AGENTS.md` and knows the SDK, the CLI, and the rules without being told. ## Errors Every failure prints the backend's own fix hint: ``` Error 400 (INVALID_INPUT): → ``` Full command list: [CLI reference](/cli/reference). # CLI reference Source: https://docs.result.dev/cli/reference Every command, with the flags that matter. All commands read credentials from `.env.local`; to override, pass the global flags before the command (`result --url --key `). Run `result --help` for full flags. ## init ```bash theme={"dark"} result init ``` Verifies credentials, persists backend rules into `AGENTS.md`/`CLAUDE.md`. Idempotent - re-run any time. ## status ```bash theme={"dark"} result status ``` Tables (with row counts), buckets, functions and auth at a glance. Run this before creating anything - it may already exist. ## db ```bash theme={"dark"} result db tables # list tables result db schema posts # one table's columns as JSON ``` ### db create-table ```bash theme={"dark"} result db create-table posts \ -c "title:string:required" \ -c "user_id:uuid" ``` * Column spec: `name:type[:required][:unique]` * Types: `string`, `integer`, `float`, `boolean`, `datetime`, `date`, `json`, `uuid` * `id`, `created_at`, `updated_at` are added automatically - never spec them * RLS is on by default. With a `user_id` uuid column the owner policy is created automatically (rows scoped to the signed-in user, `user_id` defaults from the session; `--no-policy` skips it). Without one the command warns: an RLS table with no policy denies all reads and writes. `--no-rls` for shared/public data. ### db migrate ```bash theme={"dark"} result db migrate --name add-posts-index --sql "CREATE INDEX idx_posts_title ON posts (title);" result db migrate --name setup-pgvector --file ./migration.sql ``` Raw SQL for anything `create-table` can't express: indexes, custom RLS policies, triggers, extensions (pgvector). Versioning is automatic; the migration already runs in one transaction, so never write `BEGIN`/`COMMIT`. ```bash theme={"dark"} result db migrations # list applied migrations ``` ## storage ```bash theme={"dark"} result storage buckets result storage create-bucket uploads # public: files get stable URLs result storage create-bucket private-docs --private # private: use signed URLs ``` ## realtime ```bash theme={"dark"} result realtime channels result realtime add-channel "chat:%" --description "chat rooms" ``` `%` is a wildcard - `chat:%` covers `chat:room-1`, `chat:room-2`, etc. Clients cannot subscribe until a pattern covers the channel. ## functions ```bash theme={"dark"} result functions list result functions deploy my-fn --file fn.ts # creates, or updates if it exists ``` The function runs at `$NEXT_PUBLIC_BACKEND_URL/functions/my-fn`. ## secrets ```bash theme={"dark"} result secrets list # your keys only, values never shown result secrets set OPENWEATHER_KEY=... SLACK_WEBHOOK_URL=... ``` Read inside functions with `Deno.env.get("OPENWEATHER_KEY")`. Treat secrets as permanent - deletion is unreliable on current backend versions. ## deployments ```bash theme={"dark"} result deployments deploy [dir] # upload source, build remotely, go live result deployments list # history result deployments status # one deploy, with its error if it failed result deployments cancel # stop a build still in progress result deployments env list # build variables, values never shown result deployments env set KEY=value # applies from the next deploy result deployments env delete # id from env list ``` Deploys the frontend to Result hosting. `node_modules`, `.git`, build output and `.env*` files never upload. Because env files stay home, set `NEXT_PUBLIC_BACKEND_URL` and `NEXT_PUBLIC_BACKEND_ANON_KEY` as build variables before the first deploy; never set `BACKEND_ADMIN_KEY` there. `deploy --env KEY=value` saves the variable and applies it to that build. The command polls up to 5 minutes and prints the live URL; history and status also live in the dashboard under Infrastructure, Deployments. ## auth ```bash theme={"dark"} result auth users # admin view of signed-up users ``` ## logs ```bash theme={"dark"} result logs # list sources result logs backend.logs --limit 20 # read one source ``` # Keys & security Source: https://docs.result.dev/guides/security Which key goes where, and what actually protects your data. Three credentials, three rules: | Credential | Where it may live | What it does | | ------------------------------ | -------------------------- | ------------------------------------------------- | | `NEXT_PUBLIC_BACKEND_ANON_KEY` | Browser, app code | Publishable client key - the SDK uses it | | `BACKEND_ADMIN_KEY` | Terminal, server code only | Full admin control - the CLI uses it | | User session | Managed by the SDK | Issued at sign-in, scopes every call to that user | ## The publishable key is public - plan for it Anyone can read it out of your shipped frontend. That is by design. What protects your data: 1. **Row-level security.** Tables created with a `user_id` uuid column get an owner policy that scopes rows to the signed-in user. Keep RLS on (the default) for any per-user data; custom access rules run as policy SQL through `result db migrate`. 2. **Signed-in-only writes.** Storage uploads reject anonymous callers. 3. **Functions as the trust boundary.** AI calls and email sends work with the publishable key - anyone holding it can drive spend. Put expensive or sensitive operations in a [serverless function](/sdk/functions). ## The admin key is never public * Never in client code, never in `NEXT_PUBLIC_*` vars, never in the browser. * It belongs in `.env.local` for the CLI, or server-side code only. * Everything the CLI does (schema, buckets, secrets) uses it - that's why setup is a terminal activity. ## Where the session lives The access token is held in memory. The refresh token that keeps someone signed in across reloads is a first-party cookie on your own origin, written by the SDK, which means JavaScript running on your site can read it: an XSS hole is a session-theft hole. Sanitize anything you render as HTML and keep third-party scripts off pages that matter. The alternative is `@resultdev/sdk/ssr`, where the refresh token is an httpOnly cookie your own server writes and browser JavaScript cannot touch. It costs a refresh route and a server client, and it is the right trade for apps holding something worth stealing. # Troubleshooting Source: https://docs.result.dev/guides/troubleshooting The errors you'll actually hit, with the fix for each. Every backend error returns `{ error, message, statusCode, nextActions }` - `nextActions` tells you how to fix the call. The CLI prints it automatically. ## Common errors **`REALTIME_UNAUTHORIZED` on subscribe.** No declared channel pattern covers that channel. Fix: `npx @resultdev/cli realtime add-channel "chat:%"` - the pattern alone unlocks it. **"permission denied" on storage upload or `list()`.** The caller is anonymous. Uploads and listing require a signed-in user - call `backend.auth.signUp(...)` or `signInWithPassword(...)` first. **Insert silently fails or 400s.** `insert()` takes an **array**: `.insert([{ title: "Hi" }])`, not `.insert({ title: "Hi" })`. **Signed-in queries return no rows on an RLS table (and writes 403 with code 42501).** Most often the table has no policy: RLS with zero policies denies every read and write, and reads fail silently as `[]`. Recreate the table through `result db create-table` with a `user_id:uuid` column (the owner policy is generated for you) or add a policy with `result db migrate`. If a policy exists, the rows were created without a `user_id` matching the session (e.g. seeded with the admin key) - backfill it or recreate the rows as the user. **`functions.invoke()` throws "Failed to fetch" in the browser.** `@resultdev/sdk` older than 0.3.0 routed browser calls through a retired host. Upgrade to the latest SDK. **Realtime publishes succeed but subscribers receive nothing.** Same cause: SDK older than 0.3.0 tagged incoming messages with a prefixed channel name, so channel filters never matched. Upgrade, and confirm the channel pattern is declared. **AI request with an image or audio fails (`AI_UPSTREAM_UNAVAILABLE`).** Use `google/gemini-2.5-flash` - the verified model for image and audio input. Audio must be wav/mp3/aiff/aac/ogg/flac/m4a; browser MediaRecorder's webm is rejected. **Sign-in works, then a reload shows the user signed out.** Almost always no `getCurrentUser()` call when the app mounts, so nothing redeems the stored session. That call is what restores it, and `onAuthStateChange()` does not stand in for it: it reports changes only, never the session that already exists. If you do call it, upgrade to SDK 0.7.0 or newer - before that the session rode a cookie Safari, incognito windows and framed previews refuse to send. **The login screen flashes on every reload for a signed-in user.** The UI has two states where it needs three. The restore is async, so render nothing auth-dependent until `getCurrentUser()` resolves. **No auth token in localStorage.** By design. The access token is in memory and the refresh token is a first-party cookie - check sign-in state with `backend.auth.getCurrentUser()`, never by reading browser storage. **Migration rejected.** Names allow lowercase letters, numbers and hyphens only (the CLI normalizes this for you), and the SQL must not contain `BEGIN`/`COMMIT` - it already runs in one transaction. **401 from a hand-rolled HTTP call.** Don't hand-roll - use the SDK or CLI. (If you must know: end-user calls send `Authorization: Bearer `, not an `x-api-key` header.) ## Debugging workflow 1. `npx @resultdev/cli status` - does the table/bucket/function exist at all? 2. `npx @resultdev/cli logs` - pick a source, read recent entries. 3. `npx @resultdev/cli auth users` - did the signup actually land? 4. Check `error.nextActions` on the failing SDK call. ## Facts that prevent bugs * Every table has `id`, `created_at`, `updated_at` automatically. * Secrets are effectively permanent - deleting is unreliable; rotate by setting a new key instead. * Email `from` is a sender **name**, not an address. * AI + email work with the publishable key - gate spend behind a function. # Quickstart Source: https://docs.result.dev/index From zero to a working backend call in four steps. Result Backend is your app's hosted backend: Postgres, user auth, file storage, serverless functions, realtime channels, AI models, transactional email, web analytics, and customer support. It is already provisioned - nothing to install or host. Your app's page in the Result dashboard has three credentials. Put them in `.env.local`: ```bash theme={"dark"} NEXT_PUBLIC_BACKEND_URL=... # the backend's base URL NEXT_PUBLIC_BACKEND_ANON_KEY=... # publishable client key (safe in browsers) BACKEND_ADMIN_KEY=... # admin key - server/terminal ONLY ``` ## Four steps 1. Persist the backend rules into your repo (writes `AGENTS.md`/`CLAUDE.md` so every future AI coding session knows how this backend works): ```bash theme={"dark"} npx @resultdev/cli init ``` 2. Install the SDK: ```bash theme={"dark"} npm install @resultdev/sdk ``` 3. Create the client: ```ts theme={"dark"} // lib/backend.ts import { createClient } from "@resultdev/sdk"; export const backend = createClient({ baseUrl: process.env.NEXT_PUBLIC_BACKEND_URL!, anonKey: process.env.NEXT_PUBLIC_BACKEND_ANON_KEY!, }); ``` 4. Make a call: ```ts theme={"dark"} const { data, error } = await backend.database.from("todos").select(); ``` ## The one rule * **App code** → SDK with the publishable key. Covered in the SDK section. * **One-time setup** (tables, migrations, buckets, channels, functions, secrets) → CLI with the admin key, from the terminal. Covered in the CLI section. Never hand-roll HTTP against the backend, and never let `BACKEND_ADMIN_KEY` reach the browser. Next: [create your first table](/cli/reference#db-create-table), then read [Database](/sdk/database). # AI models Source: https://docs.result.dev/sdk/ai Chat, streaming, embeddings and image generation - no API key to manage. Model ids are `provider/model` strings. Spend is billed to the backend. ```ts theme={"dark"} const completion = await backend.ai.chat.completions.create({ model: "openai/gpt-4o-mini", messages: [{ role: "user", content: "Summarize this..." }], }); console.log(completion.choices[0].message.content); ``` ## Streaming ```ts theme={"dark"} const stream = await backend.ai.chat.completions.create({ model: "openai/gpt-4o-mini", messages, stream: true, }); for await (const chunk of stream) { process.stdout.write(chunk.choices[0]?.delta?.content ?? ""); } ``` ## Embeddings ```ts theme={"dark"} const res = await backend.ai.embeddings.create({ model: "openai/text-embedding-3-small", input: "hello world", }); // res.data[0].embedding → number[] ``` Pairs with pgvector - enable it with a [SQL migration](/cli/reference#db-migrate). ## Images ```ts theme={"dark"} const img = await backend.ai.images.generate({ model: "google/gemini-2.5-flash-image", prompt: "A sunset over mountains", }); // img.data[0].b64_json → base64 PNG ``` AI calls work with the publishable key, so anyone holding it can drive spend. Keep expensive or sensitive AI calls in a [serverless function](/sdk/functions). # Web analytics Source: https://docs.result.dev/sdk/analytics Pageviews, sessions and custom events - no script tag, no cookies. ```ts theme={"dark"} import { createClient } from "@resultdev/sdk"; export const backend = createClient({ baseUrl: process.env.NEXT_PUBLIC_BACKEND_URL!, anonKey: process.env.NEXT_PUBLIC_BACKEND_ANON_KEY!, analytics: { siteId: "SITE_ID" }, }); ``` `siteId` is the workspace id shown on the dashboard's Analytics > Web page. That's the whole setup. When the client is created in a browser it tracks pageviews (including SPA route changes via `pushState`/`replaceState`/ `popstate`/`hashchange` and back-forward cache restores), pageleaves, and sessions on its own. No cookies, no fingerprinting: a random visitor id in localStorage and a session id that rotates after 30 idle minutes. In Next.js, import the client from a client component that the root layout renders - otherwise nothing runs in the browser. Tracking is a no-op during SSR and in server code. ## Custom events ```ts theme={"dark"} backend.analytics.track("Signup", { plan: "pro" }); // name capped at 64 chars backend.analytics.identify(user.id); // attach your own user id to later events ``` Declarative click events work without any JS: ```html theme={"dark"} ``` That click sends event `Signup` with prop `plan=pro`. ## Options | Option | Default | What it does | | --------------- | -------- | ------------------------------------------ | | `siteId` | required | Workspace id from Analytics > Web | | `autoTrack` | `true` | `false` disables automatic pageviews | | `domains` | all | `string[]` - only track on these hostnames | | `excludeSearch` | `false` | Drop `?query` from tracked URLs | | `excludeHash` | `false` | Drop `#hash` from tracked URLs | | `endpoint` | managed | Self-hosting only | ## Good to know * Visitors can opt out on their own machine with `localStorage.rwa_disable = "1"`. * Automation (`navigator.webdriver`) is never tracked. * If the `wa.js` script tag is also on the page, whichever loads first wins - nothing double-counts. # Authentication Source: https://docs.result.dev/sdk/authentication Google sign-in and email/password, with sessions the SDK manages for you. ```ts theme={"dark"} await backend.auth.signUp({ email, password, name }); // signs the user in immediately await backend.auth.signInWithPassword({ email, password }); const { data } = await backend.auth.getCurrentUser(); await backend.auth.signOut(); ``` After sign-in the SDK attaches the user's session to every database, storage, function, and realtime call automatically - RLS-scoped tables just work. ## Google sign-in Configured on every backend, no client ID to register: ```ts theme={"dark"} await backend.auth.signInWithOAuth("google", { redirectTo: window.location.origin, // any page of your app }); ``` There is no callback route to build. The browser comes back to `redirectTo`, the SDK exchanges the code when the client initializes on that page, and `onAuthStateChange()` fires. GitHub works the same way (`"github"`). Check what else is enabled with `(await backend.auth.getPublicAuthConfig()).data.oAuthProviders`. Call it from a click handler, not an effect. In a preview pane the SDK needs to open a window, and a browser only allows that inside a real user gesture. ### Inside a preview or an iframe Google refuses to render inside an iframe, so a framed page that redirects to it gets a 403 rather than a sign-in screen. The SDK handles this: when it detects that your app is framed, it opens the provider in a popup and finishes the sign-in in the page that opened it. Nothing to pass, and a top-level page still redirects as before. Force either flow when a product needs it: ```ts theme={"dark"} await backend.auth.signInWithOAuth("google", { redirectTo: window.location.origin, flow: "popup", // "redirect" | "popup" | "auto" (default) }); ``` Keep `redirectTo` on the same origin as the page starting the sign-in. A popup can only hand its result back to a page it shares an origin with, so a cross-origin one is refused up front with `OAUTH_REDIRECT_CROSS_ORIGIN` rather than opening a window whose answer never arrives. Relative paths are fine. If the popup is blocked, the call returns `OAUTH_POPUP_BLOCKED` and its `nextActions` tells the user what to allow. A window the user closes returns `OAUTH_POPUP_CLOSED`, so a cancelled sign-in is not an error state to invent copy for. ## Staying signed in The SDK persists the session in a first-party cookie on your own origin, so a reload, a new tab, a framed preview and a visit tomorrow all restore the same user. Requires 0.7.0 or newer. What you write is the restore call, once, when your app mounts: ```tsx theme={"dark"} const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { backend.auth.getCurrentUser() .then(({ data }) => setUser(data.user)) .finally(() => setLoading(false)); return backend.auth.onAuthStateChange(() => { backend.auth.getCurrentUser().then(({ data }) => setUser(data.user)); }); }, []); ``` Three things to get right: 1. **`getCurrentUser()` on mount is what restores the session.** Skip it and a signed-in user who reloads looks signed out. 2. **`onAuthStateChange()` reports changes only.** It does not fire for the session that already exists, so use it alongside the call above rather than instead of it. 3. **Keep a loading state.** The restore is async, so a UI with only "signed in" and "signed out" flashes the login screen on every reload. Nothing else is needed, and the usual additions are all bugs waiting to happen: no tokens in `localStorage`, no refresh timer, no expiry math, no `/api/auth/callback` route. The SDK refreshes and rotates on its own, and keeps browser storage empty, so an empty `localStorage` never means "signed out". `getCurrentUser()` answers `{ user: null }` with `error: null` for a visitor who has never signed in, and makes no request at all in that case. Want the refresh token out of reach of your own JavaScript? Use `@resultdev/sdk/ssr`, where it lives in an httpOnly cookie your own server writes instead of one the SDK can read. ## Password reset ```ts theme={"dark"} await backend.auth.sendResetPasswordEmail({ email, redirectTo }); await backend.auth.resetPassword({ newPassword, otp }); ``` ## Profiles ```ts theme={"dark"} const { data } = await backend.auth.getProfile(userId); await backend.auth.setProfile({ name, avatar_url }); ``` ## Debugging List who has actually signed up: ```bash theme={"dark"} npx @resultdev/cli auth users ``` Signup returns a session immediately - email verification is pre-configured off, so there is no "check your email" stall to handle. # Database Source: https://docs.result.dev/sdk/database PostgREST-style queries against your Postgres tables. ```ts theme={"dark"} const { data, error } = await backend.database .from("posts") .select() .order("created_at", { ascending: false }); ``` Every table automatically has `id` (uuid), `created_at`, and `updated_at` - never add those columns yourself. ## CRUD ```ts theme={"dark"} await backend.database.from("posts").insert([{ title: "Hello" }]); // always an array await backend.database.from("posts").update({ title: "Hi" }).eq("id", postId); await backend.database.from("posts").delete().eq("id", postId); // one row instead of an array const { data: post } = await backend.database .from("posts").select().eq("id", postId).single(); ``` ## Filters and modifiers * Filters chain: `.eq()` `.neq()` `.gt()` `.gte()` `.lt()` `.lte()` `.like()` `.ilike()` `.in()` `.is()` * Modifiers: `.order()` `.limit()` `.range()` `.single()` `.maybeSingle()` * Postgres functions: `backend.database.rpc("fn_name", { arg: 1 })` ## Row-level security Tables created by `result db create-table` with a `user_id` uuid column get an owner policy automatically: rows are scoped to the signed-in user and `user_id` fills in on insert. After [sign-in](/sdk/authentication), queries just return the right rows. An RLS table with no policy denies everything: writes fail with 403 (Postgres code 42501) and reads return empty arrays with no error. If that happens, add a policy (or recreate the table through the CLI) - the data is not lost. ## Changing schema Tables and migrations are CLI operations, not SDK calls: ```bash theme={"dark"} npx @resultdev/cli db create-table posts -c "title:string:required" -c "user_id:uuid" npx @resultdev/cli db migrate --name add-index --sql "CREATE INDEX ..." ``` See [CLI reference](/cli/reference#db). # Email Source: https://docs.result.dev/sdk/email Transactional email with managed delivery - no SMTP setup. ```ts theme={"dark"} const { error } = await backend.emails.send({ to: "user@example.com", // or an array, max 50 subject: "Your receipt", html: "

Thanks!

", from: "Acme Billing", // sender NAME, not an address replyTo: "support@acme.com", // optional }); ``` Delivery is managed - it works out of the box. Sends work with the publishable key. Prefer sending from server code or a [serverless function](/sdk/functions) so you control what triggers an email. # Functions Source: https://docs.result.dev/sdk/functions Serverless Deno functions: invoke from the app, deploy from the terminal. Invoke from the app: ```ts theme={"dark"} const { data, error } = await backend.functions.invoke("my-function", { method: "POST", body: { orderId }, }); ``` ## Writing and deploying Code is a Deno module - standard `fetch`/`Response`, ESM imports, no bundler: ```ts theme={"dark"} // fn.ts export default async function (req: Request) { const { orderId } = await req.json(); return new Response(JSON.stringify({ ok: true, orderId }), { headers: { "Content-Type": "application/json" }, }); } ``` ```bash theme={"dark"} npx @resultdev/cli functions deploy my-function --file fn.ts ``` The same command updates an existing function. It runs at `$NEXT_PUBLIC_BACKEND_URL/functions/my-function`. ## Secrets Store API keys once; read them inside functions as environment variables: ```bash theme={"dark"} npx @resultdev/cli secrets set OPENWEATHER_KEY=... ``` ```ts theme={"dark"} // inside the function const key = Deno.env.get("OPENWEATHER_KEY"); ``` ## When to use a function Put logic in a function (not client code) when it involves secrets, spend (AI calls, email sends), or trust (payments, fulfillment) - anything a stranger holding your publishable key must not be able to drive. # Payments Source: https://docs.result.dev/sdk/payments Sell subscriptions and one-off products through your connected payment account. ```ts theme={"dark"} import { createClient } from "@resultdev/sdk"; export const backend = createClient({ baseUrl: process.env.NEXT_PUBLIC_BACKEND_URL!, anonKey: process.env.NEXT_PUBLIC_BACKEND_ANON_KEY!, payments: {}, }); ``` That is the whole setup. Create your products in the dashboard under Finance > Products, then use the plan ids they give you. **You do not write a webhook.** Result receives every payment event, verifies its signature, handles retries and out-of-order delivery, and writes the result into `billing_customers` and `billing_subscriptions` in your own backend. Those tables already exist. Do not create them, do not write to them, and do not build a payment webhook endpoint. ## Pricing page ```ts theme={"dark"} const { data: plans } = await backend.payments.plans(); ``` ```json theme={"dark"} [ { "slug": "pro-monthly", "name": "Monthly", "productName": "Pro", "amount": "1000", "currencyCode": "USD", "interval": "month", "formattedTotal": "$10.00" } ] ``` In a browser, `formattedTotal` is display-ready. Paddle accounts localize it to the visitor's country with tax applied; connected Stripe accounts format the price and currency stored in Stripe. Server-side you get `amount` and `currencyCode` to format yourself. `amount` is a **string in the currency's lowest denomination**. `"1000"` is \$10.00. It is not always hundredths, either: `"1200"` in JPY is ¥1,200, not ¥12. Use `formattedTotal` where you can. ## Checkout ```ts theme={"dark"} await backend.payments.checkout({ plan: "pro-monthly" }); ``` Opens the configured provider's checkout. Paddle uses its overlay; connected Stripe accounts use Stripe-hosted Checkout. The signed-in user's id is attached automatically, which is how the purchase gets matched back to their account. The buyer has to be signed in. With nobody signed in this throws `PAYMENTS_SIGN_IN_REQUIRED` rather than opening a checkout, because a payment with no user attached can never grant access and there is no way to repair it after the money has moved. Send buyers through sign-in first. | Option | What it does | | ------------- | --------------------------------------------- | | `plan` | Required. The plan id from Finance > Products | | `quantity` | Seats, for per-seat pricing | | `successUrl` | Where the buyer lands after paying | | `email` | Prefill, when you already know it | | `displayMode` | `"overlay"` (default) or `"inline"` | | `frameTarget` | CSS selector, required for `"inline"` | | `customData` | Extra fields echoed back on payment events | ## Gating features ```ts theme={"dark"} const { data: allowed } = await backend.payments.hasAccess(); const { data: isPro } = await backend.payments.hasAccess("pro-monthly"); const { data: sub } = await backend.payments.subscription(); ``` Never grant access from a success URL, a redirect, or the checkout callback. The buyer can close the tab before any of them run, and you will have taken money without granting anything. `hasAccess()` reads what the payment actually wrote. Access is granted for `active`, `trialing` and **`past_due`**. `past_due` means a renewal payment failed and automatic recovery is under way, which usually succeeds. Show a banner linking to the billing portal rather than cutting the customer off: ```tsx theme={"dark"} {sub?.status === "past_due" && ( Your payment didn't go through. Update it )} ``` A scheduled cancellation keeps access until it takes effect, so someone who cancels mid-month keeps what they paid for. ## Managing a subscription ```ts theme={"dark"} const { data: url } = await backend.payments.portalUrl(); await backend.payments.changePlan({ plan: "pro-yearly" }); await backend.payments.cancel(); // at the end of the period await backend.payments.cancel({ immediately: true }); // right now ``` The portal handles payment methods, invoices and cancellation. Portal links are single-use and short-lived. Mint one per click, open it in a new tab, and never cache or iframe it. Changes a customer makes there arrive back in your database on their own. Plan changes cannot be made in the portal, which is why `changePlan()` exists. It bills the difference immediately, prorated to the minute. ## Test mode Test-mode accounts use test cards, never a real one: | Card | What happens | | ------------------- | ------------------------------------ | | 4242 4242 4242 4242 | Succeeds | | 4000 0038 0000 0446 | Succeeds, with a 3DS challenge | | 4000 0000 0000 0002 | Declined | | 4000 0027 6000 3184 | Succeeds once, then fails on renewal | Any future expiry and any name work. Paddle accounts go live with the switch in Finance > Payments. Stripe accounts connect the live Stripe account from Payments > Settings. Your running app follows the account connection, so neither path needs an app environment variable change or redeploy. ## Before you can go live Paddle's merchant-of-record setup requires three pages, publicly reachable without a login: 1. Terms and conditions, stating that Paddle.com is the reseller and Merchant of Record for all orders. 2. A refund policy with a window between 14 and 90 days. 3. A privacy notice. Login walls and hidden pricing are the most common reason a site is refused. With Stripe, you remain the merchant and keep your existing Stripe business, tax, dispute and payout configuration. Result only requests the permissions needed to show and operate that account from Finance. ## Reading the tables directly Useful for dashboards and joins: ```sql theme={"dark"} select status, plan, current_period_ends_at from billing_subscriptions where user_id = auth.uid(); ``` `billing_customers`, `billing_subscriptions` and `billing_plans` are read-only to signed-in users and scoped to their own rows. ## From the CLI ```bash theme={"dark"} result payments status # is this app set up to take money result payments plans # the plan ids to use in checkout() ``` # Realtime Source: https://docs.result.dev/sdk/realtime Channels, events and presence over WebSocket. Declare the channel pattern once (CLI), then connect from the app: ```bash theme={"dark"} npx @resultdev/cli realtime add-channel "chat:%" # % is a wildcard - covers every room ``` ```ts theme={"dark"} await backend.realtime.connect(); const res = await backend.realtime.subscribe("chat:room-1"); backend.realtime.on("new_message", (msg) => console.log(msg)); await backend.realtime.publish("chat:room-1", "new_message", { text: "Hi" }); ``` Subscribing to a channel no declared pattern covers fails with `REALTIME_UNAUTHORIZED`. Declare the pattern first - that alone unlocks it. ## Presence * `res.presence.members` on subscribe - who is here now * `presence:join` / `presence:leave` events as people come and go * `backend.realtime.getPresenceState(channel)` any time ## Rules 1. `connect()` before anything else. 2. `publish()` requires a prior `subscribe()` to the same channel. 3. Leave with `unsubscribe(channel)`. # Storage Source: https://docs.result.dev/sdk/storage File uploads, downloads and signed URLs. Create the bucket once (CLI), then upload from the app: ```bash theme={"dark"} npx @resultdev/cli storage create-bucket uploads # public npx @resultdev/cli storage create-bucket private-docs --private ``` ```ts theme={"dark"} const { data } = await backend.storage.from("uploads").uploadAuto(file); // data.key + data.url - save both to your database; data.url is directly // usable for public buckets ``` Uploads and `list()` require a signed-in user - anonymous calls are rejected. Sign the user up or in first. ## All operations ```ts theme={"dark"} await backend.storage.from("uploads").upload("avatars/me.png", file); // choose the key const { data: blob } = await backend.storage.from("uploads").download(key); await backend.storage.from("uploads").remove(key); ``` ## Private buckets Hand out expiring links instead of raw URLs: ```ts theme={"dark"} const { data: signed } = await backend.storage .from("private-docs").createSignedUrl(key, 3600); // seconds ``` # Customer support Source: https://docs.result.dev/sdk/support A support launcher on your pages. Conversations land in your Result inbox. ```ts theme={"dark"} import { createClient } from "@resultdev/sdk"; export const backend = createClient({ baseUrl: process.env.NEXT_PUBLIC_BACKEND_URL!, anonKey: process.env.NEXT_PUBLIC_BACKEND_ANON_KEY!, support: { handle: "acme" }, // your business @handle on Result, without the @ }); ``` ## Show the launcher ```ts theme={"dark"} useEffect(() => { backend.support.mount(); return () => backend.support.unmount(); }, []); ``` `mount()` puts a floating button in the bottom-right corner. Pressing it opens your support page in a panel, full-screen on phones. `unmount()` takes it back off. Both are safe to call more than once, and `mount()` does nothing during SSR, so an effect that runs twice still leaves exactly one launcher. ## What visitors get The panel is your own support page (`support..withresult.com`), so there is no support UI in your bundle and nothing to restyle or keep in step. Visitors write to you, get email notifications, and can reply by email. Every conversation lands in your Result support inbox. There are no visitor accounts. A visitor's conversation is remembered in the support page's own storage, so unmounting the launcher never loses it. ## Without the SDK The same widget as a plain script tag, for sites that install nothing: ```html theme={"dark"} ``` Use one or the other. Both mount the same launcher, so a page that does both gets two. ## Limits Set by the support API, not by the widget: * 5 new conversations per IP per hour * 30 messages per IP per 10 minutes If the widget script cannot be fetched (a CSP rule, an ad blocker, an offline visitor), `mount()` warns on the console and your page carries on without a launcher.