# Ingat API v1

Base URL: `https://ingat.ingatbiz.com` (local: `http://localhost:3000`).
Every route except `/health` needs `Authorization: Bearer ingat_dev_…`.
Times are ISO 8601 with an offset or `Z`. Phone numbers may be sent in local
form (`0812…`) and are returned as E.164 (`+62812…`).

## Devices

Tokens are issued on the server only, and shown once:

```bash
node dist/cli/device-cli.js issue "Yungki iPhone" IOS     # IOS | IPADOS | MACOS | ANDROID
node dist/cli/device-cli.js list
node dist/cli/device-cli.js revoke <device-id>
```

| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/v1/devices/me` | The calling device |
| PATCH | `/api/v1/devices/me` | Set `name` or `pushToken` |
| GET | `/api/v1/devices` | All devices |
| POST | `/api/v1/devices/:id/revoke` | Revoke another device, e.g. a lost phone |

## Accounts

Every device token belongs to one account (a customer). All routes below only ever see that account's data. The same WhatsApp number may be a person in several accounts.

| Method | Path | What it does |
|---|---|---|
| GET | `/api/v1/accounts/me` | The account's settings plus `usage` (`includedPerMonth`, `usedThisMonth`, `balance`, `available`, `planActive`) and `limits` (all `-1`: memberships differ only by reminders a month, see Pricing). A reminder is paid for when sent, one per person per lead time, from the month's included allowance first, then the balance; otherwise the delivery waits as `HELD` with `lastError: NO_BALANCE` |
| PATCH | `/api/v1/accounts/me` | Change `name`, `language`, `timezone` (an IANA zone such as `Asia/Jakarta`; anything else is `400`) or `requireOptIn` |
| POST | `/api/v1/auth/apple` | Public. Body `{ identityToken, name?, deviceName, platform }` from Sign in with Apple. Creates the account on first use and answers `{ account, device, token }`; the token is the device token used everywhere else |
| POST | `/api/v1/auth/google` | Public. Body `{ idToken, deviceName, platform }` from Sign in with Google (Android). Same answer. A verified Google e-mail that already belongs to an account joins that account, so one person can use an iPhone and an Android phone together. Needs `GOOGLE_CLIENT_IDS` on the server. Optional `nonce`: a random value the app gave Google for this sign-in; when sent, the token must carry it |

### Self reminder from the calendar

The owner's device shares the calendar events it sees; the server reminds **only the owner**, never attendees. Settings live on the account (`PATCH /api/v1/accounts/me`): `agendaMode` `OFF` | `DAILY` (one message with tomorrow's agenda; when tomorrow is empty, the next day with meetings within a week, once) | `EACH` (one reminder per meeting) | `BOTH`, `agendaTimeMinute` (when the daily digest goes, minute of the day in the account's time zone; 1140 = 19:00) and `agendaLeadMinutes` (how long before each meeting). The daily message lists each meeting on its own lines (time, title, place). When the owner wrote to Ingat within the last 24 hours it goes as a plain WhatsApp message in that layout (the reminder's `notes`); otherwise through Ingat's `lib_agenda_<n>_v2` templates (1–6 meetings; more are counted on the last line); until Meta approves them, a one-line version goes through the task template. Each delivery also carries `nextAttemptAt`: when it is later than `fireAt` the message is waiting for the person's quiet hours to end (or a retry). Reminders made this way have `source: CALENDAR`; the digest's `externalId` is `agenda:<date>`, a meeting's is `cal:<event id>`.

| Method | Path | What it does |
|---|---|---|
| PUT | `/api/v1/agenda/events` | App only. Body `{ from, to, events: [{ externalId, title, startAt, endAt, allDay?, location?, calendarName? }] }` (≤ 500). Replaces the account's events whose start falls in `[from, to)`; events outside the window are untouched. In `EACH` mode, reminders are created, updated and cancelled to match. Each device replaces only what it shared itself; the account's agenda is the union of its devices, so a phone that sees fewer calendars (or none) never removes what the Mac shared, and a meeting is dropped only when no device sees it. In EACH/BOTH mode each timed event becomes its own reminder sent as the Meeting template (title, place, time), never merged with other reminders |

### Admin (operator only)

`GET /api/v1/accounts/me` carries `isAdmin`; it is true only for the operator's own account (set on the server with `account-cli admin <name> on`). For that account, from the app only (never an API key):

| Method | Path | What it does |
|---|---|---|
| GET | `/api/v1/admin/accounts` | Every account with `ownerName` and `ownerNumber` (what the user entered in the app), `plan`, `status`, `devices`, `lastSeenAt`, `people`, `reminders`, `messagesThisMonth`, `messagesAllTime`, `failedThisMonth`, `lastMessageAt`, plus `totals` |
| GET | `/api/v1/admin/support.csv` | The same inbox as a CSV file (`User`, `WhatsApp number`, `Account`, `Membership`, `Sent at`, `Question`, `Status`, `Reply`, `Replied at`), newest first |
| GET | `/api/v1/admin/support` | Questions users sent from Help (`text`, `status` OPEN/ANSWERED, `accountName`, `fromName`, `fromNumber`, `reply`), open first |
| POST | `/api/v1/admin/support/:id/reply` | Body `{ text }`. Stores the reply, shown under Help on the user's devices. Replies are never sent on WhatsApp (`replySentOnWhatsApp` is always false) |
| GET | `/api/v1/admin/accounts/:id` | The same row plus `months` (last six: `sent`, `failed`, `replies`), `deviceList` and reminder `outcomes` |
| GET | `/api/v1/admin/pricing` | The membership parameters: for Basic (`INDIVIDUAL`), `TEAM` and `BUSINESS` the `label`, `includedPerMonth`, `priceIdr`, `blockSize` and `blockPriceIdr` |
| PUT | `/api/v1/admin/pricing/:plan` | Changes any of those fields. Metering follows within seconds: an account on that membership without an allowance of its own may send `includedPerMonth` reminders a month |
| GET | `/api/v1/admin/coupons` | Every coupon, newest first, with `redemptions` |
| POST | `/api/v1/admin/coupons` | `{ kind: FREE_REMINDERS \| MONEY_OFF \| PERCENT_OFF, value, plan?, maxRedemptions?, expiresAt?, note?, code? }`. Makes a code such as `INGAT-7KQ2-M9XA` (or uses `code`, upper-cased). Share the code with the user |
| PATCH | `/api/v1/admin/coupons/:id` | `{ active }` switches a code off or on |

Every device can read `GET /api/v1/pricing` (the same memberships, for the paywall) and redeem a code with `POST /api/v1/coupons/redeem { code }`. Free reminders go on the balance at once and release held reminders; a money or percent discount waits on the account (`discount` on `GET /accounts/me`) for the next subscription payment. Each account may use a code once; `409` when used up, expired, already used, or for another membership; `404` when unknown or switched off.

Message counts are WhatsApp messages Meta accepted (templates and buttons); free-form replies are listed separately. Every other account gets 404 from these routes, as if they did not exist.

## Help questions

| Method | Path | What it does |
|---|---|---|
| POST | `/api/v1/support` | App only. Body `{ text }`: a question to the operator, answered from the Admin screen |
| DELETE | `/api/v1/support/:id` | Takes one of the account's own questions back; `204`, or `404` for someone else's |
| GET | `/api/v1/support` | The account's questions with any replies, newest first |

## Recipients

| Method | Path | Purpose |
| --- | --- | --- |
| PUT | `/api/v1/recipients/me` | Who the account's user is: `{ name, waNumber, language? }`. Creates or updates the owner (active at once, may create reminders). Reminders with nobody chosen go to this person; messages name them as the sender |
| GET | `/api/v1/recipients` | Everyone, owner first |
| GET | `/api/v1/recipients/:id` | One person |
| POST | `/api/v1/recipients` | Add a person; sends them the opt-in message, `PENDING` until they reply YES. Whether they are asked first follows the account's `requireOptIn` (default on); `requireOptIn` in the body overrides it for this person |
| POST | `/api/v1/recipients/:id/activate` | Mark a `PENDING` person `ACTIVE` because they agreed in person; no message is sent. `409` if they replied STOP |
| POST | `/api/v1/recipients/:id/invite` | Send the opt-in again to a `PENDING` person; `409` within 10 minutes of the last one. Returns `{ sent }` |
| PATCH | `/api/v1/recipients/:id` | Change settings; a new number resets to `PENDING` and sends the opt-in again |
| DELETE | `/api/v1/recipients/:id` | Remove; cancels their unsent deliveries. Owner cannot be removed |

Fields: `name`, `aliases[]`, `waNumber`, `language` (`ID`/`EN`), `leadTimesMinutes[]`
(up to 5, e.g. `[0, 1440]`), `quietStartMinute`, `quietEndMinute` (minutes after
local midnight), `canCreate`, `escalateToOwner`.

## Groups

| Method | Path | Purpose |
| --- | --- | --- |
| GET, POST | `/api/v1/groups` | List, create (`name`, `memberIds[]`) |
| GET, PATCH, DELETE | `/api/v1/groups/:id` | Read, rename or replace members, remove |

## Reminders

| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/v1/reminders?status=&from=&to=&limit=` | List, soonest first |
| GET | `/api/v1/reminders/:id` | One reminder with recipients and deliveries |
| POST | `/api/v1/reminders` | Create |
| PATCH | `/api/v1/reminders/:id` | Edit; moving `dueAt` or changing people re-plans deliveries |
| POST | `/api/v1/reminders/:id/close` | Mark done; cancels unsent deliveries |
| DELETE | `/api/v1/reminders/:id` | Remove |

