# Duesly (payments)

> Put a customer on a recurring plan, read what they owe, and record a payment collected outside the gateway — from your own billing system.

Source: https://docs.amomic.in/api/apps-duesly

---

```endpoint
POST /api/v1/apps/duesly/subscribers
```

[Duesly](/apps/duesly) collects recurring payments over WhatsApp. These endpoints are for when the customer list already lives somewhere else, such as your billing system, a spreadsheet pipeline or a POS, and you want Duesly to do the chasing without anyone retyping it.

| Method | Path | Scope | Purpose |
|---|---|---|---|
| `POST` | `/api/v1/apps/duesly/subscribers` | `campaigns:write` | Put a customer on a recurring plan. |
| `GET` | `/api/v1/apps/duesly/subscribers` | `campaigns:read` | List them. |
| `GET` | `/api/v1/apps/duesly/dues` | `campaigns:read` | What is owed, and what has been paid. |
| `POST` | `/api/v1/apps/duesly/dues/{due_id}/paid` | `campaigns:write` | Record a payment collected elsewhere. |

Duesly is part of Campaigns, so it uses the campaign scopes rather than its own.

## The app has to be installed

Every endpoint here answers `412` when the workspace hasn't installed Duesly:

```json title="412 Precondition Failed"
{
  "error": {
    "code": "failed_precondition",
    "message": "The Duesly app is not installed in this workspace.",
    "details": { "reason": "not_found", "docs": "https://docs.amomic.in/apps/overview" },
    "request_id": "req_01JRBC2D5F8H1K4M7P0R3T6V9X",
    "correlation_id": "cor_01JRBC2D5F8H1K4M7P0R3T6V9X"
  }
}
```

That is deliberately not a `404`: a `404` on a workspace that hasn't installed the app would look the same as a typo in the URL. Install it from **Campaigns → Apps**; see [Apps](/apps/overview).

Reminders, follow-ups, templates and how you get paid are set in the console, not here. The API adds and reads the *data*; the app owns the behaviour.

## Adding a subscriber

```params
[
  {"name": "name", "type": "string", "required": true, "description": "1–140 characters. Appears in the reminder."},
  {"name": "phone", "type": "string", "required": true, "description": "6–24 characters. With a country code, or a local number in your workspace's country. Refused rather than guessed if it's neither."},
  {"name": "email", "type": "string", "required": false, "default": "null", "description": "Up to 200 characters. Stored on the subscriber and their contact."},
  {"name": "plan_name", "type": "string", "required": false, "default": "Monthly plan", "description": "Up to 120 characters. What they are paying for. Appears in the reminder."},
  {"name": "amount", "type": "number", "required": true, "description": "In rupees. More than 0, up to 1,00,00,000."},
  {"name": "cycle_unit", "type": "string", "required": false, "default": "monthly", "enum": ["weekly", "monthly", "quarterly", "half_yearly", "yearly", "custom_days"], "description": "How often they pay."},
  {"name": "cycle_days", "type": "integer", "required": false, "default": "null", "description": "1–730. Required for <code>custom_days</code>."},
  {"name": "due_day", "type": "integer", "required": false, "default": "null", "description": "1–31. Day of the month for calendar cycles. A day the month doesn't have becomes its last day."},
  {"name": "start_date", "type": "string", "required": false, "default": "today", "description": "<code>YYYY-MM-DD</code>. When the first payment is due. Omitted, today in your workspace's time zone."}
]
```

::: codegroup

```bash cURL
curl -X POST https://services.amomic.in/api/v1/apps/duesly/subscribers \
  -H "Authorization: Bearer $AMOMIC_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: sub-VH-CUST-40718" \
  -d '{
    "name": "Rohit Deshpande",
    "phone": "+919823016745",
    "email": "rohit.deshpande@example.in",
    "plan_name": "AC annual maintenance (2 units)",
    "amount": 749.5,
    "cycle_unit": "monthly",
    "due_day": 5,
    "start_date": "2026-11-05"
  }'
```

