Waitlist
Take requests from people who want in, read one back by its status token, and work the operator queue.
A waitlist signup is a request, not an invite. It carries no claim code, no role, no app grants and no funding. Three things turn a request into an invite, and all three go through the ordinary invite path: an operator approves it (mint and send), a signed-in member spends an invite they already hold on it from the pool - the applicants who agreed at signup to have their answers read - or the deployment admits it on the spot while it has open spots.
That separation is the point. An invite books protocol float the moment it exists, and only an admin may mint one - so a public form that minted per submission would spend real funds on every stranger who filled it in, and a scripted submitter could exhaust the invite supply for everyone else. Open spots are the bounded exception: a fixed number the deployment sets, off unless it does.
Signing up
POST /api/waitlist is the one route here an unauthenticated caller reaches. It takes a typed
email address or X handle - no sign-in, so no account and no wallet is created for someone who has
not been let in yet.
Four fields are required: channel (EMAIL or X_HANDLE), contact, interests (at least one)
and experience. The two prose answers, pitch (at most 800 characters) and currentWork (at
most 500), are optional with no minimum - omitted and empty are the same submission. Send
referrerAppId if you know where the visitor came from; never ask them for it.
Two more optional fields concern the pool. publicConsent is whether the applicant agrees to have
their two answers read by members - only a literal true is consent; omitted stores false,
and a form that collects it must say who reads the answers before the person writes them.
displayName (at most 40 characters) is what members see in place of the contact; leave it out and
they see the answers alone. Neither field changes how an operator treats the request.
A duplicate returns the same 200 as a new signup, with the same statusToken. There is no
"already on the list" response, deliberately: a different one would let anyone test whether a
given person had applied. A contact that does not parse still returns a normal 400 - that is the
submitter's own typo, not information about a third party.
The contact is normalized with the same rules the invite bindings use, so Alice+RT@Gmail.com and
alice@gmail.com are one row, as are @Handle, handle and HANDLE.
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST | /api/waitlist | app key | Public. Record a request to be let in. Limited to 3 a minute per IP; a duplicate is indistinguishable from a new signup. |
GET | /api/waitlist/status/{token} | app key | Public. Read one request by the statusToken handed back at signup. |
GET | /api/waitlist/open-spots | app key | Public. How many signups are admitted on the spot right now; open is null when none are. |
From the SDK
The public routes have typed methods. None needs a signed-in user - an app API key is the whole requirement, which is the point.
const { data } = await rtClient.waitlist.signup({
channel: 'EMAIL',
contact: 'ana@gmail.com',
pitch: '...', // optional, at most 800
currentWork: '...', // optional, at most 500
interests: ['WRITING'],
experience: 'NEW_TO_CRYPTO',
})
const status = await rtClient.waitlist.status(data.statusToken)
const { data: spots } = await rtClient.waitlist.openSpots() // { open: 3 } or { open: null }Hold on to statusToken - it is the only thread back to the request, so store it somewhere the
person can find it again and hand it to them as a URL.
Reading a status
GET /api/waitlist/status/{token} is how an applicant finds out they are in. An EMAIL applicant
is also emailed when a spot opens, where the deployment has a mail provider configured; an
X_HANDLE applicant never is, because there is nothing to write to. So the token is the thread
back to the request for everyone, and the page that holds it should tell them to keep it - and
should not promise a message.
While the request is PENDING the payload is just status and createdAt. Once it is INVITED
it also carries claimUrl, expiresAt, invitedAt and contactRaw - the address they typed.
Once the invite is claimed, claimUrl goes and claimed: true takes its place: the person is in.
An INVITED status with neither means the invite was revoked or expired.
The claim page itself can never show that: it knows only the binding's type, so it can say
"sign in with Google" and not which Google account. This response is scoped to the person who
filled in the form, so it can say it.
An unknown token returns the same shape as a pending one. The endpoint never confirms whether a token was real.
Open spots
A deployment can admit a fixed number of signups the moment they arrive. While spots are open, a new
signup is approved at once - the same invite an operator would send, bound to the contact typed -
and its status reads INVITED with a claimUrl by the time signup returns. A spot is held while
its invite is claimed or still claimable; an admitted invite that goes unclaimed expires after 72
hours and gives the spot back. With none open, a signup waits for an operator or a member, as above.
GET /api/waitlist/open-spots reports the number. Say it as a reading ("right now, the next three
people are let in"), never as a promise to the reader - a spot can go between the read and the
signup. open is null while the deployment admits nobody on the spot, which is the default;
show nothing then rather than "0 left".
The operator queue
Operator-only (VIEW_ADMIN_PANEL), and the surface an operator works from. The list returns pending counts crossed by
interest and experience alongside the rows, so a cohort - say, writers who have never used a wallet
- is one query rather than a scan.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /api/waitlist | user, entitlement | List requests, filtered by status, interests or experience, with the interest x experience summary. |
POST | /api/waitlist/{id}/approve | user, entitlement | Convert a request into a SENT invite and return the claim link. |
PATCH | /api/waitlist/{id} | user, entitlement | action: decline, annotate, requeue, publish (into the pool) or withdraw (out of it). |
DELETE | /api/waitlist/{id} | user, entitlement | Permanently delete a request and the contact details on it. |
The pool
The applicants who agreed to be read, for the people who can act on it.
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /api/waitlist/pool | app key + user | Signed-in members only. A page of pooled applicants, shuffled per viewer, with hasMore and no total. |
A row is in the pool only when three things hold at once, and they are three separate fields
because they answer three different questions: the applicant consented (publicConsent, theirs to
give), an operator read the text and published it (visibility is PUBLIC, ours), and the request
is still PENDING. PATCH with action: "publish" is the operator's flip; it is refused with
NO_CONSENT (403) on a row whose applicant did not agree, whatever the client showed.
What the pool serializes is the whole contract: id, displayName, currentWork, pitch,
interests, experience. No contact in either form, no channel (which narrows the search for
one), no dates (which read as a queue position), no note, no invite. The order is shuffled with a
seed per viewer, stable across that viewer's pages and different for the next person, so the bottom
of a large pool is not permanently unseen - and there is no total, because a count is the ordinal
the pool must not have.
const page = await rtClient.waitlist.pool({ take: 20, skip: 0 })
// page.data.items[0] -> { id, displayName, currentWork, pitch, interests, experience }A member who holds a DRAFT invite spends it on a pooled applicant with
POST /api/invites/{id}/send-to-waitlist - the pick handle goes up, the
server binds the invite to the contact it holds, and the member never learns how to reach the
person.
Approving
POST /api/waitlist/{id}/approve is the only place the waitlist and the invite machinery touch. It
calls the ordinary mint path unchanged, so every rule that applies to a hand-minted invite applies
here: a role that may not mint gets the same refusal, and the invite is owned by the acting operator
rather than by the protocol.
The body is a prefill the operator has already reviewed - grantAppIds, roleId and message, all
optional. GET /api/waitlist ships a suggestedMessage with each row: a builder is pointed at the
developer docs, since what they need is the SDK and an API key rather than access to an app.
An approval opens one app, whatever the applicant said they were interested in. The achievement ladder is what opens the others, and an invite that grants them up front leaves those rungs paying for doors that are already open.
Two behaviours to build against:
It is not idempotent, deliberately. A second approve on the same request is refused with
NOT_PENDING and mints nothing - two clicks must not mean two funded invites. A declined request is
refused the same way until it is returned to the queue.
The invite expires in 90 days, not the usual 30. Delivery is best-effort: an EMAIL applicant
is emailed where a provider is configured, an X_HANDLE applicant never is, and either way the
person was asked to keep a URL they had no reason to keep. A month is not long enough for that, and
an invite that expires unseen costs a slot and a person.
The response carries invite.claimUrl and notifiedAt. When notifiedAt is null nothing was
sent and the link is the delivery - hand it over. The applicant's own status page shows it
again if they come back, in either case.
Declining mints nothing and produces no revoked invite, so a request that was never going to be
accepted stays out of the invite records entirely. DELETE is a real delete - there is no on-chain
counterpart to preserve.