Ready-made recipes

View as MarkdownOpen the editor

Sell your Invite only indicator through Stripe, Patreon or a paid Telegram group, and one small file keeps its people list in step with who pays.

Each recipe is one file: copy it to any server with Node 20 or later, set a few variables, and run it, with nothing to install. It calls your indicator's Access API with an access key (Access keys and the API) and gives each paying member a personal link, keyed by the service's own id for them (Personal links). They open it, sign in or sign up, and have the indicator. When they stop paying, the file takes it away.

RecipeYou sell throughIt runs as
Stripea Payment Link or Checkout, one-time or as a subscriptiona small web server
Patreonyour Patreon tiersa small web server your patrons log in through
Telegram bota paid Telegram group or channela bot, with no public address needed

The code on this page is the code OpenMarket's tests run, byte for byte.

Stripe

Sell through a Stripe Payment Link or Checkout, one-time or as a subscription, and each buyer gets your indicator as soon as they pay. Only the prices you name count, so the same Stripe account can sell other things too.

What it does

  • Pays: after paying, Stripe sends the buyer to your server, which sends them on to their personal link. They sign in or sign up on OpenMarket and have the indicator.
  • Renews: each paid invoice moves their end date to the new paid-through date, plus 3 days in case a renewal runs late.
  • Cancels: when Stripe ends the subscription, the indicator goes with it, unless another subscription of theirs still holds one of your prices. A one-time payment keeps it for good.

Set it up

  1. In the People panel's corner menu, make an Access key for your indicator (Make a key).
  2. In Stripe, on the API keys page, click Create restricted key and give it Read on Checkout Sessions, Subscriptions and Invoices.
  3. On your Payment Link, under After the payment, redirect customers to your website at https://<your server>/thanks?session_id={CHECKOUT_SESSION_ID}. If your own code makes the Checkout Session, use that address as its success_url. Then copy the id of each price that gives the indicator (price_...): More > Product catalog, the product, its price.
  4. In Workbench (in the Developers menu), on the Webhooks tab, add a destination for the events invoice.paid and customer.subscription.deleted, of type Webhook endpoint, at https://<your server>/webhook. Click Reveal secret and copy the signing secret.
  5. Save the code below as stripe.mjs, set the variables in the table, and run node stripe.mjs (Node 20 or later, nothing to install) on a server that Stripe and your buyers reach over HTTPS.
VariableWhat it is
STRIPE_SECRET_KEYthe restricted key from step 2 (rk_live_...)
STRIPE_WEBHOOK_SECRETthe signing secret from step 4 (whsec_...)
STRIPE_PRICE_IDSthe price ids from step 3, comma separated (price_...); only these give the indicator
OPENMARKET_KEYthe access key from step 1
OPENMARKET_INDICATORyour indicator's full name, like @you/your-indicator
PORTthe port to listen on (3000 if unset)
js
// Stripe: sell your Invite only Indicator with a Payment Link or Checkout,
// one-time or as a subscription.
//
// A buyer pays, Stripe sends them to /thanks, and this server sends them on
// to their personal link. They sign in on OpenMarket and have the Indicator.
// Each paid renewal moves their end date. A subscription that ends takes the
// Indicator away. A one-time payment keeps it for good. Only the prices you
// list count: anything else you sell on the same Stripe account never does.
//
// Set these, then run `node stripe.mjs` (Node 20 or later, nothing to install):
//   STRIPE_SECRET_KEY      a restricted key that can read Checkout Sessions,
//                          Subscriptions and Invoices
//   STRIPE_WEBHOOK_SECRET  your webhook's signing secret (whsec_...)
//   STRIPE_PRICE_IDS       the prices that give the Indicator, comma separated
//                          (price_...)
//   OPENMARKET_KEY         your Indicator's access key
//   OPENMARKET_INDICATOR   your Indicator's full name, like @you/your-indicator
//   PORT                   the port to listen on (3000 if unset)
//
// In Stripe, redirect the Payment Link's buyers after payment to
//   https://<your server>/thanks?session_id={CHECKOUT_SESSION_ID}
// and send the webhook events invoice.paid and customer.subscription.deleted to
//   https://<your server>/webhook

import { createHmac, timingSafeEqual } from "node:crypto";
import { realpathSync } from "node:fs";
import { createServer } from "node:http";
import { fileURLToPath } from "node:url";

const GRACE_DAYS = 3; // a late renewal never cuts a paying member off
const TOLERANCE_SECONDS = 5 * 60; // the replay window Stripe's own libraries use
/** Subscription statuses that keep the Indicator (past_due: Stripe is retrying a renewal). */
const HOLDING = ["active", "trialing", "past_due"];

// ── Stripe ──────────────────────────────────────────────────────────────

/** Answers the buyer's redirect after paying, and Stripe's webhook. */
export async function handle(request) {
  const url = new URL(request.url);
  if (request.method === "GET" && url.pathname === "/thanks") {
    return thanks(url.searchParams.get("session_id"));
  }
  if (request.method === "POST" && url.pathname === "/webhook") return webhook(request);
  return new Response("Not found.", { status: 404 });
}

