Chase overdue invoices with a collections agent
Chase overdue invoices with a collections agent
Most "AI for collections" demos are a glorified mail merge: a model writes three increasingly stern paragraphs, a cron job fires them on days 7, 14, and 30, and everyone claps. That's fine right up until a customer replies "we paid this last Tuesday, check your records" - and your one-way email service can't read the reply, so the agent sends the day-30 nastygram anyway. Now you've insulted a paying customer and your AR team is on the phone apologizing.
Collections isn't a send-only problem. It's a polite-but-firm sequence that has to read the replies - because the two messages that matter most ("I already paid" and "I'm disputing this charge") arrive as inbound email, and they're the two signals that should immediately stop the ladder. A dunning agent that can't receive mail is the wrong tool for the job by design.
That's the whole reason to put this on an Agent Account. An Agent Account is a real, full mailbox the agent owns - it sends and receives, threads replies, and fires webhooks on inbound mail. The escalation logic and the "stop on payment or dispute" rule are your code, but the read/write/reply plumbing is handled.
I work on the Nylas CLI, so the terminal commands below are the exact ones I reach for when I'm building and debugging one of these loops. Every step has both forms: the curl HTTP call and the nylas ... equivalent.
What you actually get
An Agent Account is just a grant - the same grant_id abstraction behind every Nylas mailbox. There's nothing new to learn on the data plane. If you've ever listed messages or sent an email through Nylas, you already know the API surface.
The agent-specific part is that ar@yourcompany.com is a mailbox the agent controls end to end:
- Send dunning steps from it (
POST /v3/grants/{grant_id}/messages/send). - Receive replies as
message.createdwebhooks, app-scoped. - Read the full message body and reply in-thread so the customer sees one conversation, not three disconnected nags.
The dunning ladder, the tone, and the stop conditions live in your app and your billing system. Nylas never knows whether an invoice is paid - that's your source of truth. Keep that boundary clear and the rest is mechanical.
Why this beats a transactional ESP
A one-way provider (SendGrid, Resend, Postmark) sends beautifully and hears nothing back. For a marketing blast that's a feature. For collections it's the failure mode:
- It can't see the "I already paid" reply, so it can't stop the ladder. You escalate against a paying customer.
- It can't see a "this is a dispute" reply, so it can't route the thread to a human before things get legal.
- It can't thread. Your day-14 reminder lands as a fresh email instead of a follow-up, and the customer has to reconstruct context every time.
An Agent Account closes that loop. The reply is just inbound mail, and inbound mail is something the agent can fetch, classify, and act on.
Before you begin
You need:
- An Agent Account on a registered sending domain (a custom domain, or a
*.nylas.emailtrial subdomain). New domains warm over roughly four weeks, so don't start your first real campaign on a domain that's a day old. - An app-level
message.createdwebhook subscription so inbound replies reach you. - A billing system you can query for payment status. This is the keystone. The agent must never decide an invoice is paid; it asks your billing system.
New to Agent Accounts? Start with the Agent Accounts overview and "Give your agent its own email."
Create the account from the CLI:
nylas agent account create ar@yourcompany.com --name "Acme Billing"
Or over the API - an Agent Account is created with POST /v3/connect/custom, passing "provider": "nylas", a top-level name for the display name, and the mailbox address under settings.email:
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 Billing",
"settings": {
"email": "ar@yourcompany.com"
}
}'
Grab the grant_id it returns - that's the identifier in every call below. No refresh token, no OAuth dance; the mailbox just exists.
Subscribe to webhooks
You also need that app-level message.created subscription so replies reach you. Subscribe once at the app level with POST /v3/webhooks, listing the triggers and your endpoint 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://yourcompany.com/webhooks/nylas"
}'
Or from the CLI:
nylas webhook create --url https://yourcompany.com/webhooks/nylas --triggers message.created
Remember webhooks are app-scoped, not grant-scoped - you subscribe once and filter by grant_id per payload (more on that below).
The dunning ladder lives in your code
Before any email goes out, be clear about what Nylas does and doesn't own. The ladder - which template fires on day 7 versus day 14 versus day 30, how the tone escalates, and when to stop - is entirely your application logic. Nylas custom metadata isn't supported on Agent Account messages, so don't try to stash dunning state on the message; keep it in your own database.
A reasonable starting ladder:
| Day | Step | Tone |
|---|---|---|
| 7 | First reminder | Friendly nudge - "this may have slipped through" |
| 14 | Second reminder | Direct - past due, please remit |
| 30 | Final notice | Firm - account at risk, escalation pending |
Your scheduler (cron, a queue, a workflow engine) walks each open invoice through that table. At every tick it does two things in order:
- Ask your billing system: is this invoice still unpaid? If it's paid, stop. Don't send.
- Check your reply state: has the customer replied? If they said "I already paid" or "I dispute this," stop and route to a human.
- Otherwise, send the next step.
That's the entire control flow. Nylas is the transport for step 3; your code owns 1 and 2.
I like to timestamp every decision so the audit trail is unambiguous later - date is enough:
date -u +"%Y-%m-%dT%H:%M:%SZ"
Log that next to the invoice ID and the step you fired. When a customer disputes the charge three weeks later, you want a clean record of exactly what you sent and when.
Send a dunning step
Each rung of the ladder is a normal send. Here's the day-7 reminder, both ways.
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": "customer@example.com", "name": "Jordan Lee" }],
"subject": "Invoice #4821 - friendly reminder",
"body": "Hi Jordan, just a quick note that invoice #4821 for $1,200 was due on June 18. If it has already gone out, please disregard this. Otherwise you can pay here: https://billing.yourcompany.com/inv/4821"
}'
The CLI does the same thing without the JSON ceremony:
nylas email send <GRANT_ID> \
--to customer@example.com \
--subject "Invoice #4821 - friendly reminder" \
--body "Hi Jordan, just a quick note that invoice #4821 for \$1,200 was due on June 18. If it has already gone out, please disregard this. Otherwise you can pay here: https://billing.yourcompany.com/inv/4821"
Capture the message_id and thread_id from the response and store them against the invoice. The thread_id is what ties every later reminder - and every reply - back to this conversation.
Schedule the next step instead of polling
If you'd rather hand the timing to Nylas than run a tick loop, nylas email send takes --schedule. It accepts relative durations like 30m, 2h, 1d, 2d, or natural times like "tomorrow 9am". (It does not accept "in 2 days" - use 2d.)
nylas email send <GRANT_ID> \
--to customer@example.com \
--subject "Invoice #4821 - past due" \
--body "Hi Jordan, invoice #4821 is now a week past due..." \
--schedule "2d"
The API equivalent is the same POST /v3/grants/{grant_id}/messages/send you already use, plus a send_at field - a Unix timestamp (seconds) for when Nylas should release the message:
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": "customer@example.com" }],
"subject": "Invoice #4821 - past due",
"body": "Hi Jordan, invoice #4821 is now a week past due...",
"send_at": 1718841600
}'
The catch with scheduling ahead: a scheduled send is committed. If the customer pays or replies before it fires, you have to cancel it, or you'll send a dunning email to someone who already settled - exactly the failure we're trying to avoid.
Cancel by schedule ID:
nylas email scheduled cancel <SCHEDULE_ID>
Or over the API, DELETE /v3/grants/{grant_id}/messages/schedules/{schedule_id}:
curl --request DELETE \
--url "https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/schedules/<SCHEDULE_ID>" \
--header "Authorization: Bearer <NYLAS_API_KEY>"
Honestly, for collections I lean toward a tick loop over pre-scheduling, precisely because the stop conditions are so important. A loop re-checks payment status at send time; a pre-scheduled message is a promise you might have to chase down and cancel. Pick scheduling only if your cancel path is rock-solid.
Receive the reply
Inbound mail to the Agent Account fires the standard message.created webhook. One thing to internalize: webhooks are application-scoped, not grant-scoped. You subscribe once at the app level (POST /v3/webhooks), and events for every grant arrive at that one endpoint. Each payload carries a grant_id you filter on, so a multi-tenant AR system routes by grant_id to the right customer's collections context.
Two correctness rules that will bite you if you skip them:
- Dedup on the top-level
notification id. Nylas guarantees at-least-once delivery - the same event can arrive up to three times. The top-levelidis constant across all retries of one event, so it's your dedup key. (You can additionally guard on the innerdata.object.id, the message ID, to avoid acting twice on the same message.) - Don't trust the payload for the body. The
message.createdpayload carries summary fields (subject,from,snippet), and the docs disagree on whether the full body is inline. The safe move is to not rely on the payload body - fetch the full message by ID when you need it, and branch onmessage.created.truncated(the type Nylas uses when a message exceeds ~1 MB and the body is omitted).
A minimal handler:
// Express handler for POST /webhooks/nylas
app.post("/webhooks/nylas", async (req, res) => {
res.status(200).end(); // ack fast
const event = req.body;
if (event.type !== "message.created" && event.type !== "message.created.truncated") return;
// Dedup on the delivery id (constant across the 3 retries).
if (await seen(event.id)) return;
await markSeen(event.id);
const msg = event.data.object;
if (msg.grant_id !== AR_AGENT_GRANT_ID) return; // route by grant
// Ignore our own outbound sends, which also fire message.created.
if (isFromAgent(msg.from)) return;
// Fetch the full body by id - don't rely on the payload.
await handleReply(msg.grant_id, msg.id, msg.thread_id);
});
Read the full message
Now fetch the body so your classifier has the actual words to work with. Over the API that's 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 terminal:
nylas email read <MESSAGE_ID> <GRANT_ID>
If you also want the conversation chain - useful when the customer is replying to a thread that's already several reminders deep - pull the thread. nylas email threads show <thread-id> does it from the CLI; over the API it's GET /v3/grants/{grant_id}/threads/{thread_id}. The thread's message_ids give you every rung you've already sent, which is exactly the context a "tone so far" decision needs.
Classify, then stop or escalate
This is where the LLM earns its keep - and the only place it should. Feed the reply body (and optionally the thread history) to your model and ask one question: is this customer saying they already paid, disputing the charge, or something else?
- "I already paid." Mark the thread paused in your DB, then verify against billing. If billing confirms payment, close the dunning sequence. If billing shows it unpaid, pause anyway and route to a human - never argue with a customer over email about whether they paid. Your billing system is the truth; the agent doesn't override it.
- "I dispute this charge." Stop the ladder immediately and hand the thread to a human. Disputes are a legal and relationship surface, not an automation surface.
- Anything else (a question, a "can I get an extension") - pause the automated ladder and route to a human or a slower, human-supervised track.
The state - paused, disputed, escalated, closed - lives in your database, keyed by thread_id. Custom metadata on the message isn't an option here, and you wouldn't want billing state living in your mail provider anyway.
Reply in-thread
When a human (or the agent, for a benign acknowledgment) responds, reply in-thread so the customer sees one continuous conversation. The reply carries reply_to_message_id, which makes Nylas set the In-Reply-To and References headers so it groups correctly in Gmail, Outlook, everywhere:
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": "customer@example.com" }],
"subject": "Re: Invoice #4821 - friendly reminder",
"body": "Thanks for letting us know, Jordan. I have paused the reminders while we confirm payment on our side."
}'
The CLI reply is a one-liner - it fetches the original message to
Comments
No comments yet. Start the discussion.