DEV Community

Qasim
Qasim

Posted on

Run an event waitlist with an email agent that promotes the first yes

Most "AI email" demos point a model at a human's inbox and let it draft replies. That's fine for a copilot. It falls apart the moment you want the agent to be a participant — to own an address, send the offer, and decide who gets the spot based on who replied first. A waitlist is exactly that kind of problem, and it exposes a detail every reply-handling tutorial glosses over.

A waitlist is first-come-by-reply. When a seat opens at your sold-out event, you email the people next in line, and whoever says yes first gets it. "First" is not who you emailed first, and it is not who your webhook handler happened to schedule first — it is the order their acceptances actually arrived in your mailbox. So before any of the clever agent stuff, the core requirement is plain: you must read inbound mail in order, and you must allocate the open seat atomically so two yeses don't both win.

This post builds that. An Agent Account owns waitlist@events.yourcompany.com, emails a batch of waitlisted people when a spot opens, watches replies land in message.created order, confirms the first acceptance in-thread, and releases the rest. I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for, and every operation is shown twice — the raw HTTP call and the CLI equivalent — because the two-angle view is the fastest way to understand what's actually moving.

What you get

The whole thing rides on one abstraction: an Agent Account is just a grant. It has a grant_id, and that grant_id works with every grant-scoped endpoint you already know — Messages, Threads, Drafts, Folders, Webhooks. There's nothing new to learn on the data plane. The only new thing is who the mailbox belongs to: a piece of software, not a person.

That buys you three things for a waitlist:

  • A real inbox the agent fully controls. It sends offers from its own address and receives the replies directly, no human relay.
  • Standard threading. When a waitlisted person replies "yes, I'll take it," Nylas groups that reply into the same thread as the offer, so you always know which seat the acceptance is for.
  • Webhooks on inbound mail. Every reply fires message.created, which is your trigger to check arrival order and try to claim the seat.

Why reply order is the hard part

The naive version of this looks done in an afternoon: email everyone, wait for replies, confirm anyone who says yes. Then two people say yes to the same single seat and you've double-booked. Or your webhook handler runs on three Lambda instances and two of them race to confirm different people for the same opening.

The honest framing: Nylas tells you the order replies arrived, but it does not allocate your seats for you. That allocation is your application logic. You need two things working together:

  1. Arrival order — every message carries a date timestamp, and the message.created webhook fires in arrival order. That's your tiebreaker for "who was first."
  2. An atomic claim — when the first yes arrives, exactly one worker must win the seat. This is a SET key NX in Redis or an INSERT ... ON CONFLICT DO NOTHING (or a SELECT ... FOR UPDATE row lock) in Postgres. Whoever wins the claim sends the confirmation; everyone else sends a release.

One more constraint worth saying out loud: 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.

Before you begin

You need a Nylas API key, a registered sending domain (a custom domain, or a *.nylas.email trial subdomain for testing), and a small datastore for waitlist state. New domains warm over roughly four weeks, so don't run your first real batch from a domain you provisioned yesterday.

A note on volume: 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.

If you're brand new to Agent Accounts, start with the Agent Accounts overview and come back.

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" }
  }'
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
  }'
Enter fullscreen mode Exit fullscreen mode
nylas webhook create \
  --url https://yourapp.com/webhooks/nylas \
  --triggers message.created \
  --description "Waitlist inbound replies"
Enter fullscreen mode Exit fullscreen mode

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."
  }'
Enter fullscreen mode Exit fullscreen mode

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."
Enter fullscreen mode Exit fullscreen mode

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 onlyid, 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);
});
Enter fullscreen mode Exit fullscreen mode

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>"
Enter fullscreen mode Exit fullscreen mode

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>"
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>"
Enter fullscreen mode Exit fullscreen mode

And the CLI equivalent:

nylas email threads show <THREAD_ID> <GRANT_ID>
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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."
Enter fullscreen mode Exit fullscreen mode

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."
  }'
Enter fullscreen mode Exit fullscreen mode

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."
Enter fullscreen mode Exit fullscreen mode

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."
  }'
Enter fullscreen mode Exit fullscreen mode

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. Without the setIfAbsent guard, a redelivered message.created makes you process the same acceptance twice — and double-fire your confirmation. This is separate from the seat claim; you need both.
  • The atomic claim is the seat allocator, not your handler order. It's tempting to think "I process webhooks in order, so the first one wins." Under retries, parallel workers, or clock skew that breaks. The SET NX / ON CONFLICT claim is the only thing that actually guarantees one seat, one winner.
  • Filter the agent's own sends. Outbound messages fire message.created too. If you don't skip from === WAITLIST_EMAIL at the top, your confirmation can re-enter the handler as if it were an acceptance.
  • message.created is summary-only. Never classify "yes" off the webhook payload — fetch the full body. If the body exceeds ~1 MB the webhook type becomes message.created.truncated and the body is omitted entirely, so the API fetch is the reliable path regardless.
  • State lives in your DB, not on the message. Custom metadata isn't supported for Agent Accounts. The offer batch, the per-seat claim, and the processed-message set are all yours to store and key on thread_id / message_id.
  • Watch the daily send cap. A batch of 30 offers plus 1 confirmation and 29 releases is 60 messages against your 200/day on the free plan. Bulk waitlist promotions add up fast.

What's next

The reply-ordered claim is the spine of this; everything else is reuse of patterns the Agent Accounts cookbook already documents in depth.

AI-answer pages for agents

When this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com:

Top comments (0)