```javascript Node.js
async function addSubscriber(customer) {
  const response = await fetch("https://services.amomic.in/api/v1/apps/duesly/subscribers", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.AMOMIC_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": `sub-${customer.id}`,
    },
    body: JSON.stringify({
      name: customer.name,
      phone: customer.phone,
      email: customer.email,
      plan_name: "AC annual maintenance (2 units)",
      amount: 749.5,
      cycle_unit: "monthly",
      due_day: 5,
      start_date: "2026-11-05",
    }),
  });

  const body = await response.json();
  if (response.status === 409 && body.error.details.reason === "duplicate_recipient") {
    // Already a subscriber on this number; keep the existing one.
    return body.error.details.subscriber_id;
  }
  if (!response.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
  return body.subscriber_id;
}
```

```python Python
import os

import requests

response = requests.post(
    "https://services.amomic.in/api/v1/apps/duesly/subscribers",
    headers={
        "Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}",
        "Idempotency-Key": f"sub-{customer['id']}",
    },
    json={
        "name": customer["name"],
        "phone": customer["phone"],
        "email": customer.get("email"),
        "plan_name": "AC annual maintenance (2 units)",
        "amount": 749.5,
        "cycle_unit": "monthly",
        "due_day": 5,
        "start_date": "2026-11-05",
    },
    timeout=30,
)
body = response.json()
if response.status_code == 409 and body["error"]["details"].get("reason") == "duplicate_recipient":
    subscriber_id = body["error"]["details"]["subscriber_id"]  # already on this number
else:
    response.raise_for_status()
    subscriber_id = body["subscriber_id"]
```

:::

`201 Created`, with the subscriber:

```json title="201 Created"
{
  "subscriber_id": "sub_01JRBC6G9J2L5N8Q1S4U7W0Y3A",
  "tenant_id": "org_veloria_home",
  "contact_id": "cnt_01JRBC6H0K3M6P9R2T5V8X1Z4B",
  "contact_pending": false,
  "name": "Rohit Deshpande",
  "phone": "+919823016745",
  "email": "rohit.deshpande@example.in",
  "plan_name": "AC annual maintenance (2 units)",
  "amount": 749.5,
  "currency": "INR",
  "cycle_unit": "monthly",
  "cycle_days": null,
  "due_day": 5,
  "start_date": "2026-11-05",
  "status": "active",
  "notes": null,
  "tags": [],
  "next_due_date": "2026-11-05",
  "last_paid_at": null,
  "collected_total": 0,
  "outstanding_total": 1499.0,
  "dues_paid": 0,
  "dues_declined": 0,
  "version": 2,
  "created_at": "2026-10-14T06:21:48.093517+00:00",
  "updated_at": "2026-10-14T06:21:48.611204+00:00"
}
```

Two things happen beyond the write:

- **They become a contact**, so they appear in Contacts and every conversation with them sits in one place.
- **Their next two payments are raised immediately**, so `next_due_date` and `outstanding_total` are already filled in and a `GET /dues` right after this returns them, rather than waiting for the next scheduled run. `payment.due_created` fires for each.

The phone number is stored in E.164. `status` is `active`, `paused` or `cancelled`; pausing and cancelling a subscriber happen in the console.

::: warning One subscriber per phone number
A second subscriber on the same number is refused with `409` and `duplicate_recipient`, and `details.subscriber_id` names the existing one. That stops two subscriptions sending two reminders for one payment.
:::

If the contact can't be linked, because the directory couldn't be reached or the details belong to two different existing contacts, the subscriber is still created, with `contact_id: null` and `contact_pending: true`. Reminders still go out, and the console offers a retry. A directory blip must not stop a business taking payments.

`POST /subscribers` honours `Idempotency-Key`. Set it to your own customer id so a re-run of your sync is safe. See [Idempotency](/api/idempotency).

## Listing subscribers

```endpoint
GET /api/v1/apps/duesly/subscribers
```

| Query | Default | Notes |
|---|---|---|
| `limit` | `25` | 1–200. |
| `cursor` | none | The `next_cursor` from the previous page. |

Newest first. Returns `{"items": [subscriber, …], "next_cursor": "…"}`, each item shaped like the create response. There are no filters. See [Pagination](/api/pagination).

## Reading what is owed

```endpoint
GET /api/v1/apps/duesly/dues
```