Create fields: `id` (optional UUID chosen by the device; sending the same id again returns the
existing reminder instead of creating another), `title`, `notes`, `dueAt`, `timezone` (default Asia/Jakarta),
`repeatRule` (RRULE), `source` (`APP`/`SIRI`/`PENCIL`), `recipientIds[]`,
`groupIds[]`, `calendarKind` (`ICLOUD`/`OUTLOOK`). With no people named, the
reminder goes to the owner. Devices report the calendar event they wrote with
`PATCH {calendarEventExternalId}`.

Deliveries: one per person per lead time. `HELD` means the person has not opted
in yet; `SCHEDULED` means it will be sent at `fireAt`.

## Sync

`GET /api/v1/sync` returns every live reminder, recipient, and group, plus
`serverTime`. Pass that back as `?since=<serverTime>` next time to receive only
what changed, including removed rows with `deletedAt` set.

## WhatsApp webhook

`/webhooks/whatsapp` is the address registered in Meta's dashboard (step 18).
No device token; instead `GET` checks `META_WEBHOOK_VERIFY_TOKEN` for Meta's
subscription handshake, and every `POST` must carry a valid
`X-Hub-Signature-256` made with `META_APP_SECRET`. Messages are stored once
(repeats from Meta are ignored), delivery receipts update sent messages, and
anything for another business number is dropped.

