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.
/api/v1/apps/duesly/subscribersDuesly 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:
{"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.
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 #
namestringrequired1–140 characters. Appears in the reminder.
phonestringrequired6–24 characters. With a country code, or a local number in your workspace's country. Refused rather than guessed if it's neither.
emailstringoptionaldefault nullUp to 200 characters. Stored on the subscriber and their contact.
plan_namestringoptionaldefault Monthly planUp to 120 characters. What they are paying for. Appears in the reminder.
amountnumberrequiredIn rupees. More than 0, up to 1,00,00,000.
cycle_unitstringoptionaldefault monthlyHow often they pay.
weeklymonthlyquarterlyhalf_yearlyyearlycustom_dayscycle_daysintegeroptionaldefault null1–730. Required for custom_days.
due_dayintegeroptionaldefault null1–31. Day of the month for calendar cycles. A day the month doesn't have becomes its last day.
start_datestringoptionaldefault todayYYYY-MM-DD. When the first payment is due. Omitted, today in your workspace's time zone.
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"}'
201 Created, with the subscriber:
{"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_dateandoutstanding_totalare already filled in and aGET /duesright after this returns them, rather than waiting for the next scheduled run.payment.due_createdfires 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.
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.
Listing subscribers #
/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.
Reading what is owed #
/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.
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"
{"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. |
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: the limit is fixed and no API call raises it.
Recording a payment #
/api/v1/apps/duesly/dues/{due_id}/paidFor 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.
expected_versionintegerrequiredThe due's version from your last read. 1 or more.
amountnumberoptionaldefault the amount owedWhat actually arrived, more than 0. Stored as paid_amount. The due is paid either way.
referencestringoptionaldefault nullUp to 140 characters. A UPI transaction id, a cheque number: whatever you would look up later.
paid_atstringoptionaldefault nowISO 8601.
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"}'
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 #
invalid_argumentinvalid_destinationCreate: the phone number isn't usable. Include the country code, or write it as a local number in your workspace's country.invalid_argument`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.unauthenticatedThe key is missing, malformed, unknown, revoked, past its roll grace period, or for the wrong environment. `details.reason` says which.permission_deniedmissing_scopeReads need `campaigns:read`; creating a subscriber and marking paid need `campaigns:write`.permission_deniedworkspace_suspendedThe workspace is suspended. Restore billing to use its keys.not_found`Payment not found.` Mark paid on an unknown due.conflictduplicate_recipientCreate: a subscriber already uses this phone number. `details.subscriber_id` names them.conflictversion_conflictMark paid with a stale `expected_version`.conflictidempotency_key_reuseCreate: the same `Idempotency-Key` with a different body.failed_preconditionnot_foundDuesly isn't installed in this workspace. `details.docs` links the Apps page.failed_preconditionconflictMark paid on a due that is no longer open. `details.current_status` says what it is.rate_limitedrate_limited120 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.
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.
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 and Verifying signatures.
A worked integration #
The shape that works for most billing systems:
- 1
Mirror your customers once
POST /subscribersfor each active plan, withIdempotency-Keyset to your own customer id so a re-run is safe. Treat409 duplicate_recipientas "already there" and store thesubscriber_idit returns. - 2
Subscribe to the three payment events
Your ledger stays in step without polling.
- 3
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.
- 4
Push settlements you collect elsewhere
When money arrives in your own system, read the due for its
version, thenPOST /dues/{due_id}/paidso the chasing stops.