| Query | Default | Notes |
|---|---|---|
| `scope` | none | `open`, `overdue`, `upcoming` or `paid`. Anything else is a `400`. |
| `status` | none | One status from the table below. When you send both, `status` wins and `scope` is ignored. |
| `limit` | `25` | 1–200. |
| `cursor` | none | The `next_cursor` from the previous page. |

| `scope` | Returns | Order |
|---|---|---|
| none | Every due | Latest due date first |
| `open` | Not yet settled: `scheduled`, `reminded`, `followup_1`, `followup_2` | Latest due date first |
| `overdue` | Open, with a due date before today | **Oldest first** |
| `upcoming` | `scheduled`, due today or later | **Soonest first** |
| `paid` | `paid` | Latest due date first |

"Today" is today in your workspace's time zone.

::: codegroup

```bash cURL
curl -G https://services.amomic.in/api/v1/apps/duesly/dues \
  -H "Authorization: Bearer $AMOMIC_API_KEY" \
  --data-urlencode "scope=overdue" \
  --data-urlencode "limit=100"
```

```javascript Node.js
async function* overdueDues() {
  let cursor = null;
  do {
    const url = new URL("https://services.amomic.in/api/v1/apps/duesly/dues");
    url.searchParams.set("scope", "overdue");
    url.searchParams.set("limit", "100");
    if (cursor) url.searchParams.set("cursor", cursor);

    const response = await fetch(url, {
      headers: { Authorization: `Bearer ${process.env.AMOMIC_API_KEY}` },
    });
    const page = await response.json();
    yield* page.items;
    cursor = page.next_cursor;
  } while (cursor);
}
```

```python Python
import os

import requests


def overdue_dues():
    params = {"scope": "overdue", "limit": 100}
    while True:
        page = requests.get(
            "https://services.amomic.in/api/v1/apps/duesly/dues",
            headers={"Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}"},
            params=params,
            timeout=30,
        ).json()
        yield from page["items"]
        if not page["next_cursor"]:
            break
        params["cursor"] = page["next_cursor"]
```

:::

```json title="200 OK"
{
  "items": [
    {
      "due_id": "due_4c81e07b2f9a63d5c18e47b20a",
      "tenant_id": "org_veloria_home",
      "subscriber_id": "sub_01JQ7T2V5X8A1C4E7G0J3L6N9Q",
      "subscriber_name": "Sneha Pillai",
      "phone": "+919946102385",
      "contact_id": "cnt_01JQ7T2W6Y9B2D5F8H1K4M7P0R",
      "period_label": "October 2026",
      "amount": 1249.0,
      "currency": "INR",
      "due_date": "2026-10-05",
      "status": "followup_1",
      "collection_mode": "manual_upi",
      "payment_link": null,
      "payment_link_id": null,
      "payment_reference": null,
      "paid_at": null,
      "paid_amount": null,
      "marked_by": null,
      "marked_by_name": null,
      "reminders_sent": 2,
      "last_reminder_at": "2026-10-06T04:30:12.507311+00:00",
      "next_action_at": "2026-10-07T04:30:00+00:00",
      "campaign_id": "cmp_01JRAZ1B4D7F0H3K6M9P2R5T8V",
      "failure_reason": null,
      "version": 3,
      "created_at": "2026-10-01T00:30:04.118205+00:00",
      "updated_at": "2026-10-06T04:30:12.507311+00:00"
    }
  ],
  "next_cursor": null
}
```