/** The buyer lands here after paying, and goes on to their personal link. */
async function thanks(sessionId) {
  const session = /^cs_\w+$/.test(sessionId ?? "")
    ? await stripe(
        `checkout/sessions/${sessionId}?expand[]=line_items&expand[]=subscription.latest_invoice`,
      )
    : null;
  if (session === null) return page(404, "We could not find this payment.");
  const prices = configuredPrices();
  const paid =
    session.status === "complete" &&
    ["paid", "no_payment_required"].includes(session.payment_status) &&
    (session.line_items?.data ?? []).some((item) => prices.has(item.price?.id));
  // A one-time payment never ends. A subscription runs to what its latest invoice paid
  // for, and while that invoice is unpaid, /thanks gives out nothing.
  const subscription = session.subscription; // expanded, with its latest invoice
  let until;
  if (session.mode === "payment") until = null;
  else if (HOLDING.includes(subscription?.status)) until = paidThrough(subscription);
  if (!paid || until === undefined) {
    return page(402, "Payment not confirmed yet, or it has ended. Just paid? Reload in a minute.");
  }
  // A subscriber is their Stripe customer. A one-time payment through a Payment Link
  // usually has no customer (Stripe keeps a guest instead), so the session is the ref.
  const ref = `stripe-${session.customer ?? session.id}`;
  return Response.redirect(await personalLink(ref, until), 303);
}

/** A paid invoice moves the member's end date (and makes their link, if they closed
 *  the tab before /thanks). An ended subscription takes the Indicator away. Both read
 *  the subscription from Stripe now rather than trust the event: Stripe does not send
 *  events in order, and a late one must not undo a newer one. */
async function webhook(request) {
  const body = await request.text();
  if (!signedByStripe(request.headers.get("stripe-signature"), body)) {
    return new Response("Bad signature.", { status: 400 });
  }
  const { type, data } = JSON.parse(body);
  const object = data.object;
  if (type === "invoice.paid") {
    // From API version 2025-03-31 (basil) on, an invoice names its subscription under
    // `parent`; before, at the top. Read both. Neither means a one-off invoice.
    const id = object.parent?.subscription_details?.subscription ?? object.subscription;
    const subscription = id ? await stripe(`subscriptions/${id}?expand[]=latest_invoice`) : null;
    if (subscription !== null && holds(subscription)) await moveEndDate(subscription);
  }
  if (type === "customer.subscription.deleted" && sells(object)) {
    // The customer may hold one of your prices on another subscription: then they keep it.
    const customer = encodeURIComponent(object.customer);
    const theirs = await stripe(`subscriptions?customer=${customer}&expand[]=data.latest_invoice`);
    const other = (theirs?.data ?? []).find((sub) => sub.id !== object.id && holds(sub));
    if (other) await moveEndDate(other);
    else await removeMember(`stripe-${object.customer}`);
  }
  return new Response("OK"); // every other event is ignored
}

/** Moves the member's end date to what `subscription` is paid through. While its
 *  latest invoice is unpaid, the end date stays where it is. */
async function moveEndDate(subscription) {
  const until = paidThrough(subscription);
  if (until !== undefined) await personalLink(`stripe-${subscription.customer}`, until);
}

/** What a subscription is paid through, plus the grace days: the end of the period its
 *  latest invoice paid for. Undefined while that invoice is unpaid (a renewal Stripe is
 *  still collecting, though it already opened the next period). */
function paidThrough(subscription) {
  const invoice = subscription.latest_invoice;
  if (invoice?.status !== "paid") return undefined;
  const ends = (invoice.lines?.data ?? [])
    .filter((line) => lineSubscription(line) === subscription.id)
    .map((line) => line.period?.end);
  return endDate(ends);
}

/** The subscription an invoice line bills for. From API version 2025-03-31 (basil) on it
 *  sits under the line's `parent`; before, at the top of the line. */
function lineSubscription(line) {
  return (
    line.parent?.subscription_item_details?.subscription ??
    line.parent?.invoice_item_details?.subscription ??
    line.subscription
  );
}

/** A subscription that holds one of your prices, in a status that keeps the Indicator. */
function holds(subscription) {
  return HOLDING.includes(subscription.status) && sells(subscription);
}

/** Whether a subscription holds one of your prices. */
function sells(subscription) {
  const prices = configuredPrices();
  return (subscription.items?.data ?? []).some((item) => prices.has(item.price?.id));
}

/** STRIPE_PRICE_IDS: the prices that give the Indicator. Nothing else on the account counts. */
function configuredPrices() {
  const ids = (process.env.STRIPE_PRICE_IDS ?? "")
    .split(",")
    .map((id) => id.trim())
    .filter(Boolean);
  if (ids.length === 0)
    throw new Error("Set STRIPE_PRICE_IDS to the prices that give the Indicator");
  return new Set(ids);
}

