docs

Waitlist (waitlist)

A launch waitlist. People join with their email (and optionally a name and a friend's referral code), get their place in line and a referral link; every friend who joins with that link moves them up. Admins see the line, the numbers, and invite people in batches: each invite is an email with a single use sign-up token. Turn on the gate and only invited addresses can register.

Terminal
npx calm add waitlist

Adds#

Filesapp/blocks/waitlist/index.js
EnvironmentNothing new. Links are built from APP_URL. A mail transport is needed in production for invites.
RoutesPOST /api/waitlist, GET /api/waitlist/:code, GET /api/admin/waitlist, GET /api/admin/waitlist/stats, POST /api/admin/waitlist/invite, POST /api/admin/waitlist/:id/invite, DELETE /api/admin/waitlist/:id
Collectionscalm_waitlist, calm_waitlist_counters

Joining#

HTTP
POST /api/waitlist
{ "email": "ada@example.com", "name": "Ada", "referralCode": "K7Q2M9XH4T" }
JSON
201 {
  "data": {
    "status": "waiting",
    "position": 42,
    "waiting": 1280,
    "referrals": 0,
    "referralCode": "P3W8N2RJ6C",
    "referralUrl": "https://app.example.com/waitlist?ref=P3W8N2RJ6C"
  }
}

Joining again with the same address answers 200 with the same entry and changes nothing (not the name, not the referrer). An unknown referral code is ignored. position is null once the person is invited or registered (status says which).

Later, the person checks their place with the code they got:

HTTP
GET /api/waitlist/P3W8N2RJ6C

A minimal form:

JavaScript
const ref = new URLSearchParams(location.search).get('ref') ?? undefined;
const res = await fetch('/api/waitlist', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ email: form.email.value, name: form.name.value || undefined, referralCode: ref }),
});
const { data, error } = await res.json();
if (data) show(`You are number ${data.position}. Share ${data.referralUrl} to move up.`);
else show(error.details?.email ?? 'Something went wrong');

How the line works#

Each entry gets a join number. Its place is decided by join number - referralBoost x referrals (ties by join number), so each friend who joins with your code moves you referralBoost places up (default 5), for at most maxReferrals friends (default 50). The place is computed when asked, never stored, so it is always exact.

Admin routes#

All need the admin role.

RouteWhat it does
GET /api/admin/waitlist?status=waiting&q=ada&page=1&limit=50Entries in line order with position. status: waiting (default), invited, registered, all. q searches email and name.
GET /api/admin/waitlist/stats{ total, waiting, invited, registered, referred, referrals, joined: { last24h, last7d }, conversion } (conversion = registered / ever invited).
POST /api/admin/waitlist/invite { "count": 100 } or { "ids": [...] }Invites the next count people in line, or the listed entries. Answers { invited: [{ id, email }], count }.
POST /api/admin/waitlist/:id/inviteInvites one entry. Inviting again sends a new token; the previous one stops working.
DELETE /api/admin/waitlist/:idRemoves an entry. 204.

An admin entry looks like { id, email, name, status, position, referrals, referralCode, referredBy, invitedAt, inviteExpiresAt, registeredAt, user, createdAt }.

Invites and the gate#

An invite emails APP_URL/signup?invite=invite_...&email=ada%40example.com (option signupUrl), valid for inviteTtlDays (14). Your sign-up page registers with the token:

JavaScript
const params = new URLSearchParams(location.search);
await fetch('/api/auth/register', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ name, email: params.get('email'), password, inviteToken: params.get('invite') }),
});

When someone registers, their entry becomes registered and the token is spent. This also happens without the gate, so stats.conversion is meaningful either way; inviteToken is simply ignored then.

With gate: true:

  • POST /api/auth/register answers 403 INVITE_REQUIRED unless the body has the inviteToken of a valid invite for that exact address.
  • Other ways to create an account (a magic-link sign-up, OAuth) work only for invited addresses: they already prove the inbox, so they need no token.
  • Admins creating users with POST /api/users, and your code passing { system: true }, are not gated. Create the first admin with npx calm admin or a script.

