OAuth2
A third-party app can use the OAuth2 authorization code flow (Authorization Code + PKCE) to get an API Token that represents the user, after the user consents. The token is the same kind the user creates on the dashboard. Usage counts on that user’s account and quota.
Every OAuth2 endpoint is on https://manager.exptech.com.tw.
Authentication
Section titled “Authentication”- Authorization request (GET): none. The user signs in on the official site and consents.
- Token exchange:
client_idand the PKCEcode_verifier. A confidential client also sendsclient_secret. - User info:
Authorization: Bearer <access_token>.
Register a client
Section titled “Register a client”There is no self-service registration. Apply through the contact methods on the About page, and include:
- The app name and a one-line description (shown on the consent page, and used as the note in the user’s token list)
- The
redirect_urilist: URLs to return to after authorization. Each one can be anhttps://URL or the app’s custom scheme (for examplemyapp://oauth/callback). The comparison is an exact string match. Wildcards and prefix matches are not supported - The permissions you need
- The client type:
- Public client (a mobile app or a desktop app): no
client_secret. PKCE is the only check. - Confidential client (a website with its own server): send
client_secretwhen exchanging the code. Keep the secret on the server.
- Public client (a mobile app or a desktop app): no
-
The app generates a random
code_verifier, computescode_challenge = base64url(SHA-256(code_verifier))(no=padding), and generates a randomstate. Store both in the user’s session. -
Send the user to the authorization request URL.
-
After the server checks the parameters, it sends the user to the official site’s sign-in page (if they are not signed in) or to the consent page. This step does not issue an authorization code.
-
The user approves or denies on the consent page. The consent page sends the approve or deny request, and the server redirects the user back to your
redirect_uri:- Approve:
redirect_uri?code=<authorization code>&state=<state> - Deny:
redirect_uri?error=access_denied&state=<state>
- Approve:
-
The app checks that
statematches step 1, then calls exchange the code for a token from your server or the native app, tradingcodeandcode_verifierfor anaccess_token. -
(Optional) Call user info to get the user id and email.
-
Call the API with
access_token, the same way as any API Token: SSE streams useAuthorization: Beareror the querytoken, and HTTP APIs that require a token use theX-Api-Tokenheader. See API Token.
Authorization request
Section titled “Authorization request”Send the user’s browser to this URL (a top-level navigation, or the system browser). Do not use fetch.
https://manager.exptech.com.tw/api/v1/oauth2/authorize response_typeAlways code
client_idThe client id from registration
redirect_uriMust match one registered value exactly
code_challengebase64url(SHA-256(code_verifier)), with no padding
code_challenge_methodAlways S256. plain is not supported
scopePermission names or groups separated by spaces, for example eq.report trem.rts. If omitted, the token has no permissions
stateA random value for CSRF protection. It is returned unchanged on redirect_uri. Strongly recommended
curl -i --compressed -G "https://manager.exptech.com.tw/api/v1/oauth2/authorize" \ --data-urlencode "response_type=code" \ --data-urlencode "client_id=<client_id>" \ --data-urlencode "redirect_uri=<redirect_uri>" \ --data-urlencode "code_challenge=<code_challenge>" \ --data-urlencode "code_challenge_method=S256."const params = new URLSearchParams({ response_type: "code", client_id: "<client_id>", redirect_uri: "<redirect_uri>", code_challenge: "<code_challenge>", code_challenge_method: "S256.",});const res = await fetch(`https://manager.exptech.com.tw/api/v1/oauth2/authorize?${params}`, { redirect: "manual",});console.log(res.status, res.headers.get("location"));import requests
res = requests.get( "https://manager.exptech.com.tw/api/v1/oauth2/authorize", params={ "response_type": "code", "client_id": "<client_id>", "redirect_uri": "<redirect_uri>", "code_challenge": "<code_challenge>", "code_challenge_method": "S256.", }, allow_redirects=False, timeout=10,)print(res.status_code, res.headers.get("Location"))Parameters are valid: redirect to the official sign-in or consent page. Parameters are invalid: redirect back to redirect_uri with error
Location: https://app.example.com/oauth/callback?error=invalid_scope&state=af0ifjsldkjclient_id or redirect_uri is wrong. The server does not redirect back to the app (zstd-compressed JSON)
{ "error": "invalid_redirect_uri"}Try it
When client_id or redirect_uri is wrong, the server does not redirect anywhere (that would be an open redirect). It returns 400 JSON, and the user stays on the error page. Every other error redirects to redirect_uri with error and state in the query:
error |
Condition |
|---|---|
unsupported_response_type |
response_type is not code |
invalid_request |
code_challenge is missing, or code_challenge_method is not S256 |
invalid_scope |
scope contains a permission name that does not exist |
- Permissions actually issued = requested
scope∩ permissions registered for the client. Requesting a permission the client did not register is not an error. That permission is simply not issued. Use thescopein the token response. - Groups (
trem.*,eq.*) expand into individual permissions. - A name that does not exist fails the whole request (
invalid_scope). It is not skipped.
Approve or deny
Section titled “Approve or deny”The official consent page sends this request when the user presses a button. It requires the user to be signed in on the official site. A third-party app does not need to call it, and cannot call it directly. It is listed here to explain the redirect back to redirect_uri.
https://manager.exptech.com.tw/api/v1/oauth2/authorize query parametersSame as the authorization request (response_type, client_id, redirect_uri, code_challenge, code_challenge_method, scope, state)
actionForm field. deny rejects the request. Any other value, or omitting it, is treated as approval
curl -i -X POST "https://manager.exptech.com.tw/api/v1/oauth2/authorize"const res = await fetch("https://manager.exptech.com.tw/api/v1/oauth2/authorize", { method: "POST", redirect: "manual",});console.log(res.status, res.headers.get("location"));import requests
res = requests.post( "https://manager.exptech.com.tw/api/v1/oauth2/authorize", allow_redirects=False, timeout=10,)print(res.status_code, res.headers.get("Location"))Approved: redirect to redirect_uri with code. Denied: error=access_denied
Location: https://app.example.com/oauth/callback?code=4mXq9vKpL2sTzW8yRb1nCe7uJ0hGdA5fYiOkN3wPtQx&state=af0ifjsldkj- An authorization code is valid for 5 minutes and can be used once.
- If the user stays on the consent page too long and the sign-in has expired, they are sent back to the sign-in page, then return to the consent page after signing in.
- If the server cannot create an authorization code, it redirects to
redirect_uri?error=server_error.
Exchange the code for a token
Section titled “Exchange the code for a token”Exchange the authorization code for an access_token. The body is an application/x-www-form-urlencoded form. JSON is not accepted.
https://manager.exptech.com.tw/api/v1/oauth2/token grant_typeAlways authorization_code
codeThe authorization code from the redirect to redirect_uri
redirect_uriMust match the authorization request exactly
client_idMust match the authorization request
code_verifierThe original PKCE verifier from step 1
client_secretRequired for a confidential client. A public client does not send it
curl --compressed "https://manager.exptech.com.tw/api/v1/oauth2/token" \ --data-urlencode "grant_type=authorization_code" \ --data-urlencode "code=<code>" \ --data-urlencode "redirect_uri=<redirect_uri>" \ --data-urlencode "client_id=<client_id>" \ --data-urlencode "code_verifier=<code_verifier>"const res = await fetch("https://manager.exptech.com.tw/api/v1/oauth2/token", { method: "POST", body: new URLSearchParams({ grant_type: "authorization_code", code: "<code>", redirect_uri: "<redirect_uri>", client_id: "<client_id>", code_verifier: "<code_verifier>", }),});if (!res.ok) throw new Error(`HTTP ${res.status}`);const data = await res.json();console.log(data);import requests
res = requests.post( "https://manager.exptech.com.tw/api/v1/oauth2/token", data={ "grant_type": "authorization_code", "code": "<code>", "redirect_uri": "<redirect_uri>", "client_id": "<client_id>", "code_verifier": "<code_verifier>", }, timeout=10,)res.raise_for_status()print(res.json())Success (zstd-compressed JSON)
{ "access_token": "et_3q2-7wAAexampleexampleexampleexampleexample", "token_type": "Bearer", "scope": "trem.rts eq.report"}The authorization code is invalid, already used, or expired, or client_id, redirect_uri, or PKCE does not match
{ "error": "invalid_grant"}The client does not exist, or client_secret is wrong
{ "error": "invalid_client"}The user already has 25 tokens
{ "error": "too_many_tokens"}Response fields
Section titled “Response fields”| Field | Type | Description |
|---|---|---|
access_token | string | API Token (starts with et_). It appears only in this response. Store it on your server |
token_type | string | Always Bearer |
scope | string | Permissions actually granted, separated by spaces, expanded names. Omitted when there are no permissions |
The response has no expires_in and no refresh_token. The token does not expire until the user revokes it.
- The authorization code is invalidated when the server starts matching it. After
invalid_grant,too_many_tokens, orserver_error, that code cannot be used again. Start the authorization over.invalid_request,unsupported_grant_type, andinvalid_clienthappen before matching, so the code can be retried until it expires. - The full error list is in Error codes.
User info
Section titled “User info”Use access_token to read the authorized user’s id and email. The token’s permissions are not checked. Any valid token works.
https://manager.exptech.com.tw/api/v1/oauth2/userinfo AuthorizationBearer <access_token>. Exactly one space after Bearer
curl --compressed "https://manager.exptech.com.tw/api/v1/oauth2/userinfo" \ -H "Authorization: Bearer <access_token>. Exactly one space after Bearer"const res = await fetch("https://manager.exptech.com.tw/api/v1/oauth2/userinfo", { headers: { Authorization: "Bearer <access_token>. Exactly one space after Bearer", },});if (!res.ok) throw new Error(`HTTP ${res.status}`);const data = await res.json();console.log(data);import requests
res = requests.get( "https://manager.exptech.com.tw/api/v1/oauth2/userinfo", headers={ "Authorization": "Bearer <access_token>. Exactly one space after Bearer", }, timeout=10,)res.raise_for_status()print(res.json())Success (zstd-compressed JSON)
{ "sub": "123",}No Bearer token, or the token is invalid or revoked. Empty bodyNo response body
Try it
Response fields
Section titled “Response fields”| Field | Type | Description |
|---|---|---|
sub | string | User id (a decimal digit string). Stable for the same user. Use it to identify the account |
email | string | The user's email. It can be an empty string in rare cases |
Advanced example
Section titled “Advanced example”A complete Node.js server flow for steps 1–6 (this code runs only on the server):
import crypto from "node:crypto";
const AUTH_BASE = "https://manager.exptech.com.tw";const CLIENT_ID = "your-client-id";const REDIRECT_URI = "https://app.example.com/oauth/callback";
// Steps 1 and 2: build PKCE and state, and return the URL to redirect toexport function buildAuthorizeUrl(session) { session.codeVerifier = crypto.randomBytes(32).toString("base64url"); session.state = crypto.randomBytes(16).toString("base64url"); const codeChallenge = crypto .createHash("sha256") .update(session.codeVerifier) .digest("base64url");
const url = new URL("/api/v1/oauth2/authorize", AUTH_BASE); url.search = new URLSearchParams({ response_type: "code", client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, scope: "eq.report trem.rts", state: session.state, code_challenge: codeChallenge, code_challenge_method: "S256", }).toString(); return url.toString();}
// Steps 5 and 6: after the user returns to REDIRECT_URIexport async function handleCallback(session, query) { if (query.error) throw new Error(`authorization failed: ${query.error}`); if (query.state !== session.state) throw new Error("state does not match");
const tokenRes = await fetch(new URL("/api/v1/oauth2/token", AUTH_BASE), { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code: query.code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: session.codeVerifier, // Confidential clients only: // client_secret: process.env.EXPTECH_CLIENT_SECRET, }), }); const token = await tokenRes.json(); if (!tokenRes.ok) throw new Error(`token exchange failed: ${tokenRes.status} ${token.error}`);
const userRes = await fetch(new URL("/api/v1/oauth2/userinfo", AUTH_BASE), { headers: { Authorization: `Bearer ${token.access_token}` }, }); if (!userRes.ok) throw new Error(`user info failed: ${userRes.status}`); const user = await userRes.json();
return { accessToken: token.access_token, scope: token.scope ?? "", userId: user.sub };}- Responses are zstd-compressed. The token exchange, user info, and error JSON always send
Content-Encoding: zstd, regardless of the request’sAccept-Encoding. Recent Node.jsfetchdecompresses this automatically. curl needs zstd support (zstdin the Features line ofcurl --version) to decode with--compressed. Otherwise usecurl -s ... | zstd -d. Other HTTP clients need zstd support, or you decompress the body yourself. - Call these from your server. CORS on these endpoints allows only the official site, so
fetchin a third-party web page cannot read the response. Call the token exchange and user info from your server or a native app. A frontend-only page needs a backend for those two steps. - The token does not expire, and there is no refresh token. Do not rely on
expires_in(the response has no such field). A token stops working in one case: it is revoked. - The user revokes a token on the dashboard. After authorization, the token appears in the user’s API Token list on the dashboard, with your app name as the note. The user can delete it at any time. There is no API for the app to revoke its own token. When you need that, send the user to the dashboard to delete it. After revocation, HTTP returns 401, and an SSE stream receives
token revokedand disconnects. token_typeisBearer, but HTTP data APIs use theX-Api-Tokenheader.Authorization: Beareris for SSE streams and user info.- Each user can have at most 25 tokens, including ones issued through OAuth2. At the limit, the token exchange returns
403 too_many_tokens. The authorization code is already invalid. Ask the user to delete unused tokens, then authorize again. - Rate limit. OAuth2 endpoints are counted per IP, about 4 requests per second, with a short burst of 5. Above that, the response is 429.
- Usage is counted on the user. Data your app reads with this token counts against the user’s usage and quota. Resources that require a subscription have to be subscribed by the user.