## Development only

Answer 404 in production and need a device token.

| Method | Path | Purpose |
| --- | --- | --- |
| POST | `/dev/whatsapp/inbound` | Pretend a WhatsApp message arrived: `{from, text}` or `{from, buttonPayload}` |
| GET | `/dev/whatsapp/outbox` | The last messages the mock adapter "sent" |

## Errors

`400` validation (with `issues[]`), `401` token, `404` not found, `409` conflict
(duplicate number or name, closed reminder, owner removal), `429` rate limit
(default 120 requests per minute per address), `503` from `/health` when the
database is down.

## Message counts

| Method | Path | What it does |
|---|---|---|
| GET | `/api/v1/stats/messages` | This month and last month: `templatesSent` (billed by Meta), `templatesFailed`, `repliesSent` (free), `byTemplate` |

## Templates

| Method | Path | What it does |
|---|---|---|
| GET | `/api/v1/templates` | Ingat's library plus this account's custom templates, with `key`, `language`, `title`, `body`, `fields` (placeholders the reminder must supply), `status` (`DRAFT`, `PENDING`, `APPROVED`, `REJECTED`) |
| POST | `/api/v1/templates` | A custom template: `title`, `language`, `body` with `{{placeholders}}` (must include `{{due}}`), `hasReplyButtons`, `samples`. Submitted to Meta; usable once `APPROVED`. Team: 3, Business: unlimited |
| DELETE | `/api/v1/templates/:id` | Retire a custom template |

A reminder names a template with `templateKey` and supplies its `fields` in `variables`; the server fills `requester` and `due`. Templated reminders are sent one per message and never merged.

## Reports

| Method | Path | What it does |
|---|---|---|
| GET | `/api/v1/reports/monthly?month=YYYY-MM` | Messages sent / delivered / read / failed, reminders by template, deliveries due in the month by outcome (confirmed, snoozed, missed, undelivered, waiting), the same per person, and the plan usage. Defaults to this month |
| GET | `/api/v1/reports/monthly.csv?month=YYYY-MM` | The same as a CSV file |

