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

    Step 1 — Install the package

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

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