Options#

JavaScript
export default defineConfig({
  blocks: { waitlist: { gate: true, referralBoost: 10, inviteTtlDays: 7 } },
});
OptionDefaultWhat it does
gatefalseRegistration only for invited addresses (see above).
referralBoost5Places moved up per referral.
maxReferrals50Referrals that count at most, per entry.
referralUrlAPP_URL + '/waitlist?ref='The referral code is appended.
signupUrlAPP_URL + '/signup'The page invites link to; ?invite=&email= are appended.
inviteTtlDays14How long an invite works.
confirmationEmailfalseEmail new entries their place and referral link.
joinLimit{ windowMs: 900000, max: 20 }Joins per IP (when config.rateLimit is on). false keeps only the global limit.
maxInviteBatch500Most entries one bulk invite may touch.

In code#

JavaScript
const { join, invite, validInvite, positionOf, Entry } = app.ext.waitlist;
await invite(entryId);                                      // from a job: invite on a schedule
calm.on('waitlist.joined', ({ entry, referred }) => analytics.track('waitlist_joined', { referred }));

Security#

  • Invite tokens are 256 bit random values, stored as SHA-256 hashes, bound to one address, expiring, and spent on registration (the unique email on users stops a second account too).
  • A second join with the same address cannot change anything about the entry.
  • The join route is public and rate limited per IP. It answers whether an address is already on the list (that is what makes it idempotent) and its referral code; nothing else about the entry is public. Leave confirmationEmail off if you do not want the public route to send email to addresses nobody verified.
  • Referral farming (many fake addresses joining with one code) is capped by maxReferrals and the join rate limit. Check GET /api/admin/waitlist?q= before big invite batches if referrals matter for you.
  • Refused registrations emit security.invite_refused with a reason (no_token, invalid_token, not_invited).

Events#

EventPayload
waitlist.joined{ entry, referred }
waitlist.referred{ referrerId, entryId }
waitlist.invited{ entry, by }
waitlist.registered{ entry, userId }
waitlist.removed{ entryId, by }
security.invite_refused{ ip, reason }
<!-- block-guide:start (generated from block.json by scripts/sync-block-guides.mjs) -->

Official documentation#

Read these before changing the block; they are also in app/blocks/waitlist/AGENTS.md and in the devtools Blocks panel.

For AI agents#

Coding agents read these notes in app/blocks/waitlist/AGENTS.md:

  • Read app/blocks/waitlist/index.js first: the header lists every route and the rules of the line.
  • Options live in calm.config.js under blocks.waitlist. gate: true makes registration invitation only.
  • The place in line is computed, never stored: score (join number minus referralBoost per referral) then seq. Keep the index { status, score, seq } if you change the query.
  • Joining is idempotent per email: an existing entry is returned unchanged. Never let a second join change name, referrer or status.
  • Invite tokens are stored as hashToken(token) only; the raw token exists only in the invite email. A new invite replaces the old token.
  • The register gate is a calm.guard on POST /auth/register that checks and removes inviteToken from the body; the beforeCreate wrapper on the user service is the backstop for other sign-up paths (magic-link, OAuth). Keep both.
  • Admin creates (hasRole(ctx.user, 'admin')) and trusted code ({ system: true }) are not gated, on purpose.
  • auth.registered marks the entry registered and clears the token; that plus the unique user email makes invites single use.
  • Referral counting is capped by maxReferrals in the update filter; do not move the cap to application code (races).
  • The join route is public: keep joinLimit and leave confirmationEmail off unless you accept emails to unverified addresses.
  • Use app.ext.waitlist (join, invite, validInvite, positionOf, Entry) from jobs and scripts.
  • Events: waitlist.joined, waitlist.referred, waitlist.invited, waitlist.registered, waitlist.removed, security.invite_refused.
<!-- block-guide:end -->

Last updated