cd /news/developer-tools/drive-saas-trial-lifecycle-emails-wi… · home topics developer-tools article
[ARTICLE · art-45278] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

Drive SaaS trial lifecycle emails with an agent

A developer at Nylas built a reply-aware SaaS trial lifecycle email system using Nylas Agent Accounts. The system replaces blind drip sequences with emails sent from a replyable address that can read user replies, classify intent via LLM, and branch the sequence accordingly—pausing nudges, answering questions, or stopping emails if the user already converted. The approach uses standard Nylas API endpoints and an Agent Account grant, avoiding the limitations of one-way ESPs like SendGrid or Resend.

read13 min views1 publishedJun 30, 2026

Every SaaS trial ships with the same four emails: welcome on day 0, a nudge somewhere in the middle, a "your trial ends in 3 days" warning, and a "your trial ended" goodbye. Almost everyone sends them as a blind drip — fire each one on a schedule from no-reply@yourapp.com

, regardless of what the user does in between. The sequence runs the same whether the user is happily building or hasn't logged in once, and it runs the same whether or not they replied to the last email asking a question.

That last case is the one that quietly costs you conversions. A trial user reads your day-3 nudge, hits reply, and types "how do I connect this to Postgres?" or "does the paid plan include SSO?" or "already upgraded, you can stop emailing me." On a one-way ESP that reply lands in a black hole — and worse, your expiry warning goes out four days later anyway, asking someone who already paid to "don't lose access." The user who told you exactly what they needed gets a sequence that never heard them.

The fix isn't a better drip. It's sending the lifecycle from an address that can read the reply and branch on it. Welcome, nudge, and expiry messages go out from a replyable agent address; when a user writes back, the agent fetches the message, classifies the intent, and changes what happens next — answer the question and the nudge, or stop the whole sequence because they already converted. That replyable, automatable address is a Nylas Agent Account. I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when wiring this up; the curl

calls beside them are what the CLI runs under the hood, so either drops straight into your stack.

Be honest about the tradeoff first, because a blind drip isn't wrong — it's just deaf. A one-way ESP (SendGrid, Resend, a cron

firing templates) gets you the easy half: the right email at the right offset. What it structurally cannot do is the other direction. It sends from no-reply@

, so it never sees the reply, so the sequence can't react to it.

A reply-aware lifecycle gets you three things the drip can't:

no-reply@

throws away your best conversion lever.Where to draw the line matters, so I'll draw it: timing, branch logic, and "stop on reply/convert" are your application's job, not Nylas's. Your billing system knows which day of the trial each user is on and whether they've paid. Your code decides "day 3 → send the nudge" and "reply classified as converted → cancel the expiry email." Nylas is the transport: it sends each lifecycle message from a real mailbox and delivers the inbound replies back to you. An LLM classifies the reply text. Your database holds the trial state. Keep that boundary clear and everything below is small.

Here's what makes this tractable: an Agent Account is just a grant. It has a grant_id

, and that ID works with every grant-scoped endpoint Nylas already exposes — Messages, Drafts, Threads, Folders. There's nothing new to learn on the data plane. You provision one mailbox like trials@yourapp.com

, and from then on sending a lifecycle email is the same POST /v3/grants/{grant_id}/messages/send

you'd use for any message, and reading a reply is the same GET /v3/grants/{grant_id}/messages/{message_id}

. If you've built against a connected Gmail or Microsoft grant before, you already know this API — same endpoints, same auth, same payloads.

What's different, and what makes an Agent Account the right tool rather than a connected OAuth mailbox, is that you own this address programmatically. No human logged into Google to grant consent, there's no refresh token to expire at 2am and silently kill your expiry sends, and it sends from your domain with your DKIM signature.

You need three things:

   brew install nylas/nylas-cli/nylas

A Nylas API key. nylas init

creates an account and mints a key in one guided command, or pass an existing one with nylas init --api-key <your-key>

.

A sender domain. Every Agent Account lives on a domain. For prototyping, Nylas hands out trial *.nylas.email

subdomains, so trials@your-app.nylas.email

works immediately. For production, register a dedicated subdomain like trials.yourapp.com

and publish the DKIM and SPF records Nylas gives you. New domains warm over roughly four weeks, so don't register one the morning you launch — and since lifecycle email goes to real signups, walk the deliverability checklist before you turn on volume.

Every API example below uses the US host https://api.us.nylas.com

and a bearer token: Authorization: Bearer <NYLAS_API_KEY>

. For the EU region, point at https://api.eu.nylas.com

.

This is the only step specific to Agent Accounts, and it's one line:

nylas agent account create trials@trials.yourapp.com --name "Acme Trials"

The --name

sets the display name, so users see Acme Trials <trials@trials.yourapp.com>

instead of a bare address. The command prints the new grant's id

