{"slug": "chase-overdue-invoices-with-a-collections-agent", "title": "Chase overdue invoices with a collections agent", "summary": "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.", "body_md": "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.\n\nCollections 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.\n\nThat'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.\n\nI 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`\n\nHTTP call and the `nylas ...`\n\nequivalent.\n\nAn Agent Account is just a **grant** — the same `grant_id`\n\nabstraction 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`\n\nis a mailbox the agent controls end to end:\n\n`POST /v3/grants/{grant_id}/messages/send`\n\n).`message.created`\n\nwebhooks, 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.\n\nA 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:\n\nAn Agent Account closes that loop. The reply is just inbound mail, and inbound mail is something the agent can fetch, classify, and act on.\n\nYou need:\n\n`*.nylas.email`\n\ntrial 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`\n\nwebhook subscription so inbound replies reach you.New to Agent Accounts? Start with the [Agent Accounts overview](https://developer.nylas.com/docs/v3/agent-accounts/) and [Give your agent its own email](https://developer.nylas.com/docs/v3/getting-started/agent-own-email/).\n\nCreate the account from the CLI:\n\n```\nnylas agent account create ar@yourcompany.com --name \"Acme Billing\"\n```\n\nOr over the API — an Agent Account is created with `POST /v3/connect/custom`\n\n, passing `\"provider\": \"nylas\"`\n\n, a top-level `name`\n\nfor the display name, and the mailbox address under `settings.email`\n\n:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/connect/custom\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"provider\": \"nylas\",\n    \"name\": \"Acme Billing\",\n    \"settings\": { \"email\": \"ar@yourcompany.com\" }\n  }'\n```\n\nGrab the `grant_id`\n\nit returns — that's the identifier in every call below. No refresh token, no OAuth dance; the mailbox just exists.\n\nYou also need that app-level `message.created`\n\nsubscription so replies reach you. Subscribe once at the app level with `POST /v3/webhooks`\n\n, listing the triggers and your endpoint URL:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/webhooks\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"trigger_types\": [\"message.created\"],\n    \"webhook_url\": \"https://yourcompany.com/webhooks/nylas\"\n  }'\n```\n\nOr from the CLI:\n\n```\nnylas webhook create --url https://yourcompany.com/webhooks/nylas --triggers message.created\n```\n\nRemember webhooks are app-scoped, not grant-scoped — you subscribe once and filter by `grant_id`\n\nper payload (more on that below).\n\nBefore 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.\n\nA reasonable starting ladder:\n\n| Day | Step | Tone |\n|---|---|---|\n| 7 | First reminder | Friendly nudge — \"this may have slipped through\" |\n| 14 | Second reminder | Direct — past due, please remit |\n| 30 | Final notice | Firm — account at risk, escalation pending |\n\nYour scheduler (cron, a queue, a workflow engine) walks each open invoice through that table. At every tick it does two things in order:\n\nThat's the entire control flow. Nylas is the transport for step 3; your code owns 1 and 2.\n\nI like to timestamp every decision so the audit trail is unambiguous later — `date`\n\nis enough:\n\n```\ndate -u +\"%Y-%m-%dT%H:%M:%SZ\"\n```\n\nLog 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.\n\nEach rung of the ladder is a normal send. Here's the day-7 reminder, both ways.\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"to\": [{ \"email\": \"customer@example.com\", \"name\": \"Jordan Lee\" }],\n    \"subject\": \"Invoice #4821 — friendly reminder\",\n    \"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\"\n  }'\n```\n\nThe CLI does the same thing without the JSON ceremony:\n\n```\nnylas email send <GRANT_ID> \\\n  --to customer@example.com \\\n  --subject \"Invoice #4821 — friendly reminder\" \\\n  --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\"\n```\n\nCapture the `message_id`\n\nand `thread_id`\n\nfrom the response and store them against the invoice. The `thread_id`\n\nis what ties every later reminder — and every reply — back to this conversation.\n\nIf you'd rather hand the timing to Nylas than run a tick loop, `nylas email send`\n\ntakes `--schedule`\n\n. It accepts relative durations like `30m`\n\n, `2h`\n\n, `1d`\n\n, `2d`\n\n, or natural times like `\"tomorrow 9am\"`\n\n. (It does *not* accept \"in 2 days\" — use `2d`\n\n.)\n\n```\nnylas email send <GRANT_ID> \\\n  --to customer@example.com \\\n  --subject \"Invoice #4821 — past due\" \\\n  --body \"Hi Jordan, invoice #4821 is now a week past due...\" \\\n  --schedule \"2d\"\n```\n\nThe API equivalent is the same `POST /v3/grants/{grant_id}/messages/send`\n\nyou already use, plus a `send_at`\n\nfield — a Unix timestamp (seconds) for when Nylas should release the message:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"to\": [{ \"email\": \"customer@example.com\" }],\n    \"subject\": \"Invoice #4821 — past due\",\n    \"body\": \"Hi Jordan, invoice #4821 is now a week past due...\",\n    \"send_at\": 1718841600\n  }'\n```\n\nThe 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:\n\n```\nnylas email scheduled cancel <SCHEDULE_ID>\n```\n\nOr over the API, `DELETE /v3/grants/{grant_id}/messages/schedules/{schedule_id}`\n\n:\n\n```\ncurl --request DELETE \\\n  --url \"https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/schedules/<SCHEDULE_ID>\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\"\n```\n\nHonestly, 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.\n\nInbound mail to the Agent Account fires the standard `message.created`\n\nwebhook. One thing to internalize: **webhooks are application-scoped, not grant-scoped.** You subscribe once at the app level (`POST /v3/webhooks`\n\n), and events for *every* grant arrive at that one endpoint. Each payload carries a `grant_id`\n\nyou filter on, so a multi-tenant AR system routes by `grant_id`\n\nto the right customer's collections context.\n\nTwo correctness rules that will bite you if you skip them:\n\n`id`\n\n.`id`\n\nis constant across all retries of one event, so it's your dedup key. (You can additionally guard on the inner `data.object.id`\n\n, the message ID, to avoid acting twice on the same message.)`message.created`\n\npayload carries summary fields (`subject`\n\n, `from`\n\n, `snippet`\n\n), and the docs disagree on whether the full body is inline. The safe move is to `message.created.truncated`\n\n(the type Nylas uses when a message exceeds ~1 MB and the body is omitted).A minimal handler:\n\n```\n// Express handler for POST /webhooks/nylas\napp.post(\"/webhooks/nylas\", async (req, res) => {\n  res.status(200).end(); // ack fast\n\n  const event = req.body;\n  if (event.type !== \"message.created\" && event.type !== \"message.created.truncated\") return;\n\n  // Dedup on the delivery id (constant across the 3 retries).\n  if (await seen(event.id)) return;\n  await markSeen(event.id);\n\n  const msg = event.data.object;\n  if (msg.grant_id !== AR_AGENT_GRANT_ID) return; // route by grant\n\n  // Ignore our own outbound sends, which also fire message.created.\n  if (isFromAgent(msg.from)) return;\n\n  // Fetch the full body by id — don't rely on the payload.\n  await handleReply(msg.grant_id, msg.id, msg.thread_id);\n});\n```\n\nNow 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}`\n\n:\n\n```\ncurl --request GET \\\n  --url \"https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/<MESSAGE_ID>\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\"\n```\n\nFrom the terminal:\n\n```\nnylas email read <MESSAGE_ID> <GRANT_ID>\n```\n\nIf 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>`\n\ndoes it from the CLI; over the API it's `GET /v3/grants/{grant_id}/threads/{thread_id}`\n\n. The thread's `message_ids`\n\ngive you every rung you've already sent, which is exactly the context a \"tone so far\" decision needs.\n\nThis 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?**\n\nThe state — paused, disputed, escalated, closed — lives in *your* database, keyed by `thread_id`\n\n. Custom metadata on the message isn't an option here, and you wouldn't want billing state living in your mail provider anyway.\n\nWhen 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`\n\n, which makes Nylas set the `In-Reply-To`\n\nand `References`\n\nheaders so it groups correctly in Gmail, Outlook, everywhere:\n\n```\ncurl --request POST \\\n  --url \"https://api.us.nylas.com/v3/grants/<GRANT_ID>/messages/send\" \\\n  --header \"Authorization: Bearer <NYLAS_API_KEY>\" \\\n  --header \"Content-Type: application/json\" \\\n  --data '{\n    \"reply_to_message_id\": \"<MESSAGE_ID>\",\n    \"to\": [{ \"email\": \"customer@example.com\" }],\n    \"subject\": \"Re: Invoice #4821 — friendly reminder\",\n    \"body\": \"Thanks for letting us know, Jordan. I have paused the reminders while we confirm payment on our side.\"\n  }'\n```\n\nThe CLI reply is a one-liner — it fetches the original message to populate the recipient and subject for you, and preserves threading automatically:\n\n```\nnylas email reply <MESSAGE_ID> <GRANT_ID> \\\n  --body \"Thanks for letting us know, Jordan. I have paused the reminders while we confirm payment on our side.\"\n```\n\nThat 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, paused\" is a customer who trusts your billing isn't a runaway robot.\n\nA few things I've learned the hard way building these loops:\n\n`message.created`\n\ntoo.`from`\n\n(or your stored outbound `message_id`\n\ns) so the agent doesn't react to its own mail.`thread_id`\n\n, `In-Reply-To`\n\n, and `References`\n\nkeep the conversation intact`nylas email ...`\n\nflag 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`\n\n:", "url": "https://wpnews.pro/news/chase-overdue-invoices-with-a-collections-agent", "canonical_source": "https://dev.to/mqasimca/chase-overdue-invoices-with-a-collections-agent-4lae", "published_at": "2026-07-10 01:27:31+00:00", "updated_at": "2026-07-10 01:35:57.234099+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-products"], "entities": ["Nylas", "SendGrid", "Resend", "Postmark"], "alternates": {"html": "https://wpnews.pro/news/chase-overdue-invoices-with-a-collections-agent", "markdown": "https://wpnews.pro/news/chase-overdue-invoices-with-a-collections-agent.md", "text": "https://wpnews.pro/news/chase-overdue-invoices-with-a-collections-agent.txt", "jsonld": "https://wpnews.pro/news/chase-overdue-invoices-with-a-collections-agent.jsonld"}}