Engineering

Building a scalable payment reminder system, API architecture and best practices

Whether you're building a recovery system from scratch or evaluating existing solutions, understanding the architecture helps you make better decisions.

11 min read

A payment reminder system looks simple on the surface: a customer misses a payment, you send them a message. But at scale, thousands of overdue payments across multiple channels, escalation stages, and compliance requirements, the architecture becomes the differentiator between a system that recovers revenue and one that generates noise.

This article walks through the key architectural decisions in building a scalable payment reminder API, drawing on patterns used by fintech companies and purpose-built recovery platforms.

Core data model

The foundation of any reminder system is the data model. At minimum, you need:

Chase (or Recovery Request)

The central entity. A chase represents a single payment that needs to be recovered.

Chase {
  id: string
  amount: decimal
  currency: string          // ZAR, USD, etc.
  status: enum              // SENT, PROMISED, OVERDUE, PAID
  chaseType: enum           // INVOICE, SUBSCRIPTION, CLIENT_PAYMENT
  deliveryChannel: enum     // EMAIL, WHATSAPP, BOTH
  autoEscalation: boolean   // whether escalation runs automatically
  dueDate: datetime
  createdAt: datetime

  // Relations
  customer: Customer
  followUpLogs: FollowUpLog[]
}

Customer

Customer {
  id: string
  name: string
  email: string
  whatsappNumber: string?   // optional
}

Follow-Up Log

Every action the system takes is logged. This is critical for compliance (especially under regulations like South Africa's National Credit Act) and for measuring escalation effectiveness.

FollowUpLog {
  id: string
  chaseId: string
  type: enum          // FRIENDLY, DUE_TODAY, OVERDUE, FIRM
  channel: string     // EMAIL, WHATSAPP, BOTH
  sentAt: datetime
  aiGenerated: boolean
}

API design

A well-designed recovery API should be simple for integrators while supporting the full complexity of the recovery workflow internally.

Create a chase

POST /api/v1/chases
{
  "paymentName": "Missed instalment #1042",
  "amount": 850,
  "currency": "ZAR",
  "clientEmail": "customer@example.com",
  "clientName": "Thabo M.",
  "chaseType": "CLIENT_PAYMENT",
  "autoChase": true,
  "deliveryChannel": "BOTH"
}

The integrator pushes one event. The system handles everything else: scheduling, escalation, channel delivery, and logging.

Trigger a manual follow-up

POST /api/v1/chases/{id}/follow-up
{
  "type": "OVERDUE",
  "channel": "WHATSAPP"
}

Mark as paid

POST /api/v1/chases/{id}/paid

When a chase is marked as paid, all automated escalation stops immediately. The system should fire a webhook to notify the integrator's system.

List chases

GET /api/v1/chases?status=OVERDUE&limit=50

Scheduling and escalation engine

The scheduler is the core of the system. It determines when each follow-up is sent and manages the progression through escalation stages.

Cron-based scheduling

The simplest approach: a cron job runs periodically (e.g., every hour or every few hours) and queries for chases that need a follow-up.

// Pseudocode for the cron job
for each chase where autoEscalation = true and status != PAID:
  daysSinceLastReminder = now - chase.lastReminderSentAt
  if daysSinceLastReminder >= chase.owner.chaseFrequencyDays:
    nextEscalationStage = determineStage(chase)
    sendReminder(chase, nextEscalationStage)
    logFollowUp(chase, nextEscalationStage)

Advantages: Simple to implement, easy to reason about, works well up to tens of thousands of chases.

Limitations: At very high scale (hundreds of thousands of active chases), the query-scan-send pattern can become slow. At that point, you would typically move to an event-driven architecture with a message queue.

Escalation stage determination

The logic for determining which stage a chase is in should account for:

  • Days since creation: How long has the payment been outstanding?
  • Number of reminders sent: How many follow-ups have been delivered?
  • Current status: Has the customer promised to pay? Is it already in firm stage?
  • Due date proximity: Is the payment not yet due (pre-reminder), due today, or overdue?

Multi-channel delivery

The delivery layer abstracts away the complexity of individual channels. Internally, the system routes to the appropriate provider based on the chase's delivery channel configuration.

function sendReminder(chase, stage):
  message = renderTemplate(chase, stage)

  if chase.deliveryChannel == EMAIL or BOTH:
    emailProvider.send(chase.customer.email, message)

  if chase.deliveryChannel == WHATSAPP or BOTH:
    if chase.customer.whatsappNumber:
      whatsappProvider.send(chase.customer.whatsappNumber, message)
    else:
      // fallback to email only
      emailProvider.send(chase.customer.email, message)

For a deeper dive into multi-channel orchestration patterns, see Multi-channel recovery: WhatsApp, SMS, and email orchestration for fintech.

Webhook notifications

For integrators who need real-time updates, webhooks (or Zapier triggers) fire on key events:

  • chase.created: A new recovery chase was created
  • followup.sent: A follow-up reminder was delivered
  • chase.overdue: A chase has entered overdue status
  • chase.paid: A chase was marked as paid
// Webhook payload example
{
  "event": "chase.paid",
  "data": {
    "chaseId": "chase_abc123",
    "amount": 850,
    "currency": "ZAR",
    "paidAt": "2025-04-12T14:30:00Z",
    "totalReminders": 3,
    "recoveryDays": 5
  }
}

Template engine

Messages need to be personalised and tone-appropriate for each escalation stage. A template engine with variable substitution handles this:

  • {clientName}, Customer's name
  • {amount}, Outstanding amount with currency
  • {paymentName}, Description of what is owed
  • {dueDate}, When the payment was due
  • {companyName}, The creditor's company name

For AI-powered drafting, the template engine can call an LLM to generate contextually appropriate messages based on the escalation stage, chase type, and customer history.

Key architectural decisions

Versioned API

Use API versioning from day one (/api/v1/). Recovery integrations are embedded in critical financial systems. Breaking changes to the API break your customers' billing pipelines. Versioning gives you a path to evolve the API without disrupting existing integrators.

Idempotent operations

The create-chase endpoint should handle duplicate submissions gracefully. If a billing system retries a failed request, creating a duplicate chase means the customer gets double the reminders. Use client-provided reference IDs or deduplication logic to prevent this.

Rate limiting

Protect the system and your channel providers with rate limiting. Both on the API level (requests per minute per API key) and on the delivery level (messages per customer per day). This prevents accidental spam and protects your sender reputation.

Build or integrate?

If your core business is payment recovery, build this. If payment recovery is a critical function but not your core product, consider integrating an existing platform.

PayChasers implements the architecture described in this article: a versioned REST API with chase creation, automated escalation, multi-channel delivery, webhook notifications, template management, and full audit logging. Integration takes days, not months.

The architecture is the same whether you build or buy. Understanding it helps you evaluate solutions, design integrations, and ask the right questions when choosing a recovery platform.

Ready to automate payment recovery?

Connect your database to our recovery layer. Comprehensive REST API, native Webhooks, and Zapier integration available.