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.
/logincallsbeginLogin(), stashesflowStateon the session, and writes a 302 to the authorization URL./callbackexchanges the one-time code withcompleteLogin(), giving you aUtaSession— the user's pairwisesubplus OAuth tokens. Persist them on the session.- 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
fetchand Node'snode:crypto/node:fs— no Edge runtimes, no browser bundles) - An ESM project — set
"type": "module"inpackage.jsonor use the.mjsextension - 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
npm init -y
npm pkg set type=module
npm install usethatappStep 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.
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.
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.
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:
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 andsubinto 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.
