---
title: API keys
description: How Insy API keys look, how to get one, how to send them, and how the permission bitmask and community-role check decide what a key may do.
---

Server-to-server calls to the Insy REST API authenticate with an API key. A key carries a
fixed set of permissions and belongs to a person, so what it can do is the intersection of
its permission bits and that person's role in the community you name.

## What a key looks like

A key is the prefix `insy_` followed by 32 lowercase hexadecimal characters — 16 random
bytes rendered as hex.

```text title="Example key"
insy_4f3a9c1e7b2d5081a6c4e9f3b7d2508a
```

Insy stores only an unsalted SHA-256 digest of the key. The plaintext is displayed exactly
once, at creation, and cannot be recovered afterwards. Copy it into your secret store the
moment you receive it; if you lose it, the only remedy is a new key.

## Getting a key

You issue your own keys from
[**Account → Developer → API keys**](https://insy.io/account/developer/api-keys) — create one,
pick its permissions, pin it to your servers' IPs, rotate its secret and revoke it, without
asking anyone.

1. **Create the key**

    Open [API keys](https://insy.io/account/developer/api-keys) and press **Create key**. Give
    it a name you will recognise in six months — the name is all you see in the list, since the
    secret never appears again.

2. **Pick the permissions**

    Tick the ones from the [table below](#permissions) your integration actually needs rather
    than all of them. `MEMBERSHIP_WRITE` covers the external join-Telegram funnel; add
    `CHECKOUT_WRITE` only if you also open hosted checkout sessions. The privileged permission
    is not offered on this form at all — see
    [Privileged permissions](#privileged-permissions).

3. **Restrict it to your servers**

    Optional, and worth doing: list the public egress IPs your backend calls from, so a leaked
    key is useless anywhere else. See [IP allowlist](#ip-allowlist).

4. **Store the plaintext**

    The key is shown once, when you press create. Put it in your secret manager or environment
    before you close the dialog.

A key belongs to your **account**, not to one community: it reaches every community where that
account holds an active OWNER or ADMIN membership, and no others. See
[Community role](#community-role-is-checked-separately) below.

> **Permissions and the allowlist are set once**
>
> A key's own screen offers rotate and revoke, not edit. To widen a key's permissions or change
> its IP allowlist, create a replacement and revoke the old one — rotation only swaps the secret.

## Sending a key

Put the key in the `Authorization` header as a bearer token.

```http title="Authorization header"
Authorization: Bearer insy_4f3a9c1e7b2d5081a6c4e9f3b7d2508a
```

There is no `x-api-key` header. A request without the `Authorization` header, or with a key
that is unknown or revoked, is rejected with `401`.

> **Do not use the query-string form**
>
> The bearer strategy underneath also accepts the key as an `access_token` query parameter or
> body field. Do not use either: a key in a URL leaks into access logs, proxy history and
> browser history, and it never expires. Always send the `Authorization` header.

<CodeGroup>
```bash title="curl"
curl -X POST https://api.insy.io/membership/provision-external \
  -H "Authorization: Bearer insy_4f3a9c1e7b2d5081a6c4e9f3b7d2508a" \
  -H "Content-Type: application/json" \
  -d '{
    "communityId": "3b7d1f42-9c8a-4e51-b0d6-2a4f8e7c1953",
    "externalOrderId": "order_10241",
    "email": "buyer@example.com"
  }'
```

```js title="Node.js"
// Built-in fetch — Node 18 or newer, no dependencies.
const response = await fetch("https://api.insy.io/membership/provision-external", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.INSY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    communityId: "3b7d1f42-9c8a-4e51-b0d6-2a4f8e7c1953",
    externalOrderId: "order_10241",
    email: "buyer@example.com",
  }),
});

const body = await response.json();

if (!response.ok || !body.success) {
  throw new Error(`${body.errorCode ?? response.status}: ${body.message ?? "request failed"}`);
}

console.log(body.data);
```
</CodeGroup>

## Permissions

Permissions are a bitmask. Each permission is a power of two, and a key stores the bitwise
OR of everything it was granted. Every bit has a readable **scope string** — the same grant
in a friendlier form.

| Permission | Value | Scope string | Unlocks |
| --- | --- | --- | --- |
| `MEMBERSHIP_READ` | 1 | `membership.read` | `GET /membership/check-telegram/{communityId}/{telegramUserId}` |
| `MEMBERSHIP_WRITE` | 2 | `membership.write` | `GET /membership/join-telegram-community-external/{externalId}` |
| `CHECKOUT_WRITE` | 4 | `checkout.write` | `POST /api/checkout/buy-plan` |
| `DROP_NOTIFICATION_WRITE` | 8 | `drop_notification.write` | `POST /api/drop-notification/signup` |
| `MEMBERSHIP_PROVISION` | 16 | `membership.provision` | `POST /membership/provision-external` — **private**, see below |

So a key with value `3` holds `MEMBERSHIP_READ` and `MEMBERSHIP_WRITE`; a key with all four
**standard** permissions stores `15`. Ask for the smallest set that covers your integration —
a key you issue for a checkout page has no reason to also write memberships.

## Privileged permissions

`MEMBERSHIP_PROVISION` (value `16`) is a **privileged** permission. It unlocks
`POST /membership/provision-external`, which mints paid community access without a payment,
so it is granted only to vetted partners — request it from support@insy.io.

It sits deliberately **outside** the standard set. `provision-external` requires
`MEMBERSHIP_WRITE` **and** `MEMBERSHIP_PROVISION` together, so a broad key that holds every
standard permission (`15`) still gets a `403` there until the provision bit is added on top.
This is intentional: provisioning is never unlocked by breadth alone, only by an explicit,
separately-granted permission.

## Community role is checked separately

Whenever a request carries a `communityId` — in the body or in the path — the permission bit
is not enough on its own. The person who owns the key must also hold an active **OWNER** or
**ADMIN** membership in that community.

If the bit is set but the role is not, the request fails with `403`. This is the most common
surprise when a key that works against one community is pointed at another.

> **Two independent checks**
>
> A `MEMBERSHIP_WRITE` key works against every community its owner administers, and none of
> the others — the permission bit and the community role are checked independently.

## Lifetime and revocation

Keys you create yourself have no expiry date, and there is no rotation deadline — a key keeps
working until you rotate or revoke it. (The backend does honour an expiry when one is set, but
the self-serve form does not offer the field.)

Revocation is a soft disable: the key is marked inactive and stops authenticating
immediately, on the next request. Nothing it created is rolled back, and memberships it
provisioned stay in place.

## Rotation

Press **Rotate** on a key in
[API keys](https://insy.io/account/developer/api-keys). Rotation issues a new secret for the
same key — its name, permissions and IP allowlist are unchanged — and the plaintext is shown
once, exactly like at creation. The old secret stops working the moment you rotate, so deploy
the new one to a place it can be swapped in quickly, then rotate.

The list shows a **Last used** timestamp per key, so you can tell a live key from a forgotten
one before you retire it. It is written at most once a minute, so treat it as "used recently",
not as a call counter — there is no per-request log.

## IP allowlist

A key can be pinned to a set of source addresses. Leave the allowlist empty and the key works
from anywhere; add entries and every other source address is rejected with `401`.

Each entry is an exact IP or an IPv4 CIDR range:

```text title="Allowed IPs"
203.0.113.7
203.0.113.0/24
```

The source address is taken from the proxy chain in front of the API (the same one the rate
limiter trusts), so put your server's public egress IP here — not a private `10.x`/`192.168.x`
address, which never appears as the source. If a restricted key stops working after an
infrastructure change, its egress IP has almost certainly moved.

## Security

> **Never ship a key to a browser or mobile app**
>
> An API key is a bearer credential with no origin restriction and no user binding. Anything
> that holds it can act as you, in every community you administer, until it is revoked.
> Bundled JavaScript, mobile binaries, public repositories and CI logs all count as
> published. If you need a browser-side flow, use
> [Sign in with Insy](/developers/oauth) instead — it is designed for untrusted front ends.

- **Server-side only.** Call the API from your backend and expose your own endpoint to your
  front end.
- **Keep it out of source control.** Read it from the environment or a secret manager.
  Never commit it, and never paste it into a support ticket or an issue.
- **Keep it out of URLs.** Send it in the `Authorization` header only, never as a query
  parameter, so it does not end up in logs you do not control.
- **Scope it down.** Request only the permission bits your integration needs.
- **Rotate on suspicion.** If a key may have leaked, rotate it yourself — the old secret dies
  immediately, and the replacement keeps the same permissions and
  [IP allowlist](#ip-allowlist).

## Operational notes

- Requests authenticated with an API key bypass the platform's request-signature
  requirement, so there is nothing extra to compute or sign.
- API-key requests are not rate limited by the application. Be a good citizen anyway:
  retry with backoff rather than hammering, and use the idempotency contract described in
  [Endpoints](/developers/api/endpoints) so a retry cannot double-provision.
- The only usage signal is the **Last used** timestamp, throttled to one write a minute.
  There is no per-request log, so keep your own record of the calls you make if you need an
  audit trail.