| Field | Notes |
|---|---|
| `due_id` | `due_` followed by 26 hex characters. The id for marking it paid. |
| `period_label` | `October 2026` for monthly, quarterly, half-yearly and yearly plans; a date such as `12 Oct 2026` for weekly and custom cycles. |
| `amount`, `currency` | What is owed. Always `INR`. |
| `status` | See the ladder below. |
| `collection_mode` | `manual_upi` (your own UPI or QR) or `razorpay` (a payment link). See [How you get paid](/apps/duesly#how-you-get-paid). |
| `payment_link` | The link in the reminder, in `razorpay` mode. |
| `paid_at`, `paid_amount`, `payment_reference` | Set once it's paid. |
| `marked_by`, `marked_by_name` | Who recorded a manual payment. |
| `reminders_sent`, `last_reminder_at`, `next_action_at` | Where the chase is. |
| `campaign_id` | The reminder campaign that last messaged them. |
| `version` | Needed to mark it paid. |

The statuses are a ladder, and it is finite:

| `status` | Meaning |
|---|---|
| `scheduled` | Raised, not yet due. |
| `reminded` | First reminder sent. |
| `followup_1` / `followup_2` | One or two follow-ups sent. |
| `paid` | Settled. |
| `declined` | **We have stopped asking.** Not "the customer refused". |
| `waived` | The business decided not to collect it. |
| `cancelled` | The subscription ended before this payment came around. |

A payment gets one reminder and at most two follow-ups. See [the reminder ladder](/apps/duesly#the-reminder-ladder): the limit is fixed and no API call raises it.

## Recording a payment

```endpoint
POST /api/v1/apps/duesly/dues/{due_id}/paid
```

For money collected outside the payment link: cash, a bank transfer, UPI to your own account, a cheque. With a payment gateway connected, link payments settle themselves and you don't need this.

```params
[
  {"name": "expected_version", "type": "integer", "required": true, "description": "The due's <code>version</code> from your last read. 1 or more."},
  {"name": "amount", "type": "number", "required": false, "default": "the amount owed", "description": "What actually arrived, more than 0. Stored as <code>paid_amount</code>. The due is <code>paid</code> either way."},
  {"name": "reference", "type": "string", "required": false, "default": "null", "description": "Up to 140 characters. A UPI transaction id, a cheque number: whatever you would look up later."},
  {"name": "paid_at", "type": "string", "required": false, "default": "now", "description": "ISO 8601."}
]
```

::: codegroup

```bash cURL
curl -X POST https://services.amomic.in/api/v1/apps/duesly/dues/due_4c81e07b2f9a63d5c18e47b20a/paid \
  -H "Authorization: Bearer $AMOMIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "expected_version": 3,
    "amount": 1249,
    "reference": "UPI 428617093512",
    "paid_at": "2026-10-14T10:42:00+05:30"
  }'
```

```javascript Node.js
async function markPaid(due, settlement) {
  const response = await fetch(
    `https://services.amomic.in/api/v1/apps/duesly/dues/${due.due_id}/paid`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.AMOMIC_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        expected_version: due.version,
        amount: settlement.amount,
        reference: settlement.utr,
        paid_at: settlement.receivedAt,
      }),
    },
  );
  const body = await response.json();
  if (response.ok) return body; // the due, status "paid"

  const { code, details } = body.error;
  if (code === "failed_precondition" && details.reason === "conflict") {
    return null; // already settled: details.current_status says how
  }
  throw new Error(`${code} ${details.reason ?? ""}: ${body.error.message}`);
}
```

```python Python
import os

import requests


def mark_paid(due: dict, amount: float, reference: str, paid_at: str):
    response = requests.post(
        f"https://services.amomic.in/api/v1/apps/duesly/dues/{due['due_id']}/paid",
        headers={"Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}"},
        json={
            "expected_version": due["version"],
            "amount": amount,
            "reference": reference,
            "paid_at": paid_at,
        },
        timeout=30,
    )
    body = response.json()
    if response.ok:
        return body  # the due, status "paid"
    error = body["error"]
    if error["code"] == "failed_precondition" and error["details"].get("reason") == "conflict":
        return None  # already settled: details.current_status says how
    raise RuntimeError(f"{error['code']} {error['details'].get('reason')}: {error['message']}")
