Connect Discord or Whop

View as MarkdownOpen the editor

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).

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) 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

StatusCodeWhat to do
404user_not_foundThe username is wrong. Ask the member for it again.
403fair_useThe Protected indicator is at 1,000 people (Fair use).
429rate_limitedMore 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), compare it with your own, and PUT or DELETE the difference, or send it as one batch (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.