# Dunning (chasing overdue payments)

> Chase overdue receivables with Qualy's dunning API — send notices, track chases, and record outcomes.

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](/docs/guides/bulk-payment-splits.md) 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**

```bash
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"
  }'
```

**JavaScript**

```javascript
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
```

**PHP (Laravel)**

```php
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:

```json
{
  "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. |

> **Note — `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.

> **Warning — 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](/docs/guides/creating-payment-intents.md).

This endpoint is [idempotent](/docs/idempotency.md) — 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`:

```bash
# 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.

```bash
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**

```bash
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."
  }'
```

**JavaScript**

```javascript
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);
```

**PHP (Laravel)**

```php
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](/docs/guides/bulk-payment-splits.md).
- Make repeat duns safe with [idempotency](/docs/idempotency.md).
- Filter controls, notices, and chases with [Querying data](/docs/queries.md).