## Business API (API keys)

A Business account can make API keys from the app (`POST /api/v1/api-keys`, shown once as `ingat_live_…`; `GET` lists them, `DELETE /:id` revokes). A key is sent as `Authorization: Bearer ingat_live_…` and reaches everything above for its own account, except device-only routes (`/devices/me`) and key management itself. A revoked key, or a suspended account, answers `401`; a suspended account's devices get the same `401`.

| Method | Path | What it does |
|---|---|---|
| POST | `/api/v1/reminders` | As above; with `externalId` (your own reference) a repeat returns the existing reminder instead of making another. Reminders made with a key have `source: "API"` |
| GET | `/api/v1/reminders?externalId=…` | Find by your reference |
| POST | `/api/v1/reminders/bulk` | `{ rows: [...] }`, up to 500. Each row: `externalId`, `title`, `dueAt`, `to: { name, waNumber, language? }`, optional `templateKey` + `variables`, `notes`, `timezone` (IANA zone, else the row fails validation). The person is made if the number is new (asked on WhatsApp first if the account says so). Answers `{ created, existing, failed, rows: [{ externalId, status, reminderId?, recipientId?, error? }] }`; one bad row never stops the rest |

Example:

```bash
curl -X POST https://ingat.ingatbiz.com/api/v1/reminders/bulk \
  -H "Authorization: Bearer ingat_live_…" -H "Content-Type: application/json" \
  -d '{"rows":[{"externalId":"KPR-00123/2026-10","title":"Angsuran Oktober","dueAt":"2026-10-15T09:00:00+07:00",
        "to":{"name":"Budi Santoso","waNumber":"0812-3456-7890"},
        "templateKey":"payment_due","variables":{"name":"Budi Santoso","amount":"2.350.000","reference":"KPR-00123"}}]}'
```

## Webhooks (Business)

| Method | Path | What it does |
|---|---|---|
| POST | `/api/v1/webhooks` | `{ url, events? }`. The URL must be https and point to a public host: a private or local address, a host that resolves to one, or a URL with credentials in it is refused with `400`, and the check is repeated before every post. Redirects are not followed. Answers the endpoint with its `secret` once. Empty `events` means every event |
| GET | `/api/v1/webhooks` | Your endpoints (without secrets) |
| DELETE | `/api/v1/webhooks/:id` | Remove one |
| POST | `/api/v1/webhooks/:id/test` | Queue a `test` event |
| GET | `/api/v1/webhooks/:id/deliveries` | The last 50 posts with status, attempts and errors |
| GET | `/api/v1/reports/daily?date=YYYY-MM-DD` | One day's outcomes and who did not confirm (yesterday by default) |

Events: `reminder.sent`, `reminder.delivered`, `reminder.read`, `reminder.confirmed` (with `reply`), `reminder.snoozed` (with `until`), `reminder.missed`, `reminder.undelivered` (with `reason`), `person.opted_in`, `person.stopped`, `report.daily` (once a day from 07:00 in the account's time zone, for the day before), `test`.

Each post is JSON `{ id, event, occurredAt, reminder, person, delivery, ... }` with headers `X-Ingat-Event`, `X-Ingat-Delivery` and `X-Ingat-Signature: t=<unix seconds>,v1=<hex>`, where `v1` is HMAC-SHA256 with your secret over `"<t>.<raw body>"`. Answer 2xx within 10 seconds; anything else is retried after 1, 5, 15 and 60 minutes, then given up and shown in the deliveries log.

## Sandbox accounts

A sandbox account (ask for one) behaves like a Business account except that nothing it sends reaches WhatsApp. To test the whole flow:

| Method | Path | What it does |
|---|---|---|
| POST | `/api/v1/sandbox/replies` | `{ deliveryId, action }` with `done`, `snooze1h`, `snoozetmr`, `stop` or `yes`, or `{ deliveryId, text }`. Behaves exactly as if the person had replied on WhatsApp, but only within this sandbox account: a number that also belongs to a person in a real account is never affected |
| POST | `/api/v1/sandbox/receipts` | `{ deliveryId, status: "delivered" | "read" }` |

Delivery ids are in a reminder's `deliveries` (one per person per lead time). A sandbox account answers `403` on these routes once it is a real account.