/** GET from Stripe's API with the restricted key; null when Stripe has no such object. */
async function stripe(path) {
  const res = await fetch(`https://api.stripe.com/v1/${path}`, {
    headers: { authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}` },
  });
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`Stripe ${path} answered ${res.status}: ${await res.text()}`);
  return res.json();
}

/** Stripe's check, as its docs lay it out: Stripe-Signature holds `t=<unix seconds>` and
 *  one `v1=<hex>` per active signing secret, each an HMAC-SHA256 of `<t>.<raw body>`
 *  keyed by the whole whsec_ secret. A timestamp more than 5 minutes off is refused, so
 *  a captured event cannot be replayed later. */
function signedByStripe(header, body) {
  const pairs = (header ?? "").split(",").map((part) => part.split("="));
  const t = pairs.find(([key]) => key === "t")?.[1];
  if (!(Math.abs(Date.now() / 1000 - Number(t)) <= TOLERANCE_SECONDS)) return false;
  const hmac = createHmac("sha256", process.env.STRIPE_WEBHOOK_SECRET).update(`${t}.${body}`);
  const expected = Buffer.from(hmac.digest("hex"));
  return pairs.some(([key, value = ""]) => {
    const given = Buffer.from(value);
    return key === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
  });
}

/** The latest of `periodEnds` (Unix seconds) plus the grace days, as an ISO date. */
function endDate(periodEnds) {
  const end = Math.max(...periodEnds.filter(Number.isFinite));
  if (!Number.isFinite(end)) throw new Error("Stripe sent no period end");
  return new Date((end + GRACE_DAYS * 24 * 60 * 60) * 1000).toISOString();
}

/** A short page for a buyer with no link to go to. */
function page(status, text) {
  return new Response(`<!doctype html><meta charset="utf-8"><title>Payment</title><p>${text}</p>`, {
    status,
    headers: { "content-type": "text/html; charset=utf-8" },
  });
}

// ── OpenMarket ──────────────────────────────────────────────────────────
// Your Indicator's Access API, called with its access key.

function openmarketUrl(path) {
  const registry = process.env.OPENMARKET_REGISTRY ?? "https://registry.openmarket.xyz";
  return `${registry}/v1/packages/${process.env.OPENMARKET_INDICATOR}${path}`;
}

async function openmarket(method, path, body) {
  const res = await fetch(openmarketUrl(path), {
    method,
    headers: {
      authorization: `Bearer ${process.env.OPENMARKET_KEY}`,
      ...(body === undefined ? {} : { "content-type": "application/json" }),
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  if (!res.ok)
    throw new Error(`OpenMarket ${method} ${path} answered ${res.status}: ${await res.text()}`);
  return res.status === 204 ? null : res.json();
}

/** The member's personal link, keyed by your own id for them. The same ref always
 *  answers the same link, and a new `until` moves their end date, joined or not. */
async function personalLink(ref, until) {
  const { links } = await openmarket("POST", "/access/links/batch", { links: [{ ref, until }] });
  return links[0].url;
}

/** Take the Indicator away from the member behind `ref` (harmless if they never joined). */
async function removeMember(ref) {
  await openmarket("DELETE", `/access/ref:${encodeURIComponent(ref)}`);
}

// ── Server ──────────────────────────────────────────────────────────────
// `node <file>.mjs` serves `handle` on $PORT (3000 by default).

const MAX_BODY_BYTES = 1024 * 1024; // a webhook or a redirect is a few KB

if (isMainModule()) {
  const port = Number(process.env.PORT ?? 3000);
  createServer(async (req, res) => {
    // Until the request is read and built, a failure is the caller's (400): a method
    // fetch refuses, a target it cannot parse, a caller who hung up midway. From then
    // on it is this server's (500). Either way the server stays up.
    let failure = 400;
    try {
      const chunks = [];
      let size = 0;
      for await (const chunk of req) {
        size += chunk.length;
        if (size <= MAX_BODY_BYTES) chunks.push(chunk); // past the cap, read on and keep nothing
      }
      if (size > MAX_BODY_BYTES) {
        res.writeHead(413).end("Too large.");
        return;
      }
      const hasBody = req.method !== "GET" && req.method !== "HEAD";
      const request = new Request(`http://localhost${req.url}`, {
        method: req.method,
        headers: Object.entries(req.headers).flatMap(([k, v]) =>
          Array.isArray(v) ? v.map((x) => [k, x]) : [[k, v]],
        ),
        body: hasBody ? Buffer.concat(chunks) : undefined,
      });
      failure = 500;
      const response = await handle(request);
      res.writeHead(response.status, Object.fromEntries(response.headers));
      res.end(Buffer.from(await response.arrayBuffer()));
    } catch (error) {
      console.error(error);
      if (res.headersSent) res.destroy();
      else res.writeHead(failure).end(failure === 400 ? "Bad request." : "Something went wrong.");
    }
  }).listen(port, () => console.log(`Listening on :${port}`));
}

/** Whether `node` was asked to run this file, by its own path or through a link
 *  (on macOS, /tmp itself is a link to /private/tmp). */
