cd /news/ai-agents/chase-overdue-invoices-with-a-collec… Β· home β€Ί topics β€Ί ai-agents β€Ί article
[ARTICLE Β· art-53529] src=dev.to β†— pub= topic=ai-agents verified=true sentiment=Β· neutral

Chase overdue invoices with a collections agent

Nylas developer advocates building a collections agent that can both send and receive email, using an Agent Account with a full mailbox. The agent stops escalation on inbound replies like 'I already paid' or 'I'm disputing this charge', avoiding the common failure of one-way email services. The implementation uses Nylas API and CLI commands to create the account and handle inbound mail via webhooks.

read10 min views1 publishedJul 10, 2026

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.

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:

POST /v3/grants/{grant_id}/messages/send

).message.created

webhooks, app-scoped.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.

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:

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.

You need:

*.nylas.email

trial subdomain). New domains warm over roughly four weeks, so don't start your first real campaign on a domain that's a day old.message.created

webhook subscription so inbound replies reach you.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.

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

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:

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.

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.

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.

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:

id

.id

is constant across all retries of one event, so it's your dedup key. (You can additionally guard on the inner data.object.id

, the message ID, to avoid acting twice on the same message.)message.created

payload carries summary fields (subject

, from

, snippet

), and the docs disagree on whether the full body is inline. The safe move is to message.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);
});

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.

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?

The state β€” d, 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.

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 d the reminders while we confirm payment on our side."
  }'

The CLI reply is a one-liner β€” it fetches the original message to populate the recipient and subject for you, and preserves threading automatically:

nylas email reply <MESSAGE_ID> <GRANT_ID> \
  --body "Thanks for letting us know, Jordan. I have d the reminders while we confirm payment on our side."

That threaded acknowledgment is a small thing that does a lot of relationship work. A customer who says "I paid" and immediately gets a threaded "got it, d" is a customer who trusts your billing isn't a runaway robot.

A few things I've learned the hard way building these loops:

message.created

too.from

(or your stored outbound message_id

s) so the agent doesn't react to its own mail.thread_id

, In-Reply-To

, and References

keep the conversation intactnylas email ...

flag used above, verified against v3.1.27When this post is published, link AI agents and crawlers to the retrieval-ready version on cli.nylas.com

:

── more in #ai-agents 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/chase-overdue-invoic…] indexed:0 read:10min 2026-07-10 Β· β€”