Run an event waitlist with an email agent that promotes the first yes
Provision the Agent Account
Create the account with POST /v3/connect/custom. Provider is nylas, and settings.email must be on a domain you've registered. The optional top-level name sets the display name recipients see.
curl --request POST \
--url "https://api.us.nylas.com/v3/connect/custom" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"provider": "nylas",
"name": "Events Waitlist",
"settings": {
"email": "waitlist@events.yourcompany.com"
}
}'
The response includes the grant_id. Hold onto it - it's the identifier in every call from here on. No refresh token, no OAuth dance; the account is ready to send and receive immediately.
The CLI collapses the connector-creation step (it auto-creates the nylas connector if it's missing) into a single command:
nylas agent account create waitlist@events.yourcompany.com --name "Events Waitlist"
The API auto-creates a default workspace and policy for the account, so there's no extra setup before the waitlist flow can run. (If you later want to lock down which domains the agent can email, that's a separate policy concern, out of scope here.)
Subscribe to inbound mail
Reply order comes from the message.created webhook, so subscribe to it. Point it at your handler URL.
curl --request POST \
--url "https://api.us.nylas.com/v3/webhooks" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"trigger_types": ["message.created"],
"webhook_url": "https://yourapp.com/webhooks/nylas",
"description": "Waitlist inbound replies"
}'
nylas webhook create \
--url https://yourapp.com/webhooks/nylas \
--triggers message.created \
--description "Waitlist inbound replies"
Agent Accounts also emit message.delivered, message.bounced, and message.complaint, which are worth subscribing to so you know if an offer never landed. For the waitlist mechanics, message.created is the one that matters.
Send the offer batch
A seat opens. You pull the next N people off the waitlist (your DB knows the order) and email each one an offer that says, in effect, "a spot opened, reply YES to claim it." Send one message per person so each lives in its own thread - that keeps acceptances cleanly separated by recipient.
Here's a single offer over HTTP. Store the returned id and thread_id against the waitlist entry so you can match the reply later.
curl --request POST \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"to": [{ "email": "alice@example.com" }],
"subject": "A spot opened for DevConf - reply YES to claim it",
"body": "Hi Alice, a seat just opened for DevConf on March 12. Reply YES to claim it. First confirmed reply gets the seat."
}'
The same send from the CLI:
nylas email send <GRANT_ID> \
--to alice@example.com \
--subject "A spot opened for DevConf - reply YES to claim it" \
--body "Hi Alice, a seat just opened for DevConf on March 12. Reply YES to claim it. First confirmed reply gets the seat."
Loop that over your batch. In your DB, record the offer: the seat ID, each recipient's message_id and thread_id, the send time, and a single claimed flag (or claim row) for the seat - initialized to unclaimed. That flag is the thing the first yes will atomically flip.
Read acceptances in arrival order
Now you wait for replies. Each inbound reply fires message.created. The payload carries summary fields only - id, thread_id, from, subject, snippet, and the message's date timestamp (e.g. "date": 1723821981) - not the full body. So the webhook tells you that someone replied and when, but to read whether they actually said "yes," you fetch the full message from the API. (The arrival time you sort on is the date field on the message object - not the webhook envelope's own time.)
A sketch of the handler, with the two non-negotiable guards up front:
app.post("/webhooks/nylas", async (req, res) => {
// Verify X-Nylas-Signature, then ack fast.
res.status(200).end();
const event = req.body;
if (event.type !== "message.created") return;
const msg = event.data.object;
if (msg.grant_id !== WAITLIST_GRANT_ID) return;
// 1. Skip the agent's own outbound - sends fire message.created too.
if (msg.from?.[0]?.email === WAITLIST_EMAIL) return;
// 2. Dedup on inbound message id - webhooks are at-least-once.
const isNew = await db.processed.setIfAbsent(msg.id, { at: Date.now() });
if (!isNew) return;
await handleAcceptance(msg);
});
The date field on msg is your arrival-order key. If you process strictly in webhook-delivery order, that usually matches arrival order - but under retries and concurrency it won't always, which is exactly why the atomic claim below is the real guarantee, not the ordering of your handler.
To actually read the reply, fetch the full body. Over HTTP that's a single message GET:
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/<MESSAGE_ID>" \
--header "Authorization: Bearer <NYLAS_API_KEY>"
To list what's landed in the inbox (useful for a reconciliation sweep, or if you missed a webhook), use the messages collection:
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages?limit=20" \
--header "Authorization: Bearer <NYLAS_API_KEY>"
The CLI mirrors both. List recent inbound, then read the full body of a specific reply:
nylas email list <GRANT_ID> --limit 20
nylas email read <MESSAGE_ID> <GRANT_ID>
If you want to see the whole conversation - the offer plus the reply - read the thread directly. Over HTTP that's a thread GET:
curl --request GET \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/threads/<THREAD_ID>" \
--header "Authorization: Bearer <NYLAS_API_KEY>"
And the CLI equivalent:
nylas email threads show <THREAD_ID> <GRANT_ID>
Whether the body contains a "yes" is a classification call. A regex on /\byes\b/i covers the obvious cases; an LLM handles "sure, I'm in" and "yeah sign me up." Either way, the decision is: this reply is an acceptance for the seat tied to this thread_id.
Promote the first yes, atomically
This is the whole point. An acceptance arrived. Before you confirm anyone, try to claim the seat - and only the worker that wins the claim sends the confirmation.
async function handleAcceptance(msg) {
const offer = await db.offerByThread(msg.thread_id);
if (!offer) return; // Not part of an active waitlist batch.
const body = await fetchFullBody(msg.id); // message GET
if (!isYes(body)) return;
// Atomic claim: SET NX in Redis, or INSERT ON CONFLICT in Postgres.
// Exactly one acceptance can win the seat.
const won = await db.claimSeat(offer.seatId, { winner: msg.id });
if (won) {
await confirmSeat(offer, msg); // first yes
await releaseOthers(offer, msg.id); // everyone else in the batch
} else {
await sendTooLate(offer, msg); // a later yes for an already-claimed seat
}
}
db.claimSeat is where correctness lives. In Redis it's SET seat:{seatId} {messageId} NX; in Postgres it's an INSERT INTO seat_claims (seat_id, winner_message_id) VALUES (...) ON CONFLICT (seat_id) DO NOTHING and you check whether a row was inserted. If two acceptances arrive in the same millisecond, the database - not your handler's scheduling luck - decides which one wins. That's the only place this is safe.
Confirm the winner
The winner gets a confirmation, sent in-thread so it lands under their original offer. The CLI's reply command is built for exactly this - it fetches the original to populate recipient and subject, and preserves threading automatically:
nylas email reply <MESSAGE_ID> <GRANT_ID> \
--body "You're confirmed for DevConf on March 12. A calendar invite is on its way."
Over HTTP, threading is preserved by passing reply_to_message_id - Nylas reads the original's Message-ID and sets In-Reply-To and References on the outbound message for you:
curl --request POST \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"reply_to_message_id": "<MESSAGE_ID>",
"to": [{ "email": "alice@example.com" }],
"subject": "Re: A spot opened for DevConf - reply YES to claim it",
"body": "You'\''re confirmed for DevConf on March 12. A calendar invite is on its way."
}'
Release everyone else
Every other person in the batch needs to hear the seat is gone - both the ones who haven't replied and any later yes that lost the claim. Loop over the batch's remaining offers and reply in-thread to each, excluding the winner's message:
nylas email reply <OTHER_MESSAGE_ID> <GRANT_ID> \
--body "Thanks for the quick reply - the spot was just claimed by someone earlier in line. You're still on the waitlist for the next opening."
And the HTTP form, identical shape to the confirmation:
curl --request POST \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"reply_to_message_id": "<OTHER_MESSAGE_ID>",
"to": [{ "email": "bob@example.com" }],
"subject": "Re: A spot opened for DevConf - reply YES to claim it",
"body": "Thanks for the quick reply - the spot was just claimed by someone earlier in line. You'\''re still on the waitlist for the next opening."
}'
The release replies in-thread, so each person sees a clean follow-up under their own offer rather than a cold "sorry" with no context.
Guardrails and gotchas
A few things I'd want a teammate to know before they ship this:
- Dedup on the inbound message ID, always. Webhooks are at-least-once delivery. If you don't dedup, a single "yes" can trigger multiple claim attempts. The
setIfAbsentguard in the handler sketch is non-negotiable. - Custom metadata isn't supported on Agent Accounts. You can't stash
"seat 3, offer batch 7, claimed=false"on the Nylas message and read it back. Waitlist state lives in your database - the offer batch, who you emailed, the per-seat claim flag, and the inbound message IDs you've already processed. Nylas is the transport and the source of arrival order; it is not your state store. - Free-plan Agent Accounts cap at 200 messages per account per day. A waitlist offer batch plus confirmations and releases burns through that faster than you'd think - budget accordingly if you're promoting in bulk.
- New domains warm over roughly four weeks. Don't run your first real batch from a domain you provisioned yesterday.
- The
datefield on the message object is your arrival-order key, not the webhook envelope's own timestamp. Sort on the message'sdatefor correct ordering.
Comments
No comments yet. Start the discussion.