---
title: Endpoints
description: Reference for the five Insy REST endpoints an API key can call — membership provisioning, Telegram invites and checks, hosted checkout and drop signups.
---

An API key can call five endpoints. This is the complete list — nothing else on
`https://api.insy.io` accepts key authentication.

| Endpoint | Permission |
| --- | --- |
| `POST /membership/provision-external` | `MEMBERSHIP_WRITE` + `MEMBERSHIP_PROVISION` (private) |
| `GET /membership/join-telegram-community-external/{externalId}` | `MEMBERSHIP_WRITE` |
| `GET /membership/check-telegram/{communityId}/{telegramUserId}` | `MEMBERSHIP_READ` |
| `POST /api/checkout/buy-plan` | `CHECKOUT_WRITE` |
| `POST /api/drop-notification/signup` | `DROP_NOTIFICATION_WRITE` |

Every request carries `Authorization: Bearer insy_...`. Responses use the
[BaseResponse envelope](/developers/api#the-response-envelope), so the payloads below are what you find
under `data`.

> **Community role**
>
> Every endpoint that takes a `communityId` also requires the key owner to hold an active
> OWNER or ADMIN membership in that community. The permission bit alone is not enough — see
> [API keys](/developers/api/authentication).

## POST `/membership/provision-external`

**Permission:** `MEMBERSHIP_WRITE` **and** `MEMBERSHIP_PROVISION` (a privileged,
separately-granted permission), plus OWNER or ADMIN in `communityId`. See
[Privileged permissions](/developers/api/authentication#privileged-permissions).

Provisions a community membership from an external funnel. Insy finds the user by the
identifier you supply, creates the user if there is no match, and grants or adjusts their
membership in the community.

Use this when the sale happened somewhere else — your own checkout, a course platform, a
manual bank transfer — and you want Insy to be the system of record for access.

### Parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `communityId` | uuid | yes | The community to provision into. |
| `externalOrderId` | string | yes | Your identifier for the order. Acts as the idempotency key. |
| `email` | string | no | The buyer's email. Supply this or `telegramUserId`. |
| `telegramUserId` | integer | no | The buyer's numeric Telegram user ID. Supply this or `email`. |
| `pricingPlanId` | uuid | no | The pricing plan the membership should be attached to. |
| `validFrom` | ISO-8601 | no | When access starts. Defaults to now, and is ignored on a first provision — see below. |
| `validUntil` | ISO-8601 | no | When access ends. Omit for a lifetime membership. |
| `onOverlap` | string | no | `extend`, `replace` or `error`. Defaults to `extend`. |

Identify the user with `email` or `telegramUserId`. If you have both, sending both gives
Insy the best chance of matching an existing person rather than creating a second one.

> **validFrom is ignored when the membership is created**
>
> `validFrom` is honoured only when the buyer already has a membership row in this community.
> On a first provision — the call that answers `"status": "created"` — access starts at the
> moment of the call, whatever you sent. Read `data.validFrom` in the response to see what was
> actually stored.

### Idempotency

`externalOrderId` is the idempotency key, and the guarantee is narrower than the name
suggests. Insy keeps no ledger of the order IDs it has processed. It stores the ID on the
buyer's membership row for that community and compares against that single value, so a
replay is recognised only while it is still the most recent provision for the same buyer in
the same community. When it is recognised, the existing membership is returned untouched
with `status` set to `idempotent_return`.

If your request times out, your webhook handler runs twice, or a queue redelivers a job,
send exactly the same `externalOrderId` again and the buyer ends up with one membership.

Two replays are **not** protected, and both provision again:

- Replaying order A after you have provisioned order B for the same buyer and community.
  The stored ID is now B's, so A no longer matches.
- Sending the same `externalOrderId` with a different `email` or `telegramUserId`. That
  resolves to a different buyer, whose membership row has never seen the ID.

> **Make the key stable and unique**
>
> Derive `externalOrderId` from something durable on your side — your order ID, your payment
> intent ID — not from a timestamp or a random value generated at call time. A fresh value on
> every attempt defeats the protection and provisions repeatedly.

### Overlap strategies

`onOverlap` decides what happens when the user already has a membership in this community
that overlaps the window you are asking for.

| Value | Behaviour |
| --- | --- |
| `extend` | Default. Keeps whichever window ends later — your `validUntil` or the existing one. If either side is open-ended the membership becomes lifetime. |
| `replace` | Discards the existing window and applies yours. |
| `error` | Refuses the request with `409` rather than touching the existing membership. |

`extend` does not add your period to the time remaining. Send the absolute end date you
want, not a duration to add: a `validUntil` 30 days out for a member with 20 days left
leaves that member with 30 days in total, not 50. Compute the new end date from the existing
one on your side if you need the two to accumulate.

`onOverlap` only comes into play when the existing membership is still active. An expired
membership is overwritten with the window you send whichever strategy you choose.

Pick `extend` for renewals and top-ups, `replace` when your system is the authority on the
access window, and `error` when an overlap means something has gone wrong upstream and you
would rather investigate than guess.

### Lifetime memberships

Omit `validUntil` entirely to grant lifetime access. Do not send `null` or a far-future
date — the absence of the field is the signal.

### Result statuses

The response reports what actually happened.

| Status | Meaning |
| --- | --- |
| `created` | The buyer had no membership row in this community. |
| `extended` | A membership row existed and was active. |
| `replaced` | A membership row existed but had lapsed. |
| `idempotent_return` | The `externalOrderId` matches the one stored on the buyer's membership; it is returned unchanged. |

`status` describes the state of the buyer's membership before the call, not the strategy you
sent. An `onOverlap: "replace"` request against an active membership still reports
`extended`, and an `onOverlap: "extend"` request against a lapsed one reports `replaced`.
Use it to tell a first purchase from a renewal, not to confirm which strategy ran.

### Request

<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",
    "pricingPlanId": "a91f0c46-5d3b-4e88-9a72-1f6b0d4c8e37",
    "validFrom": "2026-09-01T00:00:00.000Z",
    "validUntil": "2026-10-01T00:00:00.000Z",
    "onOverlap": "extend"
  }'
```

```js title="Node.js"
const provision = async (order) => {
  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",
      // Stable per order, so a retry cannot provision twice.
      externalOrderId: order.id,
      email: order.buyerEmail,
      validFrom: order.paidAt,
      validUntil: order.accessEndsAt,
      onOverlap: "extend",
    }),
  });

  const body = await response.json();
  if (!response.ok || !body.success) {
    throw new Error(`${body.errorCode ?? response.status}: ${body.message}`);
  }
  return body.data;
};
```
</CodeGroup>

### Response

```json title="Response"
{
  "success": true,
  "data": {
    "membershipId": "7c1d0a58-4b2e-4f39-8e6a-05c9f2b7d413",
    "userId": "e4a2b915-83c7-4d60-9f1e-6b0a37c5d284",
    "validFrom": "2026-09-01T00:00:00.000Z",
    "validUntil": "2026-10-01T00:00:00.000Z",
    "status": "created"
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `membershipId` | uuid | The membership that was created or updated. This is the only handle you ever get on it — store it against your order. |
| `userId` | uuid | The Insy user the membership belongs to, matched or created from the identifier you sent. |
| `validFrom` | ISO-8601 | When access actually starts, as stored. On a first provision this is the time of the call, not the `validFrom` you sent. |
| `validUntil` | ISO-8601 or null | When access ends. `null` for a lifetime membership. |
| `status` | string | What state the membership was in — see [Result statuses](#result-statuses). |

A recognised repeat of the same `externalOrderId` returns the same five fields with
`"status": "idempotent_return"` and the membership exactly as it already stands.

> **Lifetime example**
>
> Send the same body without `validUntil`:
>
> ```json
> {
>   "communityId": "3b7d1f42-9c8a-4e51-b0d6-2a4f8e7c1953",
>   "externalOrderId": "order_10242",
>   "email": "buyer@example.com"
> }
> ```

## GET `/membership/join-telegram-community-external/{externalId}`

**Permission:** `MEMBERSHIP_WRITE`.

Returns a fresh Telegram invite link for a checkout session you opened with
`POST /api/checkout/buy-plan`, along with the timestamp at which the link expires.

`externalId` is the value you sent to buy-plan — not the `externalOrderId` from
`provision-external`. Insy resolves the checkout reservation by that value, and the link is
available only once the buyer's payment has completed and the reservation has been turned
into a membership. Before that you get `404` with "Membership not found, membership has not
been processed yet". Memberships created with `provision-external` have no reservation
behind them and cannot be used here at all.

Call this at the moment you are about to show the buyer their invite. The link expires 24
hours after it is minted and is bound to the buyer's Telegram account — a join request from
any other Telegram account is rejected. If more than a day has passed, call the endpoint
again for a new one.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `externalId` | path | string | yes | The `externalId` you sent to `POST /api/checkout/buy-plan`. |

### Request

```bash title="curl"
curl https://api.insy.io/membership/join-telegram-community-external/lead_88213 \
  -H "Authorization: Bearer insy_4f3a9c1e7b2d5081a6c4e9f3b7d2508a"
```

### Response

```json title="Response"
{
  "success": true,
  "data": {
    "link": "https://telegram.me/joinchat/AAAAAE1z7Kq9Rr0pXv3Ttw",
    "expiresAt": "2026-09-02T10:00:00.000Z"
  }
}
```

Present `link` to the buyer immediately and do not cache it past `expiresAt` — 24 hours
after the call.

> **Not every failure here is a non-2xx**
>
> If the community has no Telegram bot configured, this endpoint answers HTTP `200` with
> `"success": false` and the message `Telegram bot not setup`, with `data` set to `null`.
> Check for `data?.link` rather than trusting the status code alone — reading `data.link`
> without the optional chain throws on that failure path. See [Errors](/developers/api/errors).

## GET `/membership/check-telegram/{communityId}/{telegramUserId}`

**Permission:** `MEMBERSHIP_READ`, plus OWNER or ADMIN in `communityId`.

Reports whether a given Telegram user currently holds an active membership in the community.

Use it to gate something on your own side — a support form, a private feed, a bot command —
without mirroring Insy's membership state into your database.

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `communityId` | path | uuid | yes | The community to check. |
| `telegramUserId` | path | integer | yes | The numeric Telegram user ID. |

### Request

```bash title="curl"
curl https://api.insy.io/membership/check-telegram/3b7d1f42-9c8a-4e51-b0d6-2a4f8e7c1953/482910375 \
  -H "Authorization: Bearer insy_4f3a9c1e7b2d5081a6c4e9f3b7d2508a"
```

### Response

```json title="Response"
{
  "success": true,
  "data": {
    "isActive": true,
    "membershipId": "7c1d0a58-4b2e-4f39-8e6a-05c9f2b7d413"
  }
}
```

`membershipId` is `null` when there is no active membership. This is a point-in-time answer —
a membership that expires a minute later still reads as active. For state changes as they
happen, subscribe to [membership webhooks](/developers/webhooks/events) instead of polling.

## POST `/api/checkout/buy-plan`

**Permission:** `CHECKOUT_WRITE`, plus OWNER or ADMIN in `communityId`.

Creates a hosted checkout session and returns the URL to send the buyer to. Insy handles
payment, the membership grant and the receipts; you handle the redirect.

### Parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `pricingPlanId` | uuid | yes | The plan being bought. |
| `communityId` | uuid | yes | The community the plan belongs to. |
| `externalId` | string | yes | Your own identifier for the buyer. Reuse it later to fetch the Telegram invite. |
| `email` | string | yes | The buyer's email. Lower-cased and trimmed. |
| `successUrl` | url | yes | Where to send the buyer after a completed payment. |
| `cancelUrl` | url | yes | Where to send the buyer if they abandon checkout. |
| `utmSource` | string | no | Max 100 characters. Shown in Insy purchase reporting. |

`email` is not a pre-fill. It is the identity the checkout is bound to: Insy resolves it to
an existing user or creates one, and the membership from the completed payment lands on that
account. The published OpenAPI schema marks the field optional, but the endpoint rejects a
body without it.

Set `utmSource` to the campaign, page or partner that produced the click — it is the only
way to attribute a sale created this way back to its origin in Insy's purchase reporting.

> **buy-plan does not signal failure through the status code**
>
> Once the request passes validation and auth, buy-plan reports its own failures inside a
> `201` — so past that point neither the status nor `success` is a reliable check. An unknown
> `pricingPlanId` returns `"success": true` with an empty `data` and no `checkoutUrl`. A
> missing `email` returns `"success": false` with a `message` and no `errorCode`, still with
> status `201`. Treat a missing `data.checkoutUrl` as the failure signal and read `message`
> for the reason.
>
> A malformed or unauthorized request never reaches that path. A non-URL `successUrl` or
> `cancelUrl`, a malformed UUID, or a missing `externalId` is rejected by the validator with
> `400`; a missing or invalid key returns `401`; and a key without `CHECKOUT_WRITE`, or whose
> owner is not OWNER or ADMIN of the community, returns `403`.

### Request

<CodeGroup>
```bash title="curl"
curl -X POST https://api.insy.io/api/checkout/buy-plan \
  -H "Authorization: Bearer insy_4f3a9c1e7b2d5081a6c4e9f3b7d2508a" \
  -H "Content-Type: application/json" \
  -d '{
    "pricingPlanId": "a91f0c46-5d3b-4e88-9a72-1f6b0d4c8e37",
    "communityId": "3b7d1f42-9c8a-4e51-b0d6-2a4f8e7c1953",
    "externalId": "lead_88213",
    "successUrl": "https://shop.example/thanks",
    "cancelUrl": "https://shop.example/pricing",
    "email": "buyer@example.com",
    "utmSource": "newsletter-september"
  }'
```

```js title="Node.js"
const response = await fetch("https://api.insy.io/api/checkout/buy-plan", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.INSY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    pricingPlanId: "a91f0c46-5d3b-4e88-9a72-1f6b0d4c8e37",
    communityId: "3b7d1f42-9c8a-4e51-b0d6-2a4f8e7c1953",
    externalId: lead.id,
    successUrl: "https://shop.example/thanks",
    cancelUrl: "https://shop.example/pricing",
    email: lead.email,
    utmSource: "newsletter-september",
  }),
});

const body = await response.json();

// buy-plan answers 201 even when it failed, so the URL is the only signal.
const url = body?.data?.checkoutUrl;
if (!url) {
  throw new Error(body?.message ?? "checkout session was not created");
}
// Redirect the buyer to url.
```
</CodeGroup>

### Response

```json title="Response"
{
  "success": true,
  "data": {
    "checkoutUrl": "https://checkout.stripe.com/c/pay/cs_live_9d2f7a136c404b8ea1f53e07b9c2d846"
  }
}
```

Redirect the buyer to `checkoutUrl`. Do not treat the redirect to `successUrl` as proof of
payment — a buyer can reach that URL by hand. Confirm with the
[`payment.success` webhook](/developers/webhooks/events).

## POST `/api/drop-notification/signup`

**Permission:** `DROP_NOTIFICATION_WRITE`, plus OWNER or ADMIN in `communityId`.

Signs an email address up for a community's next upcoming drop slot, so the person is
notified when that drop opens. An optional phone number can be captured alongside it.

Use it behind a waitlist form on your own landing page when a community sells in limited
drops.

> **Addresses that already have an Insy account are skipped**
>
> If the email already belongs to an Insy user, no signup is created. The call still answers
> `201` with `"success": true`, but `data.signedUp` is `false` and `data.hasExistingAccount`
> is `true`. The lookup is global across Insy rather than scoped to your community, so any
> address that has ever registered is dropped this way. Always read `data.signedUp` — the
> status code and `success` will not tell you.

### Parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `communityId` | uuid | yes | The community whose next drop the person is joining. |
| `email` | string | yes | The address to notify. |
| `phoneNumber` | string | no | An optional phone number for the same signup. |

### Request

```bash title="curl"
curl -X POST https://api.insy.io/api/drop-notification/signup \
  -H "Authorization: Bearer insy_4f3a9c1e7b2d5081a6c4e9f3b7d2508a" \
  -H "Content-Type: application/json" \
  -d '{
    "communityId": "3b7d1f42-9c8a-4e51-b0d6-2a4f8e7c1953",
    "email": "waitlist@example.com",
    "phoneNumber": "+48500100200"
  }'
```

### Response

```json title="Response"
{
  "success": true,
  "data": {
    "signedUp": true,
    "hasExistingAccount": false,
    "dropSlotInfo": {
      "id": "5f8c2b41-9a70-4e2d-b3c8-71e0d6a4f925",
      "slots": 200,
      "slotsUsed": 137,
      "availableSlots": 63,
      "startsAt": "2026-09-15T18:00:00.000Z",
      "endsAt": null,
      "isCurrentlyActive": false
    }
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `signedUp` | boolean | Whether a signup was recorded. `false` when the address already has an Insy account. |
| `hasExistingAccount` | boolean | `true` when the address was skipped for that reason. |
| `dropSlotInfo` | object | The drop slot the signup was aimed at, including its start and its remaining slots. `slots` and `availableSlots` are `null` for an unlimited drop. |

`dropSlotInfo` also carries a `dropNotification` field (a notification object, or `null`) and
sometimes a `userHasNotification` boolean. Both are internal bookkeeping the client can
ignore.

The signup targets the community's next upcoming drop slot, resolved at the time of the
call. There is no way to name a specific future drop. Signing the same address up twice for
the same drop returns `409` with `errorCode` `already_exists`.

## Next

<CardGroup cols={2}>
  <Card title="Errors" href="/developers/api/errors" icon="circle-alert">
    What the failure statuses mean and what usually causes them.
  </Card>
  <Card title="Webhooks" href="/developers/webhooks" icon="webhook">
    Get told when memberships and payments change, instead of polling.
  </Card>
</CardGroup>
