Server-side flow
The confidential-client OAuth 2.0 walkthrough for Sign in with Insy — authorize, exchange the code for tokens, call a resource endpoint, and refresh.
This is the flow for a confidential client: an application with a backend that can keep
client_secret out of the browser. You send the user to Insy, receive a short-lived
authorization code on your redirect URI, and exchange that code for tokens from your server.
If your application cannot hold a secret — a single-page app, a mobile app, a browser widget — use a public client with PKCE instead. See PKCE and public clients.
Register your client
You register the client yourself at
Account → Developer → OAuth clients. You supply
an application name, your redirect URIs and the scopes you need; you receive a client_id (format
oac_ plus 24 hex characters) and
a client_secret (format ocs_ plus 48 hex characters). The secret is shown once — store it in
your secret manager, never in a repository or a client bundle.
Only redirect URIs registered up front are accepted. An unregistered redirect_uri is rejected
with an HTTP 400 rather than redirected, so register every environment you intend to run in.
Build the authorization URL
Send the user’s browser to https://api.insy.io/oauth/authorize with the parameters below. This
endpoint is browser-facing: it always answers with a 302, or a 400 in the cases where it refuses
to redirect.
| Parameter | Required | Description |
|---|---|---|
response_type |
No | Accepted but not enforced — the server never validates it and always treats the flow as Authorization Code. Send code for spec compliance. |
client_id |
Yes | Your client identifier, oac_ plus 24 hex characters. |
redirect_uri |
Yes | Must exactly match one of the redirect URIs registered for the client. |
scope |
Yes | Space- or comma-separated list. Currently memberships.read and products.read. |
state |
No | Opaque value echoed back on the redirect, max 1024 characters. Strongly recommended — it is your CSRF defence. |
code_challenge |
Public clients | Base64url SHA-256 of your code_verifier. Optional for confidential clients. |
code_challenge_method |
No | Optional — S256 is assumed whenever a code_challenge is present. Only S256 is accepted; no other method is supported. |
import { randomBytes } from "node:crypto";
const state = randomBytes(16).toString("hex");
// Persist `state` against the user's session — you compare it on the way back.
session.oauthState = state;
const authorizeUrl = new URL("https://api.insy.io/oauth/authorize");
authorizeUrl.searchParams.set("response_type", "code");
authorizeUrl.searchParams.set("client_id", process.env.INSY_CLIENT_ID);
authorizeUrl.searchParams.set(
"redirect_uri",
"https://app.example.com/auth/insy/callback",
);
authorizeUrl.searchParams.set("scope", "memberships.read products.read");
authorizeUrl.searchParams.set("state", state);
response.redirect(authorizeUrl.toString());The resulting URL looks like this:
https://api.insy.io/oauth/authorize
?response_type=code
&client_id=oac_9f2c41ab77e05d3612b8c4ef
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Finsy%2Fcallback
&scope=memberships.read%20products.read
&state=7b1e4c9a2f6d8035Handle the redirect and verify state
After the user signs in and approves the request, Insy redirects the browser back to your
redirect_uri with code and state in the query string:
https://app.example.com/auth/insy/callback?code=c4f81a30d97b6e25f0a3c8d17b492e6a&state=7b1e4c9a2f6d8035If the user declines, you get an error instead of a code — with state still echoed back:
https://app.example.com/auth/insy/callback?error=access_denied&state=7b1e4c9a2f6d8035An unknown scope comes back the same way, as error=invalid_scope. Two failures are not
redirected at all, because Insy cannot trust the destination: an unknown client_id and an
unregistered redirect_uri both return an HTTP 400 with a JSON body. If you are staring at a
400 in the browser, check those two values first.
app.get("/auth/insy/callback", async (request, response) => {
const { code, state, error } = request.query;
// Constant-time-ish comparison is unnecessary here, but the check is not:
// an unsolicited callback with no matching state is a CSRF attempt.
if (!state || state !== request.session.oauthState) {
return response.status(400).send("State mismatch");
}
delete request.session.oauthState;
if (error) {
// The redirect carries `error` and `state` only — never an error_description.
return response.status(400).send(`Insy returned ${error}`);
}
const tokens = await exchangeCode(code);
// ... persist the tokens against your own user record
});Exchange the code for tokens
POST /oauth/token accepts either application/json or
application/x-www-form-urlencoded. Send client_id and client_secret in the body.
| Field | Required | Description |
|---|---|---|
grant_type |
Yes | authorization_code for this step. |
client_id |
Yes | Your client identifier. |
client_secret |
Confidential clients | Required for confidential clients. Public clients must omit it. |
code |
Yes | The code from the redirect. |
redirect_uri |
Yes | The same value you sent to /oauth/authorize. |
code_verifier |
With PKCE | Required whenever the authorization request carried a code_challenge. |
curl -i -X POST https://api.insy.io/oauth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "authorization_code",
"client_id": "oac_9f2c41ab77e05d3612b8c4ef",
"client_secret": "ocs_3b71e0d9c85a24f6b0d78e13a45c9f27e6b18d04a7c35f92",
"code": "c4f81a30d97b6e25f0a3c8d17b492e6a",
"redirect_uri": "https://app.example.com/auth/insy/callback"
}'async function exchangeCode(code) {
const res = await fetch("https://api.insy.io/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code",
client_id: process.env.INSY_CLIENT_ID,
client_secret: process.env.INSY_CLIENT_SECRET,
code,
redirect_uri: "https://app.example.com/auth/insy/callback",
}),
});
// The success status is 201. Test for any 2xx — never for `res.status === 200`.
if (!res.ok) {
const body = await res.json();
throw new Error(`${body.error}: ${body.error_description}`);
}
return res.json();
}A successful exchange returns HTTP 201 and this body:
{
"access_token": "a1b7c3d9e5f20486b3c1d7e9f5a20486b3c1d7e9",
"refresh_token": "5e2c9a71d84f0b36c7a1e93f5b820d4ec6a37f18",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "memberships.read products.read"
}Errors come back as a JSON object with error and error_description. invalid_client is
returned with HTTP 401; every other token error is HTTP 400:
{
"error": "invalid_grant",
"error_description": "Authorization code is invalid or expired"
}Tokens are opaque random strings. Do not attempt to decode or parse them — there is no JWT
payload, no id_token and no OpenID Connect discovery document behind them.
Call a resource endpoint
Send the access token as a bearer token. Start with GET /oauth/userinfo, which requires no
scope and gives you the stable identifier to key your own records on.
curl https://api.insy.io/oauth/userinfo \
-H "Authorization: Bearer a1b7c3d9e5f20486b3c1d7e9f5a20486b3c1d7e9"const res = await fetch("https://api.insy.io/oauth/userinfo", {
headers: { Authorization: `Bearer ${accessToken}` },
});
const profile = await res.json();{
"sub": "3f6b1c88-5d2e-4a71-9c40-8b71e2f0a5d3",
"email": "maja@example.com"
}sub is the Insy user UUID and is stable — store it. email can be null, so never use it as
your primary key.
With the memberships.read scope you can then read the user’s active memberships:
curl https://api.insy.io/oauth/memberships \
-H "Authorization: Bearer a1b7c3d9e5f20486b3c1d7e9f5a20486b3c1d7e9"Resource endpoints return bare JSON, not the success / data envelope used elsewhere in the
API. On failure you get invalid_token with HTTP 401, or insufficient_scope with HTTP 403.
No WWW-Authenticate header is sent, so branch on the status code and the error field.
See the endpoint reference for every response shape.
Refresh before the access token expires
An access token lives for one hour (expires_in: 3600). A refresh token lives for 30 days on a
sliding window: each refresh issues a new pair and a fresh 30-day window, so a user who returns
at least once a month never has to sign in again.
curl -i -X POST https://api.insy.io/oauth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "refresh_token",
"client_id": "oac_9f2c41ab77e05d3612b8c4ef",
"client_secret": "ocs_3b71e0d9c85a24f6b0d78e13a45c9f27e6b18d04a7c35f92",
"refresh_token": "5e2c9a71d84f0b36c7a1e93f5b820d4ec6a37f18"
}'// Refresh a minute early so an in-flight request never races the expiry.
const REFRESH_MARGIN_MS = 60_000;
async function getAccessToken(account) {
if (Date.now() < account.expiresAt - REFRESH_MARGIN_MS) {
return account.accessToken;
}
const res = await fetch("https://api.insy.io/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "refresh_token",
client_id: process.env.INSY_CLIENT_ID,
client_secret: process.env.INSY_CLIENT_SECRET,
refresh_token: account.refreshToken,
}),
});
if (!res.ok) {
const body = await res.json();
// invalid_grant here means the refresh token is spent, revoked or 30 days old.
throw new Error(`${body.error}: ${body.error_description}`);
}
const tokens = await res.json();
await saveTokens(account.id, {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + tokens.expires_in * 1000,
});
return tokens.access_token;
}The response is the same 201 and the same body shape as the code exchange. Always persist the new
refresh_token — the old one stops working the moment the new pair is issued.
Lifetimes at a glance
| Credential | Lifetime | Notes |
|---|---|---|
| Authorization code | 60 seconds | Single use. Exchange it in the callback handler. |
| Access token | 1 hour (expires_in: 3600) |
Revoked early by a refresh or by POST /oauth/revoke. |
| Refresh token | 30 days, sliding | Each refresh mints a new pair with a fresh 30-day window and revokes the old pair. |
Signing the user out
To drop a token deliberately, call POST /oauth/revoke with your client credentials and the
token. It returns HTTP 200 with an empty body whether or not the token existed, so treat a 200 as
“the token is gone” rather than as confirmation that it was ever valid.
curl -X POST https://api.insy.io/oauth/revoke \
-H "Content-Type: application/json" \
-d '{
"client_id": "oac_9f2c41ab77e05d3612b8c4ef",
"client_secret": "ocs_3b71e0d9c85a24f6b0d78e13a45c9f27e6b18d04a7c35f92",
"token": "5e2c9a71d84f0b36c7a1e93f5b820d4ec6a37f18",
"token_type_hint": "refresh_token"
}'