---
title: Endpoint reference
description: Every Sign in with Insy endpoint — parameters, request and response examples, error codes, and the places Insy deviates from the OAuth RFCs.
---

All OAuth endpoints live under `https://api.insy.io`. There is no version prefix in the path and
no sandbox environment — you develop against production with your own test account.

The OAuth token, revoke, userinfo, memberships and products endpoints return **bare JSON**, not
the `success` / `data` envelope used by the rest of the Insy API. `GET /oauth/client/:clientId` is
the one exception and does use the envelope.

> **Grants supported**
>
> Authorization Code, with and without PKCE, plus `refresh_token`. There is no implicit grant, no
> client credentials grant, no device code grant, and no OpenID Connect layer — no `id_token`, no
> discovery document, no JWKS.

## `GET /oauth/authorize`

Starts a sign-in. Browser-facing: it always answers with a 302, or with a 400 when it refuses to
redirect or the request does not validate.

**Auth:** none. The user authenticates in the browser during this request.

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `response_type` | string | No | Accepted but not enforced — the server never validates it and always treats the flow as `code`. Send `code` for spec compliance. |
| `client_id` | string | Yes | Your client identifier, `oac_` plus 24 hex characters. |
| `redirect_uri` | string | Yes | Must exactly match a redirect URI registered for the client. |
| `scope` | string | Yes | Space- or comma-separated. `memberships.read`, `products.read`, or both. |
| `state` | string | No | Opaque value echoed back on the redirect. Max 1024 characters. Strongly recommended. |
| `code_challenge` | string | Public clients | `base64url(SHA-256(code_verifier))`. Optional for confidential clients. |
| `code_challenge_method` | string | No | Optional — `S256` is assumed whenever a `code_challenge` is present. Only `S256` is accepted; `plain` is rejected. |

```text title="Request"
GET 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
```

```http title="302 Found"
Location: https://app.example.com/auth/insy/callback
  ?code=c4f81a30d97b6e25f0a3c8d17b492e6a
  &state=7b1e4c9a2f6d8035
```

**Errors**

| Condition | Result |
| --- | --- |
| User declines the consent screen | 302 to `redirect_uri?error=access_denied&state=...` |
| Unknown scope requested | 302 to `redirect_uri?error=invalid_scope&state=...` |
| Unknown `client_id` | HTTP 400 JSON. Deliberately **not** redirected. |
| `redirect_uri` not registered for the client | HTTP 400 JSON. Deliberately **not** redirected. |
| Public client without `code_challenge` | HTTP 400 JSON, `invalid_request`. Checked before the redirect URI is trusted, so it is **not** redirected. |
| Missing `scope`, `state` over 1024 characters, `code_challenge` outside 43–128 characters, `code_challenge_method` other than `S256` | HTTP 400 JSON, `invalid_request`. Not redirected. |

Authorization codes are single-use and expire after **60 seconds**.

## `POST /oauth/token`

Exchanges an authorization code for tokens, or trades a refresh token for a new pair.

**Auth:** client credentials in the request body. Accepts `application/json` or
`application/x-www-form-urlencoded`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `grant_type` | string | Yes | `authorization_code` or `refresh_token`. |
| `client_id` | string | Yes | Your client identifier. |
| `client_secret` | string | Confidential clients | `ocs_` plus 48 hex characters. Public clients must omit it — sending one returns `invalid_client`. |
| `code` | string | For `authorization_code` | The code from the authorize redirect. |
| `redirect_uri` | string | For `authorization_code` | The same value sent to `/oauth/authorize`. |
| `code_verifier` | string | With PKCE | 43–128 characters. Required whenever the authorization request carried a `code_challenge`. |
| `refresh_token` | string | For `refresh_token` | The refresh token from a previous response. |

<CodeGroup>

```bash title="Authorization code"
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"
  }'
```

```bash title="Refresh"
curl -i -X POST https://api.insy.io/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=refresh_token \
  -d client_id=oac_9f2c41ab77e05d3612b8c4ef \
  -d client_secret=ocs_3b71e0d9c85a24f6b0d78e13a45c9f27e6b18d04a7c35f92 \
  -d refresh_token=5e2c9a71d84f0b36c7a1e93f5b820d4ec6a37f18
```

</CodeGroup>

```json title="201 Created"
{
  "access_token": "a1b7c3d9e5f20486b3c1d7e9f5a20486b3c1d7e9",
  "refresh_token": "5e2c9a71d84f0b36c7a1e93f5b820d4ec6a37f18",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "memberships.read products.read"
}
```

> **201 on success, and no HTTP Basic**
>
> The success status is **201**, not 200 — libraries that assert 200 will throw on a perfectly good
> response. And credentials are read from the body only: `client_secret_basic` is not supported, so
> configure your client for `client_secret_post`.

