← All use cases

Billing

Your billing agent recovered revenue
without sending one awkward email.

Marcus at Voxel's card fails on renewal day. Your billing agent notices, sends a payment reminder with an update link, and schedules two follow-ups with escalating urgency. Marcus fixes his card the next morning. The sequence stops immediately. No "your account is at risk" email lands six hours after he already paid. The recovered revenue gets attributed back to the first reminder.

The problem

Payment failures are quiet. A card expires, a bank declines, a spending limit triggers — and your customer doesn't know it happened. They still think they're paying you. If nobody tells them, they churn involuntarily: not because they wanted to leave, but because a billing detail fell through the cracks. Involuntary churn accounts for 20–40% of total churn at most SaaS companies. That's revenue you already earned walking out the door.

The fix seems simple: send a reminder. But simple dunning sequences have a timing problem. You queue up three emails over seven days. The customer pays after email one. Emails two and three send anyway because nothing told the sequence to check. Now you're sending "urgent: update your payment" to someone who already did. That erodes trust faster than the failed payment did.

The harder version of this problem is knowing when to escalate versus when to wait. A first-time failure from a long-tenured customer probably resolves itself. A third failure in three months from a customer who already downgraded? That needs a human.

A complete dunning workflow

Every command below outputs JSON to stdout. Pipe it into your agent's decision loop, or run the whole thing as a bash script (there's a copy-paste version at the bottom of this page).

1

Register payment webhooks

Your billing provider fires events when payments fail and succeed. Register both. The failure event starts the dunning sequence. The success event stops it. Without the success webhook, you're blind to recoveries.

molted webhooks register \
  --event payment_failed \
  --url https://your-agent.com/hooks/payment-failed \
  --mailbox billing-agent

molted webhooks register \
  --event payment_succeeded \
  --url https://your-agent.com/hooks/payment-succeeded \
  --mailbox billing-agent
2

Start the dunning sequence

When a payment fails, check fatigue (Marcus might already be getting emails from your CS agent about something else) and simulate the send. If policy clears it, send the first reminder and schedule follow-ups. The escalation pattern matters: reminder one is friendly and informational. Reminder two, 48 hours later, is more direct. Reminder three, at 120 hours, warns about service interruption. Each one uses a different template with a different tone, because a third notice shouldn't sound like the first one.

The dedupe key pattern for billing is dunning-{contact}-{invoice}-{step} — scoped to the specific invoice, not just the customer. If Marcus has two failed invoices (it happens), each one gets its own sequence.

# Check fatigue across all agents
molted analytics fatigue --contact marcus@voxel.dev
# → { "recommendation": "safe_to_send", "recentSends": 0,
#     "windows": { "24h": 0, "7d": 1, "30d": 3 } }

# Simulate the send
molted send simulate \
  --to marcus@voxel.dev \
  --template payment-failed-1 \
  --mailbox billing-agent
# → { "status": "allowed", "rulesEvaluated": 22,
#     "passed": 22, "blocked": 0 }

# Send the first reminder
molted send \
  --to marcus@voxel.dev \
  --template payment-failed-1 \
  --dedupe-key "dunning-marcus-voxel-inv_4821-1" \
  --mailbox billing-agent \
  --reason "payment failed: card declined" \
  --payload '{
    "name": "Marcus",
    "company": "Voxel",
    "amount": "$49.00",
    "invoice_id": "inv_4821",
    "update_url": "https://billing.yourapp.com/update/inv_4821"
  }'
# → { "status": "sent", "requestId": "req_bill_001" }

# Schedule follow-ups (both cancel on payment)
molted threads followup req_bill_001 \
  --contact-email marcus@voxel.dev \
  --template payment-failed-2 \
  --delay-hours 48 \
  --cancel-on-event payment_succeeded \
  --dedupe-key "dunning-marcus-voxel-inv_4821-2"