— save it, it's the handle for every send and read below. The API auto-creates a default workspace and policy, so there's nothing else to wire up. (If you later want a custom send policy, attach it with nylas workspace update <workspace-id> --policy-id <policy-id>

— there's deliberately no --workspace

flag on create.)

Under the hood the CLI is a thin wrapper over POST /v3/connect/custom

with provider: "nylas"

. The same call your provisioning code makes directly:

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": "Acme Trials",
    "settings": { "email": "trials@trials.yourapp.com" }
  }'

The response contains a grant_id

. Store it next to your other infrastructure IDs — it doesn't change, and there's no refresh token to rotate.

Before any Nylas call, your application owns the stage machine. Your billing system already knows, per user: when the trial started, what day they're on, and whether they've converted. The lifecycle is just a few transitions driven by that state plus a scheduler — a cron

or a job queue — running through your own data:

for user in db.trials_active():
    day = user.trial_day            # 0, 7, 11, 14… — your billing knows this
    if day == 0  and not user.sent("welcome"): send_stage(user, "welcome")
    if day == 7  and not user.sent("nudge")   and user.stage != "d":
        send_stage(user, "nudge")   # mid-trial check-in
    if day == 11 and not user.sent("expiry")  and not user.converted:
        send_stage(user, "expiry")  # "3 days left"
    if day == 14 and not user.converted: send_stage(user, "ended")

Nylas doesn't know any of this. It doesn't know who's on day 7 or who upgraded — that's your trial_day

, your converted

flag, your sent(...)

ledger, all in your database. (Agent Accounts don't support custom metadata on grants, so don't try to stash trial state on Nylas; keep it in your own store.) Nylas enters at exactly one moment: when send_stage

has a body ready and hands it to the send endpoint. The reply-aware part — the stage != "d"

and not user.converted

guards above — is the whole point of this post, and we'll wire it up after the send.

With the body assembled, sending it is one call. The day-0 welcome, via the CLI:

nylas email send trials@trials.yourapp.com \
  --to ada@customer.com \
  --subject "Welcome to Acme — your 14-day trial starts now" \
  --body "$WELCOME_HTML"

Pass the grant by its email (or grant_id

) as the first argument; --body

takes HTML or plain text. There's no --from

— Nylas defaults the sender to the Agent Account's own address and display name, which is what you want.

The same operation against the API is POST /v3/grants/{grant_id}/messages/send

:

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": "ada@customer.com", "name": "Ada" }],
    "subject": "Welcome to Acme — your 14-day trial starts now",
    "body": "<h2>You'\''re in, Ada.</h2><p>Here'\''s how to connect your first data source… Reply to this email any time with a question.</p>"
  }'

The mid-trial nudge and the expiry warning are the same call with a different subject and body — only the content changes per stage, not the mechanics. Encourage the reply explicitly ("reply with a question") in every stage; that's how you turn the lifecycle into a two-way channel instead of a broadcast.

If your scheduler would rather hand the send to Nylas than hold its own timer, both forms support scheduled delivery. The CLI takes a friendly duration or time on --schedule

:

nylas email send trials@trials.yourapp.com \
  --to ada@customer.com \
  --subject "3 days left on your Acme trial" \
  --body "$EXPIRY_HTML" \
  --schedule "tomorrow 9am"

--schedule

accepts durations like 30m

, 2h

, 1d

, 2d

, or natural times like "tomorrow 9am"

and "2024-01-15 14:30"

. The API equivalent is a Unix-timestamp send_at

on the same send endpoint:

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": "ada@customer.com", "name": "Ada" }],
    "subject": "3 days left on your Acme trial",
    "body": "<p>Your trial ends Friday. Upgrade to keep your data…</p>",
    "send_at": 1735732800
  }'

One honest caveat: scheduling the expiry email three days out means it'll fire even if the user converts or replies in the meantime — a scheduled send is committed. For anything that should be cancellable on a reply, keep the timer in your own scheduler and only call the send endpoint at the last moment, after you've checked the user's current state. That's the whole reason the branch logic lives in your app and not in a fire-and-forget schedule.

When a trial user replies to any stage, the inbound mail fires the standard message.created

webhook. Webhooks are application-scoped, not grant-scoped: you subscribe once at the app level, and events for every grant arrive at that one endpoint, each payload carrying a grant_id

you filter on.

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://your-app.example.com/webhooks/nylas",
    "notification_email_addresses": ["dev-team@yourapp.com"]
  }'

The handler does three things before any business logic: verify the signature, dedupe, and filter to this grant. Don't rely on the webhook payload for the body — fetch the full message by id when you need it, and branch on message.created.truncated

for oversized messages.

