UseThatApp Logo

    Gotchas

    The mistakes below break the most integrations. If you are wiring up the UseThatApp loop — sign-in, entitlement gating, and selling through purchase links — especially as an AI coding agent, read these first. They are the difference between a working funnel and one that silently fails, leaks your secret, double-builds payment plumbing you don't need, or logs everyone out.

    1. Redirect URIs are an exact-match allow-list on your domain

    ✗ Do not assume "close enough" matches. The callback URL is compared byte-for-byte.

    Every redirect URI your app sends must appear verbatim on the allow-list you register on the dashboard's Integration page. A trailing slash, an http vs https, a different port, or a www. prefix all count as a different URL and the provider rejects the request. Each entry must be HTTPS on your domain — localhost is allowed for development.

    Why agents get this wrong: it's easy to set UTA_REDIRECT_URI in code but forget to register the exact same string on the dashboard, or to register https://yourapp.com/callback while the app sends https://yourapp.com/callback/. Keep the registered URI and UTA_REDIRECT_URI identical, character for character.

    2. The client secret is server-side only — SPAs need a backend

    ✗ Never ship UTA_CLIENT_SECRET to the browser.

    The usethatapp SDK is a confidential client: it holds your client secret and validates ID tokens. That code must run on your server. Anything that reaches the browser is public — a leaked secret lets anyone impersonate your app.

    Why agents get this wrong: a single-page app (React, Vue) has no server to hold a secret, so it's tempting to call the SDK from the front end. Don't. Run the SDK in a small backend (a "backend-for-frontend") and call that from the browser — the tokens and secret never leave your server. See the Sign in with UseThatApp guide.

    3. localhost vs 127.0.0.1 breaks the state round-trip

    ✗ Don't start on one host and come back on the other.

    Browsers scope cookies by host, and localhost and 127.0.0.1 are different hosts. If you begin login on http://localhost:3000 but your UTA_REDIRECT_URI sends the user back to http://127.0.0.1:3000 (or vice versa), the session cookie holding your flow_state isn't sent on the callback. The state can't be validated, and complete_login fails with a state mismatch.

    The fix: pick one host and use it everywhere — the URL you browse to, the registered redirect URI, and UTA_REDIRECT_URI must all agree. flow_state carries the PKCE verifier and the state value, so it has to survive the round-trip in the same cookie jar.

    4. Check the callback error param before anything else

    When a user cancels or denies the consent screen, the provider redirects back to your callback with ?error=... and no code. If you call complete_login anyway, it fails on a missing code. Check error first and treat it as "login canceled" — redirect home, don't show an error page.

    Python

    callback
    if request.args.get("error"):
        session.pop("uta_flow", None)
        return redirect(url_for("home"))   # canceled — not an error

    JavaScript

    callback
    if (req.query.error) {
      delete req.session.utaFlow;
      return res.redirect("/");   // canceled — not an error
    }

    5. Reconcile on sign-out — don't clear your session eagerly

    ✗ Don't drop the user's session the moment you start logout.

    Sign-out is RP-initiated: you redirect to logout_url(id_token=...). Both outcomes — the user confirms, or chooses "Stay signed in" — return to the same post-logout URL, so you can't tell which happened from the redirect. Clearing your session before logout logs the user out even when they chose to stay.

    Reconcile on return using the token instead: a confirmed logout revokes it, so your next get_entitlement raises UtaTokenError (401) — drop the token then. If they stayed signed in, the token is still valid and they keep their session.

    logout + reconcile-on-return (Flask)
    @app.route("/logout")
    def logout():
        # Don't clear the session yet — the user may pick "Stay signed in".
        id_token = session.get("uta_id_token")
        return redirect(logout_url(
            id_token=id_token,
            post_logout_redirect_uri="https://yourapp.com/",
        ))
    
    @app.route("/")
    def home():
        token = session.get("uta_access_token")
        if token:
            try:
                ent = get_entitlement(token)
                return render_app(ent)
            except UtaTokenError:
                # Confirmed logout revoked the token — reconcile now.
                for k in ("uta_access_token", "uta_sub", "uta_id_token"):
                    session.pop(k, None)
        return '<a href="/login">Log in with UseThatApp</a>'

    6. Gate on product_public_id, not the display version

    ✗ Don't branch your feature logic on the version name.

    version is the plan's display name (e.g. "Pro"). It's mutable — you can rename a plan in the dashboard and break every version == "Pro" check. The stable product_public_id (an opaque prod_… identifier) survives a rename, and it's the same id the Pricing API returns as product_id on every price — so the plan a buyer purchases is the plan you unlock. The entitlement's product_id carries that same prod_… value (the two fields are permanent equal-valued aliases), so either field works. Show version to the user.

    gate on the stable id
    ent = get_entitlement(access_token)
    # ✗  if ent.version == "Pro":              # breaks on rename
    # ✓  gate on the stable prod_… id (product_id == product_public_id):
    if ent.entitled and ent.product_public_id == "prod_<your-pro-id>":
        unlock_pro_features()

    7. A no-token visitor IS your free tier

    A free launch carries no login by design — an anonymous, token-less visitor is your free tier. Don't force everyone through sign-in to use the app: serve your free experience to visitors with no token, and offer "Sign in with UseThatApp" to unlock paid features. A paid launch signs the user in; a signed-in user launching again skips the consent screen (seamless SSO), because only an opaque sub is shared.

    8. Your app's session and UseThatApp's session are independent

    UseThatApp signs the user in; your app then runs its own session keyed on the sub. The two have separate lifetimes — "logged into your app" and "logged into usethatapp.com" are different states. Don't conflate them: reconcile UseThatApp's state by checking the token (as in Gotcha 5), and manage your own session on your own terms.

    9. Register a post-logout URL, or users land on usethatapp.com

    The post_logout_redirect_uri you pass to logout_url must also be on the allow-list you register on the Integration page. If you register none, signing out drops the user on the UseThatApp home page instead of back in your app. Register your production post-logout URLs alongside your redirect URIs.

    10. Sign-in is a redirect chain — show a loading state

    A launch/sign-in isn't one request; it's a hop through several redirects — UseThatApp → your /login → UseThatApp's authorize endpoint → your /callback, which then exchanges the code for tokens. Each hop is a bodiless redirect, so if your /callback just returns another redirect, the browser shows a blank page the whole time — and on a cold-started server that can be several seconds.

    Render a small "Signing you in…" loading page from your /callback while complete_login runs, instead of blocking on a bare redirect, and keep your app warm (a spun-down instance adds the cold-start delay on top). The SDK already caches OIDC discovery and JWKS, so that isn't the cost. UseThatApp shows its own "Launching…" screen on the marketplace side; matching it on your callback keeps the hand-off seamless end to end.

    11. Don't build payment plumbing — the entitlement is already live

    ✗ No webhooks, no polling, no "pending purchase" state.

    Hosted checkout creates the license synchronously — by the time the buyer is redirected back into your app, the very first get_entitlement call already returns entitled: true. UseThatApp is the merchant of record: payment, tax, refunds, and chargebacks are handled for you. If you find yourself writing a payment webhook receiver or a "waiting for your purchase" spinner, stop — the loop doesn't need it.

    Related: buyers who already own the product and hit a buy link are recognized and sent straight back to your app — never double-charged — so it's safe to show Buy buttons to everyone.

    12. Never hardcode prices — render them from the pricing API

    ✗ Don't bake "$10/month" into your pricing page or templates.

    The moment a price changes on the dashboard, every hardcoded amount is wrong — and a pricing page that disagrees with checkout is a trust-killer. Fetch live prices with get_prices() / getPrices() (or plain browser fetch — the Pricing API is public and CORS-enabled) and use each price's ready-made buy_url for the Buy button.

    13. Purchase-link next URLs follow the same domain rule

    The optional next param on buy links and manage links must be HTTPS on your app's registered domain (localhost allowed for development) — the same trust rule as redirect URIs. An invalid value shows the buyer a "misconfigured link" page, so open every distinct link your site renders at least once (the seller walkthrough makes this free). Two more launch facts to remember: a buy link returns 404 unless every readiness gate is open(seller verified, app published, product active, external sales on — the manage page lists them), and purchases are limited to the countries offered at checkout.

    Quick Self-Check

    Before you ship, confirm each of these:

    • UTA_REDIRECT_URI matches a dashboard entry byte-for-byte (scheme, host, port, path, trailing slash).
    • UTA_CLIENT_SECRET lives only on your server; SPAs call a backend-for-frontend, never the SDK directly.
    • You browse, redirect, and configure on one host — localhost or 127.0.0.1, not both.
    • The callback checks error before calling complete_login.
    • Logout reconciles on return (catches UtaTokenError) instead of clearing the session eagerly.
    • Feature gating keys off product_public_id, not the mutable version.
    • Your free tier works with no token, and your post-logout URLs are registered on the dashboard.
    • Your /callback shows a loading state instead of a bare redirect, so users don't see a blank page during sign-in.
    • Your pricing page renders from get_prices / getPrices (no hardcoded amounts), and Buy buttons use the returned buy_url.
    • You have no purchase webhooks or polling — the first entitlement check after checkout is already authoritative.
    • Every next URL on your buy/manage links is HTTPS on your registered domain, and you've run the seller walkthrough end to end.

    Next Steps

    Read Error Handling for the full error model, Sign in with UseThatApp for the complete sign-in flow these guards build on, and Sell from your own website for the purchase funnel end to end.

    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.