molted threads followup req_bill_001 \
  --contact-email marcus@voxel.dev \
  --template payment-failed-3 \
  --delay-hours 120 \
  --cancel-on-event payment_succeeded \
  --dedupe-key "dunning-marcus-voxel-inv_4821-3"
3

Cancel on payment

This is the step that justifies the entire workflow. Marcus updates his card the next morning. Your payment provider fires the payment_succeeded webhook. Molted cancels every pending follow-up tied to that invoice. No second reminder. No third warning. The sequence simply stops. If you've ever received a "your account will be suspended" email an hour after you already paid, you know why this matters.

# When your payment webhook fires on success:
molted sequences cancel \
  --contact marcus@voxel.dev \
  --event payment_succeeded \
  --invoice inv_4821
# → { "cancelled": 2, "sequenceId": "seq_dun_4821",
#     "pendingFollowups": [
#       { "template": "payment-failed-2", "wasScheduledFor": "2026-03-20T10:00:00Z" },
#       { "template": "payment-failed-3", "wasScheduledFor": "2026-03-23T10:00:00Z" }
#     ] }

# Optionally send a confirmation (policy-checked like everything else)
molted send \
  --to marcus@voxel.dev \
  --template payment-recovered \
  --dedupe-key "recovered-marcus-voxel-inv_4821" \
  --mailbox billing-agent \
  --payload '{
    "name": "Marcus",
    "amount": "$49.00",
    "invoice_id": "inv_4821"
  }'
4

Handle replies

Sometimes Marcus replies instead of clicking the link. "I already updated my card, why is it still failing?" or "Can I get an extension?" Classify the intent, check what the next action should be, and route accordingly. Billing replies tend to fall into a few buckets: already paid (verify and confirm), payment trouble (escalate to support), extension request (flag for a human decision), or cancellation intent (escalate immediately).

molted classify \
  --subject "Re: Payment failed for your Voxel subscription" \
  --body "I updated my card yesterday but it's still showing failed?"
# → { "intent": "payment_trouble", "confidence": 0.89 }

molted next-action --contact marcus@voxel.dev
# → { "action": "escalate", "reason": "payment issue
#     after card update — needs billing support",
#     "priority": "high" }

# Route to support (don't auto-reply to billing trouble)
molted override escalate thr_bill_042 \
  --reason "card update didn't resolve failure" \
  --priority high \
  --assign-to billing-support
5

Record recovered revenue

Marcus paid. Record the recovery. Molted traces the revenue back to the first dunning email, so you can measure what your billing agent actually recovers versus what would have churned silently. That number is the whole point.

molted outcomes ingest \
  --contact marcus@voxel.dev \
  --event-type payment_recovered \
  --event-name "Invoice Recovered" \
  --revenue 49 \
  --metadata '{ "invoice": "inv_4821",
    "attempt": 1, "days_to_recover": 1,
    "reminder_sent": "payment-failed-1" }'

molted outcomes journey-impact --mailbox billing-agent
# → { "invoices_recovered": 142, "revenue_recovered": 8940,
#     "recovery_rate": 0.73, "avg_days_to_recover": 2.1,
#     "churned_involuntary": 53 }

What policy enforces

Billing emails walk a line. Send too few and you lose recoverable revenue. Send too many and you annoy a customer who was going to pay anyway. Policy keeps your billing agent on that line. Per-invoice dedup keys prevent duplicate reminders if the agent retries after a crash. Cooldown windows enforce minimum gaps between reminders to the same customer — no three-emails-in-one-day panic sequences. And the event-based cancellation (cancel-on-event) is the whole mechanism that stops the sequence when payment succeeds. Without it, you're back to sending reminders to people who already paid.

Fatigue checks look across every agent, not just billing. If your CS agent sent Marcus a check-in yesterday and your marketing agent has a lifecycle nudge queued for tomorrow, the billing reminder still goes out — but only because the fatigue windows haven't been exceeded. Cross-agent coordination happens at the policy layer, not in your application code. You don't need to build awareness between agents. The mailbox handles it.

