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:
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, alwaysA 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.
- In the Developer tab of your Whop dashboard, create a webhook for
membership.updatedthat points at your server, and copy its secret. - Save the handler as
whop.mjs. SetOPENMARKET_ACCESS_KEY, andWHOP_WEBHOOK_SECRETto thews_secret exactly as Whop shows it. - Run
node whop.mjs(Node 18 or later) where Whop can reach it.
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.
- In the Discord Developer Portal, on your app's Bot page, turn on Server Members Intent. The bot needs it to see role changes.
- Run
npm install discord.js@14, and save the bot asbot.mjs. - Set
OPENMARKET_ACCESS_KEYandDISCORD_TOKEN, and runnode bot.mjs.
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). |
| 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), 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.