Implementation guides

Dunning (chasing overdue payments)

Dunning is how you chase overdue money. You point Qualy at a set of overdue obligations and it consolidates them into notices, sends the reminders, and keeps an immutable log of every chase. Dunning is communications only — it sends reminders and records responses, but it never moves money.


Concepts

Four objects make up the dunning platform:

ObjectWhat it is
ObligationThe overdue thing being chased. Either a payment intent (a contact's receivable) or a payment split (a partner's keep-percentage). A bulk operation can stand in for its member splits.
NoticeA single communication, consolidated by (debtor, currency). Duning several obligations for the same debtor produces one notice, not many.
ChaseAn immutable attempt log — one entry per obligation per notice. This is where you record what happened (the debtor answered, promised to pay, disputed, …).
ControlThe per-obligation dunning run: its state (active, paused, …), its knobs (style, channels, guidance), and a rollup summary. Duning an obligation creates or reuses its control.

Chase overdue obligations

POST /v1/dunning/dun starts a chase. Pass exactly one of paymentSplits, paymentIntents, or bulkOperation — a single dun can't mix obligation types.

curl -X POST https://api.qualyhq.com/v1/dunning/dun \
  -H 'Authorization: ApiKey your-api-key-here' \
  -H 'X-TENANT-ID: your-tenant-id-here' \
  -H 'Content-Type: application/json' \
  -d '{
    "paymentIntents": ["6a4e190b9a0ac53047a25479"],
    "style": "firm",
    "payBy": "2026-07-20T00:00:00Z",
    "comment": "Second reminder — invoice 2216"
  }'

The response reports exactly what happened:

{
  "data": {
    "notices": [],
    "chased": [],
    "skipped": [
      { "id": "6a4e0d119a0ac53047a25415", "reason": "not-a-dunnable-payment-intent" }
    ]
  }
}

Obligations that aren't eligible (not overdue, already paid, paused, …) come back under skipped with a reason — the call still succeeds. Nothing under chased was skipped.

Body fields

FieldDescription
paymentIntents / paymentSplits / bulkOperationThe obligations to chase. Provide exactly one. Arrays accept up to 200 ids; bulkOperation expands to its member splits.
styleTone of the message: gentle, neutral, or firm.
payByA "Pay by" deadline shown in the email, ISO 8601 (e.g. 2026-07-20T00:00:00Z). Applies to this send only.
commentAn operator note stored on the notices (max 2000 chars).
recipientContactsPartner (split) duns only: which contacts to notify, intersected with each partnership's notifiable contacts. Omit to notify all of them. Contact duns always reach the contact.
aiGuidanceFreeform guidance persisted on each control to steer future follow-ups.

`payBy` rejects milliseconds

payBy is validated as an ISO 8601 date string. Use 2026-07-20T00:00:00Z or 2026-07-20 — the millisecond form …00.000Z is rejected.

Dunning never moves money

Duning sends reminders and records responses. It never charges, refunds, or settles anything — the underlying payment intent or split is untouched. Capturing money still goes through the payments API.

This endpoint is idempotent — repeating the same dun within the window returns the original result instead of sending twice.


Manage the run: controls

Each obligation under dunning has a control. List them, inspect one, or adjust its knobs. Controls are returned under data.controls:

# List controls
curl "https://api.qualyhq.com/v1/dunning/controls?limit=20" \
  -H 'Authorization: ApiKey your-api-key-here' \
  -H 'X-TENANT-ID: your-tenant-id-here'

# Retrieve one
curl "https://api.qualyhq.com/v1/dunning/controls/{controlId}" \
  -H 'Authorization: ApiKey your-api-key-here' \
  -H 'X-TENANT-ID: your-tenant-id-here'

Control statuses

StatusMeaning
activeBeing chased on schedule.
pausedOperator hold — the eligibility gate skips it until you resume.
suspendedHeld with a reason (e.g. disputed, incorrect amount).
promise-to-payFrozen until the promised date (plus grace). A broken promise re-arms it.
resolvedTerminal — paid, canceled, offset, or written off.
supersededTerminal — replaced by a restarted run.

Update knobs

Change the style, allowed channels, or aiGuidance with POST /v1/dunning/controls/{controlId}/update. State changes do not go through update — use the explicit actions below.

curl -X POST https://api.qualyhq.com/v1/dunning/controls/{controlId}/update \
  -H 'Authorization: ApiKey your-api-key-here' \
  -H 'X-TENANT-ID: your-tenant-id-here' \
  -H 'Content-Type: application/json' \
  -d '{ "style": "gentle", "channels": ["email"] }'

Channels is an allow-list drawn from sms, push, and email (empty means no restriction).

State actions

State transitions are explicit, auditable actions — each is a POST with an empty body (except restart):

ActionEndpoint
PausePOST /v1/dunning/controls/{controlId}/pause
ResumePOST /v1/dunning/controls/{controlId}/resume
RestartPOST /v1/dunning/controls/{controlId}/restart

Restarting supersedes the current run and mints a fresh one; history and chases stay intact, and delivered-attempt numbering carries forward so partner-facing counts never reset.


Record what the debtor said: chase outcomes

Chases are the attempt log (GET /v1/dunning/chases, returned under data.chases). When a debtor responds, record it against the chase with POST /v1/dunning/chases/{chaseId}/outcome:

curl -X POST https://api.qualyhq.com/v1/dunning/chases/{chaseId}/outcome \
  -H 'Authorization: ApiKey your-api-key-here' \
  -H 'X-TENANT-ID: your-tenant-id-here' \
  -H 'Content-Type: application/json' \
  -d '{
    "outcome": "promised-to-pay",
    "promisedAt": "2026-07-25T00:00:00Z",
    "note": "Partner replied: paying next Friday."
  }'

Recordable outcomes and their side effects:

OutcomeEffect on the control
answeredLogged; no state change.
promised-to-payFreezes the control until promisedAt (required). A broken promise re-arms chasing.
disputedSuspends the control.
no-responseLogged; chasing continues on schedule.

Delivery outcomes (sent, delivered, bounced, failed) arrive automatically from the transport and aren't user-recordable.


Notices

List sent notices with GET /v1/dunning/notices (returned under data.notices) or fetch one with GET /v1/dunning/notices/{noticeId}. A notice groups every obligation chased for one debtor in one currency, along with the comment and payBy from the dun that created it.


Next steps

Previous
Handling disputes