Collect NPS and survey replies in an agent inbox
Collect NPS and Survey Replies in an Agent Inbox
Reply-to-email surveys beat form links. People answer them at a rate that makes the marketing team jealous - no click, no page load, no "this site wants to use cookies" dialog standing between the customer and the one number you actually want. They just hit reply and type "9, the new dashboard is great."
The catch is the part nobody ships: something has to read the reply. A form link dumps a clean integer into a database. A reply-to-email survey lands a human-written sentence in some inbox, and unless you have a process that opens that inbox, finds the number, and files it, your beautiful 40% response rate is just a folder of unread mail.
Most teams solve this with two disconnected systems. One service blasts the survey out (SendGrid, a marketing tool, whatever). A completely separate place collects answers - a Google Form, a Typeform, a survey vendor. The link in the email is the seam, and seams leak: people reply to the email instead of clicking, and those replies go nowhere.
The fix is to make send and reply-parsing share one mailbox. You send the NPS email from an address that can also receive - and an agent watching that address parses every reply into a score and a comment. Closed loop. One inbox. That's what an Agent Account gives you.
What an Agent Account Is (and Why It Fits This)
An Agent Account is a Nylas-hosted mailbox your code owns. Under the hood it's just a grant - the same grant_id abstraction Nylas uses for every connected mailbox. If you've ever called /v3/grants/{grant_id}/messages against a Gmail or Microsoft account, you already know the entire data plane. There's nothing new to learn: same endpoints, same auth header, same payload shapes. The only difference is that nobody had to OAuth their way in, and there's no human reading this inbox but your program.
For survey collection that matters because the agent needs to do both halves:
- Send the survey from
nps@yourcompany.com(a real, deliverable address). - Receive the reply at that same address and parse it.
A transactional-email service can do the first half but has no inbox for the second. A connected OAuth mailbox can do both but you've now got a human's personal account in the loop, with their OAuth token to refresh and their other 4,000 emails to filter past. An Agent Account is the clean middle: a programmatic mailbox that sends and receives, with deliverability webhooks on top so you know whether the survey even landed.
I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when wiring this up. Every step shows both the raw HTTP call and the CLI equivalent - pick whichever fits your stack.
Before You Begin
You need a Nylas API key and a registered sending domain. For a custom domain (yourcompany.com) you set up DNS; for a quick trial, Nylas hands you a *.nylas.email subdomain. Either way, new domains warm up over roughly four weeks, so don't schedule a 50,000-recipient NPS campaign for day one.
In the examples I use https://api.us.nylas.com as the base host and support@yourcompany.com as the Agent Account address. Swap in your region and domain.
Step 1: Provision the Mailbox
Create the Agent Account. It's a POST /v3/connect/custom with provider: "nylas" and the email you want on a domain you control. No refresh token, no consent screen.
curl -X POST "https://api.us.nylas.com/v3/connect/custom" \
-H "Authorization: Bearer $NYLAS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"provider": "nylas",
"name": "Survey Bot",
"settings": {
"email": "support@yourcompany.com"
}
}'
The response carries a grant_id. Save it - every other call in this post is scoped to it.
From the CLI it's one line. The API auto-creates a default workspace and policy for the account, so there's nothing else to wire up:
nylas agent account create support@yourcompany.com --name "Survey Bot"
If you later want custom limits or spam rules on the mailbox, you attach a policy to its workspace with nylas workspace update <workspace-id> --policy-id <policy-id>. For survey collection the defaults are fine to start.
Step 2: Subscribe to Inbound Mail
Replies arrive as the standard message.created webhook - the same trigger you'd use on any grant. Point it at your handler:
curl -X POST "https://api.us.nylas.com/v3/webhooks" \
-H "Authorization: Bearer $NYLAS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"trigger_types": ["message.created"],
"webhook_url": "https://survey.yourcompany.com/webhooks/nylas",
"description": "NPS reply collector"
}'
Or with the CLI:
nylas webhook create \
--url https://survey.yourcompany.com/webhooks/nylas \
--triggers message.created \
--description "NPS reply collector"
That's the inbound channel. Agent Accounts also emit deliverability triggers - message.delivered, message.bounced, message.complaint - and for an NPS campaign those are worth subscribing to as well, so you can tell "they didn't answer" apart from "the survey never arrived." Add them to the same --triggers list when you want that signal.
If you'd rather not run a webhook endpoint, polling works too. A nightly job that lists the inbox and parses anything new is a perfectly reasonable design for survey collection, and I'll show the list call below.
Step 3: Send the Survey
This is plain outbound mail from the grant: POST /v3/grants/{grant_id}/messages/send. Render the survey HTML yourself and pass it as body - hosted templates aren't supported for Agent Accounts, so there's no --template-id to lean on here. That's fine; for an NPS prompt you want full control over the wording anyway, because the wording is what makes the reply parseable.
The trick that makes the loop close: ask for the answer in the reply body, in a format your parser can find. "Reply with a number from 0 to 10" trains the respondent to put the score on its own line.
curl -X POST "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/send" \
-H "Authorization: Bearer $NYLAS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": [{ "email": "customer@example.com", "name": "Dana Wright" }],
"subject": "Quick question: how likely are you to recommend us?",
"body": "<p>Hi Dana,</p><p>On a scale of 0-10, how likely are you to recommend us to a colleague?</p><p><strong>Just reply to this email with your number</strong> - and a sentence on why, if you have a moment.</p>"
}'
Note I left out the from field. When you omit it, Nylas defaults the sender to the Agent Account's own address - which is exactly what you want, because that's the address the reply needs to come back to.
The CLI form takes the grant as a positional argument:
nylas email send support@yourcompany.com \
--to customer@example.com \
--subject "Quick question: how likely are you to recommend us?" \
--body "<p>Hi Dana,</p><p>On a scale of 0-10, how likely are you to recommend us to a colleague? Just reply with your number - and a sentence on why, if you have a moment.</p>"
Store the outbound message_id and thread_id you get back, keyed to the recipient. You'll use the thread to match the reply, and you'll use the recipient to know whose score you just collected.
Step 4: Catch the Reply
When someone replies, message.created fires. Here's the part the brief in your head probably skips: the webhook payload carries summary fields only - subject, from, snippet, thread_id. It does not carry the full body. And the score lives in the body. (If the message ever exceeds ~1 MB, the trigger becomes message.created.truncated and the body is dropped entirely - another reason never to parse from the webhook.)
So the handler's job is small: acknowledge fast, then fetch the full message by ID.
app.post("/webhooks/nylas", async (req, res) => {
res.status(200).end(); // ack first; verify X-Nylas-Signature in real code
const event = req.body;
if (event.type !== "message.created") return;
const msg = event.data.object;
if (msg.grant_id !== GRANT_ID) return;
// The webhook only has summary fields. Fetch the full body to read the score.
await collectSurveyReply(msg.id, msg.thread_id);
});
Fetching the body is GET /v3/grants/{grant_id}/messages/{message_id}:
curl "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/$MESSAGE_ID" \
-H "Authorization: Bearer $NYLAS_API_KEY"
The CLI equivalent reads the message and, with --raw, gives you the body text without HTML chrome - handy when your parser wants plain text:
nylas email read $MESSAGE_ID support@yourcompany.com --raw
If you went the polling route instead of webhooks, list the inbox and walk anything unread. nylas email list filters by sender and read state out of the box:
nylas email list support@yourcompany.com --unread --limit 50
curl "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages?unread=true&limit=50" \
-H "Authorization: Bearer $NYLAS_API_KEY"
Either way you end up holding the full reply body. Now the only interesting part is left.
Step 5: Parse the Score - That's Your Code, Not Nylas
Here's the honest boundary: Nylas delivers the reply, but extracting "9" and "the new dashboard is great" out of a human-written email is your application's job. There's no score field on the message. The customer wrote a sentence; you have to read it.
For NPS specifically, a regex gets you surprisingly far, because you told them to reply with a number:
async function collectSurveyReply(messageId, threadId) {
const { data: message } = await nylas.messages.find({
identifier: GRANT_ID,
messageId,
});
const text = stripHtml(message.body);
// The score: first 0-10 integer on its own, anchored to the prompt.
const match = text.match(/\b(10|[0-9])\b/);
const score = match ? Number(match[1]) : null;
// Everything else is the comment.
const comment = text.replace(/\b(10|[0-9])\b/, "").trim();
if (score === null) {
// Couldn't find a number - file it for a human to read.
await flagForReview(messageId, threadId, text);
return;
}
const customerEmail = message.from?.[0]?.email;
await saveScore({ messageId, threadId, score, comment, customerEmail });
}
Regex handles the easy 80%. For the rest - "definitely a ten" spelled out, a score buried in a paragraph, a reply that's all comment and no number - hand the body to an LLM with a tight prompt: "Extract an NPS score 0-10 and a one-line reason from this email. Return JSON. If no score is present, return null." The model is good at exactly this kind of fuzzy extraction, and you only need it on the messages your regex missed.
Whichever you use, the output is the same shape: a score, a comment, the message_id, and which customer it came from.
Where the Parsed Scores Live: Your Database, Not Nylas
Tempting as it is to stash the parsed score back on the Nylas message as metadata, don't reach for it - custom metadata isn't supported for Agent Accounts. The mailbox is for mail. The scores belong in your database, where you can run the NPS math (percent promoters minus percent detractors), join to your customer table, and chart the trend over quarters.
A minimal row:
message_id | customer_email | score | comment | received_at
-----------+----------------+-------+------------------------+------------
<msg-id> | customer@... | 9 | the new dashboard is great | 2026-06-25
Make message_id a unique key. That single constraint is your dedup, and you need it.
Dedup on message_id, or You'll Inflate Your Own NPS
Two things will re-trigger your handler for the same reply. Webhook redelivery - Nylas retries if your endpoint is slow or returns non-2xx. And concurrent workers - if two instances pick up the same message, both parse it. Without protection, one customer's "9" gets counted twice, and your NPS quietly drifts upward for no reason anyone can explain.
The key you dedup on is the inbound reply's id - the id field from the message.created payload (the same id you fetched the body with), not the id you got back when you sent the survey. You're deduping incoming replies, so the incoming message's identity is what has to be unique. Carry it through your code as the dedup key:
async function saveScore({ messageId, threadId, score, comment, customerEmail }) {
// messageId here is the INBOUND reply's id (msg.id from message.created),
// not the send-response id from step 3.
await db.query(
`INSERT INTO survey_responses (message_id, customer_email, score, comment, received_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (message_id) DO NOTHING`,
[messageId, customerEmail, score, comment]
);
}
The fix is one line of database, not a distributed lock. The inbound message id is stable and globally unique, so the second arrival of the same reply hits the conflict and does nothing. This is the kind of thing that's invisible in a demo and load-bearing in production - the part I care about most as an SRE is the one that keeps your metric honest when the same webhook shows up twice at 2 a.m.
Thread It, If You Want the Conversation
Sometimes a reply needs a follow-up - a detractor left a score and a complaint, and you want the agent (or a human) to respond in the same thread rather than starting a cold new email. Because every reply carries a thread_id, you can pull the whole exchange:
curl "https://api.us.nylas.com/v3/grants/$GRANT_ID/threads/$THREAD_ID" \
-H "Authorization: Bearer $NYLAS_API_KEY"
nylas email threads show $THREAD_ID support@yourcompany.com
And reply in-thread so it lands as a proper conversation in the customer's mail client, not a disconnected new message:
nylas email reply $MESSAGE_ID support@yourcompany.com \
--body "Thanks for the candid feedback, Dana - passing this to the dashboard team."
curl -X POST "https://api.us.nylas.com/v3/grants/$GRANT_ID/messages/send" \
-H "Authorization: Bearer $NYLAS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": [{ "email": "customer@example.com" }],
"subject": "Re: Quick question: how likely are you to recommend us?",
"body": "Thanks for the candid feedback, Dana -"
}'
Comments
No comments yet. Start the discussion.