function isMainModule() {
  try {
    return realpathSync(process.argv[1]) === fileURLToPath(globalThis._importMeta_.url);
  } catch {
    return false; // no file to run: a REPL, or node -e
  }
}

Try it before you go live

  1. In a Stripe sandbox, repeat steps 2 and 3 with a test key and a test Payment Link that redirects to where the file runs. Run stripe listen --forward-to localhost:3000/webhook, and start the file with the whsec_... secret it prints.
  2. Pay through the test link with the card 4242 4242 4242 4242, any future date and any CVC. You land on your personal link: open it, and the indicator is yours.
  3. stripe trigger customer.subscription.created --override "subscription:items[0].price=<your test price id>" sends a test subscriber's first paid invoice, and the same with customer.subscription.deleted a cancellation. stripe listen shows your server answer [200] to each event.

Good to know

  • A subscriber who closes the tab before the redirect still has a link waiting, made by their first paid invoice. It is under Not joined yet in the People panel and in GET /access/links.csv: send it to them. A one-time buyer's link comes only from the redirect, so add that buyer with Invite people… instead.
  • Each end date comes from an invoice Stripe has been paid, read when the event arrives, so events that come late or out of order change nothing, and a renewal still being collected moves nothing until it is paid.
  • Selling the indicator both one-time and as a subscription? A buyer who does both as the same Stripe customer gets one link, and the end of their subscription ends it.

Patreon

Give your Invite only indicator to your Patreon patrons: each one logs in with Patreon on a small server you run and lands on their own personal link.

What it does

  • Pledges: a patron opens the link in your tier's welcome note, logs in with Patreon and lands on their personal link. They sign in or sign up on OpenMarket and have the indicator.
  • Renews: each charge moves their end date to 3 days past the next one.
  • Stops paying: a declined charge, an ended pledge or a deleted membership takes the indicator away.

Set it up

  1. In the Patreon Platform Portal, open Clients & API Keys and press Create Client. Set Client API Version to 2, add https://<your server>/patreon/callback under Redirect URIs, and copy the Client ID and Client Secret.

  2. Find your campaign id with the new client's Creator's Access Token. The same call lists your tiers, for when only some of them come with the indicator:

    text
    curl -H "Authorization: Bearer <Creator's Access Token>" \
      "https://www.patreon.com/api/oauth2/v2/campaigns?include=tiers&fields%5Btier%5D=title"

    data[0].id is your campaign id, and each tier under included shows its id and title.

  3. In My Webhooks, paste https://<your server>/patreon/webhook where it says "Create a new webhook by pasting your URL here" and press +. Turn on Create Member, Update Member and Delete Member, and copy the webhook's secret.

  4. Save the code below as patreon.mjs on a server Patreon can reach over HTTPS, set the variables in the table, and run node patreon.mjs (Node 20 or later, nothing to install).

  5. Add https://<your server>/patreon to the welcome note of each tier that comes with the indicator.

VariableWhat it is
PATREON_CLIENT_IDyour client's Client ID
PATREON_CLIENT_SECRETyour client's Client Secret
PATREON_CAMPAIGN_IDyour campaign id, from step 2
PATREON_WEBHOOK_SECRETyour webhook's secret, from My Webhooks
PATREON_TIER_IDSoptional: the ids of the tiers that come with the indicator, comma separated. Leave it out and every active patron gets it
PUBLIC_URLyour server's HTTPS address, like https://patrons.example.com
OPENMARKET_KEYyour indicator's access key, from Access key in its People panel
OPENMARKET_INDICATORyour indicator's full name, like @you/your-indicator
PORTthe port to listen on (3000 if unset)
js
// Patreon: give your Invite only Indicator to your patrons.
//
// Patreon does not know your patrons' OpenMarket accounts, so a patron
// proves who they are with "Log in with Patreon" on this small server, and
// it sends them on to their own personal link. A webhook keeps their end
// date in step with Patreon: a renewal moves it, and a declined, ended or
// deleted pledge takes the Indicator away.
//
// Put <PUBLIC_URL>/patreon in your tier's welcome note. Patreon sends
// patrons back to <PUBLIC_URL>/patreon/callback, and your webhook points at
// <PUBLIC_URL>/patreon/webhook.
//
// Set:
//   PATREON_CLIENT_ID       your client's Client ID (Clients & API Keys)
//   PATREON_CLIENT_SECRET   your client's Client Secret
//   PATREON_CAMPAIGN_ID     your campaign's id
//   PATREON_WEBHOOK_SECRET  your webhook's secret (My Webhooks)
//   PATREON_TIER_IDS        optional: only these tiers get the Indicator,
//                           as a comma separated list of tier ids
//   PUBLIC_URL              this server's https address
//   OPENMARKET_KEY          your Indicator's access key
//   OPENMARKET_INDICATOR    your Indicator's full name, @you/your-indicator
//   PORT                    3000 by default
//
// Run: node patreon.mjs (Node 20 or later, nothing to install).

import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { realpathSync } from "node:fs";
import { createServer } from "node:http";
import { fileURLToPath } from "node:url";

