---
title: Scopes
description: The two OAuth scopes Insy grants, what each one unlocks, how to request them, and why products.read is not what most people assume.
---

A scope is the permission a user grants your application on the consent screen. Insy has two, and
that is the complete list. Each one unlocks exactly one resource endpoint.

`scope` is **required** on every authorize request. RFC 6749 makes it optional; Insy does not. A
request without it will not produce an authorization code.

## The two scopes

| Scope | Unlocks | Returns |
| --- | --- | --- |
| `memberships.read` | `GET /oauth/memberships` | The memberships flagged active on the user's account. |
| `products.read` | `GET /oauth/products` | The digital products the user has **created as a seller**. Communities are not included. |

`GET /oauth/userinfo` needs **no scope at all**. Any valid access token can read it, so you can
identify a user without asking for either permission.

### memberships.read

Returns every membership whose `isActive` flag is set. There is no date filter on the query —
`isActive` is maintained by Insy's billing and expiry jobs, so check `validFrom` and `validUntil`
yourself if you need a hard boundary rather than a flag. The list is not paginated.

```json title="GET /oauth/memberships"
{
  "memberships": [
    {
      "id": "3f9a1c02-7b41-4e8d-9a63-5c0e2d81f7ab",
      "productType": "community",
      "isActive": true,
      "validFrom": "2026-01-14T09:12:44.000Z",
      "validUntil": "2026-09-14T09:12:44.000Z"
    }
  ]
}
```

An empty `memberships` array means the user has no membership flagged active — it does not mean the
request failed.

### products.read

Returns the digital products the user created as a seller. Communities are not products in this
sense and are never returned — a seller whose catalogue is only paid communities gets an empty array.

```json title="GET /oauth/products"
{
  "products": [
    {
      "id": "b71e4d90-2a55-4c31-8f0d-6e9a3b12c4df",
      "name": "Trading Signals",
      "slug": "trading-signals",
      "type": "course"
    }
  ]
}
```

`type` is one of `ebook`, `course`, `video`, `audio`, `document`, `consultation` or `other`, and may
be `null` if the seller never set one.

> **products.read is the seller's catalogue, not the buyer's library**
>
> `GET /oauth/products` returns products the user **created** as a seller. It does **not** return
> products the user purchased.
>
> If you are building a "what has this customer bought" feature, `products.read` is the wrong scope
> and will return an empty array for every ordinary customer. Use `memberships.read` instead — active
> memberships are how Insy represents what a user currently has access to.

## Requesting scopes

Scopes go in a single `scope` parameter, separated by a space or a comma — both work, and you
can mix them. In a URL a space is percent-encoded as `%20`. The examples below use a space,
which is the RFC 6749 convention.

<CodeGroup>
```text title="Authorize URL"
https://api.insy.io/oauth/authorize
  ?response_type=code
  &client_id=oac_9f2c41e7a83b06d5c4e19f27
  &redirect_uri=https%3A%2F%2Fshop.example%2Fcallback
  &scope=memberships.read%20products.read
  &state=1f7c9b2e4a8d
```

```js title="Node.js"
const authorizeUrl = new URL("https://api.insy.io/oauth/authorize");
authorizeUrl.searchParams.set("response_type", "code");
authorizeUrl.searchParams.set("client_id", "oac_9f2c41e7a83b06d5c4e19f27");
authorizeUrl.searchParams.set("redirect_uri", "https://shop.example/callback");
authorizeUrl.searchParams.set("scope", "memberships.read products.read");
authorizeUrl.searchParams.set("state", crypto.randomUUID());

// URLSearchParams encodes the separating space for you.
console.log(authorizeUrl.toString());
```

```html title="Login widget"
<script
  async
  src="https://insy.io/oauth/widget.js"
  data-client-id="oac_9f2c41e7a83b06d5c4e19f27"
  data-scope="memberships.read products.read"
></script>
```
</CodeGroup>

## Check what you were granted

The token response echoes the scopes actually attached to the access token.

```json title="POST /oauth/token — 201"
{
  "access_token": "…",
  "refresh_token": "…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "memberships.read"
}
```

Branch on the `scope` in the response, not on what you asked for. Refreshing a token carries the
same scopes forward; there is no way to widen a token's scope, so to gain a scope you must send the
user through the authorize flow again.

## Unknown or ungranted scopes

If you request a scope that does not exist, the user is redirected back to your `redirect_uri` with
an error instead of a code:

```text
https://shop.example/callback?error=invalid_scope&state=1f7c9b2e4a8d
```

Handle `error` on your callback route the same way you handle `code`. The `state` you sent is
returned with the error, so you can still match the response to the attempt that started it.

Calling a resource endpoint with a token that lacks the required scope returns `403`:

```json title="GET /oauth/memberships — 403"
{
  "error": "insufficient_scope",
  "error_description": "memberships.read scope required"
}
```

An expired or revoked token returns `401` with `invalid_token`. Note that neither response carries a
`WWW-Authenticate` header, so read the JSON body to tell the two cases apart.

## Ask for the minimum

Request only the scopes the feature you are shipping actually needs.

- **Signing users in?** Ask for the narrowest scope that works. `/oauth/userinfo` needs none, so a
  pure sign-in integration does not need `memberships.read` at all.
- **Gating content on an active subscription?** `memberships.read` alone.
- **Building a creator-facing dashboard over someone's own catalogue?** That is the case
  `products.read` is for.

Every extra scope is one more line on the consent screen. You cannot widen a token later, so it is
tempting to request everything up front — resist it, and re-run consent when a feature genuinely
needs more.
