# UseThatApp Documentation > Documentation for selling and integrating a web app through UseThatApp, across Python and JavaScript frameworks. UseThatApp is the MERCHANT OF RECORD for web apps: hosted buy links and a live pricing API, processing payment, remitting tax, handling refunds/chargebacks, and paying sellers via Stripe, so developers sell from their own websites. An app verifies what a buyer purchased in one of two ways: LICENSE KEYS (included in the per-sale fee) let the developer keep their own authentication and validate a key server-to-server; HOSTED SIGN-IN (an optional paid add-on) has UseThatApp run login via OpenID Connect, so buyers return from checkout already signed in and the same session answers their live entitlement. ## Important notes for AI agents - How to read this site: every route serves complete pre-rendered HTML (no JavaScript required), so any page can be fetched directly. This llms.txt is the index; https://docs.usethatapp.com/llms-full.txt contains every page as one Markdown file — prefer it over crawling. Canonical page URLs are extensionless (e.g. https://docs.usethatapp.com/license-keys) and sitemap.xml lists them all. - Selling from your own website is the core offering: UseThatApp is the merchant of record (payment, tax, refunds/chargebacks; sellers get Stripe payouts). Buy buttons link to hosted checkout at https://www.usethatapp.com/buy/{price_id}/ — get price ids AND ready-made buy_url values from the public, CORS-enabled pricing API (GET /api/v1/public/apps/{client_id}/prices/); render prices from that API, never hardcode them. Purchases are currently US-only and buy links are enabled per app during the beta (404 until enabled). The license is created synchronously at checkout — build NO payment webhooks and NO polling. How the app then verifies the purchase depends on its mode: with license keys the return redirect carries a uta_order ref to exchange for the key; with the Hosted sign-in add-on the buyer returns already signed in with a live entitlement. See /purchase/sell-from-your-website. - Two verification modes, and an app picks ONE: (1) LICENSE KEYS — included in the per-sale fee, the developer keeps their own auth; validate_license_key/validateLicenseKey, get_order/getOrder, regenerate_license_key/regenerateLicenseKey, authenticated server-to-server with UTA_CLIENT_ID + UTA_CLIENT_SECRET (HTTP Basic), see /license-keys. (2) HOSTED SIGN-IN — an optional paid add-on where UseThatApp runs login; begin_login/complete_login then get_entitlement, see /licensing. Sign-in itself is free and unmetered; the entitlement call is what the add-on gates (403 service_not_enabled when it is off). Never tell a developer they must use our login to sell. - SDK (PyPI/npm `usethatapp`) also covers the purchase surface — purchase_url/purchaseUrl and manage_url/manageUrl (pure URL builders, no network; manage needs only UTA_CLIENT_ID), get_prices/getPrices and get_app_info/getAppInfo (anonymous public reads; 404 → UtaError when external sales isn't enabled, 429/5xx → UtaServerError). The optional `next` on buy/manage links must be HTTPS on the app's registered domain. - Sign-in is OpenID Connect (OAuth 2.0): 'Sign in with UseThatApp'. The usethatapp SDK is a confidential, SERVER-SIDE client — it holds the client secret and validates ID tokens, so it must never run in the browser (single-page apps such as React/Vue use a backend-for-frontend). - Identity is a pairwise pseudonymous `sub` — stable for a user WITHIN your app, uncorrelatable across apps, and never PII (no email/name). Key your user records off `sub`, never an email. The entitlement response does NOT include `sub`; identity comes from the login (session.sub). - Entitlement gating: branch on the stable `product_public_id` (an opaque prod_… id that survives plan renames — the same id the pricing API returns per price), not the mutable `version` display name. get_entitlement returns 401 (UtaTokenError — re-authenticate/refresh) for an invalid/expired/revoked token and 403 (UtaPermissionError) for a missing scope. - Sign-out is RP-initiated. Both 'logged out' and 'Stay signed in' return to the same post-logout URL, so reconcile via the token rather than clearing your session eagerly: a confirmed logout revokes the token and the next get_entitlement returns 401. - UseThatApp also ships a remote MCP server at https://www.usethatapp.com/mcp (Streamable HTTP + OAuth) so AI agents can manage a developer's own apps: integration settings (integrations.read/write scopes) and listing content + product descriptions incl. listing_mode (listing.read/write scopes). The fee-changing Hosted sign-in add-on toggle is deliberately NOT exposed via MCP — its state is readable via get_integration_settings, which also returns the dashboard URL where the developer flips it. See /mcp for setup and the tool reference. --- # UseThatApp Documentation Sell your web app **from your own website** — UseThatApp is the **merchant of record**. Put live prices and Buy buttons on your site; UseThatApp runs the hosted checkout, processes payment, calculates and remits tax, and handles refunds and chargebacks. The buyer lands back in your app **with their purchase live** — the license is created during checkout, no webhooks — and you get payouts via Stripe. Your app verifies each purchase with a license key — or lets UseThatApp run sign-in — through one SDK: `usethatapp` on PyPI and npm. Reading this as an AI agent? Every page on this site is fully pre-rendered static HTML — no JavaScript needed. For the fastest path, fetch [docs.usethatapp.com/llms.txt](https://docs.usethatapp.com/llms.txt) (an index of all pages with summaries and integration rules) or [docs.usethatapp.com/llms-full.txt](https://docs.usethatapp.com/llms-full.txt) (the entire site as one Markdown file — no crawling required). Page URLs are extensionless routes; the same list is in [sitemap.xml](https://docs.usethatapp.com/sitemap.xml). ## One Merchant of Record, Two Ways to Verify #### Merchant of record The service everything else hangs off. Hosted buy links and a live pricing API; we take the payment, remit the tax, absorb refunds and chargebacks, and pay you via Stripe. #### License keys Verify purchases while keeping your own auth. Every purchase mints a key your server validates in one call — included in the per-sale fee, no login of ours involved. #### Hosted sign-in Or let us run login: buyers return from checkout already signed in, and the same session answers what they bought. An optional add-on, free for an introductory period. ## The Loop Everything in these docs serves one journey — a visitor on *your* website becoming a paying user of your app: 1. **Live prices on your site.** Your pricing page renders from the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) (`get_prices` / `getPrices`, or plain fetch — it's public and CORS-enabled). Never hardcode a price. 2. **Buy button → hosted checkout.** Each price carries a ready-made `buy_url` (or build one with `purchase_url` / `purchaseUrl`). UseThatApp runs checkout as the merchant of record. 3. **Back into your app.** The license is created synchronously during checkout, so what they bought is live the moment they land — no webhooks, no polling. 4. **Verify, whichever way suits your app.** Keeping your own accounts? The return carries a `uta_order` ref you exchange for a [license key](https://docs.usethatapp.com/license-keys), then `validate_license_key` / `validateLicenseKey` is your gate. Rather we ran login? [Sign in with UseThatApp](https://docs.usethatapp.com/openid-connect) returns them signed in and [`get_entitlement`](https://docs.usethatapp.com/licensing) answers from that session. Either way, branch on the stable `product_public_id`. 5. **Manage and upgrade.** Deep-link "Manage subscription" with `manage_url` / `manageUrl`; upsell with another purchase link. Your app's [marketplace listing on usethatapp.com](https://docs.usethatapp.com/purchase/sell-from-your-website) is a complementary discovery channel — in `external` listing mode its card links straight out to your own site. Purchases are currently available to US buyers, with buy links enabled per app during the beta. ## Quick Example Live prices on your page, then gate what the buyer bought — shown here in Hosted sign-in mode (keys mode swaps the gate for `validate_license_key`, see [License Keys](https://docs.usethatapp.com/license-keys)). The `usethatapp` SDK (PyPI / npm) is a **confidential, server-side** client — it holds your client secret and never runs in the browser: ### Python *app.py* ```python from usethatapp import get_entitlement, get_prices, purchase_url # Your pricing page — live prices, never hardcoded: prices = get_prices() # AppPrices(..., prices=(Price(...), ...)) for p in prices.prices: show_plan(p.product_name, p.amount, p.currency, p.buy_url) # Your gate, Hosted sign-in mode (keys mode: validate_license_key): ent = get_entitlement(session["uta_access_token"]) if ent.entitled and ent.product_public_id == "prod_": ... # serve paid content else: offer_upgrade(purchase_url("")) ``` ### JavaScript *server.mjs* ```javascript import { getEntitlement, getPrices, purchaseUrl } from "usethatapp"; // Your pricing page — live prices, never hardcoded: const { prices } = await getPrices(); prices.forEach((p) => showPlan(p.product_name, p.amount, p.currency, p.buy_url)); // Your gate, Hosted sign-in mode (keys mode: validateLicenseKey): const ent = await getEntitlement(req.session.utaAccessToken); if (ent.entitled && ent.product_public_id === "prod_") { // serve paid content } else { offerUpgrade(purchaseUrl("")); } ``` Hosted sign-in itself is two small routes (`/login` and `/callback`) — the [Quick Start](https://docs.usethatapp.com/quickstart) walks that whole loop end to end; keys mode skips both. ## Supported Frameworks Choose your language to see the full list of framework guides, or jump straight to the Quick Start. ### Python Flask, Django, FastAPI, Dash, and Streamlit — sign-in, entitlement gating, and purchase links for each framework. Explore Python ### JavaScript Express, Node.js, Fastify, Next.js, Nuxt, plus React and Vue — sign-in, entitlement gating, and purchase links for each framework. Explore JavaScript Quick Start Sell from your own website Sign in with UseThatApp --- # Quick Start Go from zero to **selling your app from your own website** — with UseThatApp as the **merchant of record** handling payment, tax, and refunds. One integration covers the whole loop: live prices and Buy buttons on your site, hosted checkout, the buyer returning to your app, and feature gating on what they bought. The `usethatapp` SDK runs **server-side** and does the crypto for you. One choice before you start How should your app verify a purchase? If it already has accounts, keep them and validate a [license key](https://docs.usethatapp.com/license-keys) — included in the per-sale fee, and you can skip every sign-in step below. This Quick Start walks the other path, the [Hosted sign-in](https://docs.usethatapp.com/openid-connect) add-on, where we run login and the same session answers what they bought. Selling works the same either way. ## The Loop You're Building 1. **Sell.** Your website renders live prices from the pricing API and links each Buy button to hosted checkout — a [purchase link](https://docs.usethatapp.com/purchase/links) on usethatapp.com. 2. **Return.** Checkout creates the license *synchronously* and sends the buyer to your registered Login URL, where your `/login` route signs them in over OIDC — no webhooks, no polling. 3. **Gate.** Anywhere it matters, read the live plan with `get_entitlement` / `getEntitlement` and branch on the stable `product_public_id`. (Keys mode calls `validate_license_key` here instead and branches on the same field.) ## Prerequisites - Python 3.9+ **or** Node.js 18+ - An app registered on usethatapp.com - Your app's **Client ID** and **Client Secret**, from the **Integration** page of your UseThatApp dashboard ## Step 1 — Install the Package ### Python *terminal* ```bash pip install usethatapp ``` ### JavaScript *terminal* ```bash npm install usethatapp ``` ## Step 2 — Register Your App Open your app's **Integration** page on the UseThatApp dashboard and register: - A **Redirect URI** — your OIDC callback URL, matched exactly (an allow-list). It must be HTTPS on your domain (`localhost` is allowed for development), e.g. `https://yourapp.com/callback`. - A **Login URL** — the page in your app that starts sign-in. This matters twice: marketplace launches land here, **and it's the default place buyers return to after checkout** — so make sure it signs them straight in. Then copy your **Client ID** and **Client Secret**. The secret is shown once — keep it on your server, never in the browser. ## Step 3 — Configure Environment Variables Both SDKs read configuration from environment variables, and both accept in-code overrides via `configure(...)` (Python also reads Django settings). Only the client id, secret, and redirect URI are required — the rest default to production: *environment* ``` UTA_CLIENT_ID=... # required UTA_CLIENT_SECRET=... # required (server-side only) UTA_REDIRECT_URI=https://yourapp.com/callback # required, must match the dashboard # optional — default to production: UTA_ISSUER=https://www.usethatapp.com/o UTA_API_URL=https://www.usethatapp.com UTA_SCOPES="openid entitlements" ``` Keep the client secret out of version control. If your host mounts secrets as files (Render Secret Files, Fly.io, Kubernetes, GCP Secret Manager, etc.) set `UTA_CLIENT_SECRET_PATH` instead. See the [Hosting Provider guide](https://docs.usethatapp.com/hosting) for per-provider recommendations. (The purchase helpers are lighter: `purchase_url` needs no config at all, and `manage_url` / `get_prices` only need `UTA_CLIENT_ID` — so a separate marketing site can use them without the OAuth secrets.) ## Step 4 — Add Login and Callback Routes Expose a `/login` route that starts sign-in and a `/callback` route (matching `UTA_REDIRECT_URI`) that completes it. These routes serve both marketplace launches and buyers returning from checkout. The SDK is framework-agnostic — you wire three small bits: read the callback query params, store `flow_state` in your session, and issue the redirects. ### Python (Flask) *app.py* ```python from flask import Flask, redirect, request, session, url_for from usethatapp import ( UtaError, begin_login, complete_login, get_entitlement, ) app = Flask(__name__) app.secret_key = "change-me" @app.route("/login") def login(): auth_url, flow_state = begin_login() session["uta_flow"] = flow_state return redirect(auth_url) @app.route("/callback") def callback(): # On cancel/deny, OAuth redirects back with ?error=... and no code. if request.args.get("error"): session.pop("uta_flow", None) return redirect(url_for("home")) try: s = complete_login( code=request.args.get("code"), state=request.args.get("state"), flow_state=session.pop("uta_flow", {}), ) except UtaError as e: return f"login failed: {e}", 400 session["uta_sub"] = s.sub session["uta_access_token"] = s.access_token return redirect(url_for("home")) @app.route("/") def home(): token = session.get("uta_access_token") if not token: return 'Log in with UseThatApp' ent = get_entitlement(token) return {"sub": session.get("uta_sub"), "version": ent.version} ``` ### JavaScript (Express) *app.mjs* ```javascript import express from "express"; import session from "express-session"; import { beginLogin, completeLogin, getEntitlement, UtaError } from "usethatapp"; const app = express(); app.use(session({ secret: "change-me", resave: false, saveUninitialized: true })); app.get("/login", async (req, res, next) => { try { const { authorizationUrl, flowState } = await beginLogin(); req.session.utaFlow = flowState; res.redirect(authorizationUrl); } catch (e) { next(e); } }); app.get("/callback", async (req, res) => { // On cancel/deny, OAuth redirects back with ?error=... and no code. if (req.query.error) { delete req.session.utaFlow; return res.redirect("/"); } try { const s = await completeLogin({ code: req.query.code, state: req.query.state, flowState: req.session.utaFlow, }); delete req.session.utaFlow; req.session.utaSub = s.sub; req.session.utaAccessToken = s.access_token; res.redirect("/"); } catch (e) { res.status(e instanceof UtaError ? 400 : 500).send(`login failed: ${e.message}`); } }); app.get("/", async (req, res) => { const token = req.session.utaAccessToken; if (!token) return res.send('Log in with UseThatApp'); const ent = await getEntitlement(token); res.json({ sub: req.session.utaSub, version: ent.version }); }); app.listen(3000); ``` ## Step 5 — Gate on the Entitlement Anywhere you gate paid functionality, read the live plan and check the stable `product_public_id` (never the mutable display `version`). The entitlement is authoritative the moment a purchase completes — and the moment a license is canceled: ### Python *any view* ``` from usethatapp import get_entitlement ent = get_entitlement(access_token) # Entitlement(entitled, version, product_public_id, status, is_free, period_end) if ent.entitled and ent.product_public_id == "prod_": unlock_pro_features() ``` ### JavaScript *any handler* ``` import { getEntitlement } from "usethatapp"; const ent = await getEntitlement(accessToken); // { entitled, version, product_public_id, status, is_free, period_end } if (ent.entitled && ent.product_public_id === "prod_") { unlockProFeatures(); } ``` ## Step 6 — Create Your Products and Prices On the dashboard, create your products (tiers) and their prices — recurring or one-time. Each product gets the stable `product_public_id` your gates branch on, and each price gets an opaque public id (`prc_…`) plus a ready-made `buy_url`, both served by the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api). ## Step 7 — Put Prices and Buy Buttons on Your Site Render your pricing page from the live pricing API — **never hardcode prices** — and point each Buy button at the hosted checkout. Buyers get one page for email + country + ZIP, card entry via Stripe, then land back in your app entitled: ### Python *pricing view* ``` from usethatapp import get_prices, purchase_url plans = get_prices() # live — no auth needed for p in plans.prices: # p.product_name, p.amount ("10.00"), p.currency ("usd"), # p.frequency ("month" or None), p.buy_url (hosted checkout) render_plan(p, buy=purchase_url(p.public_id, next="https://yourapp.com/welcome")) ``` ### JavaScript *pricing handler* ``` import { getPrices, purchaseUrl } from "usethatapp"; const { prices } = await getPrices(); // live — no auth needed for (const p of prices) { // p.product_name, p.amount ("10.00"), p.currency ("usd"), // p.frequency ("month" or null), p.buy_url (hosted checkout) renderPlan(p, purchaseUrl(p.public_id, { next: "https://yourapp.com/welcome" })); } ``` `next` is optional — leave it off and buyers return to your Login URL. It must be HTTPS on your registered domain. The pricing API is also public and CORS-enabled, so a static marketing page can fetch it straight from the browser — see [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website). ## Step 8 — Test the Funnel, Then Go Live 1. **Run the seller walkthrough.** Open your own buy link signed in as your developer account: same pages, a test banner, no Stripe calls, no charge — and it ends by setting your developer-preview entitlement (`status: "preview"`) so you can verify the return into your app end to end. See [Test your purchase flow](https://docs.usethatapp.com/purchase/testing). 2. **Ask UseThatApp to enable external sales** for your app — buy links return 404 until the per-app beta flag is on. 3. **Go live.** Purchases are currently available to United States buyers only. ## Next Steps That's the whole loop. To go deeper: [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website) (purchasing), [Licensing](https://docs.usethatapp.com/licensing) (entitlements), and [Sign in with UseThatApp](https://docs.usethatapp.com/openid-connect) (identity and sign-out). Then pick your stack: ### Python Documentation Flask, Django, FastAPI, Dash, and Streamlit guides ### JavaScript Documentation Express, Node.js, Fastify, Next.js, Nuxt, React, and Vue guides --- # Hosting Provider Selling through UseThatApp means buyers finish hosted checkout and land back in *your* deployed app, where the `usethatapp` SDK — a confidential, server-side client — verifies what they bought. This page covers what your hosting provider needs to make that work: a safe place to keep your **client secret**, plus a public HTTPS callback URL if you use the Hosted sign-in add-on (license-key verification needs no callback at all). **Hosting a separate marketing site?** The purchase surface needs none of the secrets on this page: `purchase_url` / `purchaseUrl` is a pure URL builder, `manage_url` / `manageUrl` and `get_prices` / `getPrices` need only `UTA_CLIENT_ID`, and the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) is public and CORS-enabled — a static pricing page can fetch it straight from the browser. Only the app itself (the OIDC client) needs the setup below. ## Overview You need **two things** at runtime: a public HTTPS callback your app exposes, and your OAuth client credentials kept on the server. ### Public Callback URL An HTTPS route on your app (your `UTA_REDIRECT_URI`) that UseThatApp redirects back to after sign-in. You register it on the dashboard's Integration page, where it's matched exactly. ### Your Client Secret The `UTA_CLIENT_SECRET` for your OAuth client. The SDK uses it to exchange the authorization code for tokens. Store it as a server-side secret — never in code, version control, or anything that reaches the browser. ## The Sign-In Flow The diagram below shows the authorization-code flow. The browser is redirected between your app and usethatapp.com; the code-for-tokens exchange happens server-to-server using your client secret. ``` ┌────────────┐ ┌────────────────────┐ ┌──────────────────────────┐ │ Browser │ │ usethatapp.com │ │ Your Hosting Provider │ │ │ │ (OpenID Provider)│ │ │ │ GET │──1───▶│ /login → │ │ │ │ /login │ │ begin_login() │ │ ┌────────────────────┐ │ │ │◀──2───│ redirect to │◀──────│ │ GET /login │ │ │ │ │ authorize │ │ │ begin_login() │ │ │ sign in │──3───▶│ user authenticates│ │ └────────────────────┘ │ │ │◀──4───│ redirect back │ │ ┌────────────────────┐ │ │ GET │───────────────────5───────────────▶│ │ GET /callback │ │ │ /callback │ │ │ │ │ complete_login() │ │ │ │ │ /token (exchange │◀──6───│ │ exchange code │ │ │ │ │ code → tokens) │───7──▶│ │ → UtaSession │ │ └────────────┘ └────────────────────┘ │ └─────────┬──────────┘ │ │ ┌────────▼───────────┐ │ ┌────────────────────┐ │ │ get_entitlement( │ │ │ entitlement API │◀──8───│ │ access_token) │ │ │ (Bearer token) │───9──▶│ │ → live plan │ │ └────────────────────┘ │ └────────────────────┘ │ │ ┌────────────────────┐ │ │ │ UTA_CLIENT_SECRET │ │ │ │ (env var / secret) │ │ │ └────────────────────┘ │ └──────────────────────────┘ ``` ## Step 1 — Get Your Credentials Open your app's **Integration** page on the **usethatapp.com** dashboard and collect: 1 #### Client ID Your app's OAuth client id. You'll set this as the`UTA_CLIENT_ID` environment variable. It identifies your app — there's no separate app id. 2 #### Client Secret Shown once when you create or rotate it, then stored hashed. You'll set it as `UTA_CLIENT_SECRET`.**Never commit it** — treat it like a database password, and keep it off the browser. 3 #### Redirect & Post-Logout URLs Register your production callback (your `UTA_REDIRECT_URI`) and any post-logout return URLs. Both are exact-match allow-lists — the URL your app sends must appear verbatim, HTTPS, on your domain. 4 #### Login URL The page in your deployed app that starts sign-in. Buyers returning from hosted checkout land here by default (as do marketplace launches), so it must be live on your production host before you turn on [buy links](https://docs.usethatapp.com/purchase/links). The same domain rule applies to any `next` URLs on your purchase and manage links: HTTPS, on your registered domain. ## Step 2 — Set Your Environment Variables Set the three required values; the rest default to production. Only the client secret is sensitive — store it through your provider's secret store, not a plain env var, where you can. *environment* ``` UTA_CLIENT_ID=... # required UTA_CLIENT_SECRET=... # required (server-side secret) UTA_REDIRECT_URI=https://yourapp.com/callback # required, registered on the dashboard # optional — default to production: UTA_ISSUER=https://www.usethatapp.com/o UTA_API_URL=https://www.usethatapp.com UTA_SCOPES="openid entitlements" ``` ### Mounted-File Secret (Secret Files) If your provider mounts secrets as files (Render Secret Files, Fly.io volumes, Kubernetes secret volumes, GCP Secret Manager volume mounts, AWS App Runner secret files), set `UTA_CLIENT_SECRET_PATH` instead of `UTA_CLIENT_SECRET` — the SDK reads the secret from the file. *environment (file-path alternative)* ``` UTA_CLIENT_ID=... UTA_REDIRECT_URI=https://yourapp.com/callback UTA_CLIENT_SECRET_PATH=/etc/secrets/uta_client_secret ``` ### Where to Store the Secret by Provider Use whichever your provider offers — they're purpose-built for sensitive material and rotate, audit, and encrypt it for you. The right column shows the recommended UseThatApp env var: - **Render** — Secret Files → `UTA_CLIENT_SECRET_PATH=/etc/secrets/uta_client_secret` - **Railway** — Shared Variables → `UTA_CLIENT_SECRET` - **Fly.io** — `fly secrets set UTA_CLIENT_SECRET=...` (or mount a volume and use `UTA_CLIENT_SECRET_PATH`) - **AWS** — Secrets Manager / SSM Parameter Store → fetch at boot into `UTA_CLIENT_SECRET`, or App Runner secret files → `UTA_CLIENT_SECRET_PATH` - **Google Cloud** — Secret Manager volume mount → `UTA_CLIENT_SECRET_PATH` - **Azure** — Key Vault references in App Service → `UTA_CLIENT_SECRET` - **Kubernetes** — Secret mounted as a volume → `UTA_CLIENT_SECRET_PATH` - **Vercel / Netlify** — Encrypted Environment Variables → `UTA_CLIENT_SECRET` **⚠ Security note:** The client secret must never reach the browser. Run the SDK server-side; a single-page app should call a small backend-for-frontend rather than the SDK directly. If the secret is ever committed or exposed, rotate it from the UseThatApp dashboard immediately. ## Step 3 — Expose Your Callback URL Your app must expose the HTTPS route named in `UTA_REDIRECT_URI` (e.g. `/callback`) so UseThatApp can redirect the user back to it after sign-in. Once your app is deployed and reachable, register that exact URL on your dashboard. Requirements: - **HTTPS** on your app's domain (`localhost` allowed for development). - **Exact match** — the registered URL and `UTA_REDIRECT_URI` must be byte-for-byte identical. - **Publicly reachable** — no IP allowlists or VPN-only endpoints. ## Popular Hosting Providers UseThatApp works with any provider that can run a Python or Node.js backend on a public HTTPS URL and inject a server-side secret. ### Railway Deploy from GitHub with zero config. Supports Python & Node.js. Shared Variables for the client secret. Python Node.js Git deploy ### Render Free tier available. Automatic deploys from Git. Environment variables and Secret Files built in. Python Node.js Free tier ### Fly.io Run containers close to your users. fly secrets set keeps UTA_CLIENT_SECRET encrypted at rest. Docker Global edge Secrets ### DigitalOcean App Platform Managed platform with Git-based deploys. Encrypted environment variables out of the box. Python Node.js Managed ### Heroku Classic platform-as-a-service. Store the client secret as a Config Var. Python Node.js PaaS ### AWS EC2, App Runner, or Elastic Beanstalk. Store UTA_CLIENT_SECRET in Secrets Manager and inject at boot. Python Node.js Enterprise ### Google Cloud Run Serverless containers. Bind UTA_CLIENT_SECRET directly from Google Secret Manager. Docker Serverless Secrets ### Azure App Service Microsoft's PaaS for web apps. Reference Azure Key Vault from app settings. Python Node.js Enterprise ### Vercel Great for Next.js — store UTA_CLIENT_SECRET as an encrypted environment variable per environment. Node.js Serverless Edge ## Next Steps Once your environment variables are in place and your callback URL is registered and reachable, head to the framework guide for your stack to wire up the login and callback routes: Quick Start Sign in with UseThatApp Python Docs JavaScript Docs --- # Error Handling Every failure mode in both SDKs — sign-in, entitlement checks, and the purchase/pricing surface — maps to a specific exception class that inherits from `UtaError`. This page lists each error, what triggers it, and the user-facing behavior we recommend. ## The Error Hierarchy All SDK exceptions inherit from a single base, so you can catch everything in one place if you want: *exception hierarchy* ``` UtaError (base — catch this for "any SDK failure") ├── UtaConfigError local: missing or invalid config (client id/secret, redirect URI) ├── UtaDiscoveryError couldn't fetch the OpenID discovery doc / JWKS ├── UtaAuthError login flow: state mismatch, user canceled, bad authorize request ├── UtaTokenError 401: code exchange failed, ID-token invalid, or access token expired/revoked ├── UtaPermissionError 403: valid token missing the "entitlements" scope │ └── UtaServiceNotEnabledError 403: the app hasn't enabled the Hosted sign-in add-on │ (a SUBCLASS — catch it BEFORE UtaPermissionError) ├── UtaNotFoundError 404: unknown license key, order ref, or license id (.code says which) ├── UtaOrderProcessingError 202: order is genuine but its license hasn't landed yet — retry ├── UtaLicenseCanceledError 409: can't regenerate a key whose license is terminally gone └── UtaServerError 5xx from the provider (retriable with backoff) ``` Class names are identical in Python and JavaScript. Import them from `usethatapp` in either language. Which errors you can actually hit The first six are [Hosted sign-in](https://docs.usethatapp.com/openid-connect) errors — they come from the login flow and the entitlement call. The three after them belong to the [License Key API](https://docs.usethatapp.com/license-keys) and only appear if you verify with keys. `UtaConfigError` and `UtaServerError` apply to both. Note what is *not* here: a canceled or refunded purchase never raises — it returns normally with `entitled` false, in either mode. ## Where Errors Come From The SDKs surface errors at two points: - **Login** — `complete_login(...)` / `completeLogin(...)` can raise `UtaAuthError` (the `state` didn't match, or the user canceled) or `UtaTokenError` (the authorization code couldn't be exchanged, or the returned ID token failed validation). Discovery problems surface as `UtaDiscoveryError`. - **Entitlement** — `get_entitlement(access_token)` / `getEntitlement(accessToken)` can raise `UtaTokenError` (401), `UtaPermissionError` (403), `UtaServerError` (5xx), or a plain `UtaError` (400 — the client isn't linked to an app). - **Pricing / app info** — `get_prices()` / `getPrices()` and `get_app_info()` / `getAppInfo()` hit the anonymous public API and can raise a plain `UtaError` (404 — the app is unknown, unpublished, or external sales isn't enabled for it yet) or `UtaServerError` (429 rate limit — 120 requests/minute per IP — or 5xx/network; both retriable). - **URL builders** — `purchase_url` / `purchaseUrl` and `manage_url` / `manageUrl` make no network calls. They only fail locally: an empty price id (Python `ValueError`, JavaScript `UtaError`), or a missing `UTA_CLIENT_ID` for the manage URL (`UtaConfigError`). ## Errors at a Glance | Exception | Trigger | Source | Recommended handling | | --- | --- | --- | --- | | `UtaConfigError` | Missing or invalid config (no `UTA_CLIENT_ID`/`UTA_CLIENT_SECRET`/`UTA_REDIRECT_URI`) | Local, at first SDK call | Log + fail the request with HTTP 500. This is a deploy-time bug; fix the env config and redeploy. | | `UtaDiscoveryError` | Couldn't fetch the OpenID discovery document or JWKS from the issuer | Login / entitlement | Usually a network blip or a misconfigured `UTA_ISSUER`. Retry; if it persists, check outbound connectivity to usethatapp.com. | | `UtaAuthError` | The `state` didn't match (CSRF/cookie issue), or the user canceled / denied consent | `complete_login` | Treat as "login canceled" — send the user back to start the flow again. Check the callback `error` param first so a clean cancel never reaches this. | | `UtaTokenError` | HTTP 401 — the access token is missing, expired, or revoked; or, at login, the code exchange / ID-token validation failed | `get_entitlement` / `complete_login` | **Re-authenticate or `refresh`.** On the entitlement path this is the normal signal that the user signed out of UseThatApp — reconcile and drop the token. See the section below. | | `UtaPermissionError` | HTTP 403 — a valid token that lacks the `entitlements` scope | `get_entitlement` | Re-run login requesting `openid entitlements` (the default `UTA_SCOPES`). If you narrowed the scopes, widen them. | | `UtaError` (base, 400) | HTTP 400 — your OAuth client isn't linked to an app (misconfiguration) | `get_entitlement` | Log + alert. Confirm the client on the dashboard's Integration page is attached to your app. | | `UtaError` (base, 404) | HTTP 404 from the public API — the app is unknown, unpublished, or external sales isn't enabled for it yet | `get_prices` / `get_app_info` | During the beta this usually means the flag isn't on yet — contact UseThatApp. Otherwise check the `UTA_CLIENT_ID` and that the app is published. | | `UtaServerError` | HTTP 5xx (transient), or 429 (rate limited — public API: 120/min per IP; entitlement: 60/min per token; License Key API: 120/min validate and order lookup, 30/min regenerate, per caller IP + client id). 429s carry a `Retry-After` header, surfaced on the exception as `retry_after` (Python) / `retryAfter` (JavaScript) in seconds — use it as your backoff interval. | `get_entitlement` / `get_prices` / `get_app_info` / `validate_license_key` / `get_order` / `regenerate_license_key` | Retry with exponential backoff. For entitlements, optionally serve the last-known plan while retrying; for prices, serve the last-fetched list (responses are cacheable for 60s anyway). | ## The two auth failures: 401 vs 403 `get_entitlement` distinguishes "re-authenticate" from "not permitted" so you can respond correctly: - **401 → `UtaTokenError`** — the access token is missing, expired, or revoked. `refresh(refresh_token)`, or send the user back through login. - **403 → `UtaPermissionError`** — the token is valid but lacks the `entitlements` scope. Re-run login with the right scopes. ## UtaTokenError is also your sign-out signal ⚠ Reconcile on return — don't clear the session eagerly at logout. Sign-out is RP-initiated. A confirmed logout revokes the token, so the next `get_entitlement` raises `UtaTokenError` (401). That's your cue to drop the token. If the user chose "Stay signed in," the token is still valid and they keep their session. See [Gotchas](https://docs.usethatapp.com/gotchas) for the full pattern. ### Python pattern *any view that gates on the plan* ``` from usethatapp import ( UtaError, UtaTokenError, UtaPermissionError, UtaServiceNotEnabledError, get_entitlement, ) def gated_view(request): token = request.session.get("uta_access_token") if not token: return redirect("/login") try: ent = get_entitlement(token) except UtaTokenError: # 401: token expired/revoked (or the user signed out). Reconcile and # re-authenticate — try refresh() first if you stored a refresh token. for k in ("uta_access_token", "uta_sub", "uta_id_token"): request.session.pop(k, None) return redirect("/login") except UtaServiceNotEnabledError: # 403: Hosted sign-in is switched off for this app. Nothing the # USER does can fix it — no retry, refresh, or re-consent helps. # Alert yourself (the developer) and degrade gracefully. Catch # this BEFORE UtaPermissionError: it is a subclass, so the scope # branch below would swallow it into a re-consent loop. logger.error("Hosted sign-in add-on is disabled for this app") return render(request, "dashboard.html", {"version": None}) except UtaPermissionError: # 403: valid token, missing the "entitlements" scope. return redirect("/login") # re-consent with openid entitlements except UtaError as e: # Discovery / server / config failure: log + degrade to free. logger.exception("get_entitlement failed: %s", e) return render(request, "dashboard.html", {"version": None}) return render(request, "dashboard.html", {"version": ent.version}) ``` ### JavaScript pattern *any handler that gates on the plan* ``` import { UtaError, UtaTokenError, UtaPermissionError, UtaServiceNotEnabledError, getEntitlement, } from "usethatapp"; export async function getCurrentPlan(req) { const token = req.session.utaAccessToken; if (!token) return { state: "logged-out" }; try { const ent = await getEntitlement(token); return { state: "ok", ent }; } catch (e) { if (e instanceof UtaTokenError) { // 401: expired/revoked (or signed out). Reconcile and re-auth. delete req.session.utaAccessToken; delete req.session.utaSub; delete req.session.utaIdToken; return { state: "logged-out" }; } if (e instanceof UtaServiceNotEnabledError) { // Hosted sign-in is switched off for this app. Nothing the USER // does can fix it — alert yourself and degrade. Check this BEFORE // UtaPermissionError: it is a subclass, so the branch below would // swallow it into a re-consent loop that can never succeed. console.error("Hosted sign-in add-on is disabled for this app"); return { state: "ok", ent: null }; } if (e instanceof UtaPermissionError) { return { state: "needs-scope" }; // re-consent with openid entitlements } if (e instanceof UtaError) { console.error("getEntitlement failed:", e.message); return { state: "ok", ent: null }; // degrade to free } throw e; } } ``` ## Catching Everything If you only care about "did the SDK fail?" without distinguishing reasons, catch the base class: ### Python ```python from usethatapp import UtaError, get_entitlement try: ent = get_entitlement(access_token) except UtaError as e: logger.warning("entitlement check failed: %s", e) ent = None ``` ### JavaScript ```javascript import { UtaError, getEntitlement } from "usethatapp"; try { ent = await getEntitlement(accessToken); } catch (e) { if (e instanceof UtaError) { console.warn("entitlement check failed:", e.message); ent = null; } else { throw e; } } ``` For production we recommend catching the specific subclasses above — at minimum, `UtaTokenError` needs its own handler so the user gets re-authenticated (or reconciled after sign-out) instead of a generic error page. ## Next Steps See [Sign in with UseThatApp](https://docs.usethatapp.com/openid-connect) for the full flow, and the framework-specific guides ([Flask](https://docs.usethatapp.com/python/flask), [FastAPI](https://docs.usethatapp.com/python/fastapi), [Express](https://docs.usethatapp.com/javascript/express), [Next.js](https://docs.usethatapp.com/javascript/nextjs), and others) for framework-idiomatic handling. --- # Gotchas The mistakes below break the most integrations. If you are wiring up the UseThatApp loop — sign-in, entitlement gating, and selling through purchase links — especially as an AI coding agent, read these first. They are the difference between a working funnel and one that silently fails, leaks your secret, double-builds payment plumbing you don't need, or logs everyone out. ## 1. Redirect URIs are an exact-match allow-list on your domain ✗ Do not assume "close enough" matches. The callback URL is compared byte-for-byte. Every redirect URI your app sends must appear *verbatim* on the allow-list you register on the dashboard's **Integration** page. A trailing slash, an `http` vs `https`, a different port, or a `www.` prefix all count as a different URL and the provider rejects the request. Each entry must be HTTPS on your domain — `localhost` is allowed for development. Why agents get this wrong: it's easy to set `UTA_REDIRECT_URI` in code but forget to register the exact same string on the dashboard, or to register `https://yourapp.com/callback` while the app sends `https://yourapp.com/callback/`. Keep the registered URI and `UTA_REDIRECT_URI` identical, character for character. ## 2. The client secret is server-side only — SPAs need a backend ✗ Never ship `UTA_CLIENT_SECRET` to the browser. The `usethatapp` SDK is a **confidential client**: it holds your client secret and validates ID tokens. That code must run on your server. Anything that reaches the browser is public — a leaked secret lets anyone impersonate your app. Why agents get this wrong: a single-page app (React, Vue) has no server to hold a secret, so it's tempting to call the SDK from the front end. Don't. Run the SDK in a small backend (a "backend-for-frontend") and call that from the browser — the tokens and secret never leave your server. See the [Sign in with UseThatApp](https://docs.usethatapp.com/openid-connect) guide. ## 3. `localhost` vs `127.0.0.1` breaks the `state` round-trip ✗ Don't start on one host and come back on the other. Browsers scope cookies by host, and `localhost` and `127.0.0.1` are *different hosts*. If you begin login on `http://localhost:3000` but your `UTA_REDIRECT_URI` sends the user back to `http://127.0.0.1:3000` (or vice versa), the session cookie holding your `flow_state` isn't sent on the callback. The `state` can't be validated, and `complete_login` fails with a state mismatch. The fix: pick one host and use it everywhere — the URL you browse to, the registered redirect URI, and `UTA_REDIRECT_URI` must all agree. `flow_state` carries the PKCE verifier and the `state` value, so it has to survive the round-trip in the same cookie jar. ## 4. Check the callback `error` param before anything else When a user cancels or denies the consent screen, the provider redirects back to your callback with `?error=...` and **no code**. If you call `complete_login` anyway, it fails on a missing code. Check `error` first and treat it as "login canceled" — redirect home, don't show an error page. ### Python *callback* ``` if request.args.get("error"): session.pop("uta_flow", None) return redirect(url_for("home")) # canceled — not an error ``` ### JavaScript *callback* ``` if (req.query.error) { delete req.session.utaFlow; return res.redirect("/"); // canceled — not an error } ``` ## 5. Reconcile on sign-out — don't clear your session eagerly ✗ Don't drop the user's session the moment you start logout. Sign-out is RP-initiated: you redirect to `logout_url(id_token=...)`. Both outcomes — the user confirms, or chooses "Stay signed in" — return to the *same* post-logout URL, so you can't tell which happened from the redirect. Clearing your session before logout logs the user out even when they chose to stay. Reconcile on return using the token instead: a confirmed logout revokes it, so your next `get_entitlement` raises `UtaTokenError` (401) — drop the token *then*. If they stayed signed in, the token is still valid and they keep their session. *logout + reconcile-on-return (Flask)* ``` @app.route("/logout") def logout(): # Don't clear the session yet — the user may pick "Stay signed in". id_token = session.get("uta_id_token") return redirect(logout_url( id_token=id_token, post_logout_redirect_uri="https://yourapp.com/", )) @app.route("/") def home(): token = session.get("uta_access_token") if token: try: ent = get_entitlement(token) return render_app(ent) except UtaTokenError: # Confirmed logout revoked the token — reconcile now. for k in ("uta_access_token", "uta_sub", "uta_id_token"): session.pop(k, None) return 'Log in with UseThatApp' ``` ## 6. Gate on `product_public_id`, not the display `version` ✗ Don't branch your feature logic on the version name. `version` is the plan's *display name* (e.g. "Pro"). It's mutable — you can rename a plan in the dashboard and break every `version == "Pro"` check. The stable `product_public_id` (an opaque `prod_…` identifier) survives a rename, and it's the same id the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) returns as `product_id` on every price — so the plan a buyer purchases is the plan you unlock. The entitlement's `product_id` carries that same `prod_…` value (the two fields are permanent equal-valued aliases), so either field works. Show `version` to the user. *gate on the stable id* ``` ent = get_entitlement(access_token) # ✗ if ent.version == "Pro": # breaks on rename # ✓ gate on the stable prod_… id (product_id == product_public_id): if ent.entitled and ent.product_public_id == "prod_": unlock_pro_features() ``` ## 7. A no-token visitor IS your free tier A free launch carries **no login** by design — an anonymous, token-less visitor *is* your free tier. Don't force everyone through sign-in to use the app: serve your free experience to visitors with no token, and offer "Sign in with UseThatApp" to unlock paid features. A paid launch signs the user in; a signed-in user launching again skips the consent screen (seamless SSO), because only an opaque `sub` is shared. ## 8. Your app's session and UseThatApp's session are independent UseThatApp signs the user in; your app then runs its own session keyed on the `sub`. The two have separate lifetimes — "logged into your app" and "logged into usethatapp.com" are different states. Don't conflate them: reconcile UseThatApp's state by checking the token (as in Gotcha 5), and manage your own session on your own terms. ## 9. Register a post-logout URL, or users land on usethatapp.com The `post_logout_redirect_uri` you pass to `logout_url` must also be on the allow-list you register on the Integration page. If you register none, signing out drops the user on the **UseThatApp home page** instead of back in your app. Register your production post-logout URLs alongside your redirect URIs. ## 10. Sign-in is a redirect chain — show a loading state A launch/sign-in isn't one request; it's a hop through several redirects — UseThatApp → your `/login` → UseThatApp's authorize endpoint → your `/callback`, which then exchanges the code for tokens. Each hop is a bodiless redirect, so if your `/callback` just returns another redirect, the browser shows a **blank page** the whole time — and on a cold-started server that can be several seconds. Render a small "Signing you in…" loading page from your `/callback` while `complete_login` runs, instead of blocking on a bare redirect, and keep your app warm (a spun-down instance adds the cold-start delay on top). The SDK already caches OIDC discovery and JWKS, so that isn't the cost. UseThatApp shows its own "Launching…" screen on the marketplace side; matching it on your callback keeps the hand-off seamless end to end. ## 11. Don't build payment plumbing — the entitlement is already live ✗ No webhooks, no polling, no "pending purchase" state. Hosted checkout creates the license **synchronously** — by the time the buyer is redirected back into your app, the very first `get_entitlement` call already returns `entitled: true`. UseThatApp is the merchant of record: payment, tax, refunds, and chargebacks are handled for you. If you find yourself writing a payment webhook receiver or a "waiting for your purchase" spinner, stop — the loop doesn't need it. Related: buyers who already own the product and hit a buy link are recognized and sent straight back to your app — **never double-charged** — so it's safe to show Buy buttons to everyone. ## 12. Never hardcode prices — render them from the pricing API ✗ Don't bake "$10/month" into your pricing page or templates. The moment a price changes on the dashboard, every hardcoded amount is wrong — and a pricing page that disagrees with checkout is a trust-killer. Fetch live prices with `get_prices()` / `getPrices()` (or plain browser `fetch` — the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) is public and CORS-enabled) and use each price's ready-made `buy_url` for the Buy button. ## 13. Purchase-link `next` URLs follow the same domain rule The optional `next` param on [buy links](https://docs.usethatapp.com/purchase/links) and manage links must be **HTTPS on your app's registered domain** (`localhost` allowed for development) — the same trust rule as redirect URIs. An invalid value shows the buyer a "misconfigured link" page, so open every distinct link your site renders at least once (the [seller walkthrough](https://docs.usethatapp.com/purchase/testing) makes this free). Two more launch facts to remember: buy links return **404 until external sales is enabled** for your app (per-app during the beta — contact UseThatApp), and purchases are currently **US-only**. ## Quick Self-Check Before you ship, confirm each of these: - `UTA_REDIRECT_URI` matches a dashboard entry byte-for-byte (scheme, host, port, path, trailing slash). - `UTA_CLIENT_SECRET` lives only on your server; SPAs call a backend-for-frontend, never the SDK directly. - You browse, redirect, and configure on one host — `localhost` *or* `127.0.0.1`, not both. - The callback checks `error` before calling `complete_login`. - Logout reconciles on return (catches `UtaTokenError`) instead of clearing the session eagerly. - Feature gating keys off `product_public_id`, not the mutable `version`. - Your free tier works with no token, and your post-logout URLs are registered on the dashboard. - Your `/callback` shows a loading state instead of a bare redirect, so users don't see a blank page during sign-in. - Your pricing page renders from `get_prices` / `getPrices` (no hardcoded amounts), and Buy buttons use the returned `buy_url`. - You have no purchase webhooks or polling — the first entitlement check after checkout is already authoritative. - Every `next` URL on your buy/manage links is HTTPS on your registered domain, and you've run the seller walkthrough end to end. ## Next Steps Read [Error Handling](https://docs.usethatapp.com/error-handling) for the full error model, [Sign in with UseThatApp](https://docs.usethatapp.com/openid-connect) for the complete sign-in flow these guards build on, and [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website) for the purchase funnel end to end. --- # OpenID Connect (Sign in with UseThatApp) **"Sign in with UseThatApp"** lets us run login for your app, so a buyer who finishes hosted checkout returns already signed in — and your app reads what they bought from that same session. The same flow serves marketplace launches and everyday logins. Under the hood UseThatApp is an **OpenID Provider** and this is standard **OAuth 2.0 / OpenID Connect**; you wire it up once with the `usethatapp` SDK (Python or JavaScript). You do not have to use our login Selling through UseThatApp never requires it. If your app already has accounts, keep them and verify purchases with [license keys](https://docs.usethatapp.com/license-keys) instead — included in the base per-sale fee. This page is for developers who would rather not run login at all: we handle sign-in, passwords, and recovery, and the license check answers from the same session. That pairing is the **Hosted sign-in** add-on. What costs what **Sign-in itself is free and unmetered** — wire up OIDC, get identity, pay nothing. The paid part is the [entitlement call](https://docs.usethatapp.com/licensing) that turns a session into "and here is what they bought", which the Hosted sign-in add-on enables per app. Build the login first and switch the add-on on when you are ready; without it, entitlement calls answer `403 service_not_enabled`. ## How the handoff works It's the standard authorization-code flow with PKCE. The SDK does the crypto; you wire three framework-specific things (read the callback query params, store a small `flow_state` in your session, issue redirects). 1. **Arrive.** A buyer returns from hosted checkout to your registered Login URL, a user opens your app from the marketplace, or someone clicks "Sign in with UseThatApp" inside it — all three arrive the same way. 2. **Hand off.** Your app sends them to UseThatApp to authenticate (`begin_login`). 3. **Sign in.** They authenticate on usethatapp.com. If they already have a session, this is seamless — no prompt. 4. **Return.** UseThatApp redirects back to your **redirect URI** with a one-time code, which your app exchanges for tokens (`complete_login`). 5. **Read the plan.** Your app calls the entitlement API with the access token to gate features (`get_entitlement`). ⚠ The SDK runs on your server, never in the browser. The `usethatapp` SDK is a **confidential client**: it holds your client secret and validates ID tokens. Keep it server-side. A single-page app (React, Vue) should run the SDK in a small backend (a "backend-for-frontend") and call that from the browser — see the [React](https://docs.usethatapp.com/javascript/react) guide. ## Set up your app on UseThatApp Open your app's **Integration** page on the UseThatApp dashboard. There is no handoff mode to choose — OAuth / OpenID Connect is the only integration for new apps. Copy your client credentials from the **API credentials** card, then register the URLs below in the **Hosted sign-in** card underneath it. | Setting | What it is | | --- | --- | | **Client ID** / **Client secret** | Your app's OAuth credentials. The secret is shown once and stored hashed — keep it on your server (`UTA_CLIENT_ID`, `UTA_CLIENT_SECRET`). | | **Login URL** | The page in your app that starts sign-in. Marketplace launches land here — and it's the default return destination after a completed purchase, so a buyer's first experience of your app is this page signing them in. Defaults to your app URL. | | **Redirect URIs** | Where we return the user after sign-in. An **allow-list**, matched exactly — the URL your app sends must appear here verbatim. Each must be HTTPS on your app's domain (`localhost` allowed for development). | | **Post-logout return URLs** | Where users may land after signing out — also an allow-list. Leave empty and they return to the UseThatApp home page instead of your app. | ## Install and configure Install the SDK and set these in your server's environment. Only the client id and secret are required; the rest default to production. *environment* ``` UTA_CLIENT_ID=... # required UTA_CLIENT_SECRET=... # required (server-side only) UTA_REDIRECT_URI=https://yourapp.com/auth/callback # optional — default to production: UTA_ISSUER=https://www.usethatapp.com/o UTA_API_URL=https://www.usethatapp.com UTA_SCOPES="openid entitlements" ``` ## Sign a user in `begin_login()` returns an authorization URL and a JSON-serializable `flow_state` — stash it in the session, redirect to the URL, then pass it back to `complete_login()`in your callback. On cancel/deny, the provider returns with `?error=` and no code, so check for that first. *Python — any framework* ``` from usethatapp import begin_login, complete_login # 1) Start login: auth_url, flow_state = begin_login() save_to_session("uta_flow", flow_state) return redirect(auth_url) # 2) In your callback (reads ?code=&state=, or ?error= on cancel): if read_query("error"): return redirect("/") # login canceled session = complete_login( code=read_query("code"), state=read_query("state"), flow_state=load_from_session("uta_flow"), ) save_to_session("uta_sub", session.sub) save_to_session("uta_access_token", session.access_token) ``` *JavaScript — any framework* ``` import { beginLogin, completeLogin } from "usethatapp"; // 1) Start login: const { authorizationUrl, flowState } = await beginLogin(); saveToSession("utaFlow", flowState); res.redirect(authorizationUrl); // 2) In your callback: if (req.query.error) return res.redirect("/"); // login canceled const session = await completeLogin({ code: req.query.code, state: req.query.state, flowState: loadFromSession("utaFlow"), }); ``` ## Identity: the pairwise `sub` The login result carries the user's **`sub`** — a **pairwise pseudonymous identifier**. It is stable for a user *within your app*, but different in every other app, so it can't be correlated across apps. It's the only identity claim we share:**no email, no name, no PII**. - Use `sub` as your local user key — it's persistent across logins for your app. - **Key your user records off `sub`, never an email** (we don't share one, and emails change). - Identity is delivered at login (`session.sub`); it is *not* in the entitlement response, which only describes the plan. Need to re-check who a stored access token belongs to — say, after restoring a session from your database? `userinfo()` asks the server and returns the token's claims. Like everything else here, that's the `sub` and nothing more: ```python from usethatapp import userinfo claims = userinfo(access_token) claims["sub"] # the same pairwise id complete_login() delivered # No email, no name — {"sub": "..."} is the entire response. ``` ## Read the plan: the entitlement API Call `get_entitlement(access_token)` whenever you need the user's current plan. It's always authoritative — a canceled license stops being entitled immediately, regardless of token lifetime. For the full licensing story — every status, selling from your own website, and the raw HTTP endpoint — see [Licensing](https://docs.usethatapp.com/licensing). ```python from usethatapp import get_entitlement ent = get_entitlement(access_token) # Entitlement(entitled, version, product_public_id, status, is_free, period_end) if ent.entitled and ent.product_public_id == "prod_": unlock_pro_features() ``` | Field | Meaning | | --- | --- | | `entitled` | True if the user may use the app (an active license, or a free tier). | | `product_public_id` | Stable, opaque product identifier (`prod_…`). **Gate your features on this** — it survives a plan rename, and it matches the `product_id` on every Pricing API price. | | `product_id` | Equal-valued alias of `product_public_id` — both carry the same `prod_…` value. Gate on either. | | `version` | Plan display name (e.g. "Pro"). For showing the user; mutable, so don't branch logic on it. | | `status` | `active` / `trialing` / `one_time_active` / `free` / `preview` / `none` | | `is_free` / `period_end` | Whether it's the free tier; the ISO date the current period ends (or null). | ## Sign out Sign-out is RP-initiated: redirect the user to `logout_url(id_token=…)`. Logging out of UseThatApp revokes the app's tokens, so the app loses access — this is the intended "log out everywhere" behavior. ⚠ Reconcile on return — don't clear your session eagerly. Both outcomes — the user confirms, or chooses "Stay signed in" — return to your post-logout URL, so you can't tell which happened from the redirect. Check the token instead: after returning, a confirmed logout makes `get_entitlement` raise `UtaTokenError` (401) — drop the token then. If they stayed signed in, the token is still valid and they keep their session. Clearing your session before logout logs the user out even when they chose to stay. ## Errors `get_entitlement` distinguishes two auth failures so you can tell "re-authenticate" from "not permitted": - **401 → `UtaTokenError`** — the access token is missing, expired, or revoked. Refresh, or send the user back through login. - **403 → `UtaPermissionError`** — a valid token that lacks the `entitlements` scope. Every SDK error inherits from `UtaError`. See [Error Handling](https://docs.usethatapp.com/error-handling). ## Good to know - **Seamless SSO.** A signed-in user launching your app skips the consent screen and goes straight in — safe because only an opaque pairwise `sub` is shared. - **Two independent sessions.** UseThatApp's session and your app's session are separate by design. "Logged into your app" and "logged into usethatapp.com" are different states. - **Free vs paid launches.** A free launch carries no login by design — an anonymous visitor with no token *is* your free tier. A paid launch signs the user in. - **First-ever signup isn't instant.** A brand-new user signing up on usethatapp.com must verify their email before a token is issued; subsequent logins are seamless. SSO doubles as a signup funnel for you. The exception is buyers arriving through a [purchase link](https://docs.usethatapp.com/purchase/links): their account is created during checkout and they return with a token immediately, verifying their email afterward from the receipt email. ## Next steps Pick your stack — the framework guides give concrete, copy-ready code for [Python](https://docs.usethatapp.com/python) and [JavaScript](https://docs.usethatapp.com/javascript). Then review [Error Handling](https://docs.usethatapp.com/error-handling) and [Gotchas](https://docs.usethatapp.com/gotchas). --- # Authentication This page covers the identity concepts behind the **Hosted sign-in** add-on — what your app learns about a user when we run their login, and what it deliberately never learns. Whether they bought through a [purchase link](https://docs.usethatapp.com/purchase/links) on your own website, launched from the marketplace, or just clicked "Sign in with UseThatApp", they arrive through one standard OpenID Connect sign-in. For the step-by-step how-to see [Sign in with UseThatApp](https://docs.usethatapp.com/openid-connect). Only if you want us running login Selling through UseThatApp does not require our login. Apps that keep their own accounts verify purchases with [license keys](https://docs.usethatapp.com/license-keys) and never touch anything on this page. Read on if you would rather hand us sign-in, passwords, and recovery — then one login gives your app both **identity** (who the user is, as a privacy-preserving per-app id) and the **entitlement call** (what they bought, live). ## Identity: the pairwise `sub` When a user signs in, UseThatApp gives you their **`sub`** — a **pairwise pseudonymous identifier**. It is stable for a user *within your app*, but different in every other app, so it can't be correlated across apps. It is the only identity claim we share: **no email, no name, no PII**. - Use `sub` as your local user key — it's persistent across logins for your app. - **Key your user records off `sub`, never an email** (we don't share one, and emails change). - Identity is delivered at login (`session.sub`); it is *not* in the entitlement response, which only describes the plan. ## Who owns what UseThatApp owns sign-in and the plan. Everything about your product — accounts, roles, data — is yours, keyed on the UseThatApp `sub`: | Concern | Who owns it | | --- | --- | | Signing the user in (OpenID Connect) | UseThatApp — a per-app pairwise `sub` | | Knowing the user's current plan | UseThatApp (`get_entitlement` — see [Licensing](https://docs.usethatapp.com/licensing)) | | User accounts, profiles, sign-up | **Your app** (keyed on the UseThatApp `sub`) | | Roles, permissions, multi-user orgs | **Your app** | | Storing per-user data between sessions | **Your app** | On first sign-in you create a local record keyed on `sub`; on every later sign-in you look it up. Attach your own roles, settings, and data to that record. *map the UseThatApp sub to your own account* ``` # After complete_login() you have the user's stable per-app sub: sub = session.sub # pairwise, no PII # Find-or-create YOUR account keyed on sub (never on an email): user = User.query.filter_by(uta_sub=sub).first() if user is None: user = User(uta_sub=sub) # your own roles/data hang off this db.session.add(user) db.session.commit() ``` ## Two independent sessions ⚠ "Logged into your app" and "logged into usethatapp.com" are different states. UseThatApp's session and your app's session are separate by design. UseThatApp signs the user in (yielding the `sub` and the tokens); your app then runs its own session keyed on that `sub`. The two have independent lifetimes — your app's session can outlive a single login, and a UseThatApp logout doesn't automatically tear down your app's session (you reconcile that yourself). See [Gotchas](https://docs.usethatapp.com/gotchas) for the sign-out reconcile pattern. ## Free vs paid A free launch carries **no login** — an anonymous, token-less visitor *is* your free tier. A paid launch signs the user in. A user who is already signed into UseThatApp skips the consent screen on launch (seamless SSO), because only an opaque `sub` is shared. SSO doubles as a signup funnel: a brand-new user signing up on usethatapp.com verifies their email once before a token is issued, then every subsequent login is seamless. (Buyers arriving through a [purchase link](https://docs.usethatapp.com/purchase/links) get their account created during checkout and return with a token immediately — email verification happens afterward, from the receipt email.) ## Next Steps Read [Sign in with UseThatApp](https://docs.usethatapp.com/openid-connect) for the full flow (sign-in, entitlement API, sign-out), then [Gotchas](https://docs.usethatapp.com/gotchas) and [Error Handling](https://docs.usethatapp.com/error-handling). --- # Licensing Licensing is what connects a purchase to your code. As merchant of record, UseThatApp owns the commercial relationship — checkout, tax, renewals, refunds, cancellations — and your app reads the result through one call. It is always authoritative and live: a buyer is entitled *the moment hosted checkout completes* (the license is created synchronously — no webhooks, no polling), and a refunded license stops being entitled immediately. ## Two ways to make that call The only difference is whose login your customer uses. Both read the same licenses and report the same statuses, so you can switch later without breaking anything. ### Keep your own auth Auth0, Clerk, homegrown — your login stays in charge. Each purchase mints a license key your server validates. Included in the base per-sale fee, nothing to enable → [License Keys](https://docs.usethatapp.com/license-keys) ### Hosted sign-in (this page) We run sign-in, passwords, and recovery. The entitlement answers from that same session — no keys to store, and free tiers are covered too. A paid add-on you switch on per app, free for an introductory period. The entitlement endpoint requires the add-on If the Hosted sign-in add-on is off for an app, its entitlement calls answer **403 `service_not_enabled`** — a valid, correctly-scoped token, but the service is not turned on. Nothing the end user does can fix it; the app's owner enables it under *Manage app → Integration → Hosted sign-in add-on*. Sign-in itself keeps working either way. If you would rather not take the add-on, use [license keys](https://docs.usethatapp.com/license-keys) instead — they are included in the base fee. One call, wherever you gate After a user signs in (see [Sign in with UseThatApp](https://docs.usethatapp.com/openid-connect)), call `get_entitlement(access_token)` / `getEntitlement(accessToken)` anywhere you gate paid functionality. No webhooks to receive, no license files to verify. ## Gate on `product_id` Branch on the stable `product_id` (an opaque `prod_…` identifier that survives plan renames), never the mutable `version` display name. Product ids are on your dashboard (the copy button on each version), in the MCP `list_products` tool, and on every price in the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) — so the plan a buyer purchases is exactly the plan you unlock. Read `product_public_id` Entitlement responses carry the `prod_…` value in **`product_public_id`**, and `product_id` currently still carries an older database UUID that will be replaced by the same `prod_…` value. Compare against `product_public_id` and your gating keeps working through that change; the ids you copy from the dashboard, MCP, and the Pricing API are all the `prod_…` form. ### Python *any view* ``` from usethatapp import get_entitlement, purchase_url ent = get_entitlement(access_token) if ent.entitled and ent.product_public_id == "prod_": unlock_pro_features() else: # Close the loop: send them to hosted checkout. They return # signed in with the entitlement already live. offer_upgrade(purchase_url("")) ``` ### JavaScript *any handler* ``` import { getEntitlement, purchaseUrl } from "usethatapp"; const ent = await getEntitlement(accessToken); if (ent.entitled && ent.product_public_id === "prod_") { unlockProFeatures(); } else { // Close the loop: hosted checkout — they return signed in, // entitlement already live. offerUpgrade(purchaseUrl("")); } ``` ## Entitlement fields and statuses | Field | Meaning | | --- | --- | | `entitled` | True if the user may use the app (an active license, or a free tier). | | `product_public_id` | Opaque product identifier (`prod_…`) — **gate on this**. Matches the `product_id` on every price in the Pricing API. | | `product_id` | Equal-valued alias of `product_public_id` — both carry the same `prod_…` value. | | `version` | Plan display name (e.g. "Pro") — for showing the user only. | | `status` | See the status table below. | | `is_free` / `period_end` | Whether it's the free tier; the ISO date the current period ends (or `null`). | | Status | Meaning | | --- | --- | | `active` | A paid subscription in good standing. | | `trialing` | In a trial period. | | `one_time_active` | A one-time (non-recurring) purchase. | | `free` | The app's free tier. | | `preview` | The developer's own preview entitlement — set by the dashboard preview and by the [purchase-flow walkthrough](https://docs.usethatapp.com/purchase/testing). | | `none` | No entitlement. | ## Errors - **401 → `UtaTokenError`** — the access token is missing, expired, or revoked (a confirmed logout lands here). Refresh, or send the user back through login. - **403 → `UtaPermissionError`** — a valid token that lacks the `entitlements` scope. - **403 `service_not_enabled`** — a valid, correctly-scoped token, but the app has not enabled the Hosted sign-in add-on (see above). Not a user-fixable error: surface it to yourself, not to the buyer. Full hierarchy and handling patterns: [Error Handling](https://docs.usethatapp.com/error-handling). ## Going further - **Not on Python or JavaScript?** The entitlement endpoint is plain HTTP with a Bearer token — request, response, and error tables are in the [REST API reference](https://docs.usethatapp.com/rest-api). - **Selling from your own website?** Buyers coming through a purchase link return to your app with a live entitlement, created synchronously — start with [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website). - **Who is the user?** Identity (the pairwise `sub`) comes from the login, not the entitlement — see [Authentication](https://docs.usethatapp.com/authentication). - **Already have your own accounts?** [License Keys](https://docs.usethatapp.com/license-keys) verify purchases without sending anyone through our login. --- # License Keys License keys let you sell through UseThatApp while keeping **your own authentication** — Auth0, Clerk, Supabase, homegrown, anything. Every purchase mints one key. Your server validates it with one call and unlocks what that purchase bought. Your users never see a UseThatApp login. Which mode should I use? **License keys** (this page) if your app already has accounts. Included in the base per-sale fee, nothing to enable. [Hosted sign-in](https://docs.usethatapp.com/licensing) if you would rather not run login at all — we handle sign-in, passwords, and recovery, and the entitlement answers from the same session. The two modes coexist on the same licenses: turning one on never breaks the other. ## Configure The License Key API arrived in SDK **2.2.0**. Upgrading from an earlier release? Run `pip install --upgrade usethatapp` or `npm install usethatapp@latest` before you start — the calls on this page are not in 2.1.x. A keys-mode integration needs exactly two values — your app's OAuth client id and secret, both on your dashboard under *Manage app → Integration*. The SDK signs every license call with them. No redirect URI, no end-user token, nothing else to set up. *.env (server-side only)* ```bash UTA_CLIENT_ID=your-client-id UTA_CLIENT_SECRET=your-client-secret # Pointing at a non-production instance? Also set this — it # defaults to production: # UTA_API_URL=http://localhost:8000 ``` Keep the secret on your server. These calls are server-to-server; never ship the secret to a browser or a desktop binary. ## Validate a key The call you make wherever you gate paid functionality. Keys are opaque `key_…` strings, scoped to one app. ### Python *your gate* ``` from usethatapp import validate_license_key PRO = "prod_" state = validate_license_key(user.license_key) if state.entitled and state.product_public_id == PRO: unlock_pro_features() else: offer_upgrade() ``` ### JavaScript *your gate* ``` import { validateLicenseKey } from "usethatapp"; const PRO = "prod_"; const state = await validateLicenseKey(user.licenseKey); if (state.entitled && state.product_public_id === PRO) { unlockProFeatures(); } else { offerUpgrade(); } ``` Gate on the `prod_…` id Branch on the opaque `prod_…` identifier — carried by both `product_id` and `product_public_id`, which are equal-valued aliases — never on a plan display name. It survives plan renames, and it is the same value the dashboard, the MCP `list_products` tool, and the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) give you. If a subscription moves to a sibling plan, this field reports the plan the buyer holds *now*. ## What you get back All three calls return the same `LicenseState`. | Field | Meaning | | --- | --- | | `entitled` | True if this key may use the app right now. **Gate on this.** | | `status` | Why — see the table below. Use it to word your UI honestly. | | `product_id` / `product_public_id` | The `prod_…` plan the buyer holds now. Equal-valued aliases — the same pair the entitlement endpoint carries, so gating code works identically in both modes. | | `license_id` | Stable id for this license — what you pass to regeneration. | | `period_end` | ISO date the paid period ends, or `null`. Cache against this. | | `canceled_at` | ISO date of a terminal cancellation, or `null`. | | `license_key` | Key material — set **only** by `get_order` and `regenerate_license_key`, the two calls that hand you a key. | ## Link a purchase to your own user When a buyer returns from hosted checkout, the redirect carries an opaque `uta_order` reference. Exchange it for the license key and attach it to whichever of your users is signed in — no email matching, no asking anyone to paste a key. ### Python *your return route* ``` from usethatapp import ( get_order, UtaOrderProcessingError, ) # Buyer lands on /thanks?uta_order=ord_7bQ... try: order = get_order(request.GET["uta_order"]) except UtaOrderProcessingError: # Paid, license not landed yet. Retry shortly. return render("checkout_pending.html") user.license_key = order.license_key user.save() ``` ### JavaScript *your return route* ``` import { getOrder, UtaOrderProcessingError, } from "usethatapp"; // Buyer lands on /thanks?uta_order=ord_7bQ... let order; try { order = await getOrder(req.query.uta_order); } catch (e) { if (e instanceof UtaOrderProcessingError) { return res.render("checkout-pending"); } throw e; } await saveLicenseKey(user, order.license_key); ``` The reference is a signed, expiring handle scoped to your app — not a secret, and useless without your client secret. Store the `license_key` it returns; that is what you validate from then on. Selling to desktop or CLI users with no return redirect? Have the buyer paste the key instead — it is shown on the checkout success page, in the receipt email, and any time on their [buyer portal](https://docs.usethatapp.com/purchase/links). ## Statuses | Status | `entitled` | Meaning | | --- | --- | --- | | `active` | true | Subscription in good standing. | | `trialing` | true | In a trial period. | | `one_time_active` | true | A one-time (non-recurring) purchase. | | `past_due` / `unpaid` | false | A renewal failed and we are retrying the card. Recoverable — the same key returns to `active` on its own if payment succeeds. Prompt them; don't delete their data. | | `paused` | false | Temporarily suspended; may resume. | | `canceled` | false | Terminal: cancellation ran its course, dunning was exhausted, or the purchase was refunded. | Cancellation is not instant lockout A buyer who cancels keeps `entitled: true` until `period_end` — they paid for that period. Only a refund revokes immediately. Cache against `period_end` and you get this behavior for free. ## Errors The SDKs raise typed errors; a canceled or refunded purchase is **not** one of them — it returns normally with `entitled=False`, because a key you once accepted deserves a lifecycle answer rather than an exception. | Error | When | | --- | --- | | `UtaNotFoundError` code `unknown_key` | A key we never issued, or one belonging to another app. Means "this string is not a license" — *not* "expired". | | `UtaNotFoundError` code `unknown_order` | An order reference that is unverifiable, expired, or not yours. | | `UtaNotFoundError` code `unknown_license` | Regeneration for a license id that isn't your app's. | | `UtaOrderProcessingError` | Payment cleared, license not landed yet. Retry for a few seconds — show "finishing up", not an error. | | `UtaLicenseCanceledError` | You tried to regenerate a key whose license is terminally gone. | | `UtaConfigError` | Missing `UTA_CLIENT_ID` / `UTA_CLIENT_SECRET`, or credentials rejected. | Rate limits: **120/min** on validation and order lookup, **30/min** on regeneration — bucketed per caller IP *plus* client id, so another app's traffic (or a stranger guessing secrets) can never spend your quota. A tripped limit answers `429` with a `Retry-After` header and raises `UtaServerError` — back off and retry. Full exception hierarchy: [Error Handling](https://docs.usethatapp.com/error-handling). ## Regenerating a key A compromise kill switch, not a recovery path. It rotates by `license_id`, returns the new key **once** in `license_key`, and the old key stops working at that instant — so only you can call it, because your backend is what holds the key. Deliver the replacement to your customer yourself. ### Python *revoking a leaked key* ``` from usethatapp import regenerate_license_key state = regenerate_license_key(user.license_id) user.license_key = state.license_key user.save() email_new_key(user, state.license_key) ``` ### JavaScript *revoking a leaked key* ``` import { regenerateLicenseKey } from "usethatapp"; const state = await regenerateLicenseKey(user.licenseId); await saveLicenseKey(user, state.license_key); await emailNewKey(user, state.license_key); ``` If a buyer has merely *lost* their key, do not rotate — send them to their buyer portal, where the existing key is always re-viewable. Rotating would break the key your own app still has on file. Every rotation is audit-logged. ## Staying current Verification is **pull**: re-validate on your own cadence and cache the answer until `period_end`. There are no outbound webhooks today, so cancellation, dunning, and refunds surface as a status change the next time you ask. For a user-present gate (someone opens your app, you check), that is all you need. ## Going further - **Not on Python or JavaScript?** All three calls are plain HTTPS with HTTP Basic client credentials — request, response, and error tables are in the [REST API reference](https://docs.usethatapp.com/rest-api). - **Where do buy links come from?** [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website) covers the checkout half of this flow. - **Money questions stay with us.** Card updates, cancellation, invoices, and refunds live on the hosted buyer portal — link to it rather than rebuilding it. - **Prefer we run login too?** [Hosted sign-in](https://docs.usethatapp.com/licensing) replaces key storage with a session-scoped entitlement call. --- # Sell from your own website Market your app on your own site and let UseThatApp handle the money. Your pricing page fetches **live prices** from the public Pricing API, your Buy buttons link to a **hosted checkout** on usethatapp.com, and the buyer lands back in your app with their purchase ready to verify — no webhook handling, no payment code, no tax logic on your side. Pick how your app verifies the purchase Steps 1–3 below are the same either way. Step 4 is where the two modes differ: **[license keys](https://docs.usethatapp.com/license-keys)** if your app keeps its own accounts (included in the per-sale fee), or the **[Hosted sign-in](https://docs.usethatapp.com/openid-connect)** add-on if you would rather we ran login too. Selling never requires our login. UseThatApp is the merchant of record UseThatApp processes the payment, calculates and remits applicable tax, handles refunds and chargebacks, and appears on the buyer's card statement. You get payouts via Stripe. Your website never touches card data or tax rules — "Buy" is just a link. ⚠ Beta: enabled per app, US buyers only External sales is rolling out per app — buy links return 404 until it is enabled for your app, so contact UseThatApp to be switched on. Purchases are currently available to **United States buyers only** (enforced on both the declared tax address and the card's billing address). ## The whole journey 1. **A visitor lands on your pricing page.** Your page fetches live prices from the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) (public, CORS-enabled, no auth) and renders a Buy button per plan using each price's ready-made `buy_url`. 2. **They click Buy.** The link is a hosted [purchase link](https://docs.usethatapp.com/purchase/links) on usethatapp.com: one page for email + country + ZIP, card entry via Stripe, then a confirmation. New buyers get an account auto-created (no password, no verification wall); existing accounts sign in normally, including MFA. 3. **They return to your app.** The license is created **synchronously** during checkout, so the purchase is real the moment they land — no webhook lag, no polling. Where they land is your `next` URL, or your app's registered Login URL by default. 4. **You verify, in whichever mode you chose.** With [license keys](https://docs.usethatapp.com/license-keys), the return carries a `uta_order` reference you exchange for the buyer's key, then validate it from then on. With [Hosted sign-in](https://docs.usethatapp.com/openid-connect), the OIDC flow signs them in and `get_entitlement` answers from that session. Both unlock features by the stable `product_public_id` — the same `prod_…` id the Pricing API returns as `product_id` on each price. 5. **Later, they manage the subscription** (upgrade, auto-renew, billing portal) via a manage link back on usethatapp.com. ## Step 1 — Render live prices on your site Fetch prices straight from the browser — the endpoint is anonymous and CORS-enabled. **Never hardcode prices**: the moment you edit a price on the dashboard, a hardcoded pricing page is wrong. Use the returned `buy_url` for your Buy buttons instead of assembling URLs yourself. Your `client_id` is on the dashboard's **Integration** page. *pricing.html* ```
Loading plans…
``` Responses are cacheable for 60 seconds and rate-limited at 120 requests/minute per IP — fine for a pricing page, no proxy needed. If your site has a server, the SDK does the same with types — and needs only `UTA_CLIENT_ID`, not the OAuth secrets: ### Python *server-rendered pricing* ``` from usethatapp import get_prices plans = get_prices() for p in plans.prices: render_plan(p.product_name, p.amount, p.currency, p.frequency, p.buy_url) ``` ### JavaScript *server-rendered pricing* ``` import { getPrices } from "usethatapp"; const { prices } = await getPrices(); for (const p of prices) { renderPlan(p.product_name, p.amount, p.currency, p.frequency, p.buy_url); } ``` The full response shape (including `has_free_tier` and the `product_id` that matches the entitlement’s `product_public_id`) is on the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) page. ## Step 2 — Point Buy at the hosted checkout Each price's `buy_url` is already a complete checkout link. To add parameters, use the SDK's pure URL builder — `purchase_url(price_id, next=…)` / `purchaseUrl(priceId, { next })`; it makes no network call and needs no configuration. Two optional params are worth knowing (full reference: [Purchase links](https://docs.usethatapp.com/purchase/links)): - `next` — where the buyer returns after purchase. Must be HTTPS on your app's registered domain (`localhost` allowed for development). Leave it off and the buyer is sent to your registered **Login URL** — what you want with Hosted sign-in, since OIDC signs them in with the fresh entitlement. In keys mode, point `next` at the route that reads `uta_order`, which is appended to whichever URL the buyer returns to. - `ref` — an affiliate code. Affiliates append `?ref=CODE` to your buy links (or you forward a landing-page `ref` onto them); it sets the standard 60-day attribution cookie and commissions accrue per the affiliate program. *buy link anatomy* ``` from usethatapp import purchase_url purchase_url("prc_9f2kq8w7", next="https://yourapp.com/welcome", ref="partner42") # → https://www.usethatapp.com/buy/prc_9f2kq8w7/?next=https://yourapp.com/welcome&ref=partner42 ``` ## Step 3 — What the buyer experiences 1. One page asking for email, country, and ZIP. Existing UseThatApp accounts are routed through normal sign-in (including MFA); new buyers get an account auto-created with no password and no verification wall. 2. Card entry via Stripe, then a confirmation page. 3. A success page with a "resend receipt email" button, then the redirect back into your app. The receipt email contains the receipt, an email-verification link, a set-a-password link (for auto-created accounts), and a manage link. Buyers who already own the product are never double-charged — a buy link recognizes them and sends them straight back to your app. ## Step 4 — Verify and gate when they return This is the step that depends on your mode. Both paths end at the same check, and both gate on the stable `product_public_id`. ### If you keep your own auth (license keys) The return URL carries a `uta_order` reference. Exchange it once for the buyer's license key, store the key against your own user, and validate it from then on — your login never changes. Full walkthrough: [License Keys](https://docs.usethatapp.com/license-keys). ### Python *your return route* ``` from usethatapp import get_order order = get_order(request.GET["uta_order"]) user.license_key = order.license_key user.save() # later, on any request: # state = validate_license_key(user.license_key) ``` ### JavaScript *your return route* ``` import { getOrder } from "usethatapp"; const order = await getOrder(req.query.uta_order); await saveLicenseKey(user, order.license_key); // later, on any request: // const s = await validateLicenseKey(user.licenseKey); ``` ### If we run login (Hosted sign-in add-on) The buyer arrives at your Login URL, your existing [Sign in with UseThatApp](https://docs.usethatapp.com/openid-connect) integration signs them in, and your server reads the live plan. Gate on the stable `prod_…` id — `product_id` and `product_public_id` are equal-valued aliases, and both match the `product_id` on each Pricing API price, so the plan they bought is the plan you unlock. ### Python *any view* ``` from usethatapp import get_entitlement ent = get_entitlement(access_token) # Entitlement(entitled, version, product_public_id, status, is_free, period_end) if ent.entitled and ent.product_public_id == "prod_": unlock_pro_features() ``` ### JavaScript *any handler* ``` import { getEntitlement } from "usethatapp"; const ent = await getEntitlement(accessToken); // { entitled, version, product_public_id, status, is_free, period_end } if (ent.entitled && ent.product_public_id === "prod_") { unlockProFeatures(); } ``` Not entitled? Close the loop right there — render an upgrade CTA with `purchase_url` / `purchaseUrl`. Every framework guide ([Python](https://docs.usethatapp.com/python), [JavaScript](https://docs.usethatapp.com/javascript)) shows this pattern wired into its stack. ## Step 5 — Let buyers manage their subscription Link "Manage subscription" in your app to the buyer's management page for your app — upgrades, auto-renew, and the Stripe billing portal. Build it with `manage_url` / `manageUrl` (pure, needs only `UTA_CLIENT_ID`); `next` is validated the same way as on buy links and becomes the "Back to your app" button: *manage link* ``` import { manageUrl } from "usethatapp"; manageUrl({ next: "https://yourapp.com/account" }); // → https://www.usethatapp.com/manage//?next=https://yourapp.com/account ``` ## Test it before you launch Open your own buy link while signed in as your developer account and the flow runs in **walkthrough mode**: same pages, a test banner, no Stripe calls, no charge — and it finishes by setting the developer-preview entitlement so your app sees `entitled: true, status: "preview"`. Full instructions and a launch checklist: [Test your purchase flow](https://docs.usethatapp.com/purchase/testing). --- # Purchase links A purchase link (buy link) is a URL on usethatapp.com that runs the entire hosted checkout for one price of your app: identity, payment via Stripe, tax, receipt — then returns the buyer to your app. UseThatApp is the **merchant of record**: it processes the payment, calculates and remits applicable tax, handles refunds and chargebacks, and appears on the buyer's card statement. You get payouts via Stripe. ## URL anatomy *buy link* ``` https://www.usethatapp.com/buy/{price_id}/?next=...&ref=...&email=... ``` `{price_id}` is the opaque public price identifier (prefix `prc_`). Don't construct it — get it from the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api), where every price also carries a ready-made `buy_url` you can use directly. The SDK builds these links for you — `purchase_url` / `purchaseUrl` is a **pure URL builder**: no network call, no configuration needed (an empty price id raises Python `ValueError` / JavaScript `UtaError`): ### Python *build a buy link* ``` from usethatapp import purchase_url purchase_url("", next="https://yourapp.com/welcome", ref="partner42", email="buyer@example.com") ``` ### JavaScript *build a buy link* ``` import { purchaseUrl } from "usethatapp"; purchaseUrl("", { next: "https://yourapp.com/welcome", ref: "partner42", email: "buyer@example.com", }); ``` ## Query parameters | Param | Meaning and validation | | --- | --- | | `next` *(optional)* | Post-purchase return URL. Must be **HTTPS on your app's registered domain** (`localhost` / loopback allowed for development) — the same trust rule as OAuth redirect URIs. An invalid value shows the buyer a "misconfigured link" page at link-open time, so **test your links**. Omitted: the buyer returns to your app's registered **Login URL**, landing back in the app where OIDC signs them in with a live entitlement. | | `ref` *(optional)* | Affiliate code. Sets the standard signed 60-day attribution cookie on the UseThatApp domain (last click wins); commissions accrue per the affiliate program. Affiliates append `?ref=CODE` to your buy links, or you forward a landing-page `ref` onto the buy link yourself. | | `email` *(optional)* | Prefills the buyer's email on the first checkout page. | ## What the buyer sees 1. **Identify.** Email, country, and ZIP on one page. An email that matches an existing account is sent through normal sign-in (including MFA). A new email gets an account auto-created — no password, no verification wall in the purchase path. 2. **Pay.** Card entry via Stripe. 3. **Confirm.** A confirmation page, then the charge. 4. **Return.** A success page (with a "resend receipt email" button), then the redirect to `next` or your Login URL. The license is created **synchronously** during checkout, so the entitlement is live the moment the buyer returns — no webhook lag, no polling. ## The receipt email The post-purchase email contains: - the receipt, - an email-verification link, - a set-a-password link (for auto-created accounts), and - a manage link for the subscription. ## Already-licensed buyers A buyer who already has an active license for your app and hits a buy link is **never double-charged** — they're recognized and sent straight back to your app. ## US-only purchases Purchases are currently available to **United States buyers only**. This is enforced on both the declared tax address (country + ZIP at identify) and the card's billing address at confirmation. ## Manage links The companion to a buy link: the buyer's subscription-management page scoped to your app (upgrade, auto-renew, Stripe billing portal). `client_id` is your app's public OAuth client id from the dashboard's **Integration** page; `next` is validated the same way as above and becomes the "Back to your app" button. *manage link* ``` https://www.usethatapp.com/manage/{client_id}/?next=https://yourapp.com/account ``` Or build it with the SDK — `manage_url(next=…)` / `manageUrl({ next })` — also pure, needing only `UTA_CLIENT_ID` (Python raises `UtaConfigError` without it). ⚠ Beta flag: buy links 404 until enabled Buy links return 404 until external sales is enabled for your app. During the beta this is switched on per app — contact UseThatApp to enable it. You can build and test everything else first: the [walkthrough mode](https://docs.usethatapp.com/purchase/testing) works for your developer account as soon as the flag is on. ## Next steps Get your price ids and ready-made buy URLs from the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api), walk the end-to-end story in [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website), then [test your purchase flow](https://docs.usethatapp.com/purchase/testing). --- # Pricing API Two public, read-only endpoints that let your website render your app's profile and **live prices** — no auth, no API key, CORS-enabled, callable straight from the browser. Every price carries a ready-made `buy_url`, so your pricing page and your Buy buttons stay correct the moment you edit a price on the dashboard. Never hardcode prices Render prices from this API instead of hardcoding amounts that drift the moment you change a price, and use the returned `buy_url` for your Buy buttons instead of assembling checkout URLs yourself. ## App profile *request* ``` GET https://www.usethatapp.com/api/v1/public/apps/{client_id}/ ``` `client_id` is your app's public OAuth client id, from the dashboard's **Integration** page — the same identifier your OIDC integration already uses. *response* ``` { "client_id": "AbC123dEf456...", "name": "Acme Analytics", "tagline": "Dashboards your team actually reads", "listing_mode": "external", "url": "https://acmeanalytics.com", "marketplace_url": "https://www.usethatapp.com/apps/acme-analytics/" } ``` `listing_mode` is `"marketplace"` (the app has a full product page on usethatapp.com) or `"external"` (the app still appears in UseThatApp search, but its card links out to your own registered website). You can change it from the dashboard or via the MCP `update_app_listing` tool — see [MCP Server](https://docs.usethatapp.com/mcp). ## Prices *request* ``` GET https://www.usethatapp.com/api/v1/public/apps/{client_id}/prices/ ``` *response* ``` { "client_id": "AbC123dEf456...", "app_name": "Acme Analytics", "has_free_tier": true, "prices": [ { "public_id": "prc_9f2kq8w7", "product_id": "prod_9XkKe2QGxN4Ttz1Rw7Ya", "product_name": "Pro", "amount": "10.00", "currency": "usd", "is_recurring": true, "frequency": "month", "buy_url": "https://www.usethatapp.com/buy/prc_9f2kq8w7/" }, { "public_id": "prc_x4n7d2mk", "product_id": "prod_Vt3mPa8LqZc5Ryw2Kd0B", "product_name": "Lifetime", "amount": "199.00", "currency": "usd", "is_recurring": false, "frequency": null, "buy_url": "https://www.usethatapp.com/buy/prc_x4n7d2mk/" } ] } ``` | Field | Meaning | | --- | --- | | `public_id` | Opaque price identifier (prefix `prc_`) — the id inside [buy links](https://docs.usethatapp.com/purchase/links). | | `product_id` | The product this price belongs to (`prod_…`). **Matches `product_public_id` from the entitlement endpoint**, so gate your features on it — the plan bought is the plan you unlock. (The entitlement's own `product_id` carries the same value — the fields are equal-valued aliases.) See [Licensing](https://docs.usethatapp.com/licensing). | | `amount` / `currency` | Decimal string (e.g. `"10.00"`) and lowercase ISO currency code (e.g. `"usd"`). | | `is_recurring` / `frequency` | Billing interval for recurring prices: `"day"`, `"week"`, `"month"`, or `"year"`; `frequency` is `null` for one-time prices. | | `buy_url` | Ready-made hosted checkout link for this price — use it as your Buy button's `href`. | | `has_free_tier` | True when the app offers a free product. Free tiers have no price rows, so render your "Free" column from this flag. | ## Call it with the SDK On a server, prefer the SDK — it wraps both endpoints with typed results (`AppInfo`, `AppPrices` with `Price` / `UtaPrice` entries) and needs only `UTA_CLIENT_ID`, no OAuth secrets: ### Python *server-side* ``` from usethatapp import get_app_info, get_prices info = get_app_info() # AppInfo(name, tagline, listing_mode, ...) plans = get_prices() # AppPrices(..., has_free_tier, prices) for p in plans.prices: render_plan(p) # p.buy_url is your Buy button href # async variants: get_app_info_async(), get_prices_async() ``` ### JavaScript *server-side* ``` import { getAppInfo, getPrices } from "usethatapp"; const info = await getAppInfo(); // { name, tagline, listing_mode, ... } const { prices, has_free_tier } = await getPrices(); for (const p of prices) { renderPlan(p); // p.buy_url is your Buy button href } ``` Error mapping: 404 raises a plain `UtaError` (app unknown, unpublished, or external sales not enabled yet); 429 and 5xx raise `UtaServerError` (retriable). See [Error Handling](https://docs.usethatapp.com/error-handling). ## Call it from the browser Because the endpoints are CORS-enabled, a static marketing page with no server at all can fetch them directly: *browser* ``` const CLIENT_ID = ""; const res = await fetch( `https://www.usethatapp.com/api/v1/public/apps/${CLIENT_ID}/prices/` ); const { prices, has_free_tier } = await res.json(); // render prices → each price's buy_url is your Buy button href ``` ## Behavior notes - **Visibility:** both endpoints only resolve for **published** apps with **external sales enabled** — anything else is a 404 (indistinguishable from an unknown `client_id`). - **Caching:** responses send `Cache-Control: public, max-age=60` — prices can change, so the public cache is kept short. - **Rate limit:** 120 requests/minute per IP; excess requests get 429. - **CORS:** `Access-Control-Allow-Origin: *` — call it from any origin, no proxy needed. - **Auth:** none. Everything here is public data; there are no secrets to leak from a browser call. ## Next steps See the full funnel in [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website), the link parameters in [Purchase links](https://docs.usethatapp.com/purchase/links), and the rest of the HTTP surface in the [REST API reference](https://docs.usethatapp.com/rest-api). --- # Test your purchase flow Before you put Buy buttons on your website, walk the whole funnel yourself. When your app's own developer opens a buy link, the flow runs in **walkthrough mode**: the same pages your buyers will see, with a test banner, **no Stripe calls and no charge** — ending with a preview entitlement so you can verify the return into your app end-to-end. This is the supported way to test. ## Run the walkthrough 1. Sign in to usethatapp.com as your **developer account** (the account that owns the app). 2. Open one of your app's buy links — grab a `buy_url` from the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) (or build one with `purchase_url` / `purchaseUrl`). 3. Walk the pages. You'll see a **test mode banner**; the payment step is skipped entirely — no Stripe calls, no charge. 4. Finish. Completing the walkthrough sets your **developer-preview entitlement** for the product you chose, then returns you to your app the same way a buyer would arrive. *your buy link* ``` https://www.usethatapp.com/buy/{price_id}/?next=https://yourapp.com/welcome ``` ## What your app sees Back in your app, the OIDC return signs you in and `get_entitlement` reports the previewed product — proof the full loop works without money moving: *entitlement after the walkthrough* ``` { "entitled": true, "version": "Pro", "product_id": "6f0e8f9a-2b1c-4d5e-8f7a-9b0c1d2e3f4a", "product_public_id": "prod_9XkKe2QGxN4Ttz1Rw7Ya", "status": "preview", "is_free": false, "period_end": null } ``` Gate on `product_public_id` as usual (see [Licensing](https://docs.usethatapp.com/licensing)) — the preview carries the real `product_public_id` of the product you "bought", so your feature gates light up exactly as they will for a paying customer. The `"preview"` status tells you it's a developer preview, not a paid license. ## Test your `next` URLs The `next` param is validated when the link is opened: it must be HTTPS on your app's registered domain (`localhost` allowed for development). An invalid value shows a "misconfigured link" page — better in front of you now than in front of a buyer mid-purchase. Open every distinct buy link your site renders at least once. ## Launch checklist 1. **Register your Login URL and redirect URIs** on the dashboard's Integration page — the default post-purchase return is the Login URL, and OIDC needs the redirect URIs. See [Sign in with UseThatApp](https://docs.usethatapp.com/openid-connect). 2. **Verify every `next` URL** your site uses is HTTPS on your app's registered domain. 3. **Run the walkthrough end-to-end** for each product and confirm your app shows `entitled: true, status: "preview"` with the right `product_public_id`. 4. **Ask UseThatApp to enable external sales** for your app — buy links return 404 until the beta flag is switched on for your app. ## Next steps The full selling story is in [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website); link parameters and buyer behavior are in [Purchase links](https://docs.usethatapp.com/purchase/links). --- # REST API The raw HTTP surface behind the whole UseThatApp offering — selling from your own website with UseThatApp as **merchant of record** (public app-profile and pricing endpoints feeding hosted buy links), **licensing** (the Bearer-authed entitlement endpoint and the client-credentialed license key endpoints), and **authentication** ("Sign in with UseThatApp" over OIDC). If you run Python or JavaScript, the `usethatapp` SDK wraps all of it; this page is for every other stack — and for calling the public endpoints straight from a browser. ## Conventions - **Base URL:** `https://www.usethatapp.com` - **Format:** JSON requests and responses. - **Versioning:** the REST API lives under `/api/v1/`. The OIDC endpoints (under `/o/`) and the canonical entitlement path (`/licensing/entitlement/`) are frozen, unversioned surfaces the SDKs pin. - **Live schema:** an OpenAPI 3 schema at [/api/v1/schema/](https://www.usethatapp.com/api/v1/schema/) and Swagger UI at [/api/v1/docs/](https://www.usethatapp.com/api/v1/docs/). ## Authentication models | Surface | Auth | | --- | --- | | `/api/v1/public/…` | None — anonymous, CORS-enabled (`Access-Control-Allow-Origin: *`), cacheable public reads. | | `/api/v1/entitlement/` | OAuth 2.0 Bearer access token with the `entitlements` scope, obtained via the OIDC login flow. Requires the Hosted sign-in add-on (otherwise 403 `service_not_enabled`). | | `/api/v1/licenses/…`, `/api/v1/orders/…` | HTTP Basic with your app's OAuth **client id and secret** — server-to-server, no end-user token. Included in the base per-sale fee. | ## Public endpoints Two anonymous read endpoints power selling from your own website — app profile and live prices, addressed by your app's public OAuth `client_id`. Full field-by-field reference with example responses: [Pricing API](https://docs.usethatapp.com/purchase/pricing-api). *public endpoints* ``` GET /api/v1/public/apps/{client_id}/ → app profile (name, tagline, listing_mode, url, marketplace_url) GET /api/v1/public/apps/{client_id}/prices/ → live prices, each with a ready-made buy_url ``` Both only resolve for published apps with external sales enabled (404 otherwise) and send `Cache-Control: public, max-age=60`. ## Entitlement endpoint The live source of truth for a user's plan in your app. Two paths, identical behavior: - `GET /licensing/entitlement/` — the **canonical** path, which the SDKs call. - `GET /api/v1/entitlement/` — an alias mounted so the v1 API surface is complete. Behaves identically. Send the OAuth access token from the login flow as a Bearer token. The token must carry the `entitlements` scope (part of the default `openid entitlements` scopes). *request* ``` curl -H "Authorization: Bearer " \ https://www.usethatapp.com/api/v1/entitlement/ ``` *response — 200* ``` { "entitled": true, "version": "Pro", "product_id": "6f0e8f9a-2b1c-4d5e-8f7a-9b0c1d2e3f4a", "product_public_id": "prod_9XkKe2QGxN4Ttz1Rw7Ya", "status": "active", "is_free": false, "period_end": "2026-08-27" } ``` | Field | Meaning | | --- | --- | | `entitled` | True if the user may use the app (an active license, or a free tier). | | `version` | Plan display name (e.g. "Pro"). Mutable — for display only. | | `product_public_id` | Opaque product identifier (`prod_…`). **Gate features on this.** Matches `product_id` in the Pricing API, and is kept permanently. | | `product_id` | Equal-valued alias of `product_public_id` — both carry the same `prod_…` value. Gate on either. | | `status` | `active`, `trialing`, `one_time_active`, `free`, `preview`, or `none`. | | `is_free` | Whether the entitlement is the app's free tier. | | `period_end` | ISO date the current period ends, or `null`. | ### Errors | Status | Body | Meaning | | --- | --- | --- | | `401` | `{"error": "invalid_token"}` | Missing, expired, or revoked token (a confirmed logout lands here). Refresh, or send the user back through login. | | `403` | `{"error": "insufficient_scope", "scope": "entitlements"}` | A valid token that lacks the `entitlements` scope. | | `400` | `{"error": "Client is not linked to an app"}` | The OAuth client isn't linked to an app — a configuration problem, not a user problem. | The SDKs map 401 to `UtaTokenError` and 403 to `UtaPermissionError` — see [Error Handling](https://docs.usethatapp.com/error-handling). For what the fields mean for feature gating, see [Licensing](https://docs.usethatapp.com/licensing). ## License key endpoints Server-to-server verification for apps that keep their own authentication. All three take HTTP Basic client credentials; none involves an end-user token. Field meanings, statuses, and the integration walkthrough are on [License Keys](https://docs.usethatapp.com/license-keys). *license key endpoints* ``` POST /api/v1/licenses/validate → {entitled, status, license_id, product_id, product_public_id, period_end, canceled_at} body: {"key": "key_…"} 120/min per client GET /api/v1/orders/ → the same fields + license_key 202 {"status":"processing"} if the license has not landed yet 120/min per client POST /api/v1/licenses//regenerate-key → the same fields + license_key, rotated_at. Old key dies at once. 30/min per client ``` Errors: `401 invalid_client` (bad credentials), `404 unknown_key` / `unknown_order` / `unknown_license` (never issued, or belonging to another app — the endpoints are not cross-app oracles), and `409 license_canceled` on rotation of a dead license. A key whose license was canceled or refunded is not "unknown": it validates with `status: "canceled"`. ## OIDC / OAuth endpoints UseThatApp is a standard OpenID Provider. Rather than hardcoding endpoint paths, fetch the discovery document — it enumerates the authorization, token, userinfo, JWKS, and end-session endpoints, plus supported scopes and signing algorithms: *discovery* ``` GET https://www.usethatapp.com/o/.well-known/openid-configuration ``` - **authorization_endpoint** — start the authorization-code + PKCE flow. - **token_endpoint** — exchange the code for tokens (confidential client: send your client secret). - **userinfo_endpoint** — the pairwise pseudonymous `sub` claim (no PII — see [Authentication](https://docs.usethatapp.com/authentication)). - **jwks_uri** — public keys for validating ID tokens. - **end_session_endpoint** — RP-initiated logout. If you're integrating by hand on a stack without an SDK, any certified OIDC client library pointed at this issuer (`https://www.usethatapp.com/o`) will do the protocol work. ## Rate limits | Surface | Limit | | --- | --- | | Entitlement endpoint | 60 requests/minute per token | | Public API (`/api/v1/public/…`) | 120 requests/minute per IP | ## Machine-readable schema The versioned API is described by a live OpenAPI 3 schema at [https://www.usethatapp.com/api/v1/schema/](https://www.usethatapp.com/api/v1/schema/) with interactive Swagger UI at [https://www.usethatapp.com/api/v1/docs/](https://www.usethatapp.com/api/v1/docs/). AI agents can also use [llms.txt](https://docs.usethatapp.com/llms.txt) and the [MCP server](https://docs.usethatapp.com/mcp). --- # MCP Server UseThatApp ships a remote **Model Context Protocol (MCP)** server so your AI coding agent — Claude Code, Claude Desktop, Cursor, or any MCP-capable host — can manage your apps on the platform for you: the OAuth integration settings behind sign-in and purchase returns, listing content (including the `listing_mode` that points your search card at your own website when you [sell externally](https://docs.usethatapp.com/purchase/sell-from-your-website)), and your versions and prices — the tiers behind your buy links. You authorize it with your own UseThatApp account; the agent can only ever see and edit apps you are the registered developer of. Endpoint `https://www.usethatapp.com/mcp` — Streamable HTTP transport, OAuth 2.0 authorization (the same hardened OpenID provider that powers [Sign in with UseThatApp](https://docs.usethatapp.com/openid-connect)). No API keys and nothing to install server-side. ## Connect your agent UseThatApp uses one shared, public OAuth client for all MCP connections (there is no dynamic client registration), so you configure two values: the endpoint URL and the published MCP client ID. For Claude Code: *Claude Code* ``` claude mcp add --transport http \ --client-id lZwNF30llqDK3uM0AdmVZQRyHUN2lPK4bvumxxhB \ --callback-port 45454 \ usethatapp https://www.usethatapp.com/mcp ``` - `lZwNF30llqDK3uM0AdmVZQRyHUN2lPK4bvumxxhB` is the UseThatApp MCP client ID (shared by all developers — it is a public identifier, not a secret). - `--callback-port 45454` matters: the OAuth callback must land on a pre-registered address. Callbacks on `127.0.0.1` work on any port, but hosts that default to a `localhost` callback must pin port `45454`. - Other MCP hosts: configure a remote/HTTP server with the same URL and client ID. The host handles the OAuth flow itself. On the first tool call your browser opens to usethatapp.com: sign in, review the consent screen listing exactly what the agent may do, and approve. Tokens are short-lived and refreshed automatically by your MCP host; you can revoke access at any time by signing out of usethatapp.com (which revokes all grants). ## Permissions (scopes) Capabilities are split into two scope families, shown on the consent screen. Within a family, write access implies read access. | Scope | Grants | | --- | --- | | `integrations.read` | View apps, products, and OAuth integration settings | | `integrations.write` | Change login URL and redirect URIs; rotate the client secret | | `listing.read` | View listing content (tagline, description, categories, sections, feature table) | | `listing.write` | Edit listing content and product descriptions; manage versions and prices | The discovery tools `list_apps` and `list_products` work with *any* of the four scopes, so a listing-only grant can still find app slugs and product IDs. If you authorized before a scope existed, your token won't carry it — reconnect (re-run the OAuth flow from your MCP host) to re-consent with the full set. Write tools need the seller terms on record Every write tool checks that you have accepted the current seller documents; if you have not, the call fails with an error naming the acceptance page rather than saving anything. Read tools are unaffected. This mirrors the dashboard, which gates the same edits behind the same acceptance — the agent hits it because you have not accepted yet, not because of anything the agent did. Accept once in the dashboard and re-run the tool. ## Integration tools Manage how users are handed off to your app at launch — the settings from the dashboard's OAuth integration page. See [Sign in with UseThatApp](https://docs.usethatapp.com/openid-connect) for what these settings mean. | Tool | What it does | | --- | --- | | `list_apps()` | Your apps, with slug, listing mode, published state, and `external_sales_enabled`. App slugs are the key every other tool takes. If a buy link or the public pricing API is returning 404, this is the one-call diagnosis: both `is_published` and `external_sales_enabled` must be true. | | `list_products(app_slug)` | An app's products (tiers) with the **`product_public_id`** (`prod_…`) your app should gate content on — the entitlement API returns it as `product_public_id` — plus name, description, free/active flags, and prices — each price carrying the `prc_…` `public_id` its [buy link](https://docs.usethatapp.com/purchase/sell-from-your-website) is built from. (`product_id` and `product_public_id` are equal-valued `prod_…` aliases; write tools also accept old database UUIDs on input.) Inactive products are included but flagged. | | `get_integration_settings(app_slug)` | Login URL, redirect URIs, OAuth `client_id`, and the buy-link preconditions (`is_published`, `external_sales_enabled`). Read-only: if the app has never used OAuth, `client_id` is `null` and `oauth_client_provisioned` is `false` — a client is created on the first write, never by a read. The client secret is never returned. Also reports the Hosted sign-in add-on state, its trial end, and the dashboard URL where the add-on is toggled. These same credentials authenticate the [License Key API](https://docs.usethatapp.com/license-keys), so this is also where a keys-mode integration reads its `client_id`. Both this tool and `list_apps` also return `integration_mode`, a legacy field from the retired launch-handoff setting — read-only, the same value for every current app, and safe to ignore. | | `update_integration_settings(app_slug, ...)` | Partial update: `oauth_login_url`, `redirect_uris`, `post_logout_redirect_uris`. Omitted fields are unchanged — but the two URI lists **replace the whole list** when provided: to add one URI, read the current list first and resubmit every URI you want to keep (`[]` removes all of them, which breaks sign-in). Redirect URIs must be HTTPS on your app's registered domain (localhost allowed for dev). There is no handoff mode to set — OAuth is the only integration for new apps. | | `regenerate_client_secret(app_slug)` | **Destructive.** Rotates your app's OAuth client secret; the old secret stops working immediately. The new secret is *not* returned to the agent — the response contains a single-use `secret_claim_url` instead (see below). | ### How the new client secret is delivered Anything an MCP tool returns becomes part of your agent's conversation: it is sent to the model provider and stored in the host's transcripts. A credential must never travel that path, so `regenerate_client_secret` returns a **one-time claim URL** instead of the secret itself. The URL serves the plaintext exactly once, then expires (10 minutes after rotation, or immediately after the first read). Your agent delivers the secret by embedding the claim URL in a local shell command substitution — the plaintext resolves in your shell and goes straight to its destination, never through the conversation: *Deliver without exposing* ``` # Capture first, verify non-empty, then deliver — a failed claim # must not silently become an empty credential: s=$(curl -sf "") && [ -n "$s" ] || { echo "claim failed (already used, expired, or rate-limited) — re-run regenerate_client_secret" >&2 exit 1 } # Straight into a hosting provider's env config: netlify env:set OAUTH_CLIENT_SECRET "$s" # Or into a local .env file: printf 'OAUTH_CLIENT_SECRET=%s\n' "$s" >> .env unset s ``` - **Always verify the claim produced a non-empty value before writing it anywhere** (the guard above). A spent, expired, or rate-limited claim URL yields an empty string, and an unguarded `printf`/`env:set` will happily store that empty string as your credential — a failure that only surfaces later as an unexplained authentication error. - The URL is **single-use**: if a delivery command fails after consuming it, don't retry the URL — run `regenerate_client_secret` again (the previous secret is already invalid, so a fresh rotation costs nothing extra). - Well-behaved agents (the tool description instructs them) only fetch the URL into the conversation as a last resort, when the destination offers no CLI or API. Even then the exposure is time-boxed and single-use. - The secret is stored hashed server-side and cannot be retrieved later — deliver it somewhere durable in the same step. ## Listing tools Manage your app's public marketplace listing. All writes run the same validation as the dashboard (length limits, language screening, domain checks) — the agent cannot save anything the dashboard would refuse. | Tool | What it does | | --- | --- | | `get_app_listing(app_slug)` | Full listing state: `listing_mode`, published + external-sales flags, tagline, description, categories, industries, tags, detail sections in display order, feature comparison table, and product names/descriptions. Call this first — its output feeds directly into the write tools below. | | `update_app_listing(app_slug, ...)` | Partial update: `tagline` (≤120 chars), `description` (≤1000 chars, plain text), `categories` / `industries` / `tags` as name lists (unknown names are created in the shared vocabulary; `[]` clears), and `listing_mode` — `"marketplace"` (full product page on usethatapp.com, the default) or `"external"` (the app still appears in UseThatApp search, but its card links out to the app's own registered website — see [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website)). Setting `"external"` requires external-sales approval: the write is rejected while `external_sales_enabled` is false — and if your app is in the grandfathered state (currently `"external"` with approval since revoked), switching to `"marketplace"` is **irreversible without UseThatApp support**. Omitted fields are unchanged. | | `replace_detail_sections(app_slug, sections)` | **Replace-all.** Up to 4 sections of `{title (≤120), body (≤4000)}`, in display order. Sections you don't resubmit are deleted; body accepts limited HTML (scripts are stripped at render). | | `replace_feature_table(app_slug, features)` | **Replace-all.** Rows of `{name (≤200), description (≤500), product_ids}` — the tiers each feature applies to. Rows you don't resubmit are deleted; `[]` clears the table. | | `replace_versions(app_slug, versions)` | **Replace-all. Changes live checkout prices immediately.** Each version is `{product_id?, name, is_free?, prices}` with prices as `{amount, frequency}` (subscriptions: month, year, week, or day). `product_id` (the `prod_…` id from `list_products`) identifies an existing version — omit it to create one; versions you don't resubmit are *deleted* (refused if they have active subscriptions). Prices match by frequency, so changing an amount keeps that price's `prc_…` id — buy links already on your website keep working; removing a price kills its link, and a new frequency mints a new id. At most one version is free, free versions carry no prices, and at least one version must remain. Returns the saved versions plus a `removed` receipt itemising every deleted version and killed price. | | `update_product_description(app_slug, product_id, description)` | Updates one product's description (≤1000 chars). For names, free/paid status, and prices, use `replace_versions`. | ⚠ Replace-all tools delete what you omit `replace_detail_sections`, `replace_feature_table`, and `replace_versions` replace the entire collection. Always read first (`get_app_listing`, or `list_products` for versions) and resubmit every row you want to keep. Validation failures are aggregated (e.g. `sections[2].title: ...`) and nothing is written unless the whole payload is valid. Every successful call also returns a `removed` block — the delete receipt naming exactly what the call destroyed (for `replace_versions`, each removed price is a buy link that stopped resolving). If the receipt names something you meant to keep, act on it immediately: resubmit sections and features from `get_app_listing`; removed price ids are gone for good, so recreate the price and update the buy links on your site. ## Not available via MCP - **App name and URL** — renames and domain changes affect your app's public URL and security checks; edit them in the dashboard. - **Images** — uploads go through content moderation; dashboard-only for now. - **Publishing** — the publish toggle stays a human decision. - **The Hosted sign-in add-on toggle** — enabling it changes the platform fee and permanently starts the app's one-time free window, so the switch lives only in the dashboard, next to the fee terms. Agents can read the current state (and get the dashboard link) from `get_integration_settings`. - **Stripe identifiers** — internal payment data, never exposed. Gate content on the local `product_public_id` (see [Gotchas](https://docs.usethatapp.com/gotchas)). ## Machine-readable surfaces Beyond the MCP server, agents can bootstrap from three machine-readable surfaces: - **llms.txt** — [https://docs.usethatapp.com/llms.txt](https://docs.usethatapp.com/llms.txt): an index of this documentation with key integration facts, plus a full-text variant at `llms-full.txt`. - **OpenAPI schema** — [https://www.usethatapp.com/api/v1/schema/](https://www.usethatapp.com/api/v1/schema/) (Swagger UI at [/api/v1/docs/](https://www.usethatapp.com/api/v1/docs/)): the live schema for the [REST API](https://docs.usethatapp.com/rest-api). - **MCP endpoint** — `https://www.usethatapp.com/mcp`: this server, for agents that act on your apps rather than just read about them. ## Example prompts - "List my UseThatApp apps and show the integration settings for acme-analytics." - "Add https://acme.example.com/auth/callback to my app's OAuth redirect URIs, keeping the ones already registered." - "Rewrite my app's tagline and description to emphasize the new reporting features, and tag it under analytics." - "Add a 'How it works' detail section, then build a feature comparison table across my Free and Pro tiers." - "Add a yearly price of $99 to my Pro tier, and give me the buy link for it." - "Get the product IDs for my app so we can wire up content gating." (pairs with `get_entitlement` — see [Sign in with UseThatApp](https://docs.usethatapp.com/openid-connect)) ## Limits and troubleshooting - **Rate limit:** 60 requests per minute per token; excess calls return 429 with a `Retry-After` header. - **401 / authentication errors:** access tokens are short-lived; your MCP host refreshes them automatically. If refresh fails, reconnect the server (re-run the OAuth flow). - **"This action requires the ... scope":** your grant predates that scope — reconnect to re-consent. - **OAuth callback never completes:** check the callback port — `localhost` callbacks must use port 45454; `127.0.0.1` callbacks work on any port. - **Secret claim URL returns 404:** the URL was already claimed or its 10-minute window passed. Run `regenerate_client_secret` again for a fresh secret and claim URL. - **Secret claim URL returns 429:** the claim endpoint has its own limit of 30 requests per minute per IP, answered with a `Retry-After` header. Wait, then run `regenerate_client_secret` again — with the guarded delivery command above, a rate-limited claim fails loudly instead of writing an empty secret. - **406 Not Acceptable:** `/mcp` requires an explicit `Accept: application/json` (or `application/json, text/event-stream`). A wildcard `*/*` — curl's default — is refused; pass `-H 'Accept: application/json'` when scripting the endpoint directly. - **Validation failed:** the same rules as the dashboard apply (length limits, language screening, redirect-URI domain matching). The error message lists every failing field at once. --- # Python Overview Python # Python Documentation Sell your web app from your own website with UseThatApp as the **merchant of record**, sign buyers in with their UseThatApp account over standard **OAuth 2.0 / OpenID Connect**, and gate features on their live entitlement — a buyer who completes checkout lands back in your app already signed in, entitlement live. The `usethatapp` Python SDK is framework-agnostic — it takes and returns plain strings and one JSON-able `flow_state` dict, so it works with Flask, Django, FastAPI, Dash, Streamlit, plain WSGI, or a CLI. Each framework guide below is copied from a tested example in the SDK. This guide covers the Hosted sign-in path It has UseThatApp run login, so buyers return signed in and the same session answers what they bought — the [Hosted sign-in](https://docs.usethatapp.com/openid-connect) add-on. If your app already has its own accounts, you do not need any of the sign-in wiring below: keep your login and verify purchases with a [license key](https://docs.usethatapp.com/license-keys) your server validates in one call, included in the per-sale fee. ## What the SDK does A single OIDC login gives you identity and entitlement at once, and the purchase helpers close the loop: - **Identity.** `complete_login()` returns a `UtaSession` carrying the user's `sub` — a pairwise pseudonymous id that is stable for a user *within your app*, different in every other app, and carries no PII. Use it as your local user key. - **Entitlement.** `get_entitlement(access_token)` returns the user's current, live plan so you can gate features. A canceled license stops being entitled immediately. - **Purchasing**. `purchase_url(price_id)` and `manage_url()` are pure URL builders — no network call — for hosted checkout and the buyer's subscription-management page. UseThatApp is the merchant of record (payment, tax, refunds), and the license is created synchronously at checkout, so no webhooks. `get_prices()` / `get_prices_async()` return a live `AppPrices` with a tuple of `Price` entries, each carrying a ready-made `buy_url` and the `product_id` that matches the entitlement’s `product_public_id`; `get_app_info()` / `get_app_info_async()` return your public `AppInfo` listing. See [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website) and the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api). The SDK is a **confidential, server-side client**: it holds your client secret and validates ID tokens. Keep it on your server, never in the browser. For the conceptual deep-dive, see [OpenID Connect](https://docs.usethatapp.com/openid-connect). ## Install *terminal* ```bash pip install usethatapp ``` Python 3.9+. Runtime dependencies are `httpx` and `joserfc` — no web framework required. These docs describe **2.2.0 or newer**; if you are upgrading from an earlier release, run `pip install --upgrade usethatapp` first — the License Key API, `product_id`, and `UtaServiceNotEnabledError` do not exist before it. ## Configure The SDK reads its config from `django.conf.settings` when Django is installed and configured, otherwise from environment variables — or set anything in code with `configure(...)`, which wins over both. Only the client id and secret are required; the rest default to production. *environment* ``` UTA_CLIENT_ID=... # required UTA_CLIENT_SECRET=... # required (server-side only) # or read the secret from a mounted file (Render / k8s / Fly): # UTA_CLIENT_SECRET_PATH=/run/secrets/uta_client_secret UTA_REDIRECT_URI=https://yourapp.com/callback # required # optional — default to production: UTA_ISSUER=https://www.usethatapp.com/o UTA_API_URL=https://www.usethatapp.com UTA_SCOPES="openid entitlements" ``` In code — same names, snake_case keywords (the JavaScript SDK's `configure({...})` works the same way): ```python import usethatapp # e.g. point a dev machine at a local platform instance: usethatapp.configure( api_url="http://localhost:8000", issuer="http://localhost:8000/o", ) usethatapp.load_config() # the resolved UtaConfig (cached) usethatapp.reset_config() # drop overrides; back to settings/env usethatapp.DEFAULT_API_URL # "https://www.usethatapp.com" ``` ## The shape of an integration Every framework follows the same three routes plus a gated view. The SDK does the crypto; you wire the framework-specific bits (read callback query params, store `flow_state` in your session, issue redirects). *any framework* ``` from usethatapp import begin_login, complete_login, get_entitlement # /login — start the auth-code flow: auth_url, flow_state = begin_login() save_to_session("uta_flow", flow_state) # JSON-able dict return redirect(auth_url) # /callback — reads ?code=&state=, or ?error= on cancel: if read_query("error"): return redirect("/") # login canceled session = complete_login( code=read_query("code"), state=read_query("state"), flow_state=load_from_session("uta_flow"), ) save_to_session("uta_sub", session.sub) save_to_session("uta_access_token", session.access_token) # anywhere you gate features: ent = get_entitlement(load_from_session("uta_access_token")) if ent.entitled and ent.product_public_id == "prod_": unlock_pro_features() ``` ## Framework guides - [Flask](https://docs.usethatapp.com/python/flask) — confidential server app, `/login` · `/callback` · `/logout`. - [Django](https://docs.usethatapp.com/python/django) — views and URL patterns; config read from `settings`. - [FastAPI](https://docs.usethatapp.com/python/fastapi) — async hot path with `get_entitlement_async` and Starlette sessions. - [Dash](https://docs.usethatapp.com/python/dash) — routes on the underlying Flask server, entitlement in a callback. - [Streamlit](https://docs.usethatapp.com/python/streamlit) — a small BFF companion server handles OIDC; Streamlit reads the result. ## Next steps Read [OpenID Connect](https://docs.usethatapp.com/openid-connect) for the full model, then [Error Handling](https://docs.usethatapp.com/error-handling) and [Gotchas](https://docs.usethatapp.com/gotchas). --- # Flask Python / Flask # Flask Sign users in with their UseThatApp account and read their live plan in a Flask app, over **OAuth 2.0 / OpenID Connect**. These routes wire your app into the whole selling loop: a buyer who purchases from your own website lands back here signed in, entitlement already live — and marketplace launches arrive the same way. The code on this page is taken directly from the tested `examples/flask_min/` in the Python SDK. For the conceptual model, see [OpenID Connect](https://docs.usethatapp.com/openid-connect). This guide covers the Hosted sign-in path It has UseThatApp run login, so buyers return signed in and the same session answers what they bought — the [Hosted sign-in](https://docs.usethatapp.com/openid-connect) add-on. If your app already has its own accounts, you do not need any of the sign-in wiring below: keep your login and verify purchases with a [license key](https://docs.usethatapp.com/license-keys) your server validates in one call, included in the per-sale fee. ## How it works Three routes plus a gated home view do all the work. The `usethatapp` SDK runs **on your server** as a confidential client — it holds the client secret and validates ID tokens. 1. **`/login`** — `begin_login()` returns an authorization URL and a JSON-able `flow_state`. Stash `flow_state` in the Flask session and redirect. 2. **`/callback`** — check `?error=` first (cancel/deny), then `complete_login()` exchanges the code for a `UtaSession`. Persist its `sub` and tokens. 3. **`/logout`** — redirect to `logout_url(...)` for RP-initiated sign-out. 4. **Gated view** — call `get_entitlement(access_token)` to read the live plan. ## Prerequisites - Python 3.9+ and a Flask app. - `UTA_CLIENT_ID`, `UTA_CLIENT_SECRET`, and `UTA_REDIRECT_URI` in your server environment — register the OAuth client and redirect URI on your app's Integration page on the UseThatApp dashboard. ## Step 1 — Install *terminal* ```bash pip install usethatapp flask ``` ## Step 2 — Configure The SDK reads `UTA_*` from the environment. Only the client id, secret, and redirect URI are required; the rest default to production. The redirect URI here must match a redirect URI registered on the dashboard, verbatim. *environment* ``` export UTA_CLIENT_ID=... export UTA_CLIENT_SECRET=... # server-side only export UTA_REDIRECT_URI=http://localhost:5000/callback # Pointing at a non-production instance? Set BOTH of these # (each defaults to production): # export UTA_ISSUER=http://localhost:8000/o # export UTA_API_URL=http://localhost:8000 export FLASK_SECRET_KEY=dev-only ``` ## Step 3 — Start login `begin_login()` returns the authorization URL and a small `flow_state` dict. Save it in the session so the callback can validate the round-trip, then redirect. *app.py* ```python from flask import Flask, redirect, request, session, url_for from usethatapp import begin_login @app.route("/login") def login(): auth_url, flow_state = begin_login() session["uta_flow"] = flow_state return redirect(auth_url) ``` ## Step 4 — Handle the callback Check the `error` query param first: on cancel/deny the provider redirects back with `?error=` and no code. Otherwise `complete_login()` validates state, exchanges the code, and verifies the ID token, returning a `UtaSession`. Persist its `sub` (your local user key), access token, and id token. *app.py* ```python from usethatapp import UtaError, complete_login @app.route("/callback") def callback(): # On cancel/deny, OAuth redirects back with ?error=... and no code. if request.args.get("error"): session.pop("uta_flow", None) return redirect(url_for("home")) try: s = complete_login( code=request.args.get("code"), state=request.args.get("state"), flow_state=session.pop("uta_flow", {}), ) except UtaError as e: return f"login failed: {e}", 400 session["uta_sub"] = s.sub session["uta_access_token"] = s.access_token session["uta_id_token"] = s.id_token return redirect(url_for("home")) ``` ## Step 5 — Gate on the live plan Call `get_entitlement(access_token)` wherever you need the user's current plan. It's always authoritative. A 401 means the token is expired or revoked (`UtaTokenError`) — reconcile by dropping it and falling back to the logged-out view. *app.py* ```python from usethatapp import UtaTokenError, get_entitlement @app.route("/") def home(): token = session.get("uta_access_token") if token: try: ent = get_entitlement(token) return {"sub": session.get("uta_sub"), "entitlement": ent.raw} except UtaTokenError: # Token revoked/expired (signed out of UseThatApp). Reconcile. for k in ("uta_access_token", "uta_sub", "uta_id_token"): session.pop(k, None) return 'Log in with UseThatApp' ``` Gate features on the stable `ent.product_public_id`, not the mutable `ent.version` display name: *app.py* ```python ent = get_entitlement(token) if ent.entitled and ent.product_public_id == "prod_": return render_template("dashboard_pro.html") return render_template("dashboard_free.html") ``` ## Step 6 — Sign out Sign-out is RP-initiated: redirect to `logout_url(...)`. **Don't clear your session eagerly** — the user may choose "Stay signed in", and both outcomes return to the same post-logout URL. Reconcile on return instead: a confirmed logout revokes the token, so the next `get_entitlement` in `home` raises `UtaTokenError` and drops it there. *app.py* ```python from usethatapp import logout_url @app.route("/logout") def logout(): id_token = session.get("uta_id_token") return redirect( logout_url(id_token=id_token, post_logout_redirect_uri="http://localhost:5000/") ) ``` ## Complete example The full `examples/flask_min/app.py` from the SDK: *app.py* ```python import os from flask import Flask, redirect, request, session, url_for from usethatapp import ( UtaError, UtaTokenError, begin_login, complete_login, get_entitlement, logout_url, ) app = Flask(__name__) app.secret_key = os.environ.get("FLASK_SECRET_KEY", "dev-only") @app.route("/login") def login(): auth_url, flow_state = begin_login() session["uta_flow"] = flow_state return redirect(auth_url) @app.route("/callback") def callback(): # On cancel/deny, OAuth redirects back with ?error=... and no code. if request.args.get("error"): session.pop("uta_flow", None) return redirect(url_for("home")) try: s = complete_login( code=request.args.get("code"), state=request.args.get("state"), flow_state=session.pop("uta_flow", {}), ) except UtaError as e: return f"login failed: {e}", 400 session["uta_sub"] = s.sub session["uta_access_token"] = s.access_token session["uta_id_token"] = s.id_token return redirect(url_for("home")) @app.route("/") def home(): token = session.get("uta_access_token") if token: try: ent = get_entitlement(token) return {"sub": session.get("uta_sub"), "entitlement": ent.raw} except UtaTokenError: # Token revoked/expired (signed out of UseThatApp). Reconcile. for k in ("uta_access_token", "uta_sub", "uta_id_token"): session.pop(k, None) return 'Log in with UseThatApp' @app.route("/logout") def logout(): # Don't clear the session yet — the user may choose "Stay signed in". A # real logout revokes the token, so the next get_entitlement (home) 401s # and we drop it then. id_token = session.get("uta_id_token") return redirect(logout_url(id_token=id_token, post_logout_redirect_uri="http://localhost:5000/")) if __name__ == "__main__": app.run(port=5000) ``` ## Offer the upgrade: purchase links When the entitlement check says the user is not entitled — or is on the free tier — send them to hosted checkout with `purchase_url()`. UseThatApp is the **merchant of record** (payment, tax, refunds), and the buyer comes back to your app signed in with the entitlement already live: no webhooks, no polling. Both `purchase_url()` and `manage_url()` are pure URL builders — no network call. *app.py* ```python from usethatapp import get_entitlement, manage_url, purchase_url PRO_PRICE_ID = "" # from get_prices() or your dashboard @app.route("/account") def account(): ent = get_entitlement(session["uta_access_token"]) if ent.entitled and ent.product_public_id == "prod_": # Already on Pro — offer subscription management instead. manage = manage_url(next="http://localhost:5000/account") return render_template("account.html", manage=manage) # Not on Pro — hosted checkout; the buyer returns signed in, entitled. buy = purchase_url(PRO_PRICE_ID, next="http://localhost:5000/welcome") return render_template("upgrade.html", buy=buy) ``` For a live pricing page, render from `get_prices()` — never hardcode prices. Each `Price` carries a ready-made `buy_url` and the `product_id` that matches the entitlement’s `product_public_id`. See the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) and [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website). ## Errors At the entitlement endpoint, **401** raises `UtaTokenError` (re-authenticate or `refresh`) and **403** raises `UtaPermissionError` (a valid token missing the `entitlements` scope). Every SDK error inherits from `UtaError`. See [Error Handling](https://docs.usethatapp.com/error-handling) and [Gotchas](https://docs.usethatapp.com/gotchas). --- # Django Python / Django # Django Sign users in with their UseThatApp account and read their live plan in a Django app, over **OAuth 2.0 / OpenID Connect**. These views wire your app into the whole selling loop: a buyer who purchases from your own website lands back here signed in, entitlement already live — and marketplace launches arrive the same way. The code on this page is taken directly from the tested `examples/django_min/` in the Python SDK. For the conceptual model, see [OpenID Connect](https://docs.usethatapp.com/openid-connect). This guide covers the Hosted sign-in path It has UseThatApp run login, so buyers return signed in and the same session answers what they bought — the [Hosted sign-in](https://docs.usethatapp.com/openid-connect) add-on. If your app already has its own accounts, you do not need any of the sign-in wiring below: keep your login and verify purchases with a [license key](https://docs.usethatapp.com/license-keys) your server validates in one call, included in the per-sale fee. ## How it works Three views plus a gated home view, wired into your URL conf. The `usethatapp` SDK runs **on your server** as a confidential client and reads its config from `django.conf.settings` when Django is installed (falling back to environment variables otherwise). 1. **`login`** — `begin_login()` returns an authorization URL and a JSON-able `flow_state`; stash it in `request.session` and redirect. 2. **`callback`** — check `request.GET["error"]` first, then `complete_login()` returns a `UtaSession`. 3. **`logout`** — redirect to `logout_url(...)` for RP-initiated sign-out. 4. **Gated view** — call `get_entitlement(access_token)` for the live plan. ## Prerequisites - Python 3.9+ and a Django project. - The session middleware enabled (`django.contrib.sessions.middleware.SessionMiddleware`) — `flow_state` and the tokens live in the session. - `UTA_CLIENT_ID`, `UTA_CLIENT_SECRET`, and `UTA_REDIRECT_URI` available — set them in your environment or add them to `settings.py`. Register the OAuth client and redirect URI on your app's Integration page on the UseThatApp dashboard. ## Step 1 — Install *terminal* ```bash pip install usethatapp django ``` ## Step 2 — Configure *environment* ``` export UTA_CLIENT_ID=... export UTA_CLIENT_SECRET=... # server-side only export UTA_REDIRECT_URI=http://localhost:8000/callback/ # Pointing at a non-production instance? Set BOTH of these # (each defaults to production): # export UTA_ISSUER=http://localhost:8000/o # export UTA_API_URL=http://localhost:8000 ``` ## Step 3 — Views Read the callback query params from `request.GET`, store `flow_state` in `request.session`, and issue redirects. Persist `session.sub` as your local user key. *views.py* ```python from django.http import HttpResponse, JsonResponse from django.shortcuts import redirect from usethatapp import ( UtaError, UtaTokenError, begin_login, complete_login, get_entitlement, logout_url, ) def login(request): auth_url, flow_state = begin_login() request.session["uta_flow"] = flow_state # stash for the callback return redirect(auth_url) def callback(request): # On cancel/deny, OAuth redirects back with ?error=... and no code. if request.GET.get("error"): request.session.pop("uta_flow", None) return redirect("home") try: session = complete_login( code=request.GET.get("code"), state=request.GET.get("state"), flow_state=request.session.pop("uta_flow", {}), ) except UtaError as e: return HttpResponse(f"login failed: {e}", status=400) # Persist what you need against your own session. request.session["uta_sub"] = session.sub request.session["uta_access_token"] = session.access_token request.session["uta_id_token"] = session.id_token return redirect("home") def home(request): token = request.session.get("uta_access_token") if token: try: ent = get_entitlement(token) return JsonResponse({"sub": request.session.get("uta_sub"), "entitlement": ent.raw}) except UtaTokenError: # Token revoked/expired (e.g. signed out of UseThatApp). Reconcile # by dropping it; fall through to the logged-out view. for k in ("uta_access_token", "uta_sub", "uta_id_token"): request.session.pop(k, None) return HttpResponse('Log in with UseThatApp') def logout(request): # Don't clear the session yet — the user may choose "Stay signed in" at # UseThatApp. Reconcile on return: a real logout revokes the token, so the # next get_entitlement (in home) returns 401 and we drop it then. id_token = request.session.get("uta_id_token") return redirect(logout_url(id_token=id_token, post_logout_redirect_uri="http://localhost:8000/")) ``` ## Step 4 — URLs Wire the views into your URL conf. The callback path must match the path component of your registered `UTA_REDIRECT_URI`. *urls.py* ```python from django.urls import path from . import views urlpatterns = [ path("", views.home, name="home"), path("login/", views.login, name="login"), path("callback/", views.callback, name="callback"), path("logout/", views.logout, name="logout"), ] ``` ## Gate on the live plan Gate features on the stable `ent.product_public_id`, not the mutable `ent.version` display name. The entitlement is always authoritative — a canceled license stops being entitled immediately. *views.py* ```python def dashboard(request): token = request.session.get("uta_access_token") if not token: return redirect("login") ent = get_entitlement(token) if ent.entitled and ent.product_public_id == "prod_": return render(request, "dashboard_pro.html") return render(request, "dashboard_free.html") ``` ## Offer the upgrade: purchase links When the entitlement check says the user is not entitled — or is on the free tier — send them to hosted checkout with `purchase_url()`. UseThatApp is the **merchant of record** (payment, tax, refunds), and the buyer comes back to your app signed in with the entitlement already live: no webhooks, no polling. Both `purchase_url()` and `manage_url()` are pure URL builders — no network call. *views.py* ```python from usethatapp import get_entitlement, manage_url, purchase_url PRO_PRICE_ID = "" # from get_prices() or your dashboard def account(request): token = request.session.get("uta_access_token") if not token: return redirect("login") ent = get_entitlement(token) if ent.entitled and ent.product_public_id == "prod_": # Already entitled — offer subscription management instead. manage = manage_url(next="http://localhost:8000/account/") return render(request, "account.html", {"manage": manage}) # Not entitled — hosted checkout; the buyer returns signed in, entitled. buy = purchase_url(PRO_PRICE_ID, next="http://localhost:8000/welcome/") return render(request, "upgrade.html", {"buy": buy}) ``` For a live pricing page, render from `get_prices()` — never hardcode prices. Each `Price` carries a ready-made `buy_url` and the `product_id` that matches the entitlement’s `product_public_id`. See the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) and [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website). ## Errors At the entitlement endpoint, **401** raises `UtaTokenError` (re-authenticate or `refresh`) and **403** raises `UtaPermissionError` (a valid token missing the `entitlements` scope). Every SDK error inherits from `UtaError`. See [Error Handling](https://docs.usethatapp.com/error-handling) and [Gotchas](https://docs.usethatapp.com/gotchas). --- # FastAPI Python / FastAPI # FastAPI Sign users in with their UseThatApp account and read their live plan in a FastAPI app, over **OAuth 2.0 / OpenID Connect**. These routes wire your app into the whole selling loop: a buyer who purchases from your own website lands back here signed in, entitlement already live — and marketplace launches arrive the same way. The code on this page is taken directly from the tested `examples/fastapi_min/` in the Python SDK. For the conceptual model, see [OpenID Connect](https://docs.usethatapp.com/openid-connect). This guide covers the Hosted sign-in path It has UseThatApp run login, so buyers return signed in and the same session answers what they bought — the [Hosted sign-in](https://docs.usethatapp.com/openid-connect) add-on. If your app already has its own accounts, you do not need any of the sign-in wiring below: keep your login and verify purchases with a [license key](https://docs.usethatapp.com/license-keys) your server validates in one call, included in the per-sale fee. ## How it works Three routes plus a gated home view. The `usethatapp` SDK runs **on your server** as a confidential client. FastAPI is async, so use `get_entitlement_async` on the hot path; the one-time callback can call the sync `complete_login` (it's just a login round-trip). 1. **`/login`** — `begin_login()` then a `RedirectResponse`. Stash `flow_state` in the session. 2. **`/callback`** — check the `error` param first, then `complete_login()`. 3. **`/logout`** — redirect to `logout_url(...)`. 4. **Gated view** — `await get_entitlement_async(access_token)`. ## Prerequisites - Python 3.9+ and a FastAPI app served by Uvicorn. - Starlette's `SessionMiddleware` (it requires `itsdangerous`) — `flow_state` and the tokens live in the signed-cookie session. - `UTA_CLIENT_ID`, `UTA_CLIENT_SECRET`, and `UTA_REDIRECT_URI` in your server environment. Register the OAuth client and redirect URI on your app's Integration page on the UseThatApp dashboard. ## Step 1 — Install *terminal* ```bash pip install usethatapp fastapi uvicorn itsdangerous ``` ## Step 2 — Configure *environment* ``` export UTA_CLIENT_ID=... export UTA_CLIENT_SECRET=... # server-side only export UTA_REDIRECT_URI=http://localhost:8000/callback # Pointing at a non-production instance? Set BOTH of these # (each defaults to production): # export UTA_ISSUER=http://localhost:8000/o # export UTA_API_URL=http://localhost:8000 ``` ## Step 3 — App and session middleware Add `SessionMiddleware` so `request.session` is available to the login flow. *app.py* ```python import os from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from starlette.middleware.sessions import SessionMiddleware from usethatapp import ( UtaError, UtaTokenError, begin_login, complete_login, get_entitlement_async, logout_url, ) app = FastAPI() app.add_middleware(SessionMiddleware, secret_key=os.environ.get("SESSION_SECRET", "dev-only")) ``` ## Step 4 — Login and callback `begin_login()` returns the authorization URL and a `flow_state` dict; stash it in the session and redirect. In the callback, check the `error` param first (cancel/deny), then exchange the code with `complete_login()` and persist `s.sub` as your local user key. *app.py* ```python @app.get("/login") def login(request: Request): auth_url, flow_state = begin_login() request.session["uta_flow"] = flow_state return RedirectResponse(auth_url) @app.get("/callback") def callback(request: Request, code: str = "", state: str = "", error: str = ""): # On cancel/deny, OAuth redirects back with ?error=... and no code. if error: request.session.pop("uta_flow", None) return RedirectResponse("/") try: s = complete_login( code=code, state=state, flow_state=request.session.pop("uta_flow", {}) ) except UtaError as e: return JSONResponse({"error": str(e)}, status_code=400) request.session["uta_sub"] = s.sub request.session["uta_access_token"] = s.access_token request.session["uta_id_token"] = s.id_token return RedirectResponse("/") ``` ## Step 5 — Gate on the live plan (async) On the request hot path use `await get_entitlement_async(token)`. Gate on the stable `ent.product_public_id`, not the mutable `ent.version`. A `UtaTokenError` (401) means the token was revoked or expired — reconcile by dropping it. *app.py* ```python @app.get("/") async def home(request: Request): token = request.session.get("uta_access_token") if token: try: ent = await get_entitlement_async(token) return {"sub": request.session.get("uta_sub"), "entitlement": ent.raw} except UtaTokenError: # Token revoked/expired (signed out of UseThatApp). Reconcile. for k in ("uta_access_token", "uta_sub", "uta_id_token"): request.session.pop(k, None) return HTMLResponse('Log in with UseThatApp') ``` ## Step 6 — Sign out Redirect to `logout_url(...)` for RP-initiated sign-out. **Don't clear your session eagerly**: both "Stay signed in" and a confirmed logout return to the same post-logout URL. A confirmed logout revokes the token, so the next `get_entitlement_async` in `home` raises `UtaTokenError` and drops it. *app.py* ```python @app.get("/logout") def logout(request: Request): id_token = request.session.get("uta_id_token") return RedirectResponse( logout_url(id_token=id_token, post_logout_redirect_uri="http://localhost:8000/") ) ``` ## Offer the upgrade: purchase links When the entitlement check says the user is not entitled — or is on the free tier — send them to hosted checkout with `purchase_url()`. UseThatApp is the **merchant of record** (payment, tax, refunds), and the buyer comes back to your app signed in with the entitlement already live: no webhooks, no polling. `purchase_url()` and `manage_url()` are pure URL builders — no network call, so there is nothing to await. *app.py* ```python from usethatapp import manage_url, purchase_url PRO_PRICE_ID = "" # from get_prices_async() or your dashboard @app.get("/account") async def account(request: Request): token = request.session.get("uta_access_token") if not token: return RedirectResponse("/login") ent = await get_entitlement_async(token) if ent.entitled and ent.product_public_id == "prod_": # Already entitled — offer subscription management instead. manage = manage_url(next="http://localhost:8000/account") return HTMLResponse(f'Manage subscription') # Not entitled — hosted checkout; the buyer returns signed in, entitled. buy = purchase_url(PRO_PRICE_ID, next="http://localhost:8000/welcome") return HTMLResponse(f'Upgrade to Pro') ``` For a live pricing page, render from `await get_prices_async()` — never hardcode prices. Each `Price` carries a ready-made `buy_url` and the `product_id` that matches the entitlement’s `product_public_id`. See the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) and [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website). ## Run it *terminal* ```bash uvicorn app:app ``` ## Errors At the entitlement endpoint, **401** raises `UtaTokenError` (re-authenticate or `refresh`) and **403** raises `UtaPermissionError` (a valid token missing the `entitlements` scope). Every SDK error inherits from `UtaError`. See [Error Handling](https://docs.usethatapp.com/error-handling) and [Gotchas](https://docs.usethatapp.com/gotchas). --- # Dash Python / Dash # Dash Sign users in with their UseThatApp account and read their live plan in a Dash app, over **OAuth 2.0 / OpenID Connect**. These routes wire your app into the whole selling loop: a buyer who purchases from your own website lands back here signed in, entitlement already live — and marketplace launches arrive the same way. A Dash app has an underlying Flask server (`app.server`): you add the `/login`, `/callback`, and `/logout` routes there, then read the entitlement inside a Dash callback (which runs with the Flask session context). For the conceptual model, see [OpenID Connect](https://docs.usethatapp.com/openid-connect). This guide covers the Hosted sign-in path It has UseThatApp run login, so buyers return signed in and the same session answers what they bought — the [Hosted sign-in](https://docs.usethatapp.com/openid-connect) add-on. If your app already has its own accounts, you do not need any of the sign-in wiring below: keep your login and verify purchases with a [license key](https://docs.usethatapp.com/license-keys) your server validates in one call, included in the per-sale fee. ## How it works 1. **Routes on `app.server`** — the OIDC flow is plain Flask: `/login` starts it, `/callback` finishes it, `/logout` ends the session. The `usethatapp` SDK runs **on your server** as a confidential client. 2. **Entitlement in a Dash callback** — a Dash callback can read the Flask `session`, so it pulls the access token and calls `get_entitlement` to drive what the UI shows. ## Prerequisites - Python 3.9+ and a Dash app. - A Flask secret key on `app.server` so the session cookie survives the redirect to `/callback`. - `UTA_CLIENT_ID`, `UTA_CLIENT_SECRET`, and `UTA_REDIRECT_URI` in your server environment. Register the OAuth client and redirect URI on your app's Integration page on the UseThatApp dashboard. - Pointing at a non-production instance? Also set `UTA_ISSUER` and `UTA_API_URL` — each defaults to production. ## Step 1 — Install *terminal* ```bash pip install usethatapp dash ``` ## Step 2 — App and Flask server Grab the underlying Flask server with `app.server` and give it a secret key. OAuth needs the session cookie to survive the top-level redirect back to `/callback` — `SameSite=Lax` is sufficient for that GET. The common local-dev gotcha is a host mismatch (`127.0.0.1` vs `localhost`): the cookie is scoped to one host, so always browse the app on the same host as your `UTA_REDIRECT_URI`. *app.py* ```python import os from dash import Dash, html, callback, Output, Input from flask import jsonify, redirect, request, session from usethatapp import ( UtaError, UtaTokenError, begin_login, complete_login, get_entitlement, logout_url, ) app = Dash(__name__) server = app.server server.secret_key = os.environ.get("FLASK_SECRET_KEY", "dev-only-change-me") server.config.update(SESSION_COOKIE_SAMESITE="Lax", SESSION_COOKIE_HTTPONLY=True) ``` ## Step 3 — OIDC routes on the server Register `/login`, `/callback`, and `/logout` on `server` (the Flask app). A marketplace launch redirects the browser to `/login`; you can also hit it directly from a "Log in with UseThatApp" link. *app.py* ```python @server.get("/login") def uta_login(): auth_url, flow_state = begin_login() session["uta_flow"] = flow_state # JSON-serializable; stash for the callback return redirect(auth_url, code=302) @server.get("/callback") def uta_callback(): # On cancel/deny (or a provider rejection), OAuth redirects back here with # ?error=... and NO code. Handle it gracefully instead of failing. if request.args.get("error"): session.pop("uta_flow", None) return redirect("/", code=303) flow = session.pop("uta_flow", None) if not flow: # The cookie did not round-trip — usually a host mismatch. Browse the # app on the SAME host as UTA_REDIRECT_URI (e.g. http://localhost:8050, # not 127.0.0.1). return jsonify({"error": "session lost between /login and /callback"}), 400 try: s = complete_login( code=request.args.get("code"), state=request.args.get("state"), flow_state=flow, ) except UtaError as e: return jsonify({"error": str(e)}), 400 session["uta_sub"] = s.sub session["uta_access_token"] = s.access_token session["uta_id_token"] = s.id_token return redirect("/", code=303) @server.get("/logout") def uta_logout(): # Don't clear our session yet — the user may choose "Stay signed in" at # UseThatApp. Reconcile on return: a real logout revokes the token, so the # next entitlement call (in the Dash callback) returns 401 and clears it. id_token = session.get("uta_id_token") try: target = logout_url( id_token=id_token, post_logout_redirect_uri=request.host_url.rstrip("/") + "/", ) return redirect(target, code=302) except UtaError: return redirect("/", code=302) ``` ## Step 4 — Layout and links *app.py* ```python app.layout = html.Div([ html.H1("Your App Version"), html.Div([ html.A("Log in with UseThatApp", href="/login", style={"marginRight": "12px"}), html.A("Log out", href="/logout"), ]), html.Div(id="version-display", children="…"), html.Button("Refresh Version", id="update-button", n_clicks=0), ]) ``` ## Step 5 — Read entitlement in a callback A Dash callback runs with the Flask request context, so it can read the session. No token means an anonymous visitor — in the OAuth model a free launch carries no login by design, so for an app with a free tier "no token" *is* the free experience. A `UtaTokenError` (401) means the token was revoked or expired (e.g. the user signed out of UseThatApp) — drop back to the anonymous experience. *app.py* ```python @callback( Output("version-display", "children"), Input("update-button", "n_clicks"), ) def display_version(_n_clicks): token = session.get("uta_access_token") if not token: # Anonymous — a free launch carries no login, so this IS the free tier. return "Free version (not signed in)" try: ent = get_entitlement(token) except UtaTokenError: # Token expired/revoked — reconcile and fall back to the free experience. for k in ("uta_access_token", "uta_sub", "uta_id_token"): session.pop(k, None) return "Free version — signed out (log in to see your plan)" except UtaError as e: return f"Error: {e}" # Gate features on the stable ent.product_public_id, not the mutable ent.version. state = "entitled" if ent.entitled else "not entitled" return f"{ent.version or 'free'} — {state} (status={ent.status})" ``` ## Offer the upgrade: purchase links When the entitlement read says the user is not entitled — or is an anonymous free-tier visitor — send them to hosted checkout with `purchase_url()`. UseThatApp is the **merchant of record** (payment, tax, refunds), and the buyer comes back to your app signed in with the entitlement already live: no webhooks, no polling. `purchase_url()` and `manage_url()` are pure URL builders — no network call — so they're fine to call inside a Dash callback (or anywhere else server-side), and the CTA is just an `html.A` output. Add `html.Div(id="upgrade-cta")` to the layout from Step 4. *app.py* ```python from usethatapp import manage_url, purchase_url PRO_PRICE_ID = "" # from get_prices() or your dashboard @callback( Output("upgrade-cta", "children"), Input("update-button", "n_clicks"), ) def upgrade_cta(_n_clicks): token = session.get("uta_access_token") if token: try: ent = get_entitlement(token) if ent.entitled and ent.product_public_id == "prod_": # Already entitled — offer subscription management instead. return html.A("Manage subscription", href=manage_url()) except UtaError: pass # fall through to the upgrade CTA # Anonymous or not entitled — hosted checkout. No next= means the buyer # returns via your registered Login URL, already signed in and entitled. return html.A("Upgrade to Pro", href=purchase_url(PRO_PRICE_ID)) ``` For a live pricing page, render from `get_prices()` — never hardcode prices. Each `Price` carries a ready-made `buy_url` and the `product_id` that matches the entitlement’s `product_public_id`. See the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) and [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website). ## Run it *app.py* ```python if __name__ == "__main__": app.run(debug=True) # serves on http://localhost:8050 ``` ## Errors At the entitlement endpoint, **401** raises `UtaTokenError` (re-authenticate or `refresh`) and **403** raises `UtaPermissionError` (a valid token missing the `entitlements` scope). Every SDK error inherits from `UtaError`. See [Error Handling](https://docs.usethatapp.com/error-handling) and [Gotchas](https://docs.usethatapp.com/gotchas). --- # Streamlit Python / Streamlit # Streamlit Sign users in with their UseThatApp account and read their live plan from a Streamlit app, over **OAuth 2.0 / OpenID Connect**. Streamlit has no request/route model, so it can't be a confidential OIDC client on its own. The realistic pattern is a small **companion server** (a backend-for-frontend, or BFF) that runs the SDK and handles `/login`, `/callback`, and `/logout`; Streamlit reads the resulting session and entitlement from it. The same BFF wires your app into the whole selling loop: a buyer who purchases from your own website lands back signed in, entitlement already live — and marketplace launches arrive the same way. For the conceptual model, see [OpenID Connect](https://docs.usethatapp.com/openid-connect). ⚠ This is a sidecar / BFF setup. The `usethatapp` SDK is a confidential client — it holds the client secret and validates ID tokens, so it must run server-side. A Streamlit script restarts top-to-bottom on every interaction and has no place to receive the OAuth redirect, so the OIDC flow lives in a small companion server (Flask or FastAPI) and Streamlit just consumes its result. This guide covers the Hosted sign-in path It has UseThatApp run login, so buyers return signed in and the same session answers what they bought — the [Hosted sign-in](https://docs.usethatapp.com/openid-connect) add-on. If your app already has its own accounts, you do not need any of the sign-in wiring below: keep your login and verify purchases with a [license key](https://docs.usethatapp.com/license-keys) your server validates in one call, included in the per-sale fee. ## The shape 1. **BFF server (Flask/FastAPI).** Owns the OIDC flow: `/login` → `begin_login`, `/callback` → `complete_login`, `/logout` → `logout_url`. It also exposes a tiny `/me` endpoint that returns the current `sub` and entitlement for the browser's session cookie. This is the same Flask integration documented on the [Flask](https://docs.usethatapp.com/python/flask) page, with one extra read endpoint. 2. **Streamlit app.** Links to the BFF's `/login` and `/logout`, and calls `/me` to find out who the user is and what plan they're on. It never touches the SDK or the client secret. ## Step 1 — Install *terminal* ```bash pip install usethatapp flask streamlit requests ``` ## Step 2 — The BFF read endpoint Add `/login`, `/callback`, and `/logout` exactly as on the [Flask](https://docs.usethatapp.com/python/flask) page. Then add a JSON `/me` endpoint that reports the live entitlement for the current session, reconciling a revoked token. *bff.py* ```python from flask import session from usethatapp import UtaTokenError, get_entitlement @app.get("/me") def me(): token = session.get("uta_access_token") if not token: return {"signed_in": False} # anonymous = your free tier try: ent = get_entitlement(token) except UtaTokenError: # Token revoked/expired (signed out of UseThatApp). Reconcile. for k in ("uta_access_token", "uta_sub", "uta_id_token"): session.pop(k, None) return {"signed_in": False} return { "signed_in": True, "sub": session.get("uta_sub"), "entitled": ent.entitled, "product_public_id": ent.product_public_id, "version": ent.version, "status": ent.status, } ``` ## Step 3 — Streamlit reads the BFF Point Streamlit at the BFF. Forward the browser's cookies so the BFF can identify the session, then gate the UI on the stable `product_public_id`, not the mutable `version` display name. Link out to the BFF for sign-in and sign-out — those are full-page redirects the companion server owns. *streamlit_app.py* ```python import requests import streamlit as st BFF = "http://localhost:5000" me = requests.get(f"{BFF}/me", cookies=st.context.cookies).json() if not me.get("signed_in"): st.markdown(f"[Log in with UseThatApp]({BFF}/login)") st.info("You're on the free tier. Log in to see your plan.") else: st.markdown(f"[Log out]({BFF}/logout)") if me["entitled"] and me["product_public_id"] == "prod_": st.success(f"Pro features unlocked — {me['version']}") else: st.write(f"Plan: {me['version']} (status={me['status']})") ``` Use `sub` as the user key The BFF returns the user's pairwise `sub` — stable per app, uncorrelatable across apps, no PII. Key your user records off `sub`, never an email. The entitlement response does not include `sub`; it comes from the login, which the BFF holds. ## Offer the upgrade: purchase links When `/me` says the user is not entitled — or is on the free tier — send them to hosted checkout. UseThatApp is the **merchant of record** (payment, tax, refunds), and the buyer comes back signed in with the entitlement already live: no webhooks, no polling. It fits the BFF split cleanly: `purchase_url()` and `manage_url()` are pure URL builders — no network call — so the BFF hands ready-made URLs to Streamlit, which just renders them. *bff.py* ```python from usethatapp import manage_url, purchase_url PRO_PRICE_ID = "" # from get_prices() or your dashboard @app.get("/me") def me(): # ... token check and entitlement read exactly as in Step 2 ... return { "signed_in": True, "sub": session.get("uta_sub"), "entitled": ent.entitled, "product_public_id": ent.product_public_id, "version": ent.version, "status": ent.status, # Pure URL builders — no network call. Streamlit just renders them. "buy_url": purchase_url(PRO_PRICE_ID), "manage_url": manage_url(), } ``` *streamlit_app.py* ```python if me.get("signed_in"): if me["entitled"] and me["product_public_id"] == "prod_": st.link_button("Manage subscription", me["manage_url"]) else: # Hosted checkout; the buyer returns signed in, entitled. st.link_button("Upgrade to Pro", me["buy_url"]) ``` For a live pricing page, have the BFF render from `get_prices()` — never hardcode prices. Each `Price` carries a ready-made `buy_url` and the `product_id` that matches the entitlement’s `product_public_id`. See the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) and [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website). ## Run it Run the BFF and Streamlit side by side. Register the BFF's `/callback` as the redirect URI on the UseThatApp dashboard. *terminal* ```bash # terminal 1 — the BFF export UTA_CLIENT_ID=... UTA_CLIENT_SECRET=... export UTA_REDIRECT_URI=http://localhost:5000/callback # Pointing at a non-production instance? Set BOTH of these # (each defaults to production): # export UTA_ISSUER=http://localhost:8000/o # export UTA_API_URL=http://localhost:8000 python bff.py # terminal 2 — Streamlit streamlit run streamlit_app.py ``` ## Errors Error handling lives in the BFF, at the entitlement endpoint: **401** raises `UtaTokenError` (re-authenticate or `refresh`) and **403** raises `UtaPermissionError` (a valid token missing the `entitlements` scope). Every SDK error inherits from `UtaError`. See [Error Handling](https://docs.usethatapp.com/error-handling) and [Gotchas](https://docs.usethatapp.com/gotchas). --- # JavaScript Overview JavaScript # JavaScript Documentation Sell your JavaScript web app from your own website — one platform for **purchasing** (UseThatApp is the merchant of record: hosted buy links plus a live pricing API), **licensing** (live entitlements), and **authentication** ("Sign in with UseThatApp" over **OAuth 2.0 / OpenID Connect**). A buyer who purchases on your site lands back in your app signed in, with the entitlement already live — sign-in plus the live plan is how you gate features. The `usethatapp` npm package is a **confidential, server-side** client — it holds your client secret and validates ID tokens, so it runs on your Node server, never in the browser. Each framework guide below uses the tested example from the package — copy, paste, and ship. Read the protocol first The [OpenID Connect](https://docs.usethatapp.com/openid-connect) page covers the model these guides build on — the authorization-code flow, the pairwise `sub`, and the entitlement API. The framework pages here are the concrete wiring. This guide covers the Hosted sign-in path It has UseThatApp run login, so buyers return signed in and the same session answers what they bought — the [Hosted sign-in](https://docs.usethatapp.com/openid-connect) add-on. If your app already has its own accounts, you do not need any of the sign-in wiring below: keep your login and verify purchases with a [license key](https://docs.usethatapp.com/license-keys) your server validates in one call, included in the per-sale fee. ## What the SDK does A single OIDC login gives you both **identity** (a privacy-preserving, per-app `sub`) and access to the **entitlement API** (the user's live plan). Three phases: 1. **Sign in.** Start a login with `beginLogin()`, send the user to UseThatApp to authenticate, and finish in your callback with `completeLogin()`. You get a `UtaSession` carrying the user's pairwise `sub` and OAuth tokens. 2. **Read the plan.** Call `getEntitlement(accessToken)` whenever you gate features. Always authoritative — a canceled license stops being entitled immediately. 3. **Offer the upgrade.** When the user isn't entitled, send them to hosted checkout with `purchaseUrl(priceId)` — UseThatApp is the merchant of record (payment, tax, refunds), and the buyer returns signed in with the entitlement already live, no webhooks. `getPrices()` renders your live pricing page; `manageUrl()` links entitled users to subscription management. ## Installation *terminal* ```bash npm install usethatapp ``` These docs describe **2.2.0 or newer**; if you are upgrading from an earlier release, run `npm install usethatapp@latest` first — the License Key API, `product_id`, and `UtaServiceNotEnabledError` do not exist before it. ## Prerequisites - **Node.js 18+, server-side only.** The SDK uses the global `fetch` plus Node's `node:crypto` and `node:fs` — it does **not** run on Edge runtimes (Cloudflare Workers, Vercel Edge, Next.js `middleware.ts`) or in a browser bundle. Do entitlement checks in a Node server route. - **ESM-only.** Set `"type": "module"` in `package.json` or use the `.mjs` extension. `require("usethatapp")` from CommonJS is not supported — from a CJS file, use dynamic import: `const uta = await import("usethatapp");` - An OAuth client registered on your app's **Integration** page — see [OpenID Connect](https://docs.usethatapp.com/openid-connect) for registering your redirect URI and copying credentials. ## Configuration The SDK reads from `process.env` by default; override any setting programmatically with `configure({...})`. Only the client id, secret, and redirect URI are required; the rest default to production. Keep the secret server-side. *environment* ``` UTA_CLIENT_ID=... # required UTA_CLIENT_SECRET=... # required (server-side only) UTA_REDIRECT_URI=https://yourapp.com/callback # optional — default to production: UTA_ISSUER=https://www.usethatapp.com/o UTA_API_URL=https://www.usethatapp.com UTA_SCOPES="openid entitlements" ``` If your hosting provider mounts secrets as files, set `UTA_CLIENT_SECRET_PATH` instead of `UTA_CLIENT_SECRET` and the SDK reads the secret from that file. ## Public API Everything imports from `"usethatapp"`. All calls are async and return Promises. *usethatapp* ``` import { beginLogin, // () => { authorizationUrl, flowState } completeLogin, // ({ code, state, flowState }) => UtaSession getEntitlement, // (accessToken) => Entitlement refresh, // (refreshToken) => UtaSession userinfo, // (accessToken) => { sub } logoutUrl, // ({ idToken, postLogoutRedirectUri }) => string // Selling — hosted checkout & live pricing purchaseUrl, // (priceId, { next, ref, email }?) => string — pure URL builder manageUrl, // ({ next }?) => string — subscription management, pure getPrices, // () => Promise — live prices, no auth getAppInfo, // () => Promise — public app profile, no auth configure, // override env config resetConfig, // Errors (all extend UtaError) UtaError, UtaConfigError, UtaDiscoveryError, UtaAuthError, UtaTokenError, // 401 UtaPermissionError, // 403 UtaServerError, // Types (TypeScript) type UtaSession, type Entitlement, type UtaFlowState, type AppInfo, type AppPrices, type UtaPrice, } from "usethatapp"; ``` ### UtaSession and Entitlement `completeLogin()` returns a `UtaSession` — the user's pairwise pseudonymous `sub` plus tokens. Key your user records off `sub`, never an email (none is shared). `getEntitlement()` returns an `Entitlement` — gate on the stable `product_public_id`, display the mutable `version`. *usethatapp/types* ``` interface UtaSession { readonly sub: string; // pairwise, stable per-app — your local user key readonly access_token: string; // pass to getEntitlement readonly expires_at: number; // unix seconds readonly refresh_token: string | null; // null when no refresh token was granted readonly id_token: string | null; // pass to logoutUrl readonly scope: string; readonly token_type: string; readonly claims: Record; // validated ID-token claims } interface Entitlement { readonly entitled: boolean; // may the user use the app readonly version: string | null; // plan display name, e.g. "Pro" — mutable readonly product_id: string | null; // stable prod_... id — gate features on this readonly product_public_id: string | null; // same prod_... value — equal-valued alias readonly status: string; // active / trialing / free / preview / none / ... readonly is_free: boolean; readonly period_end: string | null; readonly raw: Record; // the full decoded response } ``` ### AppPrices, UtaPrice, and AppInfo The selling surface. `getPrices()` returns your app's live prices — never hardcode them: each price carries a ready-made `buy_url` and the `product_id` that matches the entitlement’s `product_public_id` you gate on. Both GET helpers are anonymous (no login); a 404 (app unknown, unpublished, or external sales not enabled) raises `UtaError`, and a 429 rate limit raises a retriable `UtaServerError`. See the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) and [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website). *usethatapp/types* ``` interface AppPrices { readonly client_id: string; readonly app_name: string; readonly has_free_tier: boolean; readonly prices: readonly UtaPrice[]; // readonly — copy with [...prices] before sorting readonly raw: Record; } interface UtaPrice { readonly public_id: string; // prc_... — pass to purchaseUrl() readonly product_id: string; // matches the entitlement's product_public_id readonly product_name: string; readonly amount: string; // decimal string, e.g. "10.00" readonly currency: string; // lowercase ISO code, e.g. "usd" readonly is_recurring: boolean; readonly frequency: string | null; // "day" | "week" | "month" | "year"; null = one-time readonly buy_url: string; // ready-made hosted-checkout buy link } interface AppInfo { readonly client_id: string; readonly name: string; readonly tagline: string; readonly listing_mode: string; // "marketplace" or "external" readonly url: string; readonly marketplace_url: string; readonly raw: Record; } ``` ## Quickstart — any framework *usethatapp* ``` import { beginLogin, completeLogin, getEntitlement } from "usethatapp"; // 1) Start login — however your framework spells "redirect": const { authorizationUrl, flowState } = await beginLogin(); saveToSession("utaFlow", flowState); // JSON-serializable res.redirect(authorizationUrl); // 2) In your callback (reads ?code=...&state=... off the request). // On cancel/deny the provider sends ?error=... and no code — handle it first: if (req.query.error) return res.redirect("/"); // login was canceled const session = await completeLogin({ code: req.query.code, state: req.query.state, flowState: loadFromSession("utaFlow"), }); saveToSession("utaSub", session.sub); saveToSession("utaAccessToken", session.access_token); // 3) Anywhere you gate features: const ent = await getEntitlement(loadFromSession("utaAccessToken")); if (ent.entitled && ent.product_public_id === "prod_") { /* ... */ } ``` ⚠ Browser apps need a backend. The SDK is a confidential client — it must never ship to the browser. A single-page app (React, Vue) runs the SDK in a small backend (a "backend-for-frontend") and calls that from the browser. See the [React](https://docs.usethatapp.com/javascript/react) guide for the pattern. ## Choose your framework Each guide is a complete walkthrough with the tested example from the SDK. ### Express /login, /callback, /logout routes with express-session, plus entitlement gating. ### Node.js (no framework) Plain node:http server with a tiny cookie session. The same routes, no dependencies. ### Fastify The same confidential flow in Fastify idioms — @fastify/session and reply.redirect. ### Next.js App Router route handlers for login and callback, with the SDK on the server only. ### Nuxt Nuxt 3 / h3 server handlers for the OIDC flow, server-side only. ### React Pair a React UI with a backend-for-frontend that runs the confidential SDK. ### Vue Pair a Vue UI with a Nuxt backend-for-frontend that runs the confidential SDK. ## Next steps Review [Error Handling](https://docs.usethatapp.com/error-handling) for the typed errors `getEntitlement` raises, and [Gotchas](https://docs.usethatapp.com/gotchas) for the edge cases. --- # Express JavaScript / Express # Express Add "Sign in with UseThatApp" to an Express app over **OAuth 2.0 / OpenID Connect**, then gate features on the user's live entitlement. These routes are one end of the full selling loop: a buyer who purchases on your website through a UseThatApp buy link lands back here signed in, entitlement already live — and the same routes serve marketplace launches. The `usethatapp` SDK is a **confidential, server-side** client — it holds your client secret and validates ID tokens, so it lives in your Express server, never in the browser. The code on this page is taken directly from the tested `examples/express-min/` in the JS SDK. New here? Read [OpenID Connect](https://docs.usethatapp.com/openid-connect) first for the model behind this page — the authorization-code flow, the pairwise `sub`, and how the entitlement API works. This page is the Express-specific wiring. This guide covers the Hosted sign-in path It has UseThatApp run login, so buyers return signed in and the same session answers what they bought — the [Hosted sign-in](https://docs.usethatapp.com/openid-connect) add-on. If your app already has its own accounts, you do not need any of the sign-in wiring below: keep your login and verify purchases with a [license key](https://docs.usethatapp.com/license-keys) your server validates in one call, included in the per-sale fee. ## How it works The SDK is framework-agnostic: it returns plain strings and one JSON-serializable `flowState` object. You wire three Express-specific bits — read the callback query params, store `flowState` in the session, issue redirects. 1. `/login` calls `beginLogin()`, stashes `flowState` in `req.session`, and redirects the user to UseThatApp to authenticate. 2. `/callback` exchanges the one-time code with `completeLogin()`, giving you a `UtaSession` — the user's pairwise `sub` plus OAuth tokens. Persist them on the session. 3. Anywhere you gate features, call `getEntitlement(accessToken)` for the user's live plan. ## Prerequisites - Node.js 18+, server-side only (the SDK uses the global `fetch` and Node's `node:crypto`/`node:fs` — no Edge runtimes, no browser bundles) - An Express 4 or 5 app using ESM — set `"type": "module"` in `package.json` (or use a `.mjs` extension), since `usethatapp` is ESM-only - An OAuth client registered on your app's **Integration** page — see [OpenID Connect](https://docs.usethatapp.com/openid-connect) for registering your redirect URI and copying credentials ## Step 1 — Install the packages *terminal* ```bash npm install usethatapp express express-session ``` ## Step 2 — Configure The SDK reads `process.env` by default. Only the client id, secret, and redirect URI are required; the rest default to production. Keep the secret server-side. *environment* ``` UTA_CLIENT_ID=... # required UTA_CLIENT_SECRET=... # required (server-side only) UTA_REDIRECT_URI=http://localhost:3000/callback # optional — default to production: UTA_ISSUER=https://www.usethatapp.com/o UTA_API_URL=https://www.usethatapp.com UTA_SCOPES="openid entitlements" ``` ## Step 3 — Wire login, callback, and logout The full `examples/express-min/app.mjs` from the SDK. It shows all three routes plus entitlement gating on the home page, using `express-session` to hold `flowState` and the tokens. *app.mjs* ```javascript import express from "express"; import session from "express-session"; import { beginLogin, completeLogin, getEntitlement, logoutUrl, UtaError, UtaTokenError } from "usethatapp"; export function createApp() { const app = express(); app.use(session({ secret: process.env.SESSION_SECRET ?? "dev-only", resave: false, saveUninitialized: true })); app.get("/login", async (req, res, next) => { try { const { authorizationUrl, flowState } = await beginLogin(); req.session.utaFlow = flowState; res.redirect(authorizationUrl); } catch (e) { next(e); } }); app.get("/callback", async (req, res) => { // On cancel/deny, OAuth redirects back with ?error=... and no code. if (req.query.error) { delete req.session.utaFlow; return res.redirect("/"); } try { const s = await completeLogin({ code: req.query.code, state: req.query.state, flowState: req.session.utaFlow, }); delete req.session.utaFlow; req.session.utaSub = s.sub; req.session.utaAccessToken = s.access_token; req.session.utaIdToken = s.id_token; res.redirect("/"); } catch (e) { const code = e instanceof UtaError ? 400 : 500; res.status(code).send(`login failed: ${e.message}`); } }); app.get("/", async (req, res, next) => { const token = req.session.utaAccessToken; if (token) { try { const ent = await getEntitlement(token); return res.json({ sub: req.session.utaSub, entitlement: ent.raw }); } catch (e) { if (!(e instanceof UtaTokenError)) return next(e); // Token revoked/expired (signed out of UseThatApp). Reconcile. delete req.session.utaAccessToken; delete req.session.utaSub; delete req.session.utaIdToken; } } res.send('Log in with UseThatApp'); }); app.get("/logout", async (req, res, next) => { try { // Don't clear the session yet — the user may choose "Stay signed in". A // real logout revokes the token, so the next getEntitlement (home) 401s // and we drop it then. const idToken = req.session.utaIdToken; res.redirect(await logoutUrl({ idToken, postLogoutRedirectUri: "http://localhost:3000/" })); } catch (e) { next(e); } }); return app; } if (import.meta.url === `file://${process.argv[1]}`) { const port = Number(process.env.PORT ?? 3000); createApp().listen(port, () => { console.log(`express-min listening on http://127.0.0.1:${port}`); }); } ``` ⚠ Check `req.query.error` first. When a user cancels or denies consent, OAuth redirects back to `/callback` with `?error=...` and **no code**. Handle that branch before you try `completeLogin()`, or the exchange will fail. ## Identity: the pairwise `sub` `completeLogin()` returns a `UtaSession` whose `sub` is a **pairwise pseudonymous identifier** — stable for a user within your app, different in every other app, and the only identity claim shared (no email, no name, no PII). Key your user records off `sub`, never an email. The entitlement response does not include `sub`; it only describes the plan. ## Gating a route Build a small middleware that resolves the entitlement and gates on the stable `product_public_id` (display the mutable `version` to the user). `getEntitlement` is always live — a canceled license stops being entitled immediately. Reconcile a `UtaTokenError` (401) by dropping the session; map a `UtaPermissionError` (403) to a forbidden response. *middleware/requirePro.mjs* ```javascript import { getEntitlement, UtaError, UtaTokenError, UtaPermissionError } from "usethatapp"; const PRO_PRODUCT_ID = process.env.PRO_PRODUCT_ID; // stable prod_... product id export async function requirePro(req, res, next) { const token = req.session.utaAccessToken; if (!token) { return res.status(401).json({ error: "not signed in" }); } try { const ent = await getEntitlement(token); if (!ent.entitled || ent.product_public_id !== PRO_PRODUCT_ID) { return res.status(403).json({ error: "Pro plan required" }); } req.utaEntitlement = ent; // ent.version is the display name, e.g. "Pro" next(); } catch (e) { if (e instanceof UtaTokenError) { // Token expired or revoked (signed out of UseThatApp). Drop it. req.session.destroy(() => res.status(401).json({ error: "re-authenticate" })); return; } if (e instanceof UtaPermissionError) { return res.status(403).json({ error: "missing entitlements scope" }); } if (e instanceof UtaError) { return res.status(502).json({ error: e.message }); } next(e); } } ``` *app.mjs* ```javascript import { requirePro } from "./middleware/requirePro.mjs"; app.get("/pro/feature", requirePro, (req, res) => { res.json({ feature: "unlocked", plan: req.utaEntitlement.version }); }); ``` ## Offer the upgrade: purchase links When the entitlement check says the user is not entitled (or is on the free tier), send them to hosted checkout with `purchaseUrl(priceId)`. UseThatApp is the **merchant of record** — it processes the payment, remits tax, and handles refunds — and the buyer comes back to your app signed in with the entitlement already live. No webhooks, no polling: the next `getEntitlement()` simply says entitled. For already-entitled users, `manageUrl()` links to their hosted subscription-management page. *app.mjs* ```javascript import { getEntitlement, manageUrl, purchaseUrl } from "usethatapp"; const PRO_PRODUCT_ID = process.env.PRO_PRODUCT_ID; // stable prod_... product id const PRO_PRICE_ID = process.env.PRO_PRICE_ID; // prc_... — from getPrices() or your dashboard app.get("/account", async (req, res, next) => { try { const ent = await getEntitlement(req.session.utaAccessToken); if (ent.entitled && ent.product_public_id === PRO_PRODUCT_ID) { // Already on Pro — link to the hosted subscription-management page. return res.json({ plan: ent.version, manage: manageUrl({ next: "http://localhost:3000/account" }) }); } // Not on Pro — hosted checkout. They return signed in, entitled. res.json({ plan: ent.version, upgrade: purchaseUrl(PRO_PRICE_ID, { next: "http://localhost:3000/account" }) }); } catch (e) { next(e); } }); ``` Both helpers are pure URL builders — no network call. To render a live pricing page, call `getPrices()` and never hardcode prices — each price carries a ready-made `buy_url` and the `product_id` that matches the entitlement’s `product_public_id` you gate on. See the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) and [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website) for the full flow (buy links are enabled per app during the beta). ## Signing out Sign-out is RP-initiated: redirect the user to `logoutUrl({ idToken, postLogoutRedirectUri })`. Both outcomes — they confirm, or they choose "Stay signed in" — return to the same post-logout URL, so you can't tell which happened from the redirect alone. ⚠ Reconcile on return — don't clear the session eagerly. Don't destroy `req.session` when you start logout. A confirmed logout revokes the token, so your next `getEntitlement()` throws `UtaTokenError` (401) — drop the token then (the home route above does exactly this). If they stayed signed in, the token is still valid and they keep their session. Clearing eagerly logs the user out of your app even when they chose to stay. ## Using an Express Router *routes/uta.mjs* ```javascript import { Router } from "express"; import { beginLogin, completeLogin, logoutUrl, UtaError } from "usethatapp"; const router = Router(); router.get("/login", async (req, res, next) => { try { const { authorizationUrl, flowState } = await beginLogin(); req.session.utaFlow = flowState; res.redirect(authorizationUrl); } catch (e) { next(e); } }); router.get("/callback", async (req, res) => { if (req.query.error) { delete req.session.utaFlow; return res.redirect("/"); } try { const s = await completeLogin({ code: req.query.code, state: req.query.state, flowState: req.session.utaFlow, }); delete req.session.utaFlow; req.session.utaSub = s.sub; req.session.utaAccessToken = s.access_token; req.session.utaIdToken = s.id_token; res.redirect("/"); } catch (e) { res.status(e instanceof UtaError ? 400 : 500).send(`login failed: ${e.message}`); } }); export default router; ``` *app.mjs* ```javascript import express from "express"; import session from "express-session"; import utaRoutes from "./routes/uta.mjs"; const app = express(); app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false })); app.use("/auth", utaRoutes); app.listen(3000); ``` ## Errors Every SDK error inherits from `UtaError`, so a single `catch` can handle them all. At the entitlement call, `getEntitlement` distinguishes the two auth failures: **401 → `UtaTokenError`** (re-authenticate or refresh) and **403 → `UtaPermissionError`** (a valid token missing the `entitlements` scope). See [Error Handling](https://docs.usethatapp.com/error-handling) and [Gotchas](https://docs.usethatapp.com/gotchas). --- # Node.js JavaScript / Node.js # Node.js Add "Sign in with UseThatApp" to a plain Node.js HTTP server — no framework, no session library — over **OAuth 2.0 / OpenID Connect**. This server is one end of the full selling loop: a buyer who purchases on your website through a UseThatApp buy link lands back here signed in, entitlement already live — and the same routes serve marketplace launches. The `usethatapp` SDK is a **confidential, server-side** client that holds your client secret and validates ID tokens, so it runs in your server, never in the browser. The code on this page is taken directly from the tested `examples/node-http-min/` in the JS SDK. New here? Read [OpenID Connect](https://docs.usethatapp.com/openid-connect) first for the model behind this page — the authorization-code flow, the pairwise `sub`, and how the entitlement API works. This page is the `node:http`-specific wiring. This guide covers the Hosted sign-in path It has UseThatApp run login, so buyers return signed in and the same session answers what they bought — the [Hosted sign-in](https://docs.usethatapp.com/openid-connect) add-on. If your app already has its own accounts, you do not need any of the sign-in wiring below: keep your login and verify purchases with a [license key](https://docs.usethatapp.com/license-keys) your server validates in one call, included in the per-sale fee. ## How it works The SDK is framework-agnostic: it returns plain strings and one JSON-serializable `flowState` object. You wire three bits yourself — read the callback query params, store `flowState` in your session, issue redirects. 1. `/login` calls `beginLogin()`, stashes `flowState` on the session, and writes a 302 to the authorization URL. 2. `/callback` exchanges the one-time code with `completeLogin()`, giving you a `UtaSession` — the user's pairwise `sub` plus OAuth tokens. Persist them on the session. 3. Anywhere you gate features, call `getEntitlement(accessToken)` for the user's live plan. ## Prerequisites - Node.js 18+, server-side only (the SDK uses the global `fetch` and Node's `node:crypto`/`node:fs` — no Edge runtimes, no browser bundles) - An ESM project — set `"type": "module"` in `package.json` or use the `.mjs` extension - An OAuth client registered on your app's **Integration** page — see [OpenID Connect](https://docs.usethatapp.com/openid-connect) for registering your redirect URI and copying credentials ## Step 1 — Install the package *terminal* ```bash npm init -y npm pkg set type=module npm install usethatapp ``` ## Step 2 — Configure The SDK reads `process.env` by default. Only the client id, secret, and redirect URI are required; the rest default to production. Keep the secret server-side. *environment* ``` UTA_CLIENT_ID=... # required UTA_CLIENT_SECRET=... # required (server-side only) UTA_REDIRECT_URI=http://localhost:3000/callback # optional — default to production: UTA_ISSUER=https://www.usethatapp.com/o UTA_API_URL=https://www.usethatapp.com UTA_SCOPES="openid entitlements" ``` ## Step 3 — The complete server The full `examples/node-http-min/app.mjs` from the SDK. It uses a tiny in-memory cookie session to show the three bits you wire yourself; in production, swap in a real session store. *app.mjs* ```javascript import { createServer } from "node:http"; import { randomBytes } from "node:crypto"; import { beginLogin, completeLogin, getEntitlement, logoutUrl, UtaError, UtaTokenError } from "usethatapp"; // Toy server-side session store keyed by a cookie. Use a real session in prod. const sessions = new Map(); function getSession(req, res) { const sid = (req.headers.cookie ?? "").match(/sid=([^;]+)/)?.[1]; if (sid && sessions.has(sid)) return sessions.get(sid); const id = randomBytes(16).toString("hex"); const data = {}; sessions.set(id, data); res.setHeader("Set-Cookie", `sid=${id}; HttpOnly; Path=/`); return data; } export function createApp() { return createServer(async (req, res) => { const url = new URL(req.url, "http://localhost"); const sess = getSession(req, res); try { if (url.pathname === "/login") { const { authorizationUrl, flowState } = await beginLogin(); sess.utaFlow = flowState; res.writeHead(302, { Location: authorizationUrl }).end(); } else if (url.pathname === "/callback") { // On cancel/deny, OAuth redirects back with ?error=... and no code. if (url.searchParams.get("error")) { delete sess.utaFlow; res.writeHead(302, { Location: "/" }).end(); return; } const s = await completeLogin({ code: url.searchParams.get("code"), state: url.searchParams.get("state"), flowState: sess.utaFlow, }); delete sess.utaFlow; sess.utaSub = s.sub; sess.utaAccessToken = s.access_token; sess.utaIdToken = s.id_token; res.writeHead(302, { Location: "/" }).end(); } else if (url.pathname === "/logout") { // Don't clear the session yet — the user may choose "Stay signed in". A // real logout revokes the token, so the next getEntitlement (home) 401s // and we drop it then. const idToken = sess.utaIdToken; res.writeHead(302, { Location: await logoutUrl({ idToken }) }).end(); } else { if (sess.utaAccessToken) { try { const ent = await getEntitlement(sess.utaAccessToken); res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ sub: sess.utaSub, entitlement: ent.raw })); return; } catch (e) { if (!(e instanceof UtaTokenError)) throw e; // Token revoked/expired (signed out of UseThatApp). Reconcile. delete sess.utaAccessToken; delete sess.utaSub; delete sess.utaIdToken; } } res.end('Log in with UseThatApp'); } } catch (e) { res.writeHead(e instanceof UtaError ? 400 : 500).end(`error: ${e.message}`); } }); } if (import.meta.url === `file://${process.argv[1]}`) { const port = Number(process.env.PORT ?? 3000); createApp().listen(port, () => { console.log(`node-http-min listening on http://127.0.0.1:${port}`); }); } ``` ⚠ Check `?error=` first. When a user cancels or denies consent, OAuth redirects back to `/callback` with `?error=...` and **no code**. Handle that branch before you call `completeLogin()`, or the exchange will fail. ## Identity: the pairwise `sub` `completeLogin()` returns a `UtaSession` whose `sub` is a **pairwise pseudonymous identifier** — stable for a user within your app, different in every other app, and the only identity claim shared (no email, no name, no PII). Key your user records off `sub`, never an email. The entitlement response does not include `sub`; it only describes the plan. ## Reading the entitlement The home route above already shows the pattern: call `getEntitlement(accessToken)` for the user's live plan, gate on the stable `product_public_id`, and display the mutable `version`. It's always authoritative — a canceled license stops being entitled immediately. *gate.mjs* ```javascript import { getEntitlement, UtaTokenError, UtaPermissionError } from "usethatapp"; const PRO_PRODUCT_ID = process.env.PRO_PRODUCT_ID; // stable prod_... product id export async function isPro(accessToken) { try { const ent = await getEntitlement(accessToken); return ent.entitled && ent.product_public_id === PRO_PRODUCT_ID; } catch (e) { if (e instanceof UtaTokenError) return null; // re-authenticate if (e instanceof UtaPermissionError) return false; // missing entitlements scope throw e; } } ``` ## Offer the upgrade: purchase links When the entitlement check says the user is not entitled (or is on the free tier), send them to hosted checkout with `purchaseUrl(priceId)`. UseThatApp is the **merchant of record** — it processes the payment, remits tax, and handles refunds — and the buyer comes back to your app signed in with the entitlement already live. No webhooks, no polling: the next `getEntitlement()` simply says entitled. For already-entitled users, `manageUrl()` links to their hosted subscription-management page. Add an `/account` branch to the server: *app.mjs* ```javascript import { getEntitlement, manageUrl, purchaseUrl } from "usethatapp"; const PRO_PRODUCT_ID = process.env.PRO_PRODUCT_ID; // stable prod_... product id const PRO_PRICE_ID = process.env.PRO_PRICE_ID; // prc_... — from getPrices() or your dashboard // Inside createApp()'s request handler, next to the other branches: if (url.pathname === "/account") { const ent = await getEntitlement(sess.utaAccessToken); res.setHeader("Content-Type", "application/json"); if (ent.entitled && ent.product_public_id === PRO_PRODUCT_ID) { // Already on Pro — link to the hosted subscription-management page. res.end(JSON.stringify({ plan: ent.version, manage: manageUrl({ next: "http://localhost:3000/account" }) })); } else { // Not on Pro — hosted checkout. They return signed in, entitled. res.end(JSON.stringify({ plan: ent.version, upgrade: purchaseUrl(PRO_PRICE_ID, { next: "http://localhost:3000/account" }) })); } } ``` Both helpers are pure URL builders — no network call. To render a live pricing page, call `getPrices()` and never hardcode prices — each price carries a ready-made `buy_url` and the `product_id` that matches the entitlement’s `product_public_id` you gate on. See the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) and [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website) for the full flow (buy links are enabled per app during the beta). ## Choosing a real session store Plain `node:http` doesn't ship a session layer. The in-memory `Map` above resets on restart and won't survive multiple instances — pick a store that fits your deployment: - **Signed cookies** via `jose` — encrypt the tokens and `sub` into the cookie value itself. - **Redis** via `ioredis` — keep the session id in the cookie and the tokens under that id. - **Switch to Express** — the [Express guide](https://docs.usethatapp.com/javascript/express) shows the same flow with `express-session`. ## Signing out Sign-out is RP-initiated: redirect the user to `logoutUrl({ idToken })`. Both outcomes — they confirm, or they choose "Stay signed in" — return to the same post-logout URL, so you can't tell which happened from the redirect alone. ⚠ Reconcile on return — don't clear the session eagerly. Don't delete the session when you start logout. A confirmed logout revokes the token, so your next `getEntitlement()` throws `UtaTokenError` (401) — drop the token then (the home route above does exactly this). If they stayed signed in, the token is still valid and they keep their session. Clearing eagerly logs the user out of your app even when they chose to stay. ## Errors Every SDK error inherits from `UtaError`, so a single `catch` can handle them all. At the entitlement call, `getEntitlement` distinguishes the two auth failures: **401 → `UtaTokenError`** (re-authenticate or refresh) and **403 → `UtaPermissionError`** (a valid token missing the `entitlements` scope). See [Error Handling](https://docs.usethatapp.com/error-handling) and [Gotchas](https://docs.usethatapp.com/gotchas). --- # Fastify JavaScript / Fastify # Fastify Add "Sign in with UseThatApp" to a Fastify app over **OAuth 2.0 / OpenID Connect**, then gate features on the user's live entitlement. These routes are one end of the full selling loop: a buyer who purchases on your website through a UseThatApp buy link lands back here signed in, entitlement already live — and the same routes serve marketplace launches. The `usethatapp` SDK is a **confidential, server-side** client — it holds your client secret and validates ID tokens, so it lives in your Fastify server, never in the browser. This page mirrors the tested `examples/express-min/` logic, adapted to Fastify idioms. New here? Read [OpenID Connect](https://docs.usethatapp.com/openid-connect) first for the model behind this page — the authorization-code flow, the pairwise `sub`, and how the entitlement API works. This page is the Fastify-specific wiring. This guide covers the Hosted sign-in path It has UseThatApp run login, so buyers return signed in and the same session answers what they bought — the [Hosted sign-in](https://docs.usethatapp.com/openid-connect) add-on. If your app already has its own accounts, you do not need any of the sign-in wiring below: keep your login and verify purchases with a [license key](https://docs.usethatapp.com/license-keys) your server validates in one call, included in the per-sale fee. ## How it works The SDK is framework-agnostic: it returns plain strings and one JSON-serializable `flowState` object. You wire three Fastify-specific bits — read the callback query params, store `flowState` in `request.session`, and reply with redirects. 1. `/login` calls `beginLogin()`, stashes `flowState` in the session, and `reply.redirect()`s to UseThatApp. 2. `/callback` exchanges the one-time code with `completeLogin()`, giving you a `UtaSession` — the user's pairwise `sub` plus OAuth tokens. Persist them on the session. 3. Anywhere you gate features, call `getEntitlement(accessToken)` for the user's live plan. ## Prerequisites - Node.js 18+, server-side only (the SDK uses the global `fetch` and Node's `node:crypto`/`node:fs` — no Edge runtimes, no browser bundles) - A Fastify 4+ app using ESM - An OAuth client registered on your app's **Integration** page — see [OpenID Connect](https://docs.usethatapp.com/openid-connect) for registering your redirect URI and copying credentials ## Step 1 — Install the packages *terminal* ```bash npm install usethatapp fastify @fastify/cookie @fastify/session ``` ## Step 2 — Configure The SDK reads `process.env` by default. Only the client id, secret, and redirect URI are required; the rest default to production. Keep the secret server-side. *environment* ``` UTA_CLIENT_ID=... # required UTA_CLIENT_SECRET=... # required (server-side only) UTA_REDIRECT_URI=http://localhost:3000/callback # optional — default to production: UTA_ISSUER=https://www.usethatapp.com/o UTA_API_URL=https://www.usethatapp.com UTA_SCOPES="openid entitlements" ``` ## Step 3 — Register sessions Fastify holds `flowState` and the tokens in a server-side session. Register `@fastify/cookie` and `@fastify/session` before your routes. *app.mjs* ```javascript import Fastify from "fastify"; import cookie from "@fastify/cookie"; import session from "@fastify/session"; const app = Fastify({ logger: false }); await app.register(cookie); await app.register(session, { secret: process.env.SESSION_SECRET ?? "a-secret-with-at-least-32-characters", cookie: { secure: false }, // set true behind HTTPS }); ``` ## Step 4 — Wire login, callback, and logout The same flow as the Express example, in Fastify idioms: `fastify.get(...)` handlers and `reply.redirect()`. The home route reads the entitlement and reconciles a revoked token. *app.mjs* ```javascript import Fastify from "fastify"; import cookie from "@fastify/cookie"; import session from "@fastify/session"; import { beginLogin, completeLogin, getEntitlement, logoutUrl, UtaError, UtaTokenError } from "usethatapp"; export async function buildApp() { const app = Fastify({ logger: false }); await app.register(cookie); await app.register(session, { secret: process.env.SESSION_SECRET ?? "a-secret-with-at-least-32-characters", cookie: { secure: false }, // set true behind HTTPS }); app.get("/login", async (req, reply) => { const { authorizationUrl, flowState } = await beginLogin(); req.session.utaFlow = flowState; return reply.redirect(authorizationUrl); }); app.get("/callback", async (req, reply) => { // On cancel/deny, OAuth redirects back with ?error=... and no code. if (req.query.error) { delete req.session.utaFlow; return reply.redirect("/"); } try { const s = await completeLogin({ code: req.query.code, state: req.query.state, flowState: req.session.utaFlow, }); delete req.session.utaFlow; req.session.utaSub = s.sub; req.session.utaAccessToken = s.access_token; req.session.utaIdToken = s.id_token; return reply.redirect("/"); } catch (e) { reply.code(e instanceof UtaError ? 400 : 500); return `login failed: ${e.message}`; } }); app.get("/", async (req, reply) => { const token = req.session.utaAccessToken; if (token) { try { const ent = await getEntitlement(token); return { sub: req.session.utaSub, entitlement: ent.raw }; } catch (e) { if (!(e instanceof UtaTokenError)) throw e; // Token revoked/expired (signed out of UseThatApp). Reconcile. delete req.session.utaAccessToken; delete req.session.utaSub; delete req.session.utaIdToken; } } reply.type("text/html"); return 'Log in with UseThatApp'; }); app.get("/logout", async (req, reply) => { // Don't clear the session yet — the user may choose "Stay signed in". A // real logout revokes the token, so the next getEntitlement (home) 401s // and we drop it then. const idToken = req.session.utaIdToken; return reply.redirect(await logoutUrl({ idToken, postLogoutRedirectUri: "http://localhost:3000/" })); }); return app; } if (import.meta.url === `file://${process.argv[1]}`) { const port = Number(process.env.PORT ?? 3000); const app = await buildApp(); await app.listen({ port, host: "127.0.0.1" }); console.log(`fastify-min listening on http://127.0.0.1:${port}`); } ``` ⚠ Check `req.query.error` first. When a user cancels or denies consent, OAuth redirects back to `/callback` with `?error=...` and **no code**. Handle that branch before you call `completeLogin()`, or the exchange will fail. ## Identity: the pairwise `sub` `completeLogin()` returns a `UtaSession` whose `sub` is a **pairwise pseudonymous identifier** — stable for a user within your app, different in every other app, and the only identity claim shared (no email, no name, no PII). Key your user records off `sub`, never an email. The entitlement response does not include `sub`; it only describes the plan. ## Gating a route Use a Fastify `preHandler` hook to require a plan before the route runs. Gate on the stable `product_public_id` and display the mutable `version`. `getEntitlement` is always live — a canceled license stops being entitled immediately. *app.mjs* ```javascript import { getEntitlement, UtaError, UtaTokenError, UtaPermissionError } from "usethatapp"; const PRO_PRODUCT_ID = process.env.PRO_PRODUCT_ID; // stable prod_... product id async function requirePro(req, reply) { const token = req.session.utaAccessToken; if (!token) { return reply.code(401).send({ error: "not signed in" }); } try { const ent = await getEntitlement(token); if (!ent.entitled || ent.product_public_id !== PRO_PRODUCT_ID) { return reply.code(403).send({ error: "Pro plan required" }); } req.utaEntitlement = ent; // ent.version is the display name, e.g. "Pro" } catch (e) { if (e instanceof UtaTokenError) { // Token expired or revoked (signed out of UseThatApp). Drop it. req.session.destroy(); return reply.code(401).send({ error: "re-authenticate" }); } if (e instanceof UtaPermissionError) { return reply.code(403).send({ error: "missing entitlements scope" }); } if (e instanceof UtaError) { return reply.code(502).send({ error: e.message }); } throw e; } } app.get("/pro/feature", { preHandler: requirePro }, async (req) => ({ feature: "unlocked", plan: req.utaEntitlement.version, })); ``` ## Offer the upgrade: purchase links When the entitlement check says the user is not entitled (or is on the free tier), send them to hosted checkout with `purchaseUrl(priceId)`. UseThatApp is the **merchant of record** — it processes the payment, remits tax, and handles refunds — and the buyer comes back to your app signed in with the entitlement already live. No webhooks, no polling: the next `getEntitlement()` simply says entitled. For already-entitled users, `manageUrl()` links to their hosted subscription-management page. *app.mjs* ```javascript import { getEntitlement, manageUrl, purchaseUrl } from "usethatapp"; const PRO_PRODUCT_ID = process.env.PRO_PRODUCT_ID; // stable prod_... product id const PRO_PRICE_ID = process.env.PRO_PRICE_ID; // prc_... — from getPrices() or your dashboard app.get("/account", async (req) => { const ent = await getEntitlement(req.session.utaAccessToken); if (ent.entitled && ent.product_public_id === PRO_PRODUCT_ID) { // Already on Pro — link to the hosted subscription-management page. return { plan: ent.version, manage: manageUrl({ next: "http://localhost:3000/account" }) }; } // Not on Pro — hosted checkout. They return signed in, entitled. return { plan: ent.version, upgrade: purchaseUrl(PRO_PRICE_ID, { next: "http://localhost:3000/account" }) }; }); ``` Both helpers are pure URL builders — no network call. To render a live pricing page, call `getPrices()` and never hardcode prices — each price carries a ready-made `buy_url` and the `product_id` that matches the entitlement’s `product_public_id` you gate on. See the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) and [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website) for the full flow (buy links are enabled per app during the beta). ## Signing out Sign-out is RP-initiated: redirect the user to `logoutUrl({ idToken, postLogoutRedirectUri })`. Both outcomes — they confirm, or they choose "Stay signed in" — return to the same post-logout URL, so you can't tell which happened from the redirect alone. ⚠ Reconcile on return — don't clear the session eagerly. Don't `req.session.destroy()` when you start logout. A confirmed logout revokes the token, so your next `getEntitlement()` throws `UtaTokenError` (401) — drop the token then (the home route above does exactly this). If they stayed signed in, the token is still valid and they keep their session. Clearing eagerly logs the user out of your app even when they chose to stay. ## Errors Every SDK error inherits from `UtaError`, so a single `catch` can handle them all. At the entitlement call, `getEntitlement` distinguishes the two auth failures: **401 → `UtaTokenError`** (re-authenticate or refresh) and **403 → `UtaPermissionError`** (a valid token missing the `entitlements` scope). See [Error Handling](https://docs.usethatapp.com/error-handling) and [Gotchas](https://docs.usethatapp.com/gotchas). --- # Next.js JavaScript / Next.js # Next.js Sign users into your Next.js app with their UseThatApp account and gate features on their live plan, over standard **OAuth 2.0 / OpenID Connect**. These routes are one end of the full selling loop: a buyer who purchases on your website through a UseThatApp buy link lands back here signed in, entitlement already live — and the same routes serve marketplace launches. Next.js already has a server, so the SDK lives in your **Route Handlers** — no separate backend needed. ⚠ The SDK is server-only. The `usethatapp` SDK is a **confidential client**: it holds your client secret and validates ID tokens. Import it only in server code — Route Handlers (`app/api/.../route.ts`), Server Components, or server actions. Never import it into a Client Component (`"use client"`), or the secret ends up in your browser bundle. Client Components read the plan by `fetch`ing a Route Handler. It also needs the **Node.js runtime** (`node:crypto`): it cannot run in Edge routes (`export const runtime = "edge"`) or in `middleware.ts`, which is Edge-only — do entitlement checks in a Route Handler and pin `export const runtime = "nodejs"`. This guide covers the Hosted sign-in path It has UseThatApp run login, so buyers return signed in and the same session answers what they bought — the [Hosted sign-in](https://docs.usethatapp.com/openid-connect) add-on. If your app already has its own accounts, you do not need any of the sign-in wiring below: keep your login and verify purchases with a [license key](https://docs.usethatapp.com/license-keys) your server validates in one call, included in the per-sale fee. ## Architecture 1. **Route Handlers run the SDK.** `/api/login` (redirect to UseThatApp), `/api/callback` (exchange the code for tokens), `/api/logout`, and `/api/entitlement` (read the live plan). Tokens live in an `httpOnly` cookie session via `cookies()`. 2. **Server Components** read the plan directly (await the session, call `getEntitlement`).**Client Components** call `/api/entitlement` over `fetch` when they need the live tier. ## Prerequisites - Node.js 18+ and a Next.js 13+ project using the App Router - An OAuth client from your app's **Integration** page: `UTA_CLIENT_ID` and `UTA_CLIENT_SECRET`, with `http://localhost:3000/api/callback` registered as a redirect URI for development — see [OpenID Connect](https://docs.usethatapp.com/openid-connect) ## Step 1 — Install and configure *terminal* ```bash npm install usethatapp iron-session ``` *.env.local (server-side only)* ```bash UTA_CLIENT_ID=... # required UTA_CLIENT_SECRET=... # required (server-side only) UTA_REDIRECT_URI=http://localhost:3000/api/callback SESSION_SECRET=... # 32+ chars, for the encrypted session cookie # optional — default to production: UTA_ISSUER=https://www.usethatapp.com/o UTA_API_URL=https://www.usethatapp.com UTA_SCOPES="openid entitlements" ``` A small helper wraps the encrypted session cookie. We store only the OAuth tokens, the user's `sub`, and the in-flight `flowState`. *lib/session.ts* ```typescript import { getIronSession } from "iron-session"; import { cookies } from "next/headers"; import type { UtaFlowState } from "usethatapp"; export interface UtaSessionData { flow?: UtaFlowState; sub?: string; accessToken?: string; idToken?: string; } export function getSession() { return getIronSession(cookies(), { password: process.env.SESSION_SECRET!, // 32+ chars cookieName: "uta-session", cookieOptions: { secure: true, sameSite: "lax", httpOnly: true }, }); } ``` ## Step 2 — Login and callback routes `beginLogin()` returns an authorization URL plus a JSON-serializable `flowState` — stash it in the session, redirect, then hand it back to `completeLogin()` in the callback. On cancel/deny the provider returns with `?error=` and no code, so check that first. *app/api/login/route.ts* ```typescript import { beginLogin } from "usethatapp"; import { NextResponse } from "next/server"; import { getSession } from "@/lib/session"; // The SDK needs Node (node:crypto) — pin it so a later "edge" default // or refactor can't silently move this route off the Node runtime. export const runtime = "nodejs"; export async function GET() { const session = await getSession(); const { authorizationUrl, flowState } = await beginLogin(); session.flow = flowState; // stash across the redirect await session.save(); return NextResponse.redirect(authorizationUrl); } ``` *app/api/callback/route.ts* ```typescript import { completeLogin, UtaError } from "usethatapp"; import { NextResponse } from "next/server"; import { getSession } from "@/lib/session"; export async function GET(request: Request) { const params = new URL(request.url).searchParams; const session = await getSession(); const home = new URL("/", request.url); // On cancel/deny, OAuth redirects back with ?error=... and no code. if (params.get("error")) { session.flow = undefined; await session.save(); return NextResponse.redirect(home); } try { const s = await completeLogin({ code: params.get("code")!, state: params.get("state")!, flowState: session.flow!, }); session.flow = undefined; session.sub = s.sub; // pairwise, per-app id — your user key session.accessToken = s.access_token; session.idToken = s.id_token; await session.save(); return NextResponse.redirect(home); } catch (e) { if (e instanceof UtaError) return NextResponse.redirect(home); // back, not signed in throw e; } } ``` ## Step 3 — Entitlement and logout routes `getEntitlement(accessToken)` is always authoritative — a canceled license stops being entitled immediately. On `UtaTokenError` (401), drop the token and report signed-out; the client falls back to the signed-out view. *app/api/entitlement/route.ts* ```typescript import { getEntitlement, UtaError, UtaTokenError } from "usethatapp"; import { NextResponse } from "next/server"; import { getSession } from "@/lib/session"; export async function GET() { const session = await getSession(); if (!session.accessToken) { return NextResponse.json({ error: "not signed in" }, { status: 401 }); } try { const entitlement = await getEntitlement(session.accessToken); return NextResponse.json({ sub: session.sub, entitlement }); } catch (e) { if (e instanceof UtaTokenError) { // Token revoked/expired (e.g. signed out of UseThatApp). Reconcile. session.destroy(); return NextResponse.json({ error: "session ended" }, { status: 401 }); } if (e instanceof UtaError) { return NextResponse.json({ error: e.message }, { status: 502 }); } throw e; } } ``` *app/api/logout/route.ts* ```typescript import { logoutUrl } from "usethatapp"; import { NextResponse } from "next/server"; import { getSession } from "@/lib/session"; export async function GET(request: Request) { // Don't clear the session yet — the user may choose "Stay signed in". A real // logout revokes the token, so the next /api/entitlement 401s and reconciles. const session = await getSession(); const target = await logoutUrl({ idToken: session.idToken, postLogoutRedirectUri: new URL("/", request.url).toString(), }); return NextResponse.redirect(target); } ``` ## Step 4 — Gate from a Server Component Server Components run on the server, so they read the plan directly — no client fetch needed. Gate logic on the stable `product_public_id`; show the mutable `version` (plan name) to the user. *app/page.tsx* ```tsx import { getEntitlement } from "usethatapp"; import { getSession } from "@/lib/session"; const PRO_PRODUCT_ID = "prod_"; // stable prod_... id from the dashboard export default async function Page() { const session = await getSession(); if (!session.accessToken) { return (