/** Days added to Patreon's next charge, so a late renewal never cuts a paying patron off. */
const GRACE_DAYS = 3;

/** The patron's memberships, each with its campaign, its tiers, its status and its charges. */
const IDENTITY_URL =
  "https://www.patreon.com/api/oauth2/v2/identity" +
  "?include=memberships.campaign,memberships.currently_entitled_tiers" +
  "&fields%5Bmember%5D=patron_status,next_charge_date,last_charge_date,pledge_cadence";

/** Every request this server answers. */
export async function handle(request) {
  const { pathname } = new URL(request.url);
  if (request.method === "GET" && pathname === "/patreon") return logIn();
  if (request.method === "GET" && pathname === "/patreon/callback") return callback(request);
  if (request.method === "POST" && pathname === "/patreon/webhook") return webhook(request);
  return new Response("Not found.", { status: 404 });
}

// ── Log in with Patreon ─────────────────────────────────────────────────

/** Send the patron to Patreon, with a one-time state kept in a cookie. */
function logIn() {
  const state = randomBytes(16).toString("hex");
  const authorize = new URL("https://www.patreon.com/oauth2/authorize");
  authorize.search = new URLSearchParams({
    response_type: "code",
    client_id: process.env.PATREON_CLIENT_ID,
    redirect_uri: redirectUri(),
    scope: "identity identity.memberships",
    state,
  }).toString();
  const secure = process.env.PUBLIC_URL?.startsWith("https:") ? "; Secure" : "";
  return new Response(null, {
    status: 302,
    headers: {
      location: authorize.href,
      "set-cookie": `patreon_state=${state}; Path=/patreon; HttpOnly; SameSite=Lax; Max-Age=600${secure}`,
    },
  });
}

/** Patreon sends the patron back here: find their pledge and send them to their link. */
async function callback(request) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  const cookie = /(?:^|;\s*)patreon_state=([0-9a-f]+)/.exec(request.headers.get("cookie") ?? "");
  if (!code || !sameText(url.searchParams.get("state"), cookie?.[1])) {
    return page(400, "Patreon did not sign you in. Open the link from Patreon and try again.");
  }
  const token = await patreon("https://www.patreon.com/api/oauth2/token", {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      code,
      grant_type: "authorization_code",
      client_id: process.env.PATREON_CLIENT_ID,
      client_secret: process.env.PATREON_CLIENT_SECRET,
      redirect_uri: redirectUri(),
    }),
  });
  const identity = await patreon(IDENTITY_URL, {
    headers: { authorization: `Bearer ${token.access_token}` },
  });
  const member = identity.included?.find((item) => item.type === "member" && entitled(item));
  if (member === undefined) {
    return page(403, "Your Patreon account has no active pledge that includes this Indicator.");
  }
  const ref = `patreon-${identity.data.id}`;
  const link = await personalLink(ref, await endDate(member, ref));
  return new Response(null, { status: 303, headers: { location: link } });
}

// ── Webhook ─────────────────────────────────────────────────────────────

/** A renewal moves the end date; a declined, ended or deleted pledge removes the Indicator. */
async function webhook(request) {
  const body = Buffer.from(await request.arrayBuffer());
  const secret = process.env.PATREON_WEBHOOK_SECRET;
  // Patreon signs the raw body: HMAC-MD5 keyed by the webhook's secret, as hex.
  const expected = secret ? createHmac("md5", secret).update(body).digest("hex") : "";
  if (!sameText(request.headers.get("x-patreon-signature")?.toLowerCase(), expected)) {
    return new Response("Bad signature.", { status: 400 });
  }
  const event = request.headers.get("x-patreon-event") ?? "";
  const member = JSON.parse(body.toString()).data;
  const userId = member?.relationships?.user?.data?.id;
  if (!event.startsWith("members:") || !userId || !inCampaign(member)) {
    return new Response("Ignored.");
  }
  const ref = `patreon-${userId}`;
  if (event.endsWith(":delete") || !entitled(member)) await removeMember(ref);
  else await personalLink(ref, await endDate(member, ref));
  return new Response("Done.");
}

// ── Patreon ─────────────────────────────────────────────────────────────

async function patreon(url, init) {
  const res = await fetch(url, init);
  if (!res.ok) {
    throw new Error(`Patreon ${new URL(url).pathname} answered ${res.status}: ${await res.text()}`);
  }
  return res.json();
}

/** A membership of your campaign. */
function inCampaign(member) {
  return member.relationships?.campaign?.data?.id === process.env.PATREON_CAMPAIGN_ID;
}

/** An active patron of your campaign, in one of PATREON_TIER_IDS when you set it. */
function entitled(member) {
  if (!inCampaign(member) || member.attributes?.patron_status !== "active_patron") return false;
  const tiers = (process.env.PATREON_TIER_IDS ?? "")
    .split(",")
    .map((id) => id.trim())
    .filter(Boolean);
  const held = member.relationships?.currently_entitled_tiers?.data ?? [];
  return tiers.length === 0 || held.some((tier) => tiers.includes(tier.id));
}

