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.
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
- Routes on
app.server— the OIDC flow is plain Flask:/loginstarts it,/callbackfinishes it,/logoutends the session. TheusethatappSDK runs on your server as a confidential client. - Entitlement in a Dash callback — a Dash callback can read the Flask
session, so it pulls the access token and callsget_entitlementto drive what the UI shows.
Prerequisites
- Python 3.9+ and a Dash app.
- A Flask secret key on
app.serverso the session cookie survives the redirect to/callback. UTA_CLIENT_ID,UTA_CLIENT_SECRET, andUTA_REDIRECT_URIin 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_ISSUERandUTA_API_URL— each defaults to production.
Step 1 — Install
pip install usethatapp dashStep 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.
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.
@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.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.
@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.
from usethatapp import manage_url, purchase_url
PRO_PRICE_ID = "<prc-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_<your-pro-id>":
# 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 and Sell from your own website.
Run it
if __name__ == "__main__":
app.run(debug=True) # serves on http://localhost:8050Errors
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 and Gotchas.