Welcome

Sign in with your UseThatApp account to see your plan.

Sign in with UseThatApp
); } const ent = await getEntitlement(session.accessToken); const isPro = ent.entitled && ent.product_public_id === PRO_PRODUCT_ID; return (

{isPro ? `${ent.version} Dashboard` : "Free Dashboard"}

{!isPro && Upgrade} Sign out
); } ``` ## Step 5 — Live tier in a Client Component When a Client Component needs the live tier (e.g. after an upgrade mid-session), fetch `/api/entitlement`. A `401` means signed-out — show the sign-in link. *app/Tier.tsx* ```tsx "use client"; import { useEffect, useState } from "react"; const PRO_PRODUCT_ID = "prod_"; export default function Tier() { const [state, setState] = useState<{ loading: boolean; pro: boolean; version: string | null }>( { loading: true, pro: false, version: null }, ); useEffect(() => { fetch("/api/entitlement", { credentials: "include" }) .then(async (res) => { if (res.status === 401) return setState({ loading: false, pro: false, version: null }); const { entitlement: ent } = await res.json(); setState({ loading: false, pro: ent.entitled && ent.product_public_id === PRO_PRODUCT_ID, version: ent.version ?? null, }); }) .catch(() => setState((s) => ({ ...s, loading: false }))); }, []); if (state.loading) return

Loading…

; if (!state.version) return Sign in with UseThatApp; return

{state.pro ? `${state.version} Dashboard` : "Free Dashboard"}

; } ``` ## Offer the upgrade: purchase links When the entitlement check says the user is not entitled (or is on the free tier), send them to hosted checkout with `purchaseUrl(priceId)`. UseThatApp is the **merchant of record** — it processes the payment, remits tax, and handles refunds — and the buyer comes back to your app signed in with the entitlement already live. No webhooks, no polling: the next `getEntitlement` simply says entitled. A server-only Route Handler builds the URL and redirects — point the "Upgrade" links from Steps 4 and 5 at `/api/upgrade`. For already-entitled users, `manageUrl()` links to their hosted subscription-management page. *app/api/upgrade/route.ts* ```typescript import { getEntitlement, manageUrl, purchaseUrl } from "usethatapp"; import { NextResponse } from "next/server"; import { getSession } from "@/lib/session"; const PRO_PRODUCT_ID = "prod_"; // stable prod_... id from the dashboard const PRO_PRICE_ID = ""; // prc_... — from getPrices() or the dashboard export async function GET(request: Request) { const session = await getSession(); const returnTo = new URL("/", request.url).toString(); if (session.accessToken) { const ent = await getEntitlement(session.accessToken); if (ent.entitled && ent.product_public_id === PRO_PRODUCT_ID) { // Already on Pro — hosted subscription management instead. return NextResponse.redirect(manageUrl({ next: returnTo })); } } // Not on Pro — hosted checkout. They return signed in, entitled. return NextResponse.redirect(purchaseUrl(PRO_PRICE_ID, { next: returnTo })); } ``` Both helpers are pure URL builders — no network call. To render a live pricing page, call `getPrices()` in a Server Component and never hardcode prices — each price carries a ready-made `buy_url` and the `product_id` that matches the entitlement’s `product_public_id` you gate on. See the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) and [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website) for the full flow (buy links are enabled per app during the beta). ## Identity: the pairwise `sub` `session.sub` from `completeLogin()` is a **pairwise pseudonymous identifier**: stable for a user *within your app*, different in every other app, with no email or PII. Key your user records off `sub`, never an email. It's delivered at login, not in the entitlement response. ## Sign out ⚠ Reconcile on return — don't clear the session eagerly. Both outcomes — the user confirms, or chooses "Stay signed in" — return to your post-logout URL, so the redirect alone can't tell you which happened. That's why `/api/logout` leaves the session intact: a confirmed logout revokes the token, so the next `getEntitlement` throws `UtaTokenError` (401) — `/api/entitlement` destroys the session then. If they stayed signed in, the token is still valid and they keep their session. ## Errors - **401 → `UtaTokenError`** — the access token is missing, expired, or revoked. Send the user back through `/api/login`. - **403 → `UtaPermissionError`** — a valid token lacking the `entitlements` scope. - Every SDK error inherits from `UtaError`. See [Error Handling](https://docs.usethatapp.com/error-handling). For the underlying OAuth flow, see [OpenID Connect](https://docs.usethatapp.com/openid-connect). The same route logic in a plain server lives in the [Express guide](https://docs.usethatapp.com/javascript/express). Also review [Gotchas](https://docs.usethatapp.com/gotchas). --- # Nuxt JavaScript / Nuxt # Nuxt Sign users into your Nuxt app with their UseThatApp account and gate features on their live plan, over standard **OAuth 2.0 / OpenID Connect**. These routes are one end of the full selling loop: a buyer who purchases on your website through a UseThatApp buy link lands back here signed in, entitlement already live — and the same routes serve marketplace launches. Nuxt ships a server (Nitro), so the SDK lives in **server routes** — your Vue app just calls them. ⚠ The SDK is server-only. The `usethatapp` SDK is a **confidential client**: it holds your client secret and validates ID tokens. Import it only in Nitro server code — `server/api/*` routes and `server/utils/*`. Never import it into a Vue component or anything that ships to the browser, or the secret ends up in your bundle. This guide covers the Hosted sign-in path It has UseThatApp run login, so buyers return signed in and the same session answers what they bought — the [Hosted sign-in](https://docs.usethatapp.com/openid-connect) add-on. If your app already has its own accounts, you do not need any of the sign-in wiring below: keep your login and verify purchases with a [license key](https://docs.usethatapp.com/license-keys) your server validates in one call, included in the per-sale fee. ## Architecture 1. **Nitro server routes run the SDK.** `server/api/login.get.ts` (redirect to UseThatApp), `server/api/callback.get.ts` (exchange the code), `server/api/logout.get.ts`, and `server/api/entitlement.get.ts` (read the live plan). Tokens live in an encrypted session via `useSession`. 2. **The Vue app** reads the plan with `useFetch('/api/entitlement')` and navigates to `/api/login` to sign in. It never sees a token. ## Prerequisites - Node.js 18+ and a Nuxt 3 project - An OAuth client from your app's **Integration** page: `UTA_CLIENT_ID` and `UTA_CLIENT_SECRET`, with `http://localhost:3000/api/callback` registered as a redirect URI for development — see [OpenID Connect](https://docs.usethatapp.com/openid-connect) ## Step 1 — Install and configure *terminal* ```bash npm install usethatapp ``` *.env (server-side only)* ```bash UTA_CLIENT_ID=... # required UTA_CLIENT_SECRET=... # required (server-side only) UTA_REDIRECT_URI=http://localhost:3000/api/callback NUXT_SESSION_PASSWORD=... # 32+ chars, for the encrypted session cookie # optional — default to production: UTA_ISSUER=https://www.usethatapp.com/o UTA_API_URL=https://www.usethatapp.com UTA_SCOPES="openid entitlements" ``` Nuxt reads `UTA_*` straight from the environment. A tiny server util wraps the session so every route shares the same shape. *server/utils/utaSession.ts* ```typescript import type { H3Event } from "h3"; import type { UtaFlowState } from "usethatapp"; export interface UtaSessionData { flow?: UtaFlowState; sub?: string; accessToken?: string; idToken?: string; } export function utaSession(event: H3Event) { return useSession(event, { password: process.env.NUXT_SESSION_PASSWORD!, // 32+ chars name: "uta-session", }); } ``` ## Step 2 — Login and callback routes `beginLogin()` returns an authorization URL plus a JSON-serializable `flowState` — stash it in the session, redirect, then hand it back to `completeLogin()` in the callback. On cancel/deny the provider returns with `?error=` and no code, so check that first. *server/api/login.get.ts* ```typescript import { beginLogin } from "usethatapp"; export default defineEventHandler(async (event) => { const session = await utaSession(event); const { authorizationUrl, flowState } = await beginLogin(); await session.update({ flow: flowState }); // stash across the redirect return sendRedirect(event, authorizationUrl); }); ``` *server/api/callback.get.ts* ```typescript import { completeLogin, UtaError } from "usethatapp"; export default defineEventHandler(async (event) => { const query = getQuery(event); const session = await utaSession(event); // On cancel/deny, OAuth redirects back with ?error=... and no code. if (query.error) { await session.update({ flow: undefined }); return sendRedirect(event, "/"); } try { const s = await completeLogin({ code: String(query.code), state: String(query.state), flowState: session.data.flow!, }); await session.update({ flow: undefined, sub: s.sub, // pairwise, per-app id — your user key accessToken: s.access_token, idToken: s.id_token, }); return sendRedirect(event, "/"); } catch (e) { if (e instanceof UtaError) return sendRedirect(event, "/"); // back, not signed in throw e; } }); ``` ## Step 3 — Entitlement and logout routes `getEntitlement(accessToken)` is always authoritative — a canceled license stops being entitled immediately. On `UtaTokenError` (401), clear the session and report signed-out; the app falls back to the signed-out view. *server/api/entitlement.get.ts* ```typescript import { getEntitlement, UtaError, UtaTokenError } from "usethatapp"; export default defineEventHandler(async (event) => { const session = await utaSession(event); const token = session.data.accessToken; if (!token) { throw createError({ statusCode: 401, statusMessage: "not signed in" }); } try { const entitlement = await getEntitlement(token); return { sub: session.data.sub, entitlement }; } catch (e) { if (e instanceof UtaTokenError) { // Token revoked/expired (e.g. signed out of UseThatApp). Reconcile. await session.clear(); throw createError({ statusCode: 401, statusMessage: "session ended" }); } if (e instanceof UtaError) { throw createError({ statusCode: 502, statusMessage: e.message }); } throw e; } }); ``` *server/api/logout.get.ts* ```typescript import { logoutUrl } from "usethatapp"; export default defineEventHandler(async (event) => { // Don't clear the session yet — the user may choose "Stay signed in". A real // logout revokes the token, so the next /api/entitlement 401s and reconciles. const session = await utaSession(event); const target = await logoutUrl({ idToken: session.data.idToken, postLogoutRedirectUri: getRequestURL(event).origin + "/", }); return sendRedirect(event, target); }); ``` ## Step 4 — Read the plan in Vue Use `useFetch` to read `/api/entitlement`. A `401` just means signed-out — render the "Sign in with UseThatApp" view. Gate logic on the stable `product_public_id`; show the mutable `version` (plan name) to the user. *app.vue* ``` ``` ## Offer the upgrade: purchase links When the entitlement check says the user is not entitled (or is on the free tier), send them to hosted checkout with `purchaseUrl(priceId)`. UseThatApp is the **merchant of record** — it processes the payment, remits tax, and handles refunds — and the buyer comes back to your app signed in with the entitlement already live. No webhooks, no polling: the next `getEntitlement` simply says entitled. A Nitro server route builds the URL and redirects — point the "Upgrade" link in Step 4 at `/api/upgrade`. For already-entitled users, `manageUrl()` links to their hosted subscription-management page. *server/api/upgrade.get.ts* ```typescript import { getEntitlement, manageUrl, purchaseUrl } from "usethatapp"; const PRO_PRODUCT_ID = "prod_"; // stable prod_... id from the dashboard const PRO_PRICE_ID = ""; // prc_... — from getPrices() or the dashboard export default defineEventHandler(async (event) => { const session = await utaSession(event); const returnTo = getRequestURL(event).origin + "/"; const token = session.data.accessToken; if (token) { const ent = await getEntitlement(token); if (ent.entitled && ent.product_public_id === PRO_PRODUCT_ID) { // Already on Pro — hosted subscription management instead. return sendRedirect(event, manageUrl({ next: returnTo })); } } // Not on Pro — hosted checkout. They return signed in, entitled. return sendRedirect(event, purchaseUrl(PRO_PRICE_ID, { next: returnTo })); }); ``` Both helpers are pure URL builders — no network call. To render a live pricing page, call `getPrices()` in a server route and never hardcode prices — each price carries a ready-made `buy_url` and the `product_id` that matches the entitlement’s `product_public_id` you gate on. See the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) and [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website) for the full flow (buy links are enabled per app during the beta). ## Identity: the pairwise `sub` `session.data.sub` from `completeLogin()` is a **pairwise pseudonymous identifier**: stable for a user *within your app*, different in every other app, with no email or PII. Key your user records off `sub`, never an email. It's delivered at login, not in the entitlement response — keep it on the server. ## Sign out ⚠ Reconcile on return — don't clear the session eagerly. Both outcomes — the user confirms, or chooses "Stay signed in" — return to your post-logout URL, so the redirect alone can't tell you which happened. That's why `/api/logout` leaves the session intact: a confirmed logout revokes the token, so the next `getEntitlement` throws `UtaTokenError` (401) — `/api/entitlement` clears the session then, and the app shows the signed-out view. If they stayed signed in, the token is still valid and they keep their session. ## Errors - **401 → `UtaTokenError`** — the access token is missing, expired, or revoked. Send the user back through `/api/login`. - **403 → `UtaPermissionError`** — a valid token lacking the `entitlements` scope. - Every SDK error inherits from `UtaError`. See [Error Handling](https://docs.usethatapp.com/error-handling). For the underlying OAuth flow, see [OpenID Connect](https://docs.usethatapp.com/openid-connect). The same route logic in a plain server lives in the [Express guide](https://docs.usethatapp.com/javascript/express). Also review [Gotchas](https://docs.usethatapp.com/gotchas). --- # React JavaScript / React # React Sign users into your React app with their UseThatApp account and gate features on their live plan, over standard **OAuth 2.0 / OpenID Connect**. This wiring is one end of the full selling loop: a buyer who purchases on your website through a UseThatApp buy link lands back in the app signed in, entitlement already live — and the same routes serve marketplace launches. The browser never touches the SDK: a tiny **backend-for-frontend** (BFF) runs the `usethatapp` SDK, and your React UI just calls it. ⚠ The SDK is server-only — never put it in the browser bundle. The `usethatapp` SDK is a **confidential client**: it holds your client secret and validates ID tokens. It must run on a server. A Vite/CRA React app has no server of its own, so you pair it with a small Node/Express **backend-for-frontend** that owns `/login`, `/callback`, `/logout` and `/api/entitlement`. The browser only ever calls that backend — the client secret and the SDK stay out of your bundle entirely. This guide covers the Hosted sign-in path It has UseThatApp run login, so buyers return signed in and the same session answers what they bought — the [Hosted sign-in](https://docs.usethatapp.com/openid-connect) add-on. If your app already has its own accounts, you do not need any of the sign-in wiring below: keep your login and verify purchases with a [license key](https://docs.usethatapp.com/license-keys) your server validates in one call, included in the per-sale fee. ## Architecture 1. **BFF (`server.js`).** A small Express server runs the SDK. It owns `/login` (redirect to UseThatApp), `/callback` (exchange the code for tokens), `/logout`, and `/api/entitlement` (read the live plan). Tokens live in a server-side session keyed by an `httpOnly` cookie. 2. **React SPA.** The app calls `fetch('/api/entitlement', { credentials: 'include' })` to read the plan and navigates to `/login` to sign in. It never sees a token. 3. **Vite proxy.** In dev, Vite proxies `/login`, `/callback`, `/logout` and `/api` to the BFF so the browser talks to one origin. ## Prerequisites - Node.js 18+, server-side only (uses the global `fetch` and Node's `node:crypto`/`node:fs` — the SDK can never ship in your React bundle) - A Vite (or CRA) React app, plus a small Node/Express server alongside it - An OAuth client from your app's **Integration** page: `UTA_CLIENT_ID` and `UTA_CLIENT_SECRET`, with `http://localhost:5173/callback` registered as a redirect URI for development — see [OpenID Connect](https://docs.usethatapp.com/openid-connect) ## Step 1 — Install *terminal* ```bash npm install usethatapp express cookie-parser dotenv ``` *.env (server-side only)* ```bash UTA_CLIENT_ID=... # required UTA_CLIENT_SECRET=... # required (server-side only — never in the bundle) UTA_REDIRECT_URI=http://localhost:5173/callback # optional — default to production: UTA_ISSUER=https://www.usethatapp.com/o UTA_API_URL=https://www.usethatapp.com UTA_SCOPES="openid entitlements" ``` ## Step 2 — The backend-for-frontend This is the only place the SDK runs. `beginLogin()` returns an authorization URL plus a JSON-serializable `flowState` — stash it in the session, redirect, then hand it back to `completeLogin()` in the callback. On cancel/deny the provider returns with `?error=` and no code, so check that first. *server.js* ```javascript import 'dotenv/config'; import { randomBytes } from 'node:crypto'; import cookieParser from 'cookie-parser'; import express from 'express'; import { beginLogin, completeLogin, configure, getEntitlement, logoutUrl, UtaError, UtaTokenError, } from 'usethatapp'; // The SDK is a confidential, server-side client — it runs here in the BFF, // never in the browser. Reads UTA_* from the environment; configure() lets // you override per-process. configure({ client_id: process.env.UTA_CLIENT_ID, client_secret: process.env.UTA_CLIENT_SECRET, redirect_uri: process.env.UTA_REDIRECT_URI, }); const APP_URL = process.env.APP_URL ?? 'http://localhost:5173'; const SESSION_COOKIE = 'uta_session'; const sessions = new Map(); // in-memory; use a real store in production function getSession(req, res) { const sid = req.cookies?.[SESSION_COOKIE]; if (sid && sessions.has(sid)) return sessions.get(sid); const id = randomBytes(24).toString('hex'); const data = {}; sessions.set(id, data); res.cookie(SESSION_COOKIE, id, { httpOnly: true, sameSite: 'lax', maxAge: 86_400_000 }); return data; } const peekSession = (req) => sessions.get(req.cookies?.[SESSION_COOKIE]); const app = express(); app.use(cookieParser()); // Start sign-in. Also set this as your app's "Login URL" on the Integration page. app.get('/login', async (req, res, next) => { try { const session = getSession(req, res); const { authorizationUrl, flowState } = await beginLogin(); session.flow = flowState; // stash across the redirect res.redirect(authorizationUrl); } catch (e) { next(e); } }); // Finish sign-in: exchange the code, store tokens, return to the app. app.get('/callback', async (req, res, next) => { const session = getSession(req, res); if (req.query.error) { // user canceled/denied — no code delete session.flow; return res.redirect(303, '/'); } try { const s = await completeLogin({ code: req.query.code, state: req.query.state, flowState: session.flow, }); delete session.flow; session.sub = s.sub; // pairwise, per-app id — your user key session.accessToken = s.access_token; session.idToken = s.id_token; res.redirect(303, '/'); } catch (e) { if (e instanceof UtaError) return res.redirect(303, '/'); // back, not signed in next(e); } }); // Live entitlement for the signed-in user. app.get('/api/entitlement', async (req, res, next) => { const session = peekSession(req); if (!session?.accessToken) return res.status(401).json({ error: 'not signed in' }); try { const entitlement = await getEntitlement(session.accessToken); res.json({ sub: session.sub, entitlement }); } catch (e) { if (e instanceof UtaTokenError) { // Token revoked/expired (e.g. signed out of UseThatApp). Reconcile by // dropping it; the SPA falls back to the signed-out view. delete session.accessToken; delete session.sub; delete session.idToken; return res.status(401).json({ error: 'session ended' }); } if (e instanceof UtaError) return res.status(502).json({ error: e.message }); next(e); } }); // Sign out — RP-initiated. Don't clear the session yet (see Sign out, below). app.get('/logout', async (req, res, next) => { const session = peekSession(req); try { res.redirect(await logoutUrl({ idToken: session?.idToken, postLogoutRedirectUri: `${APP_URL}/`, })); } catch (e) { next(e); } }); app.listen(3001, () => console.log('BFF on http://localhost:3001')); ``` ## Step 3 — Proxy the BFF in Vite Proxy the auth and API routes to the BFF so the browser only ever talks to one origin (and cookies stay first-party). *vite.config.ts* ```typescript import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], server: { proxy: { '/api': 'http://localhost:3001', '/login': 'http://localhost:3001', '/callback': 'http://localhost:3001', '/logout': 'http://localhost:3001', }, }, }); ``` ## Step 4 — Read the plan in React A hook fetches `/api/entitlement` once with `credentials: 'include'`. A `401` just means signed-out — render the "Sign in with UseThatApp" view. Gate logic on the stable `product_public_id`; show the mutable `version` (plan name) to the user. *src/hooks/useAccessLevel.ts* ```typescript import { useEffect, useState } from 'react'; export interface AccessState { loggedIn: boolean; loading: boolean; entitled: boolean; productId: string | null; // stable prod_... id — gate on this version: string | null; // plan display name, e.g. "Pro" — show this status: string | null; // active / trialing / free / preview / none / … } const initial: AccessState = { loggedIn: false, loading: true, entitled: false, productId: null, version: null, status: null, }; export function useAccessLevel(): AccessState { const [state, setState] = useState(initial); useEffect(() => { let cancelled = false; (async () => { try { const res = await fetch('/api/entitlement', { credentials: 'include' }); if (res.status === 401) return; // not signed in — leave defaults const { entitlement: ent } = await res.json(); if (!res.ok || cancelled) return; setState({ loggedIn: true, loading: false, entitled: !!ent?.entitled, productId: ent?.product_public_id ?? null, version: ent?.version ?? null, status: ent?.status ?? null, }); } catch (err) { console.error('entitlement check failed', err); } finally { if (!cancelled) setState((s) => ({ ...s, loading: false })); } })(); return () => { cancelled = true; }; }, []); return state; } ``` ## Step 5 — Share it through context Lift the hook into context so any component can read the plan, then gate UI off it. *src/context/AccessProvider.tsx* ```tsx import { createContext, useContext, type ReactNode } from 'react'; import { useAccessLevel, type AccessState } from '../hooks/useAccessLevel'; const AccessContext = createContext({ loggedIn: false, loading: true, entitled: false, productId: null, version: null, status: null, }); export function AccessProvider({ children }: { children: ReactNode }) { return ( {children} ); } export const useAccess = () => useContext(AccessContext); ``` *src/App.tsx* ```tsx import { AccessProvider, useAccess } from './context/AccessProvider'; const PRO_PRODUCT_ID = 'prod_'; // stable prod_... id from the dashboard function Dashboard() { const { loading, loggedIn, entitled, productId, version } = useAccess(); if (loading) return

