How to Build a Subscription Billing AI Agent (2026)
What you will build
A billing agent that tracks customer subscriptions, generates a fresh USDC invoice on each renewal date, escalates polite reminders when payment is late, pauses access after a dunning window, handles cancellations with optional pro-rated refunds, and exposes an MRR line that updates every night. The agent runs as a small Node service with a cron-driven worker — no third-party billing platform, no webhook forests, no subscription-management SaaS bill.
This pattern is purpose-built for AI agent products: per-seat monthly plans for agent teams, metered plans for token-based services, annual plans for enterprise buyers. All the accounting happens in USDC on Polygon or Base with final settlement in seconds.
Prerequisites
- Node 20+, Postgres (or SQLite for dev), and a MoltPe API key.
- An email sender (Resend, Postmark).
- A cron runner — built-in
node-cron, Cloud Scheduler, or GitHub Actions on a schedule. - A signup flow where customers connect a MoltPe wallet with a per-month spending allowance to your receiving wallet. The quickstart covers wallet creation.
Step 1 — Subscription data model
Subscriptions are boring and boring is good. Small schema, explicit state machine, human-readable intervals.
-- subscriptions.sql
CREATE TABLE subscriptions (
id TEXT PRIMARY KEY, -- sub_01...
customer_id TEXT NOT NULL,
customer_wallet TEXT NOT NULL, -- MoltPe agent wallet address
plan_id TEXT NOT NULL, -- e.g. 'pro-monthly'
amount_usdc TEXT NOT NULL,
interval_months INTEGER NOT NULL, -- 1 or 12
status TEXT NOT NULL, -- active | paused | cancelled
dunning_state TEXT, -- null | reminder_1 | reminder_2 | paused
next_renewal TEXT NOT NULL, -- ISO date
last_paid_at TEXT,
created_at TEXT NOT NULL
);
Step 2 — Nightly renewal worker
The worker runs once per day. It finds subscriptions due for renewal, creates an invoice tied to each, and advances next_renewal when the invoice lands paid (via webhook). If the invoice does not land within the window, the dunning worker picks it up.
// renewal-worker.js — run daily via cron.
import Database from "better-sqlite3";
import { ulid } from "ulid";
const db = new Database("subscriptions.db");
const RECEIVING_AGENT = "ag_01JMWBE2A3C4D5E6F7G8H9J0KL";
export async function runRenewals() {
const due = db.prepare(`SELECT * FROM subscriptions
WHERE status='active' AND next_renewal <= date('now')`).all();
for (const sub of due) {
// 1. Create a MoltPe invoice for this renewal.
const inv = await fetch("https://api.moltpe.com/v1/invoices/create", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.MOLTPE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
agent_id: RECEIVING_AGENT,
amount: sub.amount_usdc,
token: "USDC",
network: "polygon",
due_date: new Date(Date.now() + 3 * 86400000).toISOString().slice(0, 10),
external_id: `renewal_${sub.id}_${ulid()}`,
metadata: { subscription_id: sub.id, type: "renewal" },
}),
}).then(r => r.json());
// 2. Try to auto-charge via the customer's agent wallet (pull-style).
const charge = await fetch("https://api.moltpe.com/v1/payments/request", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.MOLTPE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from_wallet: sub.customer_wallet,
to_agent_id: RECEIVING_AGENT,
amount: sub.amount_usdc,
memo: inv.memo,
}),
}).then(r => r.json());
if (charge.status === "settled") {
const nextRenewal = new Date();
nextRenewal.setMonth(nextRenewal.getMonth() + sub.interval_months);
db.prepare(`UPDATE subscriptions SET
last_paid_at=datetime('now'),
next_renewal=?, dunning_state=NULL
WHERE id=?`).run(nextRenewal.toISOString().slice(0, 10), sub.id);
}
}
}
Step 3 — Dunning for failed renewals
If the customer's wallet has insufficient balance or the auto-charge fails, MoltPe returns a typed error and you enter dunning. The pattern is three escalating reminders over two weeks, then pause access.
// dunning-worker.js — runs daily, escalates unpaid invoices.
const stale = db.prepare(`SELECT s.*, i.created_at AS inv_created FROM subscriptions s
JOIN invoices i ON i.metadata_subscription_id = s.id
WHERE i.status = 'open' AND s.status = 'active'`).all();
for (const row of stale) {
const ageDays = Math.floor((Date.now() - Date.parse(row.inv_created)) / 86400000);
let nextState = row.dunning_state;
if (ageDays >= 3 && !row.dunning_state) nextState = "reminder_1";
else if (ageDays >= 7 && row.dunning_state === "reminder_1") nextState = "reminder_2";
else if (ageDays >= 14 && row.dunning_state === "reminder_2") nextState = "paused";
if (nextState !== row.dunning_state) {
if (nextState === "paused") {
db.prepare(`UPDATE subscriptions SET status='paused', dunning_state='paused' WHERE id=?`).run(row.id);
await sendEmail(row.customer_id, "Your subscription is paused", "Please complete payment to restore access.");
} else {
db.prepare(`UPDATE subscriptions SET dunning_state=? WHERE id=?`).run(nextState, row.id);
await sendEmail(row.customer_id, "Payment reminder", `Your renewal of ${row.amount_usdc} USDC is still open.`);
}
}
}
Step 4 — Cancellations and refunds
Cancellations flip status to cancelled and skip the next renewal run. If the customer wants a pro-rated refund, compute it simply — no fancy math needed.
app.post("/subscriptions/:id/cancel", async (req, res) => {
const sub = db.prepare(`SELECT * FROM subscriptions WHERE id=?`).get(req.params.id);
if (!sub) return res.status(404).send("not found");
db.prepare(`UPDATE subscriptions SET status='cancelled' WHERE id=?`).run(sub.id);
// Optional pro-rated refund if last payment was within current period.
if (req.body.refund && sub.last_paid_at) {
const periodDays = sub.interval_months * 30;
const usedDays = Math.floor((Date.now() - Date.parse(sub.last_paid_at)) / 86400000);
const remaining = Math.max(0, periodDays - usedDays);
const refundAmount = ((parseFloat(sub.amount_usdc) * remaining) / periodDays).toFixed(2);
if (parseFloat(refundAmount) > 0) {
await fetch("https://api.moltpe.com/v1/payments/refund", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.MOLTPE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ subscription_id: sub.id, amount: refundAmount, reason: "cancellation" }),
});
}
}
res.json({ ok: true });
});
Step 5 — MRR dashboard query
MRR is one SQL line. Normalize yearly plans to monthly, sum across active subscriptions. Render it on a dashboard and you are good for the next investor update.
-- MRR (monthly recurring revenue) in USDC.
SELECT ROUND(SUM(CAST(amount_usdc AS REAL) / interval_months), 2) AS mrr_usdc
FROM subscriptions
WHERE status = 'active';
Testing the agent
Create two test subscriptions — one monthly, one yearly — with funded customer wallets. Set next_renewal to today and run the worker manually. Confirm both renew, the MRR query adds up correctly, and next_renewal advances properly (one month vs twelve).
node --experimental-modules -e "import('./renewal-worker.js').then(m => m.runRenewals())"
Then intentionally drain one customer wallet below the renewal amount and re-run. The renewal should fail, the invoice stays open, and after three simulated days the dunning worker sends reminder 1. Fast-forward by editing the invoice's created_at timestamp in the DB if needed — the state machine should flow reminder_1 → reminder_2 → paused at the right boundaries.
Production checklist
- Run workers on Cloud Scheduler / GitHub Actions, not a long-running process — scheduled jobs are easier to monitor.
- Alert on dunning volume — a sudden spike in
reminder_2state usually means upstream wallet issues or a pricing change shock. - Keep an immutable event log of renewals, refunds, and cancellations — makes disputes easy.
- Test state transitions with a replay harness before shipping to paying customers.
- Expose a self-service portal where customers can cancel, update plan, or view invoices — reduces support burden 70%.
Frequently Asked Questions
Can USDC support pull-based subscriptions like credit cards?
Not natively — most USDC flows are push. The practical pattern is agent-to-agent pull: the customer's MoltPe agent wallet has a spending policy that allows up to amount USDC per month to your receiving wallet. On renewal day you POST a payment request and the customer's agent honors it within the cap. This is as close to pull as crypto gets today.
How does dunning work without card networks?
Dunning in USDC is a conversation, not a retry loop. You cannot force a retry because there is no card to re-charge. Instead, you email escalating reminders, show a pay-now link, and finally pause access until the customer pays. Conversion on dunning typically runs higher than card — customers who can pay, do pay; they don't get blocked by expired cards.
What's the right way to handle pro-rated refunds?
Compute days_used and days_remaining, calculate the refund as (days_remaining / period_days) * amount, and call POST /v1/payments/refund with that amount. MoltPe supports partial refunds out of the box. Document the policy on your pricing page so there's no surprise for customers.
Can the agent upgrade or downgrade a subscription?
Yes. For an upgrade, charge the pro-rated difference and update the plan_id immediately. For a downgrade, change plan_id but apply it at next_renewal to avoid refund complexity. The Node handler is about 20 lines — math, invoice call, DB update.
How do I measure MRR if some customers pay yearly?
Normalize to monthly. For a yearly subscription charging 120 USDC, the MRR contribution is 120 / 12 = 10. Sum normalized contributions across active subscriptions. Annual plans with a discount should use the discounted price, not list price, so your MRR reflects what actually hits the wallet.
Launch recurring USDC billing
One Node service, one cron, one MoltPe API key. Your first renewal tomorrow.
Create your billing wallet →About MoltPe
MoltPe is AI-native payment infrastructure that gives AI agents isolated wallets with programmable spending policies for autonomous USDC transactions. Live on Polygon PoS, Base, and Tempo. Supports REST, MCP, and x402.