Skip to content

Sign in with your STX account

Describes @stxapp/stx-typescript 0.9.0, the current release.

There are two ways to bring a member’s STX account into your app. Both end with tokens your server uses to call STX for that member.

Use it when You call
Connect your STX account Your app has its own login, and a signed-in user links their STX account. It works with any login system: your own passwords or a login service you already use. STX never sees it. OAuthClient
Sign in with your STX account The STX account is the login to your app. createConnect()

Choosing between them, the scopes and the consent screen are described in Add STX accounts to your app; this page shows the SDK calls.

It is for confidential, server-side apps. The client secret and the tokens never go to a browser or a mobile app.

createConnect runs the OpenID Connect sign-in from your server. The member taps one button in your app, signs in to STX (with Google, Apple, X or email), finishes STX onboarding if they are new, and approves your app. Your server gets back:

  • tokens to call STX for the member. Keep them on your server.
  • identity: a signed statement of who the member is. Key your own user record on identity.sub.
import { createConnect } from "@stxapp/stx-typescript/oauth";
const connect = createConnect({
issuer: "https://demo.stxapp.io",
clientId: process.env.STX_CLIENT_ID!,
clientSecret: process.env.STX_CLIENT_SECRET!,
redirectUri: "https://your.app/auth/stx/callback",
scopes: ["profile.read", "balance.read", "orders.read", "orders.write"],
});

openid is added to the scopes for you. Endpoints and signing keys come from the issuer’s /.well-known/openid-configuration.

app.get("/auth/stx/start", async (req, res) => {
const signIn = await connect.start({ connection: "google", loginHint: req.query.email });
req.session.stxSignIn = signIn; // state, codeVerifier, nonce
res.redirect(signIn.url);
});
  • connection: google, apple or x goes straight to that provider and skips STX’s login page. Leave it out to show STX’s login page.
  • loginHint: the member’s email, if you know it, so the provider can preselect the account.
  • prompt: "none": a silent reconnect for a member who has already approved your app.
app.get("/auth/stx/callback", async (req, res) => {
const { tokens, identity } = await connect.finish(req.originalUrl, req.session.stxSignIn);
delete req.session.stxSignIn;
await users.upsert({ stxMemberId: identity.sub, email: identity.email });
await tokenStore.save(identity.sub, tokens);
res.redirect("/");
});

finish checks the state, exchanges the code, and verifies the ID token: its signature against the published keys, the issuer, that it was issued to your app, the nonce from this sign-in, and that it has not expired. Any failure throws STXOAuthException; see When the member does not come back connected.

const fresh = await connect.refresh(stored.refreshToken); // STX rotates the refresh token: store what comes back
await connect.disconnect(stored); // when the member disconnects

disconnect revokes the refresh token, which ends the connection at STX: every token on it stops working, and the member’s next sign-in asks for consent again.

Call the exchange for the member with the access token, through OAuthClient.memberClient() or new STX({ auth: new OAuthBearerAuth(token) }).

const me = await connect.userInfo(stored.accessToken, { sub: user.stxMemberId });
console.log(me.sub, me.email, me.emailVerified);

userInfo reads the same claims from the userinfo endpoint with the member’s access token. Pass the sub you stored and an answer about anyone else is refused. A token that has stopped working throws STXOAuthException with error: "invalid_token".

Your app already knows who the user is. OAuthClient sends them to STX to approve your app, and stores the tokens under your own user key.

import { MemoryPendingAuthorizationStore, MemoryTokenStore, OAuthClient } from "@stxapp/stx-typescript/oauth";
// Reads the exchange's discovery document, so its endpoints and scopes are used.
const oauth = await OAuthClient.discover({
baseUrl: "https://demo.stxapp.io",
clientId: process.env.STX_CLIENT_ID!,
clientSecret: process.env.STX_CLIENT_SECRET!,
redirectUri: "https://your.app/stx/callback",
scope: ["balance.read", "orders.read", "orders.write"],
});
const pending = new MemoryPendingAuthorizationStore(); // sign-ins in progress; use your session store or database
const store = new MemoryTokenStore(); // members' tokens; use your database
app.get("/stx/connect", async (req, res) => {
const { url } = await oauth.beginAuthorization(pending, {
connection: "google", // optional: go straight to Google, Apple ("apple") or X ("x")
loginHint: req.user.email, // optional: preselect the account
data: { userId: req.user.id },
});
res.redirect(url);
});
app.get("/stx/callback", async (req, res) => {
await oauth.completeAuthorization(pending, req.originalUrl, { store, memberKey: (data) => String(data?.userId) });
res.redirect("/");
});

beginAuthorization creates the PKCE pair and the state and remembers them; completeAuthorization checks the state (single use), exchanges the code and saves the tokens.

OAuthClient.discover() throws STXNotAvailableException (feature: "oauth") on an exchange without OAuth. Built with discover(), the client also knows which scopes the exchange offers: asking for one it does not offer throws STXNotAvailableException with feature: "scope:<name>" instead of a bare invalid_scope. new OAuthClient({...}) still works and skips the discovery request.

const stx = oauth.memberClient(store, userId); // an STX client acting for that member
console.log(await stx.balance());
await oauth.unlink(store, userId); // when the user disconnects

memberClient refreshes the access token before it expires and stores the rotated refresh token for you. See After the member is connected for the errors a call can throw once the member’s situation changes.

When the member does not come back connected

Section titled “When the member does not come back connected”

finish and completeAuthorization throw one of these, all STXOAuthException. No tokens exist in any of these cases.

