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:
| Object | What it is |
|---|---|
| Obligation | The 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. |
| Notice | A single communication, consolidated by (debtor, currency). Duning several obligations for the same debtor produces one notice, not many. |
| Chase | An immutable attempt log — one entry per obligation per notice. This is where you record what happened (the debtor answered, promised to pay, disputed, …). |
| Control | The 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"
}'
const res = await fetch('https://api.qualyhq.com/v1/dunning/dun', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'ApiKey your-api-key-here',
'X-TENANT-ID': 'your-tenant-id-here',
},
body: JSON.stringify({
paymentIntents: ['6a4e190b9a0ac53047a25479'], // contact receivables
style: 'firm', // 'gentle' | 'neutral' | 'firm'
payBy: '2026-07-20T00:00:00Z', // "Pay by" date shown in the email
comment: 'Second reminder — invoice 2216',
}),
});
const { data } = await res.json();
console.log(data.notices); // consolidated communications sent
console.log(data.chased); // obligations chased this run
console.log(data.skipped); // [{ id, reason }] — anything not chased
use Illuminate\Support\Facades\Http;
$data = Http::withHeaders([
'Authorization' => 'ApiKey your-api-key-here',
'X-TENANT-ID' => 'your-tenant-id-here',
])->post('https://api.qualyhq.com/v1/dunning/dun', [
'paymentIntents' => ['6a4e190b9a0ac53047a25479'],
'style' => 'firm',
'payBy' => '2026-07-20T00:00:00Z',
'comment' => 'Second reminder — invoice 2216',
])->json('data');
print_r($data['notices']);
print_r($data['skipped']);
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
| Field | Description |
|---|---|
paymentIntents / paymentSplits / bulkOperation | The obligations to chase. Provide exactly one. Arrays accept up to 200 ids; bulkOperation expands to its member splits. |
style | Tone of the message: gentle, neutral, or firm. |
payBy | A "Pay by" deadline shown in the email, ISO 8601 (e.g. 2026-07-20T00:00:00Z). Applies to this send only. |
comment | An operator note stored on the notices (max 2000 chars). |
recipientContacts | Partner (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. |
aiGuidance | Freeform 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
| Status | Meaning |
|---|---|
active | Being chased on schedule. |
paused | Operator hold — the eligibility gate skips it until you resume. |
suspended | Held with a reason (e.g. disputed, incorrect amount). |
promise-to-pay | Frozen until the promised date (plus grace). A broken promise re-arms it. |
resolved | Terminal — paid, canceled, offset, or written off. |
superseded | Terminal — 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):
| Action | Endpoint |
|---|---|
| Pause | POST /v1/dunning/controls/{controlId}/pause |
| Resume | POST /v1/dunning/controls/{controlId}/resume |
| Restart | POST /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."
}'
const res = await fetch(
'https://api.qualyhq.com/v1/dunning/chases/{chaseId}/outcome',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'ApiKey your-api-key-here',
'X-TENANT-ID': 'your-tenant-id-here',
},
body: JSON.stringify({
outcome: 'promised-to-pay', // answered | promised-to-pay | disputed | no-response
promisedAt: '2026-07-25T00:00:00Z', // required for promised-to-pay
note: 'Partner replied: paying next Friday.',
}),
},
);
const { data: chase } = await res.json();
console.log(chase);
use Illuminate\Support\Facades\Http;
$chase = Http::withHeaders([
'Authorization' => 'ApiKey your-api-key-here',
'X-TENANT-ID' => 'your-tenant-id-here',
])->post('https://api.qualyhq.com/v1/dunning/chases/{chaseId}/outcome', [
'outcome' => 'promised-to-pay',
'promisedAt' => '2026-07-25T00:00:00Z',
'note' => 'Partner replied: paying next Friday.',
])->json('data');
Recordable outcomes and their side effects:
| Outcome | Effect on the control |
|---|---|
answered | Logged; no state change. |
promised-to-pay | Freezes the control until promisedAt (required). A broken promise re-arms chasing. |
disputed | Suspends the control. |
no-response | Logged; 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
- Chase partner shares grouped by settlement with bulk payment splits.
- Make repeat duns safe with idempotency.
- Filter controls, notices, and chases with Querying data.