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.
npx calm add waitlistAdds#
| Files | app/blocks/waitlist/index.js |
| Environment | Nothing new. Links are built from APP_URL. A mail transport is needed in production for invites. |
| Routes | POST /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 |
| Collections | calm_waitlist, calm_waitlist_counters |
Joining#
POST /api/waitlist
{ "email": "ada@example.com", "name": "Ada", "referralCode": "K7Q2M9XH4T" }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:
GET /api/waitlist/P3W8N2RJ6CA minimal form:
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.
| Route | What it does |
|---|---|
GET /api/admin/waitlist?status=waiting&q=ada&page=1&limit=50 | Entries 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/invite | Invites one entry. Inviting again sends a new token; the previous one stops working. |
DELETE /api/admin/waitlist/:id | Removes 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:
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/registeranswers403 INVITE_REQUIREDunless the body has theinviteTokenof 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 withnpx calm adminor a script.
Options#
export default defineConfig({
blocks: { waitlist: { gate: true, referralBoost: 10, inviteTtlDays: 7 } },
});| Option | Default | What it does |
|---|---|---|
gate | false | Registration only for invited addresses (see above). |
referralBoost | 5 | Places moved up per referral. |
maxReferrals | 50 | Referrals that count at most, per entry. |
referralUrl | APP_URL + '/waitlist?ref=' | The referral code is appended. |
signupUrl | APP_URL + '/signup' | The page invites link to; ?invite=&email= are appended. |
inviteTtlDays | 14 | How long an invite works. |
confirmationEmail | false | Email 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. |
maxInviteBatch | 500 | Most entries one bulk invite may touch. |
In code#
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
confirmationEmailoff 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
maxReferralsand the join rate limit. CheckGET /api/admin/waitlist?q=before big invite batches if referrals matter for you. - Refused registrations emit
security.invite_refusedwith areason(no_token,invalid_token,not_invited).
Events#
| Event | Payload |
|---|---|
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 } |
Official documentation#
Read these before changing the block; they are also in app/blocks/waitlist/AGENTS.md and in the devtools Blocks panel.
- OWASP Forgot Password Cheat Sheet (single use token handling)
- OWASP Authentication Cheat Sheet (account enumeration)
- MongoDB unique indexes
- MongoDB findOneAndUpdate
- MongoDB aggregation $group
For AI agents#
Coding agents read these notes in app/blocks/waitlist/AGENTS.md:
- Read
app/blocks/waitlist/index.jsfirst: the header lists every route and the rules of the line. - Options live in
calm.config.jsunderblocks.waitlist.gate: truemakes registration invitation only. - The place in line is computed, never stored:
score(join number minusreferralBoostper referral) thenseq. 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.guardonPOST /auth/registerthat checks and removesinviteTokenfrom the body; thebeforeCreatewrapper 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.registeredmarks the entryregisteredand clears the token; that plus the unique user email makes invites single use.- Referral counting is capped by
maxReferralsin the update filter; do not move the cap to application code (races). - The join route is public: keep
joinLimitand leaveconfirmationEmailoff 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.