/** The end date to send, never none, so access always runs out without a renewal:
 *  Patreon's next charge plus GRACE_DAYS. Without one (Patreon leaves it out on an
 *  annual downgrade), the last charge plus one pledge cadence (in months) plus
 *  GRACE_DAYS. Without either, a patron who already joined keeps the end date they
 *  have (undefined leaves it as it is), and a new one gets a month plus GRACE_DAYS. */
async function endDate(member, ref) {
  const {
    next_charge_date: next,
    last_charge_date: last,
    pledge_cadence: cadence,
  } = member.attributes ?? {};
  let paidThrough = next ? new Date(next) : null;
  if (!paidThrough && last && Number(cadence) > 0) {
    paidThrough = addMonths(new Date(last), Number(cadence));
  }
  if (!paidThrough) {
    if (await joined(ref)) return undefined;
    paidThrough = addMonths(new Date(), 1);
  }
  return new Date(paidThrough.getTime() + GRACE_DAYS * 24 * 60 * 60 * 1000).toISOString();
}

function addMonths(date, months) {
  const later = new Date(date);
  later.setUTCMonth(later.getUTCMonth() + months);
  return later;
}

/** Whether the patron behind `ref` already opened their link and holds the Indicator. */
async function joined(ref) {
  const path = `/access/ref:${encodeURIComponent(ref)}`;
  const res = await fetch(openmarketUrl(path), {
    headers: { authorization: `Bearer ${process.env.OPENMARKET_KEY}` },
  });
  if (res.status === 404) return false;
  if (!res.ok)
    throw new Error(`OpenMarket GET ${path} answered ${res.status}: ${await res.text()}`);
  return true;
}

function redirectUri() {
  return `${(process.env.PUBLIC_URL ?? "").replace(/\/+$/, "")}/patreon/callback`;
}

/** Compares two secrets in constant time. */
function sameText(a, b) {
  if (!a || !b) return false;
  const left = Buffer.from(a);
  const right = Buffer.from(b);
  return left.length === right.length && timingSafeEqual(left, right);
}

/** A short plain page for the patron. */
function page(status, message) {
  return new Response(
    `<!doctype html>\n<meta charset="utf-8">\n<title>Patreon</title>\n<p>${message}</p>\n`,
    {
      status,
      headers: { "content-type": "text/html; charset=utf-8" },
    },
  );
}

// ── OpenMarket ──────────────────────────────────────────────────────────
// Your Indicator's Access API, called with its access key.

function openmarketUrl(path) {
  const registry = process.env.OPENMARKET_REGISTRY ?? "https://registry.openmarket.xyz";
  return `${registry}/v1/packages/${process.env.OPENMARKET_INDICATOR}${path}`;
}

