Skip to content
Console

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.

  • Authorization request (GET): none. The user signs in on the official site and consents.
  • Token exchange: client_id and the PKCE code_verifier. A confidential client also sends client_secret.
  • User info: Authorization: Bearer <access_token>.

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_uri list: URLs to return to after authorization. Each one can be an https:// URL or the app’s custom scheme (for example myapp://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_secret when exchanging the code. Keep the secret on the server.
  1. The app generates a random code_verifier, computes code_challenge = base64url(SHA-256(code_verifier)) (no = padding), and generates a random state. Store both in the user’s session.

  2. Send the user to the authorization request URL.

  3. 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.

  4. 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>
  5. The app checks that state matches step 1, then calls exchange the code for a token from your server or the native app, trading code and code_verifier for an access_token.

  6. (Optional) Call user info to get the user id and email.

  7. Call the API with access_token, the same way as any API Token: SSE streams use Authorization: Bearer or the query token, and HTTP APIs that require a token use the X-Api-Token header. See API Token.

Send the user’s browser to this URL (a top-level navigation, or the system browser). Do not use fetch.

GEThttps://manager.exptech.com.tw/api/v1/oauth2/authorize
ServerManager
Query parameters
  • response_typestringRequired

    Always code

  • client_idstringRequired

    The client id from registration

  • redirect_uristringRequired

    Must match one registered value exactly

  • code_challengestringRequired

    base64url(SHA-256(code_verifier)), with no padding

  • code_challenge_methodstringRequired

    Always S256. plain is not supported

  • scopestringOptional

    Permission names or groups separated by spaces, for example eq.report trem.rts. If omitted, the token has no permissions

  • statestringOptional

    A random value for CSRF protection. It is returned unchanged on redirect_uri. Strongly recommended

Request
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."
Response

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=af0ifjsldkj

client_id or redirect_uri is wrong. The server does not redirect back to the app (zstd-compressed JSON)

{
"error": "invalid_redirect_uri"
}
Try it
Query parameters

The token is sent from your browser directly to the API.

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 the scope in 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.

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.

POSThttps://manager.exptech.com.tw/api/v1/oauth2/authorize
ServerManager
Query parameters
  • query parametersstring

    Same as the authorization request (response_type, client_id, redirect_uri, code_challenge, code_challenge_method, scope, state)

Request body
  • actionstringOptional

    Form field. deny rejects the request. Any other value, or omitting it, is treated as approval

Request
curl -i -X POST "https://manager.exptech.com.tw/api/v1/oauth2/authorize"
Response

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 authorization code for an access_token. The body is an application/x-www-form-urlencoded form. JSON is not accepted.

POSThttps://manager.exptech.com.tw/api/v1/oauth2/token
ServerManager
Request body
  • grant_typestringRequired

    Always authorization_code

  • codestringRequired

    The authorization code from the redirect to redirect_uri

  • redirect_uristringRequired

    Must match the authorization request exactly

  • client_idstringRequired

    Must match the authorization request

  • code_verifierstringRequired

    The original PKCE verifier from step 1

  • client_secretstringOptional

    Required for a confidential client. A public client does not send it

Request
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>"
Response

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"
}
FieldTypeDescription
access_tokenstringAPI Token (starts with et_). It appears only in this response. Store it on your server
token_typestringAlways Bearer
scopestringPermissions 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, or server_error, that code cannot be used again. Start the authorization over. invalid_request, unsupported_grant_type, and invalid_client happen before matching, so the code can be retried until it expires.
  • The full error list is in Error codes.

Use access_token to read the authorized user’s id and email. The token’s permissions are not checked. Any valid token works.

GEThttps://manager.exptech.com.tw/api/v1/oauth2/userinfo
ServerManager
Headers
  • AuthorizationRequired

    Bearer <access_token>. Exactly one space after Bearer

Request
curl --compressed "https://manager.exptech.com.tw/api/v1/oauth2/userinfo" \
-H "Authorization: Bearer <access_token>. Exactly one space after Bearer"
Response

Success (zstd-compressed JSON)

{
"sub": "123",
"email": "[email protected]"
}

No Bearer token, or the token is invalid or revoked. Empty bodyNo response body

Try it
Headers

The token is sent from your browser directly to the API.

FieldTypeDescription
substringUser id (a decimal digit string). Stable for the same user. Use it to identify the account
emailstringThe user's email. It can be an empty string in rare cases

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 to
export 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_URI
export 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’s Accept-Encoding. Recent Node.js fetch decompresses this automatically. curl needs zstd support (zstd in the Features line of curl --version) to decode with --compressed. Otherwise use curl -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 fetch in 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 revoked and disconnects.
  • token_type is Bearer, but HTTP data APIs use the X-Api-Token header. Authorization: Bearer is 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.