**Errors**

```json title="400 Bad Request"
{
  "error": "invalid_grant",
  "error_description": "Authorization code is invalid or expired"
}
```

`invalid_client` is returned with HTTP **401**. Every other token error is HTTP **400**.

**Lifetimes:** access token 1 hour, refresh token 30 days on a sliding window. A refresh issues a
new pair and immediately revokes both the old refresh token and the old access token. Tokens are
opaque random strings — do not parse them.

## `POST /oauth/revoke`

Revokes an access token or a refresh token.

**Auth:** client credentials in the request body.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_id` | string | Yes | Your client identifier. |
| `client_secret` | string | Yes | Required on this endpoint even for public clients — but a public client's secret is never accepted, so `/oauth/revoke` is confidential-clients-only today. |
| `token` | string | Yes | The token to revoke. |
| `token_type_hint` | string | No | `access_token` or `refresh_token`. |

```bash title="Request"
curl -i -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"
  }'
```

```http title="200 OK"
HTTP/1.1 200 OK
```

Client authentication is checked first: bad or missing credentials answer 401 with
`{"error": "invalid_client"}` and no `error_description`. Once the client is authenticated, the
response body is empty and the status is 200 whether or not the token existed. A 200 tells you the
token is not usable — it is not confirmation that it ever was.

## `GET /oauth/userinfo`

The identity of the signed-in user. This is the endpoint to call first, because `sub` is the value
you key your own records on.

**Auth:** `Authorization: Bearer <access_token>`. No scope required.

```bash title="Request"
curl https://api.insy.io/oauth/userinfo \
  -H "Authorization: Bearer a1b7c3d9e5f20486b3c1d7e9f5a20486b3c1d7e9"
```

```json title="200 OK"
{
  "sub": "3f6b1c88-5d2e-4a71-9c40-8b71e2f0a5d3",
  "email": "maja@example.com"
}
```

| Field | Type | Description |
| --- | --- | --- |
| `sub` | string (uuid) | The Insy user identifier. Stable — store this. |
| `email` | string or null | The user's email address. Can be `null`, so never use it as a key. |

**Errors:** `invalid_token` with HTTP 401.

## `GET /oauth/memberships`

The user's currently active memberships.

**Auth:** `Authorization: Bearer <access_token>` with the `memberships.read` scope.

```bash title="Request"
curl https://api.insy.io/oauth/memberships \
  -H "Authorization: Bearer a1b7c3d9e5f20486b3c1d7e9f5a20486b3c1d7e9"
```

```json title="200 OK"
{
  "memberships": [
    {
      "id": "b28d5f0a-91c4-4e7d-8a63-2f5c07be1d94",
      "productType": "community",
      "isActive": true,
      "validFrom": "2026-07-01T00:00:00.000Z",
      "validUntil": "2026-09-01T00:00:00.000Z"
    }
  ]
}
```

| Field | Type | Description |
| --- | --- | --- |
| `id` | string (uuid) | Membership identifier. |
| `productType` | string | The kind of product the membership grants access to: `community`, `digital_product`, `clipping_campaign` or `agency`. |
| `isActive` | boolean | Always `true` in this response — only active memberships are returned. |
| `validFrom` | string (ISO-8601) | Start of the current access period. |
| `validUntil` | string (ISO-8601) | End of the current access period. Absent or null for lifetime access. |

Only currently-active memberships appear, so an empty array means "no access right now", not "never
had access". The list is **not paginated**.

**Errors:** `invalid_token` with HTTP 401, `insufficient_scope` with HTTP 403.

## `GET /oauth/products`

The products the user has **created as a seller**.

> **Not a purchase history**
>
> These are the user's own products, not the products they have bought. To find out what a user has
> access to, call `GET /oauth/memberships`.

**Auth:** `Authorization: Bearer <access_token>` with the `products.read` scope.

```bash title="Request"
curl https://api.insy.io/oauth/products \
  -H "Authorization: Bearer a1b7c3d9e5f20486b3c1d7e9f5a20486b3c1d7e9"