async function openmarket(method, path, body) {
  const res = await fetch(openmarketUrl(path), {
    method,
    headers: {
      authorization: `Bearer ${process.env.OPENMARKET_KEY}`,
      ...(body === undefined ? {} : { "content-type": "application/json" }),
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  if (!res.ok)
    throw new Error(`OpenMarket ${method} ${path} answered ${res.status}: ${await res.text()}`);
  return res.status === 204 ? null : res.json();
}

/** The member's personal link, keyed by your own id for them. The same ref always
 *  answers the same link, and a new `until` moves their end date, joined or not. */
async function personalLink(ref, until) {
  const { links } = await openmarket("POST", "/access/links/batch", { links: [{ ref, until }] });
  return links[0].url;
}

/** Take the Indicator away from the member behind `ref` (harmless if they never joined). */
async function removeMember(ref) {
  await openmarket("DELETE", `/access/ref:${encodeURIComponent(ref)}`);
}

// ── Server ──────────────────────────────────────────────────────────────
// `node <file>.mjs` serves `handle` on $PORT (3000 by default).

const MAX_BODY_BYTES = 1024 * 1024; // a webhook or a redirect is a few KB

if (isMainModule()) {
  const port = Number(process.env.PORT ?? 3000);
  createServer(async (req, res) => {
    // Until the request is read and built, a failure is the caller's (400): a method
    // fetch refuses, a target it cannot parse, a caller who hung up midway. From then
    // on it is this server's (500). Either way the server stays up.
    let failure = 400;
    try {
      const chunks = [];
      let size = 0;
      for await (const chunk of req) {
        size += chunk.length;
        if (size <= MAX_BODY_BYTES) chunks.push(chunk); // past the cap, read on and keep nothing
      }
      if (size > MAX_BODY_BYTES) {
        res.writeHead(413).end("Too large.");
        return;
      }
      const hasBody = req.method !== "GET" && req.method !== "HEAD";
      const request = new Request(`http://localhost${req.url}`, {
        method: req.method,
        headers: Object.entries(req.headers).flatMap(([k, v]) =>
          Array.isArray(v) ? v.map((x) => [k, x]) : [[k, v]],
        ),
        body: hasBody ? Buffer.concat(chunks) : undefined,
      });
      failure = 500;
      const response = await handle(request);
      res.writeHead(response.status, Object.fromEntries(response.headers));
      res.end(Buffer.from(await response.arrayBuffer()));
    } catch (error) {
      console.error(error);
      if (res.headersSent) res.destroy();
      else res.writeHead(failure).end(failure === 400 ? "Bad request." : "Something went wrong.");
    }
  }).listen(port, () => console.log(`Listening on :${port}`));
}

/** Whether `node` was asked to run this file, by its own path or through a link
 *  (on macOS, /tmp itself is a link to /private/tmp). */
function isMainModule() {
  try {
    return realpathSync(process.argv[1]) === fileURLToPath(globalThis._importMeta_.url);
  } catch {
    return false; // no file to run: a REPL, or node -e
  }
}

Try it before you go live

Patreon has no test mode, so use its test send and a real pledge of your own:

  1. In My Webhooks, press Send test on Update Member. Patreon signs the sample event with your secret, and your server answers 200 when PATREON_WEBHOOK_SECRET matches it (400 when it does not).
  2. From a second Patreon account, join your lowest tier. Open https://<your server>/patreon, log in with Patreon as that account, and sign in on OpenMarket with a second account: the indicator is under Shared with you.
  3. Cancel that pledge. When Patreon ends it, the second account drops off your People list.

Good to know

  • A patron who closed the tab opens https://<your server>/patreon again and lands on the same link. One who lost the indicator to a declined charge does the same after paying, and gets a fresh link.
  • Webhooks get missed now and then. The end date is your safety net: without a renewal, access ends 3 days after the charge that never came.
  • Logging in asks each patron to share their Patreon memberships, so the server can find the one in your campaign. It keeps nothing, and uses the Patreon login once.

Telegram bot

Sell access through a paid Telegram group or channel, and a small bot gives each member your indicator while they are in it.

What it does

  • Joins: a member of your paid group or channel sends your bot /start and gets a personal link. Opening it gives them the indicator.
  • Stays: they keep it while they stay in the group or channel.
  • Leaves: they lose it when they leave or you remove them.

Set it up

  1. In Telegram, open @BotFather, send /newbot, and pick a name and a username. BotFather answers with your bot's token. Keep it secret: anyone with it controls your bot.
  2. Add the bot to your paid group or channel and make it an admin. Only an admin sees who leaves, so this step matters.
  3. Right after, open https://api.telegram.org/bot<your token>/getUpdates in your browser. The negative number after "chat":{"id":, like -1001234567890, is your chat's id.
  4. In your indicator's People panel, open Access key in the corner menu and press Create key.
  5. Save the code below as telegram-bot.mjs, set the variables in the table, and run node telegram-bot.mjs (Node 20 or later, nothing to install) on any computer or server that stays on. It needs no public address.
VariableWhat it is
TELEGRAM_BOT_TOKENyour bot's token, from @BotFather
TELEGRAM_CHAT_IDyour paid group's or channel's id, like -1001234567890
OPENMARKET_KEYthe access key from step 4
OPENMARKET_INDICATORyour indicator's full name, like @you/your-indicator
js
// Telegram bot: the members of your paid Telegram group or channel get your
// Invite only Indicator, and lose it when they leave.
//
// A member sends your bot /start. While they are in the paid group or channel,
// the bot answers with their personal link: they open it, sign in or sign up
// on OpenMarket, and the Indicator is theirs. When they leave or are removed,
// the bot takes it away.
//
// Set these, then run `node telegram-bot.mjs` (Node 20 or later):
//   TELEGRAM_BOT_TOKEN    your bot's token, from @BotFather
//   TELEGRAM_CHAT_ID      the paid group or channel, like -1001234567890
//                         (make the bot an admin there: only admins see who leaves)
//   OPENMARKET_KEY        an access key on your Indicator
//   OPENMARKET_INDICATOR  your Indicator's full name, like @you/your-indicator
//
// The bot asks Telegram for its updates (long polling), so it needs no public
// address and runs anywhere.

import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";

// ── OpenMarket ──────────────────────────────────────────────────────────
// Your Indicator's Access API, called with its access key.

function openmarketUrl(path) {
  const registry = process.env.OPENMARKET_REGISTRY ?? "https://registry.openmarket.xyz";
  return `${registry}/v1/packages/${process.env.OPENMARKET_INDICATOR}${path}`;
}

async function openmarket(method, path, body) {
  const res = await fetch(openmarketUrl(path), {
    method,
    headers: {
      authorization: `Bearer ${process.env.OPENMARKET_KEY}`,
      ...(body === undefined ? {} : { "content-type": "application/json" }),
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  if (!res.ok)
    throw new Error(`OpenMarket ${method} ${path} answered ${res.status}: ${await res.text()}`);
  return res.status === 204 ? null : res.json();
}

/** The member's personal link, keyed by your own id for them. The same ref always
 *  answers the same link, and a new `until` moves their end date, joined or not. */
async function personalLink(ref, until) {
  const { links } = await openmarket("POST", "/access/links/batch", { links: [{ ref, until }] });
  return links[0].url;
}

/** Take the Indicator away from the member behind `ref` (harmless if they never joined). */
async function removeMember(ref) {
  await openmarket("DELETE", `/access/ref:${encodeURIComponent(ref)}`);
}

// ── Telegram ────────────────────────────────────────────────────────────
// The Bot API, called with your bot's token.

async function telegram(method, params) {
  const url = `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/${method}`;
  const res = await fetch(url, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(params),
  });
  const { ok, result, description } = await res.json();
  if (!ok) throw new Error(`Telegram ${method}: ${description}`);
  return result;
}

/** Whether a chat member is in the chat now: the owner, an admin, a member,
 *  or a restricted member who has not left. */
function isIn(member) {
  return (
    ["creator", "administrator", "member"].includes(member.status) ||
    (member.status === "restricted" && member.is_member)
  );
}

// ── The bot ─────────────────────────────────────────────────────────────

/** /start: the member's personal link, while they are in the paid chat. */
async function start(message) {
  const reply = (text, reply_markup) =>
    telegram("sendMessage", { chat_id: message.chat.id, text, reply_markup });
  try {
    const member = await telegram("getChatMember", {
      chat_id: process.env.TELEGRAM_CHAT_ID,
      user_id: message.from.id,
    });
    if (isIn(member)) {
      const url = await personalLink(`telegram-${message.from.id}`, null);
      await reply("Open your personal link and sign in to OpenMarket to get the Indicator.", {
        inline_keyboard: [[{ text: "Get the Indicator", url }]],
      });
    } else {
      await reply("Join the paid group or channel first, then send /start again.");
    }
  } catch (error) {
    // The member can send /start again, so a failure here holds up nothing behind it.
    console.error(error);
    await reply("Something went wrong. Send /start again in a minute.").catch(console.error);
  }
}

/** One update: answer /start in a private chat, and take the Indicator from
 *  whoever leaves the paid chat or is removed from it. A removal that fails
 *  throws, so Telegram hands the update over again on the next poll. */
export async function handleUpdate(update) {
  const { message, chat_member: change } = update;
  if (message?.chat.type === "private" && message.text?.startsWith("/start")) {
    await start(message);
  }
  if (change && String(change.chat.id) === process.env.TELEGRAM_CHAT_ID) {
    const { new_chat_member: member } = change;
    if (!isIn(member)) await removeMember(`telegram-${member.user.id}`);
  }
}

/** One long poll: waits up to 50 seconds for updates, handles each, and returns
 *  the offset for the next poll. Leaves go first: a removal that fails throws
 *  before any reply goes out, so the retry repeats nothing a member sees. */
export async function pollOnce(offset) {
  const updates = await telegram("getUpdates", {
    offset,
    timeout: 50,
    allowed_updates: ["message", "chat_member"],
  });
  for (const update of updates) if (update.chat_member) await handleUpdate(update);
  for (const update of updates) if (!update.chat_member) await handleUpdate(update);
  return Math.max(offset, ...updates.map((update) => update.update_id + 1));
}

// ── Run ─────────────────────────────────────────────────────────────────
// `node telegram-bot.mjs` checks the bot is an admin of the paid chat, then
// polls until you stop it.

if (isMainModule()) {
  const bot = await telegram("getMe", {});
  const self = await telegram("getChatMember", {
    chat_id: process.env.TELEGRAM_CHAT_ID,
    user_id: bot.id,
  });
  if (self.status !== "administrator") {
    console.error(`Make @${bot.username} an admin of the paid chat: only admins see who leaves.`);
    process.exit(1);
  }
  console.log(`@${bot.username} is answering /start`);
  let offset = 0;
  for (;;) {
    try {
      offset = await pollOnce(offset);
    } catch (error) {
      console.error(error);
      await new Promise((resolve) => setTimeout(resolve, 5000));
    }
  }
}

/** Whether `node` was asked to run this file, by its own path or through a link
 *  (on macOS, /tmp itself is a link to /private/tmp). */
function isMainModule() {
  try {
    return realpathSync(process.argv[1]) === fileURLToPath(globalThis._importMeta_.url);
  } catch {
    return false; // no file to run: a REPL, or node -e
  }
}

Try it before you go live

  1. Make a test group in Telegram, add your bot to it as an admin, and run the bot with TELEGRAM_CHAT_ID set to the test group's id.
  2. From a second Telegram account, join the group and send your bot /start. Open the link signed in to a second OpenMarket account: the indicator is under Shared with you in the Indicators dialog.
  3. Leave the group from that account. They drop off your People panel.

Good to know

  • The Bot API cannot list a group's members, so the bot follows leaves as they happen. People already in the group when you start the bot send /start like anyone else.
  • While the bot is down, Telegram keeps its updates for up to 24 hours. Restart it within a day: anyone who left during a longer outage keeps the indicator until you remove them in the People panel.
  • A personal link works for the first OpenMarket account that opens it. Someone who leaves and comes back sends /start again.