Loading…

; if (!loggedIn) { return (

Welcome

Sign in with your UseThatApp account to see your plan.

{/* a plain link — navigating to the BFF starts the OAuth redirect */} Sign in with UseThatApp
); } // Gate on the stable product_public_id; show the mutable version name. if (entitled && productId === PRO_PRODUCT_ID) { return

{version} Dashboard

; } return (

Free Dashboard

Upgrade
); } export default function App() { return (
); } function Header() { const { loading, loggedIn } = useAccess(); if (loading) return null; return (
{loggedIn ? Sign out : Sign in with UseThatApp}
); } ``` ## Offer the upgrade: purchase links When the entitlement check says the user is not entitled (or is on the free tier), send them to hosted checkout with `purchaseUrl(priceId)`. UseThatApp is the **merchant of record** — it processes the payment, remits tax, and handles refunds — and the buyer comes back to your app signed in with the entitlement already live. No webhooks, no polling: the next `/api/entitlement` fetch simply says entitled. Like the rest of the SDK, `purchaseUrl()` and `manageUrl()` run in the BFF (they're pure URL builders — no network call); expose them from an endpoint and let the SPA render plain links. *server.js* ```javascript import { manageUrl, purchaseUrl } from 'usethatapp'; const PRO_PRICE_ID = process.env.PRO_PRICE_ID; // prc_... — from getPrices() or the dashboard // Alongside /api/entitlement — URLs for the SPA's upgrade / manage links. app.get('/api/upgrade-url', (req, res) => { const session = peekSession(req); if (!session?.accessToken) return res.status(401).json({ error: 'not signed in' }); res.json({ upgrade: purchaseUrl(PRO_PRICE_ID, { next: `${APP_URL}/` }), // hosted checkout manage: manageUrl({ next: `${APP_URL}/` }), // subscription management }); }); ``` *src/components/UpgradeCta.tsx* ```tsx import { useEffect, useState } from 'react'; import { useAccess } from '../context/AccessProvider'; const PRO_PRODUCT_ID = 'prod_'; // stable prod_... id from the dashboard export function UpgradeCta() { const { loggedIn, entitled, productId } = useAccess(); const [urls, setUrls] = useState<{ upgrade: string; manage: string } | null>(null); useEffect(() => { if (!loggedIn) return; fetch('/api/upgrade-url', { credentials: 'include' }) .then((res) => (res.ok ? res.json() : null)) .then(setUrls) .catch(() => setUrls(null)); }, [loggedIn]); if (!loggedIn || !urls) return null; if (entitled && productId === PRO_PRODUCT_ID) { return Manage subscription; } // Not on Pro — hosted checkout. They return signed in, entitled. return Upgrade to Pro; } ``` To render a live pricing page, call `getPrices()` in the BFF and never hardcode prices — each price carries a ready-made `buy_url` and the `product_id` that matches the entitlement’s `product_public_id` you gate on. See the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) and [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website) for the full flow (buy links are enabled per app during the beta). ## Identity: the pairwise `sub` The BFF stores `session.sub` from `completeLogin()` — a **pairwise pseudonymous identifier**: stable for a user *within your app*, different in every other app, with no email or PII. Key your user records off `sub`, never an email. It's delivered at login, not in the entitlement response — so the SPA doesn't need it; keep it on the server. ## Sign out Sign-out is RP-initiated: the `/logout` route redirects to `logoutUrl(...)`. ⚠ Reconcile on return — don't clear the session eagerly. Both outcomes — the user confirms, or chooses "Stay signed in" — return to your post-logout URL, so the redirect alone can't tell you which happened. That's why the BFF leaves the session intact: a confirmed logout revokes the token, so the next `getEntitlement` at `/api/entitlement` throws `UtaTokenError` (401) — the route drops the token then, and the SPA shows the signed-out view. If they stayed signed in, the token is still valid and they keep their session. ## Errors - **401 → `UtaTokenError`** — the access token is missing, expired, or revoked. The BFF clears it and returns 401; the SPA shows the signed-out view (send the user back through `/login`). - **403 → `UtaPermissionError`** — a valid token lacking the `entitlements` scope. - Every SDK error inherits from `UtaError`. See [Error Handling](https://docs.usethatapp.com/error-handling). The BFF here is a plain Express app — the full route logic and patterns live in the [Express guide](https://docs.usethatapp.com/javascript/express). See also [OpenID Connect](https://docs.usethatapp.com/openid-connect) and [Gotchas](https://docs.usethatapp.com/gotchas). --- # Vue JavaScript / Vue # Vue Sign users into your Vue app with their UseThatApp account and gate features on their live plan, over standard **OAuth 2.0 / OpenID Connect**. This wiring is one end of the full selling loop: a buyer who purchases on your website through a UseThatApp buy link lands back in the app signed in, entitlement already live — and the same routes serve marketplace launches. The browser never touches the SDK: a tiny **backend-for-frontend** (BFF) runs the `usethatapp` SDK, and your Vue UI just calls it. ⚠ The SDK is server-only — never put it in the browser bundle. The `usethatapp` SDK is a **confidential client**: it holds your client secret and validates ID tokens. It must run on a server. A Vite Vue app has no server of its own, so you pair it with a small Node/Express **backend-for-frontend** that owns `/login`, `/callback`, `/logout` and `/api/entitlement`. The browser only ever calls that backend — the client secret and the SDK stay out of your bundle entirely. This guide covers the Hosted sign-in path It has UseThatApp run login, so buyers return signed in and the same session answers what they bought — the [Hosted sign-in](https://docs.usethatapp.com/openid-connect) add-on. If your app already has its own accounts, you do not need any of the sign-in wiring below: keep your login and verify purchases with a [license key](https://docs.usethatapp.com/license-keys) your server validates in one call, included in the per-sale fee. ## Architecture 1. **BFF (`server.js`).** A small Express server runs the SDK. It owns `/login` (redirect to UseThatApp), `/callback` (exchange the code for tokens), `/logout`, and `/api/entitlement` (read the live plan). Tokens live in a server-side session keyed by an `httpOnly` cookie. 2. **Vue SPA.** The app calls `fetch('/api/entitlement', { credentials: 'include' })` to read the plan and navigates to `/login` to sign in. It never sees a token. 3. **Vite proxy.** In dev, Vite proxies `/login`, `/callback`, `/logout` and `/api` to the BFF so the browser talks to one origin. ## Prerequisites - Node.js 18+, server-side only (uses the global `fetch` and Node's `node:crypto`/`node:fs` — the SDK can never ship in your Vue bundle) - A Vite + Vue 3 app, plus a small Node/Express server alongside it - An OAuth client from your app's **Integration** page: `UTA_CLIENT_ID` and `UTA_CLIENT_SECRET`, with `http://localhost:5173/callback` registered as a redirect URI for development — see [OpenID Connect](https://docs.usethatapp.com/openid-connect) ## Step 1 — Install *terminal* ```bash npm install usethatapp express cookie-parser dotenv ``` *.env (server-side only)* ```bash UTA_CLIENT_ID=... # required UTA_CLIENT_SECRET=... # required (server-side only — never in the bundle) UTA_REDIRECT_URI=http://localhost:5173/callback # optional — default to production: UTA_ISSUER=https://www.usethatapp.com/o UTA_API_URL=https://www.usethatapp.com UTA_SCOPES="openid entitlements" ``` ## Step 2 — The backend-for-frontend This is the only place the SDK runs. `beginLogin()` returns an authorization URL plus a JSON-serializable `flowState` — stash it in the session, redirect, then hand it back to `completeLogin()` in the callback. On cancel/deny the provider returns with `?error=` and no code, so check that first. (This BFF is framework-agnostic — identical whether your front end is Vue or React.) *server.js* ```javascript import 'dotenv/config'; import { randomBytes } from 'node:crypto'; import cookieParser from 'cookie-parser'; import express from 'express'; import { beginLogin, completeLogin, configure, getEntitlement, logoutUrl, UtaError, UtaTokenError, } from 'usethatapp'; // The SDK is a confidential, server-side client — it runs here in the BFF, // never in the browser. Reads UTA_* from the environment; configure() lets // you override per-process. configure({ client_id: process.env.UTA_CLIENT_ID, client_secret: process.env.UTA_CLIENT_SECRET, redirect_uri: process.env.UTA_REDIRECT_URI, }); const APP_URL = process.env.APP_URL ?? 'http://localhost:5173'; const SESSION_COOKIE = 'uta_session'; const sessions = new Map(); // in-memory; use a real store in production function getSession(req, res) { const sid = req.cookies?.[SESSION_COOKIE]; if (sid && sessions.has(sid)) return sessions.get(sid); const id = randomBytes(24).toString('hex'); const data = {}; sessions.set(id, data); res.cookie(SESSION_COOKIE, id, { httpOnly: true, sameSite: 'lax', maxAge: 86_400_000 }); return data; } const peekSession = (req) => sessions.get(req.cookies?.[SESSION_COOKIE]); const app = express(); app.use(cookieParser()); // Start sign-in. Also set this as your app's "Login URL" on the Integration page. app.get('/login', async (req, res, next) => { try { const session = getSession(req, res); const { authorizationUrl, flowState } = await beginLogin(); session.flow = flowState; // stash across the redirect res.redirect(authorizationUrl); } catch (e) { next(e); } }); // Finish sign-in: exchange the code, store tokens, return to the app. app.get('/callback', async (req, res, next) => { const session = getSession(req, res); if (req.query.error) { // user canceled/denied — no code delete session.flow; return res.redirect(303, '/'); } try { const s = await completeLogin({ code: req.query.code, state: req.query.state, flowState: session.flow, }); delete session.flow; session.sub = s.sub; // pairwise, per-app id — your user key session.accessToken = s.access_token; session.idToken = s.id_token; res.redirect(303, '/'); } catch (e) { if (e instanceof UtaError) return res.redirect(303, '/'); // back, not signed in next(e); } }); // Live entitlement for the signed-in user. app.get('/api/entitlement', async (req, res, next) => { const session = peekSession(req); if (!session?.accessToken) return res.status(401).json({ error: 'not signed in' }); try { const entitlement = await getEntitlement(session.accessToken); res.json({ sub: session.sub, entitlement }); } catch (e) { if (e instanceof UtaTokenError) { // Token revoked/expired (e.g. signed out of UseThatApp). Reconcile by // dropping it; the SPA falls back to the signed-out view. delete session.accessToken; delete session.sub; delete session.idToken; return res.status(401).json({ error: 'session ended' }); } if (e instanceof UtaError) return res.status(502).json({ error: e.message }); next(e); } }); // Sign out — RP-initiated. Don't clear the session yet (see Sign out, below). app.get('/logout', async (req, res, next) => { const session = peekSession(req); try { res.redirect(await logoutUrl({ idToken: session?.idToken, postLogoutRedirectUri: `${APP_URL}/`, })); } catch (e) { next(e); } }); app.listen(3001, () => console.log('BFF on http://localhost:3001')); ``` ## Step 3 — Proxy the BFF in Vite Proxy the auth and API routes to the BFF so the browser only ever talks to one origin (and cookies stay first-party). *vite.config.ts* ```typescript import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; export default defineConfig({ plugins: [vue()], server: { proxy: { '/api': 'http://localhost:3001', '/login': 'http://localhost:3001', '/callback': 'http://localhost:3001', '/logout': 'http://localhost:3001', }, }, }); ``` ## Step 4 — Read the plan in Vue A composable fetches `/api/entitlement` once with `credentials: 'include'` and exposes reactive state. A `401` just means signed-out — render the "Sign in with UseThatApp" view. Gate logic on the stable `product_public_id`; show the mutable `version` (plan name) to the user. *src/composables/useAccessLevel.ts* ```typescript import { reactive, readonly, onMounted } from 'vue'; export interface AccessState { loggedIn: boolean; loading: boolean; entitled: boolean; productId: string | null; // stable prod_... id — gate on this version: string | null; // plan display name, e.g. "Pro" — show this status: string | null; // active / trialing / free / preview / none / … } export function useAccessLevel() { const state = reactive({ loggedIn: false, loading: true, entitled: false, productId: null, version: null, status: null, }); onMounted(async () => { try { const res = await fetch('/api/entitlement', { credentials: 'include' }); if (res.status === 401) return; // not signed in — leave defaults const { entitlement: ent } = await res.json(); if (!res.ok) return; state.loggedIn = true; state.entitled = !!ent?.entitled; state.productId = ent?.product_public_id ?? null; state.version = ent?.version ?? null; state.status = ent?.status ?? null; } catch (err) { console.error('entitlement check failed', err); } finally { state.loading = false; } }); return readonly(state); } ``` For app-wide access, call the composable once at the root and `provide()` it; children read it with `inject()` instead of re-fetching. ## Step 5 — Gate the UI *src/App.vue* ``` ``` ## Offer the upgrade: purchase links When the entitlement check says the user is not entitled (or is on the free tier), send them to hosted checkout with `purchaseUrl(priceId)`. UseThatApp is the **merchant of record** — it processes the payment, remits tax, and handles refunds — and the buyer comes back to your app signed in with the entitlement already live. No webhooks, no polling: the next `/api/entitlement` fetch simply says entitled. Like the rest of the SDK, `purchaseUrl()` and `manageUrl()` run in the BFF (they're pure URL builders — no network call); expose them from an endpoint and let the SPA render plain links. *server.js* ```javascript import { manageUrl, purchaseUrl } from 'usethatapp'; const PRO_PRICE_ID = process.env.PRO_PRICE_ID; // prc_... — from getPrices() or the dashboard // Alongside /api/entitlement — URLs for the SPA's upgrade / manage links. app.get('/api/upgrade-url', (req, res) => { const session = peekSession(req); if (!session?.accessToken) return res.status(401).json({ error: 'not signed in' }); res.json({ upgrade: purchaseUrl(PRO_PRICE_ID, { next: `${APP_URL}/` }), // hosted checkout manage: manageUrl({ next: `${APP_URL}/` }), // subscription management }); }); ``` *src/components/UpgradeCta.vue* ``` ``` To render a live pricing page, call `getPrices()` in the BFF and never hardcode prices — each price carries a ready-made `buy_url` and the `product_id` that matches the entitlement’s `product_public_id` you gate on. See the [Pricing API](https://docs.usethatapp.com/purchase/pricing-api) and [Sell from your own website](https://docs.usethatapp.com/purchase/sell-from-your-website) for the full flow (buy links are enabled per app during the beta). ## Identity: the pairwise `sub` The BFF stores `session.sub` from `completeLogin()` — a **pairwise pseudonymous identifier**: stable for a user *within your app*, different in every other app, with no email or PII. Key your user records off `sub`, never an email. It's delivered at login, not in the entitlement response — so the SPA doesn't need it; keep it on the server. ## Sign out Sign-out is RP-initiated: the `/logout` route redirects to `logoutUrl(...)`. ⚠ Reconcile on return — don't clear the session eagerly. Both outcomes — the user confirms, or chooses "Stay signed in" — return to your post-logout URL, so the redirect alone can't tell you which happened. That's why the BFF leaves the session intact: a confirmed logout revokes the token, so the next `getEntitlement` at `/api/entitlement` throws `UtaTokenError` (401) — the route drops the token then, and the SPA shows the signed-out view. If they stayed signed in, the token is still valid and they keep their session. ## Errors - **401 → `UtaTokenError`** — the access token is missing, expired, or revoked. The BFF clears it and returns 401; the SPA shows the signed-out view (send the user back through `/login`). - **403 → `UtaPermissionError`** — a valid token lacking the `entitlements` scope. - Every SDK error inherits from `UtaError`. See [Error Handling](https://docs.usethatapp.com/error-handling). The BFF here is a plain Express app — the full route logic and patterns live in the [Express guide](https://docs.usethatapp.com/javascript/express). See also [OpenID Connect](https://docs.usethatapp.com/openid-connect) and [Gotchas](https://docs.usethatapp.com/gotchas). ---