---
title: "Connect Discord or Whop"
description: "Sell access through Whop or a paid Discord role, and a small script you run uses an access key to keep your indicator's people list in sync."
order: 65
section: "sharing"
---

<!-- source: docs/indicators/sharing/connect-discord-or-whop.md; generated by packages/cli/scripts/gen-indicator-docs.ts, do not edit -->

# Connect Discord or Whop

Sell access through Whop or a paid Discord role, and a small script you run uses an access key to keep your indicator's people list in sync.

There is no official connector. This is a small script you run yourself, on
your own server, and OpenMarket's side is two calls.

## The idea

When someone gains access on your side, add them. When they lose it, remove
them:

```text
Base URL       https://registry.openmarket.xyz/v1/packages/@you/your-indicator
Authorization  Bearer <access key>

PUT     /access/<their OpenMarket username>    they paid, or got the role: 200
DELETE  /access/<their OpenMarket username>    they left, or lost the role: 204, always
```

A PUT can carry `{"until": "<ISO date>"}` to end access on a date. Without
it, access lasts until your DELETE. Both calls are safe to repeat, so an
event that arrives twice does no harm. The key comes from the People panel
([Make a key](access-keys-and-api.md#make-a-key)).

## Collect each member's username

The calls name a person by their OpenMarket username, so that is the one
thing you must collect. Ask once, and keep it beside the customer:

- **Whop**: add a custom field to your checkout that asks for it. The answer
  arrives with the membership.
- **Discord**: members type `/openmarket <username>` in your server, and the
  bot below saves it.

Or skip usernames: make each customer a personal link keyed by your own
`ref` ([Personal links](access-keys-and-api.md#personal-links)) and send it
to them yourself. They join by opening it, and `DELETE /access/ref:<your ref>`
removes them later.

## Whop

Whop calls your server with a webhook when a membership changes. Subscribe it
to `membership.updated`: Whop sends it when a membership activates and when
it deactivates, with the membership's current `status`. The handler checks
Whop's signature, adds the member while the status keeps access (`trialing`,
`active`, `past_due`, `completed`), and removes them when it is `canceled` or
`expired`.

1. In the Developer tab of your Whop dashboard, create a webhook for
   `membership.updated` that points at your server, and copy its secret.
2. Save the handler as `whop.mjs`. Set `OPENMARKET_ACCESS_KEY`, and
   `WHOP_WEBHOOK_SECRET` to the `ws_` secret exactly as Whop shows it.
3. Run `node whop.mjs` (Node 18 or later) where Whop can reach it.

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const PACKAGE = "@you/your-indicator"; // your indicator's full name
const QUESTION = "OpenMarket username"; // the label of your Whop checkout field
const KEEP = ["trialing", "active", "past_due", "completed"]; // Whop statuses with access
const END = ["canceled", "expired"]; // Whop statuses without it
const ACCESS = `https://registry.openmarket.xyz/v1/packages/${PACKAGE}/access`;

// PUT or DELETE one person. false: no OpenMarket account has that username.
async function access(method, username) {
  const res = await fetch(`${ACCESS}/${encodeURIComponent(username)}`, {
    method,
    headers: { Authorization: `Bearer ${process.env.OPENMARKET_ACCESS_KEY}` },
  });
  if (res.ok) return true;
  const { error } = await res.json().catch(() => ({}));
  if (error?.code === "user_not_found") return false;
  throw new Error(`${method} ${username}: ${res.status} ${error?.code ?? ""}`);
}

// Whop signs "<webhook-id>.<webhook-timestamp>.<body>" with HMAC-SHA256, keyed
// by the whole ws_ secret, and sends "v1,<base64>" in webhook-signature.
function signedByWhop(headers, body) {
  const { "webhook-id": id, "webhook-timestamp": sent, "webhook-signature": signatures = "" } = headers;
  if (!id || !(Math.abs(Date.now() / 1000 - Number(sent)) <= 300)) return false;
  const hmac = createHmac("sha256", process.env.WHOP_WEBHOOK_SECRET).update(`${id}.${sent}.${body}`);
  const expected = Buffer.from(`v1,${hmac.digest("base64")}`);
  return signatures.split(" ").some((entry) => {
    const given = Buffer.from(entry);
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}

// The member's answer to your checkout field.
function usernameOf(membership) {
  return membership.custom_field_responses?.find((field) => field.question === QUESTION)?.answer.trim();
}

createServer(async (req, res) => {
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  const body = Buffer.concat(chunks).toString();
  if (!signedByWhop(req.headers, body)) return res.writeHead(401).end();
  const { type, data } = JSON.parse(body);
  const username = type === "membership.updated" ? usernameOf(data) : undefined;
  try {
    if (username && KEEP.includes(data.status) && !(await access("PUT", username))) {
      console.warn(`${username} is not an OpenMarket username (Whop membership ${data.id})`);
    }
    if (username && END.includes(data.status)) await access("DELETE", username);
    res.writeHead(200).end();
  } catch (error) {
    console.error(error);
    res.writeHead(500).end(); // Whop sends the event again later
  }
}).listen(Number(process.env.PORT ?? 3000));
```

If your webhook's payload carries no `custom_field_responses` (newer Whop API
versions leave them out), `usernameOf` is the one function to change.

A username that matches no OpenMarket account is logged, so you can ask that
member again. Any other failure answers Whop with an error, and Whop sends
the event again later.

## Discord

A bot watches your paid role. A member who gets the role is added; one who
loses it, or leaves the server, is removed. `/openmarket` saves the member's
username and, if they already have the role, adds them at once.

1. In the Discord Developer Portal, on your app's **Bot** page, turn on
   **Server Members Intent**. The bot needs it to see role changes.
2. Run `npm install discord.js@14`, and save the bot as `bot.mjs`.
3. Set `OPENMARKET_ACCESS_KEY` and `DISCORD_TOKEN`, and run `node bot.mjs`.

```javascript
import { Client, Events, GatewayIntentBits, MessageFlags, SlashCommandBuilder } from "discord.js";

const PACKAGE = "@you/your-indicator"; // your indicator's full name
const PAID_ROLE = "Premium"; // the role your members pay for
const ACCESS = `https://registry.openmarket.xyz/v1/packages/${PACKAGE}/access`;

const usernames = new Map(); // Discord user id to OpenMarket username

// PUT or DELETE one person. false: no OpenMarket account has that username.
async function access(method, username) {
  const res = await fetch(`${ACCESS}/${encodeURIComponent(username)}`, {
    method,
    headers: { Authorization: `Bearer ${process.env.OPENMARKET_ACCESS_KEY}` },
  });
  if (res.ok) return true;
  const { error } = await res.json().catch(() => ({}));
  if (error?.code === "user_not_found") return false;
  throw new Error(`${method} ${username}: ${res.status} ${error?.code ?? ""}`);
}

const paid = (member) => member.roles.cache.some((role) => role.name === PAID_ROLE);

const command = new SlashCommandBuilder()
  .setName("openmarket")
  .setDescription("Link your OpenMarket username")
  .addStringOption((option) =>
    option.setName("username").setDescription("Your OpenMarket username").setRequired(true),
  );

const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] });

client.once(Events.ClientReady, async () => {
  for (const guild of client.guilds.cache.values()) {
    await guild.members.fetch(); // load everyone, so each role change arrives with the roles before it
    await guild.commands.set([command]);
  }
});

client.on(Events.GuildMemberUpdate, async (before, after) => {
  const username = usernames.get(after.id);
  if (username && paid(before) !== paid(after)) await access(paid(after) ? "PUT" : "DELETE", username);
});

client.on(Events.GuildMemberRemove, async (member) => {
  const username = usernames.get(member.id);
  if (username && paid(member)) await access("DELETE", username);
});

client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isChatInputCommand() || interaction.commandName !== "openmarket") return;
  await interaction.deferReply({ flags: MessageFlags.Ephemeral });
  const username = interaction.options.getString("username", true).trim();
  const previous = usernames.get(interaction.user.id);
  usernames.set(interaction.user.id, username);
  let reply = `Saved. Your access starts when you get the ${PAID_ROLE} role.`;
  try {
    if (paid(interaction.member)) {
      if (previous && previous !== username) await access("DELETE", previous);
      reply = (await access("PUT", username))
        ? "You're in. Find it under Shared with you in Indicators on OpenMarket."
        : "No OpenMarket account has that username. Check it and try again.";
    }
  } catch (error) {
    console.error(error);
    reply = "Something went wrong. Try again in a minute.";
  }
  await interaction.editReply(reply);
});

process.on("unhandledRejection", console.error);
client.login(process.env.DISCORD_TOKEN);
```

The sample keeps usernames in memory, so a restart forgets them. Keep them in
a database.

## Checks and limits

| Status | Code | What to do |
| --- | --- | --- |
| 404 | `user_not_found` | The username is wrong. Ask the member for it again. |
| 403 | `fair_use` | The Protected indicator is at 1,000 people ([Fair use](overview.md#fair-use)). |
| 429 | `rate_limited` | More than 600 writes in a minute on this key. Wait the seconds the `retry-after` header names. |

Webhooks get missed and bots restart. Once a night, read the list with
`GET /access`, a page at a time
([Check and list](access-keys-and-api.md#check-and-list)), compare it with
your own, and PUT or DELETE the difference, or send it as one batch
([Many at once](access-keys-and-api.md#many-at-once)).

The key works for this one indicator and nothing else. **Revoke** on its
sheet in the People panel stops it at once.
