UseThatApp Logo
    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 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 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 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 for registering your redirect URI and copying credentials

    Step 1 — Install the packages

    terminal
    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
    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
    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 '<a href="/login">Log in with UseThatApp</a>';
      });
    
      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
    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
    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 and Sell from your own website for the full flow (buy links resolve once your app passes its readiness checklist).

    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 and Gotchas.

    AI agents: this site is fully pre-rendered — fetch /llms.txt for an indexed summary of every page, or /llms-full.txt for the whole documentation as one Markdown file.