DEV Community

Drive SaaS trial lifecycle emails with an agent

Drive SaaS trial lifecycle emails with an agent

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 pause 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.

Why reply-aware beats a blind drip

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:

  • Branch on intent, not just timing. "Send pricing details," "I have a setup question," and "I already upgraded" each demand a different next step. A drip can only ask "has 3 days passed?" An agent that reads the reply can ask "what did they actually say?" and route accordingly.
  • Stop on convert. The single worst lifecycle email is "your trial expires soon" sent to a paying customer. The only reliable way to suppress it is to know they converted - and a reply ("just upgraded, thanks") is one of the clearest convert signals you get. The drip can't hear it; the agent can.
  • A real support surface inside the funnel. Half of mid-trial replies are questions. Answering "does it do SSO?" in-thread, in seconds, is the difference between a conversion and a churned trial. Routing that question to 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.

The grant is the whole data plane

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.

Before you begin

You need three things:

  1. The CLI. On macOS or Linux it's a Homebrew tap:
    brew install nylas/nylas-cli/nylas
    
  2. 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>.
  3. 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.

Provision the trial mailbox

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.

Map the lifecycle stages (this part is yours)

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:

# your_app/trials.py - runs on a schedule against YOUR billing 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 != "paused":
        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 != "paused" and not user.converted guards above - is the whole point of this post, and we'll wire it up after the send.

Send each lifecycle email

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.

Schedule a stage instead of firing it now

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.

Catch the reply (message.created)

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.

Read the inbound 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.

Branch the sequence on the reply

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 needs to handle.

Comments

No comments yet. Start the discussion.