```

```json title="200 OK"
{
  "products": [
    {
      "id": "0c9a4e17-6b3d-4c02-9f81-73ad5e6c2b18",
      "name": "Trading Signals",
      "slug": "trading-signals",
      "type": "course"
    }
  ]
}
```

| Field | Type | Description |
| --- | --- | --- |
| `id` | string (uuid) | Product identifier. |
| `name` | string | Display name. |
| `slug` | string | URL slug on the storefront. |
| `type` | string or null | Product type. One of `ebook`, `course`, `video`, `audio`, `document`, `consultation`, `other`. Can be `null` for older products. |

**Errors:** `invalid_token` with HTTP 401, `insufficient_scope` with HTTP 403.

## `GET /oauth/client/{clientId}`

The public display identity of a client — name, description and logo. Use it to render your own
consent or "connect" screen without hardcoding the branding.

**Auth:** none. This endpoint is public and unauthenticated.

| Parameter | In | Description |
| --- | --- | --- |
| `clientId` | path | The client identifier, `oac_` plus 24 hex characters. |
| `widget_origin` | query | Optional. The browser origin asking to receive this client's authorization code. When present, the response's `data` carries a `widgetOriginAllowed` boolean. Only the login widget's callback sends it; the consent screen omits it. |

```bash title="Request"
curl https://api.insy.io/oauth/client/oac_9f2c41ab77e05d3612b8c4ef
```

This is the one OAuth endpoint that uses the standard Insy response envelope:

```json title="200 OK"
{
  "success": true,
  "data": {
    "clientId": "oac_9f2c41ab77e05d3612b8c4ef",
    "name": "Example Shop",
    "description": "Sign in to see your memberships",
    "logoUrl": "https://cdn.example.com/logo.png"
  }
}
```

On failure the envelope carries the reason instead:

```json title="Failure envelope"
{
  "success": false,
  "errorCode": "not_found",
  "message": "Unknown client"
}
```

An unknown `clientId` answers with HTTP 404 and this envelope; `errorCode` values are lowercase
snake_case. Keys that are unset are omitted rather than sent as `null` — a successful response
carries no `errorCode`, and a failure carries no `data`.

## Error codes

Token and revoke errors, and errors from the resource endpoints, use the RFC shape:

```json
{
  "error": "invalid_grant",
  "error_description": "Authorization code is invalid or expired"
}
```

Authorize errors are delivered as query parameters on the redirect instead, with `state` echoed
back — except for the ones listed above that are refused before the redirect URI is trusted, which
come back as a 400 in the same RFC shape.

| `error` | HTTP status | Where it appears | Meaning |
| --- | --- | --- | --- |
| `invalid_request` | 400 | `/oauth/token`, `/oauth/revoke`, `/oauth/authorize` | A required parameter is missing or malformed. |
| `invalid_client` | **401** | `/oauth/token`, `/oauth/revoke` | Unknown client, wrong secret, a confidential client that sent no secret, or a public client that sent one. The `/oauth/revoke` variant carries no `error_description`. |
| `invalid_grant` | 400 | `/oauth/token` | The code or refresh token is expired, already used, revoked, or the `code_verifier` does not match the challenge. |
| `invalid_scope` | — | `/oauth/authorize` (as a redirect parameter) | A requested scope is not one of `memberships.read`, `products.read`. Delivered as a 302 redirect, never a 400 body. |
| `access_denied` | — | `/oauth/authorize` (as a redirect parameter) | The user declined the consent screen. |
| `invalid_token` | **401** | Resource endpoints | The access token is missing, expired, revoked or unknown. |
| `insufficient_scope` | **403** | Resource endpoints | The token is valid but lacks the scope, for example `"memberships.read scope required"`. |

## Known deviations from the RFCs

Plan around these rather than assuming a library will cope.

| Deviation | Impact |
| --- | --- |
| `POST /oauth/token` returns **201**, not the RFC 6749 200. | Strict clients throw on success. Accept any 2xx. |
| `client_secret_basic` is not supported. | Credentials must go in the body. Configure `client_secret_post`; most libraries default to Basic. |
| No `WWW-Authenticate` header on 401/403 from resource endpoints. | Bearer-token middleware that parses that header for the error will see nothing. Branch on the status code and the `error` field. |
| `scope` is **required** on `/oauth/authorize`. | RFC 6749 makes it optional. An authorize request without a scope fails. |
| `/oauth/revoke` is unusable by public clients. | RFC 7009 allows an unauthenticated public client to revoke. Insy requires `client_secret` on the request and rejects any secret presented by a public client, so there is no request a public client can make that succeeds. Discard the token locally and let it expire. |
| No refresh-token reuse detection. | A replayed refresh token fails with `invalid_grant` and nothing else happens — there is no cascade revocation and no security signal. Coordinate refreshes yourself when several processes share a token. |
| No OpenID Connect. | No `id_token`, no `/.well-known` discovery document, no JWKS. Identity comes from `GET /oauth/userinfo`. |
| No official SDKs. | Every integration is plain HTTP. The examples in these docs are the reference implementation. |

## Related

<CardGroup cols={2}>
  <Card title="Server-side flow" href="/developers/oauth/server-side" icon="terminal">
    The confidential-client walkthrough, end to end.
  </Card>
  <Card title="PKCE and public clients" href="/developers/oauth/pkce" icon="shield-check">
    For SPAs, mobile apps and anything without a secret.
  </Card>
</CardGroup>
