Skip to content
Insy
For developers
Esc
navigateopen⌘Jpreview
On this page

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

Handle 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=7b1e4c9a2f6d8035

If 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=7b1e4c9a2f6d8035

An 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"
  }'

Next

Was this page helpful?