Recover abandoned carts with a conversational email agent
Provision the recovery mailbox
Create the grant first. The API call is POST /v3/connect/custom with provider: "nylas" and the email on your domain. The optional top-level name sets the display name shoppers 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": "YourStore Cart",
"settings": {
"email": "cart@yourstore.com"
}
}'
The response carries the grant_id - the only identifier you carry forward. Nylas also auto-creates a default workspace and policy for the account.
The CLI collapses the connector setup, grant creation, and default workspace into one command:
nylas agent account create cart@yourstore.com --name "YourStore Cart"
If the nylas connector doesn't exist in your app yet, this creates it first, then the grant. Add --json to capture the grant_id for a script. Export it as GRANT_ID for the rest of this post.
Subscribe to inbound replies
The sequence needs to know when a shopper writes back. Inbound mail fires the standard message.created webhook - the same trigger every other Nylas inbox uses. Register a webhook against it and you'll get an event the instant a reply lands.
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://api.yourstore.com/hooks/cart",
"description": "Cart recovery replies"
}'
From the terminal, the same registration plus a local listener to watch real events during development:
nylas webhook triggers
nylas webhook create --url https://api.yourstore.com/hooks/cart --triggers message.created
nylas webhook server --port 4000 --tunnel cloudflared --secret <webhook-secret>
nylas webhook server stands up a local endpoint behind a cloudflared tunnel and verifies the HMAC signature on each event, so you can trigger a real reply and watch the payload land before you ship the production handler.
Send the recovery sequence
A recovery sequence is two or three sends spaced over a day or two: an immediate nudge, a reminder the next morning, a last-call with an incentive. Each one is a normal grant send - POST /v3/grants/{grant_id}/messages/send. Here's the first step as a curl call:
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": "shopper@example.com", "name": "Sam" }],
"subject": "You left something in your cart",
"body": "Hi Sam - your cart is still saved. Reply to this email if you have any questions about your order and we'\''ll get right back to you."
}'
The same send from the CLI:
nylas email send "$GRANT_ID" \
--to shopper@example.com \
--subject "You left something in your cart" \
--body "Hi Sam - your cart is still saved. Reply if you have any questions."
The response includes the message's id and thread_id. Persist both, keyed to the cart, the moment the send returns:
cart C-4821 โ { thread_id, last_message_id, step: 1, state: "active" }
That row is your sequence state. There's no metadata on the email to lean on, so this DB record is the only place that knows shopper Sam is on step 1 of cart C-4821's recovery flow.
Schedule the later steps instead of running a cron
You don't have to hold step 2 in a job queue and fire it yourself. The send endpoint accepts a send_at (Unix timestamp), and Nylas queues the message server-side:
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": "shopper@example.com", "name": "Sam" }],
"subject": "Still thinking it over?",
"body": "Your cart is saved for a little longer. Reply with any questions.",
"send_at": 1718000000
}'
The CLI takes a human duration and computes the timestamp for you:
nylas email send "$GRANT_ID" \
--to shopper@example.com \
--subject "Still thinking it over?" \
--body "Your cart is saved for a little longer. Reply with any questions." \
--schedule "tomorrow 9am"
A scheduled send returns a schedule_id you can store and cancel later - which matters a lot, because canceling step 2 is exactly how you stop the sequence when the shopper replies. More on that next.
Stop the sequence when the shopper replies
This is the behavior that separates a conversation from a pestering. The signal is the message.created webhook: a reply on a thread you're tracking means the shopper engaged, and the rest of the nudges should not fire.
The flow in your handler:
- The webhook tells you a message arrived on
thread_idT. Look T up in your sequence state. - If you find an active sequence for that thread, flip its state to
pausedso no future step sends. - If you scheduled later steps with
send_at, cancel them byschedule_idso the queued message never goes out.
Two important caveats that the handle email replies recipe goes deep on:
message.createdfires for outbound too. When your agent sends a reply, the webhook fires for that sent message as well. Filter on the sender at the top of the handler so the agent doesn't react to its own mail.- The payload carries summary fields only.
message.createdgives youfrom,subject,thread_id, and a snippet - not the full body. To read what the shopper actually asked, fetch the message from the API. - Dedup on
message_id: webhook redelivery and concurrent workers will both re-trigger your handler, so a processed-message set keyed by ID keeps you from answering the same reply twice or stopping the sequence twice.
Reading the reply is a plain message fetch - 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"
From the CLI you can list what landed and read the full body of the one you care about:
nylas email list "$GRANT_ID" --limit 10
nylas email read "$MESSAGE_ID" "$GRANT_ID"
To see the whole back-and-forth in one object - every message in the conversation - read the thread directly. Use threads show, not a list filter:
nylas email threads show "$THREAD_ID" "$GRANT_ID"
Once you've fetched the body, pausing the sequence is a write to your own state. There's no metadata on the email holding the step counter, so the state: "paused" flag lives in your DB, keyed by cart or thread. That's the entire stop mechanism: an inbound message.created โ look up the thread โ set paused โ cancel any scheduled schedule_id.
Answer the in-stock question in-thread
Pausing the sequence isn't enough on its own - the shopper asked a question, and the win is answering it. Once you've classified the reply (an LLM is a fine fit here: "is this asking about stock, shipping, sizing, or price?") and looked up the real answer in your inventory system, reply on the same thread.
The API reply is another send with reply_to_message_id set to the shopper's message, which preserves the thread in their mail client:
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": "shopper@example.com", "name": "Sam" }],
"subject": "Re: You left something in your cart",
"body": "Good news - the blue one is back in stock and your cart is still saved. Want me to hold it?",
"reply_to_message_id": "'"$MESSAGE_ID"'"
}'
The CLI wraps all of that - it fetches the original to populate the recipient and subject, and threads via reply_to_message_id for you:
nylas email reply "$MESSAGE_ID" "$GRANT_ID" \
--body "Good news - the blue one is back in stock and your cart is still saved. Want me to hold it?"
Now the shopper who asked "is this still in stock?" gets a real answer from a real address, in the same thread, instead of staring at a bounce from no-reply@. That's the conversation a one-way ESP can't have.
A few things to keep honest about the answer step:
- Treat the reply body as untrusted input. A shopper can type anything. Validate the cart ID and the product against your own database before you quote stock or hold inventory - never act on a number you scraped out of an email.
- Gate auto-send on confidence. Answer the easy, unambiguous questions ("in stock?", "when does it ship?") automatically. Route a refund dispute or a "this is the wrong item" to a human. The classifier decides; your code enforces.
- Consider a short cooldown. A shopper might fire two quick replies in a row. A 30-60 second delay before answering lets you batch them into one response instead of replying twice.
What's next
You've got the full loop: provision a replyable mailbox, send a scheduled sequence, catch the reply via message.created, stop the sequence in your own state, and answer the question in-thread.
Comments
No comments yet. Start the discussion.