Consent and suppression still apply. If Marcus unsubscribed from marketing emails, that doesn't block transactional billing notices (they're a different consent category). But if he filed a spam complaint, everything stops. Risk budgets sit underneath: if your billing mailbox accumulates too many bounces in a rolling window — maybe you're sending to a batch of contacts with stale email addresses — sending pauses before it damages your domain.

Measuring whether it works

Recovery rate is the number: what percentage of failed payments did the billing agent recover? But the denominator matters. Some of those failures would have self-resolved (the bank retried, the customer noticed on their own). The metric that isolates your agent's impact is time to recovery — how many days between the first reminder and the successful charge. A good billing agent compresses that window from "maybe they notice next month" to "resolved in 48 hours." Track revenue recovered, recovery rate, average days to recovery, and involuntary churn rate. The last one should trend down. If it doesn't, your dunning sequence needs work, not more volume.

The complete script

Copy this, wire up your payment webhooks, and you have a working billing agent. The cancel-on-event flag is the key detail — it stops the sequence the instant payment succeeds.

#!/usr/bin/env bash
# billing-agent.sh — Dunning recovery workflow for a billing agent

set -euo pipefail

CONTACT="marcus@voxel.dev"
INVOICE="inv_4821"
AMOUNT="49.00"
MAILBOX="billing-agent"
DEDUPE_BASE="dunning-$CONTACT-$INVOICE"

# 1. Check fatigue
FATIGUE=$(molted analytics fatigue --contact "$CONTACT")
REC=$(echo "$FATIGUE" | jq -r '.recommendation')
if [ "$REC" = "stop_sending" ]; then
  echo "Fatigue limit reached — skipping" >&2
  exit 0
fi

# 2. Simulate the send
SIM=$(molted send simulate \
  --to "$CONTACT" \
  --template payment-failed-1 \
  --mailbox "$MAILBOX")
STATUS=$(echo "$SIM" | jq -r '.status')
if [ "$STATUS" != "allowed" ]; then
  echo "Policy blocked: $(echo "$SIM" | jq -r '.reason')" >&2
  exit 0
fi

# 3. Send the first reminder
RESULT=$(molted send \
  --to "$CONTACT" \
  --template payment-failed-1 \
  --dedupe-key "$DEDUPE_BASE-1" \
  --mailbox "$MAILBOX" \
  --reason "payment failed: card declined" \
  --payload '{
    "name": "Marcus",
    "company": "Voxel",
    "amount": "$'"$AMOUNT"'",
    "invoice_id": "'"$INVOICE"'",
    "update_url": "https://billing.yourapp.com/update/'"$INVOICE"'"
  }')
REQ_ID=$(echo "$RESULT" | jq -r '.requestId')

# 4. Schedule follow-ups (cancel on payment)
molted threads followup "$REQ_ID" \
  --contact-email "$CONTACT" \
  --template payment-failed-2 \
  --delay-hours 48 \
  --cancel-on-event payment_succeeded \
  --dedupe-key "$DEDUPE_BASE-2"

molted threads followup "$REQ_ID" \
  --contact-email "$CONTACT" \
  --template payment-failed-3 \
  --delay-hours 120 \
  --cancel-on-event payment_succeeded \
  --dedupe-key "$DEDUPE_BASE-3"

echo "Dunning started: $REQ_ID (invoice: $INVOICE)"

# --- When payment_succeeded webhook fires ---
# molted sequences cancel --contact "$CONTACT" \
#   --event payment_succeeded --invoice "$INVOICE"
# molted outcomes ingest --contact "$CONTACT" \
#   --event-type payment_recovered --revenue "$AMOUNT" \
#   --metadata '{ "invoice": "'"$INVOICE"'" }'

Try it

Sign up, create a mailbox, send a policy-checked email. Takes about five minutes.

$ npx @molted/cli auth signup

Related use cases