// Node.js / Express
app.post("/webhooks/nylas", async (req, res) => {
  res.status(200).end(); // ack fast; process after

  const event = req.body;
  if (event.type !== "message.created") return;

  // Dedup on the top-level notification id — constant across all retries
  // of one event (the API delivers at-least-once, up to 3 attempts).
  if (await seen(event.id)) return;
  await markSeen(event.id);

  const msg = event.data.object;
  if (msg.grant_id !== TRIALS_GRANT_ID) return; // only our trial mailbox
  if (msg.from?.[0]?.email === "trials@trials.yourapp.com") return; // skip our own sends

  // The webhook carries summary fields; fetch the full body by id.
  await handleTrialReply(msg);
});

Dedup matters because the same event arrives up to three times. The top-level notification id

is constant across all retries of one event — that's your delivery dedup key. The inner data.object.id

(the message id) identifies the message itself; you can guard on it too if you want belt-and-suspenders against acting twice on the same message.

The webhook told you a reply exists; now read its body to classify it. The CLI reads by message id:

nylas email read <message-id> trials@trials.yourapp.com

The API call is GET /v3/grants/{grant_id}/messages/{message_id}

:

curl --request GET \
  --url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/<MESSAGE_ID>" \
  --header "Authorization: Bearer <NYLAS_API_KEY>"

If you want the full prior exchange (useful when the same trial has gone back and forth), the webhook payload also carries a thread_id

, and nylas email threads show <thread-id>

— or GET /v3/grants/{grant_id}/threads/{thread_id}

— returns the whole conversation. Reading does not mark the message read, by the way; that's a separate PUT /v3/grants/{id}/messages/{id}

with {"unread": false}

if you want it. Keep fetch and mark-read as distinct operations.

Here's where the lifecycle stops being a drip. You have the body; hand it to an LLM to classify into the intents your sequence cares about, then change state accordingly. The classifier is the only model in the loop — everything else is your code reading your database:

async function handleTrialReply(msg) {
  const full = await nylas.messages.find({
    identifier: TRIALS_GRANT_ID,
    messageId: msg.id,
  });

  // LLM classifies into intents YOUR sequence acts on.
  const intent = await classify(full.data.body);
  // -> "question" | "converted" | "objection" | "unsubscribe"

  const user = await db.userByEmail(full.data.from[0].email);

  switch (intent) {
    case "converted":
      // The most important branch: kill the expiry email.
      await db.update(user, { converted: true, stage: "won" });
      break;
    case "question":
      //  the nudge, answer in-thread.
      await db.update(user, { stage: "d" });
      await answerInThread(full.data, user);
      break;
    case "objection":
      await db.update(user, { stage: "d" });
      await routeToHuman(full.data, user);
      break;
    case "unsubscribe":
      await db.update(user, { stage: "stopped" });
      break;
  }
}

Notice the branch logic is plain application code — the stage

and converted

flags it writes are exactly the guards the stage machine reads back at the top of this post (stage != "d"

, not user.converted

). That round-trip is the whole reply-aware loop: send a stage → user replies → classify → update state → the next scheduled stage reads that state and either fires, s, or cancels.

To answer a question in-thread, reply on the original message so it threads correctly in the user's mail client. The CLI fetches the original to fill in recipient and subject for you:

nylas email reply <message-id> trials@trials.yourapp.com \
  --body "Yes — SSO is included on every paid plan. Here's the setup doc…"

Against the API, a reply is a normal send with reply_to_message_id

, the recipient, and a body:

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": "ada@customer.com", "name": "Ada" }],
    "subject": "Re: 3 days left on your Acme trial",
    "body": "Yes — SSO is included on every paid plan. Here'\''s the setup doc…"
  }'

Passing reply_to_message_id

makes Nylas set the In-Reply-To

and References

headers, so the answer lands as a threaded reply instead of a disconnected new email. The user asked their question inside your lifecycle sequence and got an answer inside the same thread — which is the experience a no-reply@

drip can never offer.

A few practical details that bite if you skip them:

send_at

/--schedule

, it fires even if the user converts the next morning. Keep cancellable stages on your own timer and only call send at the last moment, after re-checking state. Use Nylas scheduling for stages that genuinely don't need to be recalled.message.created

fires for that sent message. Filter on the sender at the top of the handler (shown above) so the agent doesn't try to classify and respond to itself.id

, not the message body.id

is constant across retries — that's the dedup key. Without it, one reply can trigger your "converted" branch three times.from.*

(address, domain, TLD) — they can't see subject or content. Intent routing is app code after the webhook (fetch → classify → act), exactly as above.message.created

independently and check whether you've already advanced state since the last inbound, so a follow-up correction doesn't double-branch.nylas

command shown above

── more in #developer-tools 4 stories · sorted by recency
── more on @nylas 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/drive-saas-trial-lif…] indexed:0 read:13min 2026-06-30 ·