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 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 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 Express-specific bits — read the callback query params, store flowState in the session, issue redirects.
/logincallsbeginLogin(), stashesflowStateinreq.session, and redirects the user to UseThatApp to authenticate./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 Express 4 or 5 app using ESM — set
"type": "module"inpackage.json(or use a.mjsextension), sinceusethatappis ESM-only - 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
npm install usethatapp express express-sessionStep 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 — 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.
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('<a href="/login">Log in with UseThatApp</a>');
});
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.
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);
}
}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.
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 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 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
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;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 and Gotchas.