```

:::

`200 OK`, with the due as it now stands: `status` `paid`, `paid_amount`, `paid_at`, `payment_reference`, and `version` one higher. The subscriber's totals are updated, the chase stops, and `payment.paid` fires. If you've set up a receipt in Duesly, the customer gets it.

Recording a payment also tries to cancel the due's live payment link, so nobody pays twice. That cancel is best-effort: if it fails, the payment is still recorded.

### Idempotent by state, not by key

This endpoint ignores `Idempotency-Key`. A due can only be marked paid while it's open, so a retried call can't rewrite a settled payment:

| Situation | Response |
|---|---|
| The due is open and `expected_version` matches | `200`, the due is now `paid`. |
| `expected_version` is stale (someone or something changed the due) | `409 conflict`, `reason: "version_conflict"`, with `details.current_version`. |
| The due is already `paid`, `declined`, `waived` or `cancelled` | `412 failed_precondition`, `reason: "conflict"`, with `details.current_status`. Message: `This payment is already paid.` |
| No such due in this workspace | `404 not_found`, `Payment not found.` |

The version is checked first. So a retry of a call that already worked usually gets the `409`, because marking it paid moved the version on. In both cases, read the due again: if it's `paid`, your payment, or someone else's, is already recorded.

## Failures

```responses
[
  {"status": 400, "code": "invalid_argument", "reason": "invalid_destination", "description": "Create: the phone number isn't usable. Include the country code, or write it as a local number in your workspace's country."},
  {"status": 400, "code": "invalid_argument", "reason": "", "description": "`Request validation failed.` A field outside its limits, `custom_days` without `cycle_days`, an unknown `scope`, or a `limit` outside 1–200. `details.errors` names it."},
  {"status": 401, "code": "unauthenticated", "reason": "", "description": "The key is missing, malformed, unknown, revoked, past its roll grace period, or for the wrong environment. `details.reason` says which."},
  {"status": 403, "code": "permission_denied", "reason": "missing_scope", "description": "Reads need `campaigns:read`; creating a subscriber and marking paid need `campaigns:write`."},
  {"status": 403, "code": "permission_denied", "reason": "workspace_suspended", "description": "The workspace is suspended. Restore billing to use its keys."},
  {"status": 404, "code": "not_found", "reason": "", "description": "`Payment not found.` Mark paid on an unknown due."},
  {"status": 409, "code": "conflict", "reason": "duplicate_recipient", "description": "Create: a subscriber already uses this phone number. `details.subscriber_id` names them."},
  {"status": 409, "code": "conflict", "reason": "version_conflict", "description": "Mark paid with a stale `expected_version`."},
  {"status": 409, "code": "conflict", "reason": "idempotency_key_reuse", "description": "Create: the same `Idempotency-Key` with a different body."},
  {"status": 412, "code": "failed_precondition", "reason": "not_found", "description": "Duesly isn't installed in this workspace. `details.docs` links the Apps page."},
  {"status": 412, "code": "failed_precondition", "reason": "conflict", "description": "Mark paid on a due that is no longer open. `details.current_status` says what it is."},
  {"status": 429, "code": "rate_limited", "reason": "rate_limited", "description": "120 requests per minute per key. See [Rate limits](/api/rate-limits)."}
]
```

## Events

The reason to integrate here rather than poll:

| Event | When | Payload |
|---|---|---|
| `payment.due_created` | A payment is raised by the cycle | `due_id`, `subscriber_id`, `amount`, `currency`, `period`, `due_date` |
| `payment.paid` | Settled | `due_id`, `subscriber_id`, `amount`, `period`, `reference`, `source` |
| `payment.declined` | The chase ended unpaid | `due_id`, `subscriber_id`, `amount`, `period` |

`payment.due_created` fires the moment a payment is raised, so your own ledger can mirror the invoice without polling for it.

::: warning Don't rely on `source` for payments you record
`payment.paid` carries `source`, `gateway` or `manual`. A payment marked in the console reports `manual` and a payment link settlement reports `gateway`, but a payment you record with this API currently also reports `gateway`. To tell your own settlements apart, match on `due_id` or on the `reference` you sent.
:::

::: info What the payload doesn't carry
No payment link, no gateway credentials, and nothing about the card. The projection names the fields it forwards rather than forwarding whatever the internal event holds.
:::

See [Webhooks](/webhooks/overview) and [Verifying signatures](/webhooks/signatures).

## A worked integration

The shape that works for most billing systems:

::: steps
::: step Mirror your customers once
`POST /subscribers` for each active plan, with `Idempotency-Key` set to your own customer id so a re-run is safe. Treat `409 duplicate_recipient` as "already there" and store the `subscriber_id` it returns.
:::
::: step Subscribe to the three payment events
Your ledger stays in step without polling.
:::
::: step Let Duesly do the chasing
Reminders, follow-ups and the payment link are the app's job. You don't need to call anything on a schedule.
:::
::: step Push settlements you collect elsewhere
When money arrives in your own system, read the due for its `version`, then `POST /dues/{due_id}/paid` so the chasing stops.
:::
:::
