Guides
Build an app (OAuth 2.0)
Third-party apps act on behalf of other TheComNet merchants. The merchant approves your app once; you get tokens scoped to their tenant through the OAuth 2.0 authorization code flow with PKCE.
Register an app
Any tenant owner or admin can register apps under Developer Console → Aplikasi. An app has:
- A client id (
tcn_app_…) and a client secret (tcn_cs_…). The secret is shown once; rotate it to get a new one, after which the old secret stops working. - Redirect URIs, up to 10, matched exactly (no prefix or wildcard matching). They must be
https;httpis allowed only for the loopback hostslocalhostand127.0.0.1. Fragments (#…) are not allowed. - The maximum scopes the app may request. The scopes are the same as for API keys; see the scopes table.
- Optionally, a webhook URL and events, delivered for every installation (see App webhooks).
App lifecycle
| Status | Who can authorize | Notes |
|---|---|---|
development | Only the developer’s own tenant | For testing. New apps start here. |
in_review | Only the developer’s own tenant | Set by Ajukan review, which requires a description and a website. |
published | Any tenant | TheComNet approved the app. |
rejected | Only the developer’s own tenant | The review note says why. Fix the app and submit again. |
suspended | Nobody | All tokens stop working and the token endpoint answers unauthorized_client. |
Other tenants that open the authorization URL of an unpublished app see an error on TheComNet (OAUTH_APP_NOT_AVAILABLE).
Flow
- 1Your appGenerates state and a PKCE code_verifier, stores both in the user’s session, and redirects the browser to /oauth/authorize.
- 2TheComNetSigns the user in, lets them pick a store account (tenant) where they are owner/admin, and shows the consent screen.
- 3MerchantApproves (or denies) the requested scopes.
- 4TheComNetRedirects the browser to <redirect_uri>?state=…&code=… (or error=access_denied).
- 5Your appChecks state, then POSTs code + code_verifier to /v1/oauth/token with its client credentials (server-side).
- 6TheComNetReturns an access token (1 h), a refresh token (90 days), the granted scopes, tenant_id, and installation_id.
- 7Your appCalls the API with Authorization: Bearer tcn_at_…, and refreshes before the access token expires.
1. Authorization request
Send the merchant’s browser to https://app.comnethub.app/oauth/authorize:
https://app.comnethub.app/oauth/authorize
?client_id=tcn_app_…
&redirect_uri=https%3A%2F%2Fexample.com%2Foauth%2Fcallback
&scope=orders%3Aread%20products%3Aread
&state=Zk3q…
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256| Parameter | Required | Meaning |
|---|---|---|
client_id | yes | Your app’s client id. |
redirect_uri | yes | One of the registered redirect URIs, exactly. |
scope | no | Space-separated scopes, a subset of the app’s. Empty or omitted means all of the app’s scopes. |
state | recommended | Opaque value echoed back on the redirect. Bind it to the user’s session and check it (CSRF). |
code_challenge | recommended | Base64url SHA-256 of code_verifier, without padding (43 characters). |
code_challenge_method | with challenge | S256 |
PKCE is recommended for every app and required in practice for public or native clients, which cannot keep a secret. When a challenge was sent, the token request must carry the matching code_verifier.
The merchant signs in, chooses a store account (tenant) where they are owner or admin, and approves. The installing tenant needs the Pro plan (API access); the consent screen says so otherwise. Authorizing again for an existing installation replaces its scopes.
Redirect back to your app
https://example.com/oauth/callback?state=Zk3q…&code=8fKq…
https://example.com/oauth/callback?state=Zk3q…&error=access_denied- The code is valid for 10 minutes and can be used once.
- An unknown
client_idor an unregisteredredirect_uriis shown as an error on TheComNet and never redirected, so a forged request cannot bounce users to an arbitrary site.
2. Exchange the code
POST https://api.comnethub.app/v1/oauth/token- Body:
application/x-www-form-urlencoded(JSON is accepted too). - Client authentication: HTTP Basic with
client_id:client_secret, orclient_idandclient_secretin the body. grant_type=authorization_codewithcode,redirect_uri(the same value as in the authorization request), andcode_verifier(when a challenge was sent).
curl -X POST "https://api.comnethub.app/v1/oauth/token" \
-u "$THECOMNET_CLIENT_ID:$THECOMNET_CLIENT_SECRET" \
-d grant_type=authorization_code \
-d code="$CODE" \
--data-urlencode redirect_uri="https://example.com/oauth/callback" \
-d code_verifier="$CODE_VERIFIER"{
"access_token": "tcn_at_…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "tcn_rt_…",
"scope": "orders:read products:read",
"tenant_id": "0192f0c0-1111-7aaa-8bbb-0123456789ab",
"installation_id": "0192f0e1-2c3d-7e4f-8a5b-6c7d8e9fa0b1"
}scope is what the merchant granted. Key your stored connection on tenant_id (or installation_id).
3. Refresh tokens
Access tokens last one hour (expires_in: 3600). Get a new pair with grant_type=refresh_token:
curl -X POST "https://api.comnethub.app/v1/oauth/token" \
-u "$THECOMNET_CLIENT_ID:$THECOMNET_CLIENT_SECRET" \
-d grant_type=refresh_token \
-d refresh_token="$REFRESH_TOKEN"- Refresh tokens last 90 days and rotate on every use: the response carries a new refresh token and the old one stops working.
- Store the new refresh token atomically with the access token, before using it. Serialize refreshes per installation.
- Presenting an already-used refresh token, or replaying an authorization code, is treated as theft: every token of the installation is revoked and the merchant must authorize again.
Token endpoint errors
The token and revoke endpoints answer in the OAuth format (RFC 6749), not as problem details:
{
"error": "invalid_grant",
"error_description": "Authorization code expired"
}| error | Status | When |
|---|---|---|
invalid_request | 400 | A required parameter is missing (e.g. token on revoke). |
invalid_client | 401 | Unknown client id or wrong secret. Sent with a www-authenticate: Basic header. |
invalid_grant | 400 | Unknown, expired, or replayed code or refresh token; redirect_uri or code_verifier mismatch; the app was uninstalled. |
unauthorized_client | 400 | The app is suspended. |
unsupported_grant_type | 400 | grant_type is not authorization_code or refresh_token. |
Handle invalid_grant on refresh by marking the connection as disconnected and asking the merchant to reconnect. The token and revoke endpoints are rate limited to 120 requests per minute per IP. See also Errors.
Revoke a token
POST https://api.comnethub.app/v1/oauth/revoke with token=… (RFC 7009) and the same client authentication. It always answers 200 for an authenticated client, including for unknown tokens.
curl -X POST "https://api.comnethub.app/v1/oauth/revoke" \
-u "$THECOMNET_CLIENT_ID:$THECOMNET_CLIENT_SECRET" \
-d token="$REFRESH_TOKEN"- Revoking a refresh token ends every token of the installation (the app stays installed but must be authorized again).
- Revoking an access token ends only that token.
4. Call the API
curl "https://api.comnethub.app/v1/developer/token-info" \
-H "Authorization: Bearer $ACCESS_TOKEN"- App tokens use the same endpoints and scopes as API keys; see the API reference and the scopes table. Endpoints not open to API keys answer 403
API_KEY_NOT_ALLOWED. - Rate limit: 600 requests per minute per installation. See Rate limits.
- The token acts as the member who installed the app. If that member is removed or loses owner/admin, calls answer 401 and the tenant must authorize again.
{
"tenantId": "0192f0c0-1111-7aaa-8bbb-0123456789ab",
"kind": "app",
"keyId": null,
"keyName": null,
"appId": "0192f0e0-1b2c-7d3e-8f4a-5b6c7d8e9f0a",
"appName": "Acme ERP Sync",
"installationId": "0192f0e1-2c3d-7e4f-8a5b-6c7d8e9fa0b1",
"scopes": [
"orders:read",
"products:read"
],
"sandbox": false
}Full example
Express app: redirect with PKCE and state, callback, code exchange, refresh, and an API call.
import crypto from 'node:crypto';
import express from 'express';
import session from 'express-session';
const APP = 'https://app.comnethub.app';
const API = 'https://api.comnethub.app';
const CLIENT_ID = process.env.THECOMNET_CLIENT_ID; // tcn_app_…
const CLIENT_SECRET = process.env.THECOMNET_CLIENT_SECRET; // tcn_cs_…, server-side only
const REDIRECT_URI = 'https://example.com/oauth/callback'; // registered exactly
const SCOPES = 'orders:read products:read';
const app = express();
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }));
const b64url = (buf) => buf.toString('base64url');
// 1. Send the merchant to TheComNet with state + PKCE.
app.get('/connect', (req, res) => {
const state = b64url(crypto.randomBytes(24));
const verifier = b64url(crypto.randomBytes(32)); // 43 chars
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
req.session.oauth = { state, verifier };
const qs = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: SCOPES,
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
res.redirect(`${APP}/oauth/authorize?${qs}`);
});
// Token endpoint call with HTTP Basic client auth.
async function tokenRequest(params) {
const res = await fetch(`${API}/v1/oauth/token`, {
method: 'POST',
headers: {
Authorization: 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64'),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(params),
});
const body = await res.json();
if (!res.ok) throw Object.assign(new Error(body.error_description ?? body.error), { oauth: body.error });
return body;
}
// 2. Callback: verify state, exchange the code (valid 10 min, single use).
app.get('/oauth/callback', async (req, res) => {
const saved = req.session.oauth;
delete req.session.oauth;
if (!saved || req.query.state !== saved.state) return res.status(400).send('Invalid state');
if (req.query.error) return res.status(400).send(`Not connected: ${req.query.error}`);
const t = await tokenRequest({
grant_type: 'authorization_code',
code: String(req.query.code),
redirect_uri: REDIRECT_URI,
code_verifier: saved.verifier,
});
await db.saveConnection({
tenantId: t.tenant_id,
installationId: t.installation_id,
accessToken: t.access_token,
refreshToken: t.refresh_token,
scope: t.scope,
expiresAt: Date.now() + t.expires_in * 1000,
});
res.send('Connected');
});
// 3. Refresh shortly before expiry. Refresh tokens rotate: store the new pair atomically.
async function accessTokenFor(tenantId) {
const c = await db.getConnection(tenantId);
if (c.expiresAt - Date.now() > 60_000) return c.accessToken;
try {
const t = await tokenRequest({ grant_type: 'refresh_token', refresh_token: c.refreshToken });
await db.updateTokens(tenantId, {
accessToken: t.access_token,
refreshToken: t.refresh_token,
expiresAt: Date.now() + t.expires_in * 1000,
});
return t.access_token;
} catch (e) {
// Uninstalled, expired, or replayed: ask the merchant to reconnect.
if (e.oauth === 'invalid_grant') await db.markDisconnected(tenantId);
throw e;
}
}
// 4. Call the API as the installation.
async function listOrders(tenantId) {
const res = await fetch(`${API}/v1/orders?limit=50`, {
headers: { Authorization: `Bearer ${await accessTokenFor(tenantId)}` },
});
if (res.status === 401) await db.markDisconnected(tenantId); // uninstalled or installer lost access
if (!res.ok) throw new Error(`${res.status} ${(await res.json()).code}`);
return res.json();
}
app.listen(3000);Uninstall
A merchant can uninstall your app at any time under Pengaturan → Integrasi → Aplikasi terpasang. Its access tokens stop at once (401) and refresh fails with invalid_grant. Handle both by marking the connection as disconnected and offering a reconnect.
App webhooks
If the app has a webhook URL and events, every installing tenant automatically gets a webhook endpoint managed by the app.
- Deliveries are signed with the app’s webhook secret, using the same scheme as Webhooks. One secret covers all installations.
- The envelope’s
tenantIdtells which installation an event belongs to. - Changing the URL or events, or rotating the secret, applies to all installations at once.
- The tenant sees the endpoint but cannot edit or delete it (
WEBHOOK_MANAGED_BY_APP). Uninstalling the app removes it.
Best practices
- Keep the client secret server-side. Never ship it in browser or mobile code.
- Generate a fresh
stateper request and verify it on the callback. - Use PKCE (
S256) for every authorization request. - Refresh shortly before expiry, one refresh at a time per installation, and store rotated tokens atomically.
- On
invalid_grantor a 401, ask the merchant to reconnect instead of retrying. - Request the fewest scopes your app needs.
Test with your own tenant while the app is in development (sandbox mode), then press Ajukan review.