Exception error What happened What to do
STXAccountPendingException account_pending The member’s STX account is still being verified (approval pending, or verification not finished). See Members still being verified. Show your own “we’ll let you know” state, and start again once STX has approved the account.
STXAccessDeniedException access_denied The member declined, or their STX account cannot be connected (for example it is suspended or closed). Show your “not connected” state.
STXTermsNotAcceptedException terms_required STX showed the member its terms page during sign-in, and the member went back to your app without agreeing. Show your “not connected” state. The member can start again when they are ready.
STXOAuthException interaction_required A silent reconnect (prompt: "none") needs the member, for example to accept new terms. Start again without prompt: "none".
STXOAuthException invalid_state The callback does not belong to a sign-in you started, or it was used already. Start again.
STXOAuthException invalid_grant The code was used or expired. Start again.
STXOAuthException invalid_id_token (finish only) The ID token did not verify. Do not sign the member in.
import { STXAccessDeniedException, STXAccountPendingException } from "@stxapp/stx-typescript/oauth";
try {
await connect.finish(req.originalUrl, req.session.stxSignIn);
} catch (err) {
if (err instanceof STXAccountPendingException) return res.redirect("/stx/pending");
if (err instanceof STXAccessDeniedException) return res.redirect("/stx/not-connected");
throw err;
}

A member’s account can go back into verification after they connected. STX then issues no new tokens for them until it is done, and the connection itself stays.

Exception From What happened What to do
STXAccountPendingException (error is invalid_grant, errorReason is account_pending) refresh(), a memberClient() call, the code exchange The member’s STX account is being verified. Keep the stored tokens: the same refresh token works once the member is verified. memberClient() keeps them for you. Show your “being verified” state and try again later.
STXGrantRevokedException refresh(), a memberClient() call The connection has ended: the member disconnected your app, or the refresh token expired or was used twice. Delete the tokens (memberClient() does) and send the member through the connect step again.

A member accepts the STX terms on an STX page, signed in to their own account. Your app cannot accept them for a member, and no scope asks for it. During sign-in STX shows that page itself, before the consent screen.

When new terms take effect for a member who is already connected, the connection stays. The tokens, reads, cancels and WebSocket channels keep working. Only placing orders is refused until the member has agreed. STX gives no advance notice, so handle it when it happens.

What your app sees From What to do
STXTermsRequiredException, with termsUrl placeOrder() and placeOrders() on a memberClient() Send the member’s browser to termsUrlWithReturn(err.termsUrl, page). The member signs in to STX if needed, agrees, and lands back on page. The same call then works with the same tokens.
termsRequired: true and termsUrl on the refreshed tokens refresh(), and a memberClient() refresh (which also calls the onTermsRequired session hook) The refresh succeeded: store the tokens as usual. Ask the member to accept before they next trade. Both fields are gone from the next refresh after the member agrees.

page is where the member comes back to in your app. It must be one of your registered redirect URIs, or a page on the same origin as one. STX sends the member back to it unchanged.

import { STXTermsRequiredException, termsUrlWithReturn } from "@stxapp/stx-typescript/oauth";
app.post("/orders", async (req, res) => {
const stx = oauth.memberClient(store, req.user.id);
try {
res.json(await stx.placeOrder("0c1e7a52-9b3f-4d8e-a6c4-2f5b8d1e9a70", "buy", "limit", { price: "0.55", quantity: "10" }));
} catch (err) {
if (err instanceof STXTermsRequiredException && err.termsUrl) {
// https://demo.stxapp.io/connect/terms?client_id=stxapp_client_9f2c41d7&return_to=https%3A%2F%2Fyour.app%2Fportfolio
const termsUrl = termsUrlWithReturn(err.termsUrl, "https://your.app/portfolio");
return res.status(409).json({ error: "terms_required", termsUrl });
}
throw err;
}
});

acceptTerms() is for your own account with your own API key. Called with a member’s access token it throws STXForbiddenException (terms_not_delegable).

Some calls belong to your app, not to a member: reading the catalog, and creating markets. They use an app token, minted from your client id and secret with no member involved.

import { AppScopes, OAuthClient } from "@stxapp/stx-typescript/oauth";
const oauth = new OAuthClient({ baseUrl: "https://demo.stxapp.io", clientId, clientSecret });
const stx = oauth.appClient([AppScopes.EVENTS]); // mints the token, reuses it and renews it before it expires
const events = await stx.events({ status: "scheduled", limit: 10 });

oauth.clientCredentials(scopes) returns one raw token when you want to manage it yourself. An app token has no refresh token; mint another.

A client that holds a write scope (markets.write) has an IP allow-list, and it applies to every write that client makes. A write from another address throws STXIPNotAllowedException (a STXForbiddenException, error: "ip_not_allowed"); reads keep working.

The same flow, started from your server. Your app opens /auth/stx/start in the system browser sheet (ASWebAuthenticationSession on iOS, Custom Tabs on Android), and your server sends the member back to the app with a universal link or app link carrying your own session. The app never holds STX tokens.

import * as WebBrowser from "expo-web-browser";
// ticket: a one-time code from your server, so your own session token never goes into a URL
const start = `https://api.example.com/auth/stx/start?ticket=${ticket}&connection=google&login_hint=${encodeURIComponent(email)}`;
const result = await WebBrowser.openAuthSessionAsync(start, "myapp://stx-connected");
// result.url is myapp://stx-connected?status=connected (or cancelled)

connection and login_hint are hints: they preselect the member’s account, and the member still signs in themselves. Your server’s /auth/stx/start and /auth/stx/callback are the start() and finish() routes above; on success it redirects to myapp://stx-connected?status=connected. Use the system sheet, not an embedded web view: Google blocks sign-in inside embedded web views, and RFC 8252 (OAuth 2.0 for Native Apps) requires the system browser.