# REST API Documentation

## Overview

The REST API provides CRM, account, property, unit, buyer, hold, payment-plan, incoming-payment, private-document and change-event endpoints. API access is available to **Admins**, **Finance**, **Wahi Agents**, and **External Agents** via API key authentication, subject to key scopes and configured role permissions.

## Base URL

```
https://<provided-via-private-channel>/functions/v1/admin-api
```

The live production endpoint and a valid API key are issued privately after access is approved by the WAHI Group account team. Replace `<provided-via-private-channel>` with the endpoint you received.


## Authentication

### API Key Authentication

All requests must include an API key in one of the following formats. API keys are issued privately by the WAHI Group account team after access is approved.

**Header Option 1: X-API-Key**
```
X-API-Key: your-api-key-here
```

**Header Option 2: Bearer Token**
```
Authorization: Bearer your-api-key-here
```

### Supported Roles

API keys can be created by users with the following roles:

| Role | Access Level |
|---|---|
| `admin` | Configured role permissions plus key scopes; account-management actions are admin only |
| `finance` | Configured finance/document permissions plus key scopes; payment plans and documents admit admin/finance only |
| `wahi_agent` | Configured permissions; leads and buyer actions restricted to assigned accounts |
| `external_agent` | Configured permissions; leads and buyer actions restricted to assigned accounts |

Non-admin legacy `/leads` and `/crm-leads` reads are limited to the API key owner's assignments. `/users` account-management actions require admin. Key scopes never override role restrictions.

---

## Rate Limiting

- Default: **60 requests per minute** per API key
- Rate limits can be customized per key
- When exceeded, returns `429 Too Many Requests`

### Rate Limit Headers

Every response includes:
```
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
```

---

## CRM Leads (Recommended for n8n / External Integrations)

These endpoints operate directly on the `leads` table **without creating auth accounts**. Auth accounts are only auto-created when a lead moves to the "Closing" stage. This is the recommended endpoint set for n8n, Meta Leads Centre, and website form integrations.

### List All CRM Leads

**Request**
```http
GET /crm-leads
GET /crm-leads?stage_id=stage-uuid
GET /crm-leads?parent_id=agent-uuid
GET /crm-leads?stage_id=stage-uuid&parent_id=agent-uuid
X-API-Key: your-api-key
```

**Query Parameters:**
- `stage_id` (optional) - Filter by pipeline stage
- `parent_id` (optional) - Filter by assigned agent

**Response (200 OK)**
```json
{
  "data": [
    {
      "id": "lead-uuid",
      "full_name": "John Doe",
      "email": "john@example.com",
      "phone": "+62812345678",
      "company_name": "Acme Corp",
      "country_of_residence": "Indonesia",
      "current_stage_id": "stage-uuid",
      "lead_status": "New",
      "parent_id": "agent-uuid-or-null",
      "auth_user_id": null,
      "notes": "Interested in villa",
      "source": "meta_leads",
      "project_id": "property-project-uuid-or-null",
      "created_at": "2026-02-01T10:00:00Z",
      "updated_at": "2026-02-01T10:00:00Z"
    }
  ],
  "count": 1
}
```

---

### Get Single CRM Lead

```http
GET /crm-leads/{lead_id}
X-API-Key: your-api-key
```

---

### Create CRM Lead

Creates a lead directly in the `leads` table without an auth account. If `current_stage_id` points to "Closing", an auth account is auto-created.

**Request**
```http
POST /crm-leads
Content-Type: application/json
X-API-Key: your-api-key

{
  "full_name": "Jane Smith",
  "email": "jane@example.com",
  "phone": "+62812345678",
  "company_name": "Tech Corp",
  "country_of_residence": "Singapore",
  "source": "meta_leads",
  "notes": "From Facebook ad campaign",
  "project_id": "property-project-uuid"
}
```

**Required Fields:** `full_name`, `email`

**Optional Fields:**
- `phone` - Phone number (E.164 format recommended)
- `company_name` - Company name
- `country_of_residence` - Country
- `current_stage_id` - UUID of pipeline stage (default: first active stage)
- `lead_status` - Status name (default: first status of the stage)
- `parent_id` - UUID of agent to assign
- `notes` - Free-text notes
- `source` - **Lead source / origin channel** (e.g. `"Website"`, `"Instagram"`, `"Facebook"`, `"TikTok"`, `"WhatsApp"`, `"Google Ads"`, `"Referral"`, `"Event / Expo"`, `"Walk-in"`, `"Cold Call"`, `"Partner Agent"`, or any custom string). Used by the admin Lead Analytics dashboard to break down performance per channel. **Defaults to `"API"` if omitted on `POST /crm-leads`.**
- `project_id` - UUID of property project (from `/properties` or property_projects table)

**Response (201 Created)**
```json
{
  "message": "CRM lead created successfully",
  "data": {
    "id": "new-lead-uuid",
    "full_name": "Jane Smith",
    "email": "jane@example.com",
    "source": "meta_leads",
    "current_stage_id": "stage-uuid",
    "lead_status": "New",
    "auth_user_id": null
  },
  "account_created": false
}
```

---

### Update CRM Lead

```http
PUT /crm-leads/{lead_id}
Content-Type: application/json
X-API-Key: your-api-key

{
  "full_name": "Updated Name",
  "notes": "Updated notes",
  "source": "website_form"
}
```

Supports: `full_name`, `email`, `phone`, `company_name`, `country_of_residence`, `current_stage_id`, `lead_status`, `notes`, `source`, `project_id`.

---

### Delete CRM Lead

```http
DELETE /crm-leads/{lead_id}
X-API-Key: your-api-key
```

---

### Move CRM Lead to Stage

Moves a lead to a different pipeline stage. If the target stage is "Closing" and the lead has no auth account, one is auto-created.

**Request**
```http
PUT /crm-leads/{lead_id}/move-stage
Content-Type: application/json
X-API-Key: your-api-key

{
  "stage_id": "target-stage-uuid",
  "lead_status": "optional-status-in-target-stage"
}
```

**Required:** `stage_id`

**Optional:** `lead_status` (must belong to target stage), `email` (override for account creation), `password` (override for account creation)

**Response (200 OK)**
```json
{
  "message": "CRM lead moved to stage successfully",
  "data": {
    "id": "lead-uuid",
    "stage_id": "target-stage-uuid",
    "lead_status": "New",
    "account_created": true,
    "auth_user_id": "new-auth-uuid"
  }
}
```

---

### Assign CRM Lead to Agent

```http
PUT /crm-leads/{lead_id}/assign
Content-Type: application/json
X-API-Key: your-api-key

{
  "parent_id": "agent-uuid"
}
```

Set `parent_id` to `null` to unassign.

---

## n8n Integration Guide

### Setup: Meta Leads Centre → CRM

1. **n8n Webhook Trigger** — receives data from Meta Leads Centre
2. **HTTP Request Node** — sends to CRM:

```
Method: POST
URL: https://<provided-via-private-channel>/functions/v1/admin-api/crm-leads
Headers:
  X-API-Key: your-api-key
  Content-Type: application/json
Body:
{
  "full_name": "{{ $json.full_name }}",
  "email": "{{ $json.email }}",
  "phone": "{{ $json.phone_number }}",
  "source": "meta_leads",
  "notes": "Campaign: {{ $json.campaign_name }}"
}
```

### Setup: Website Form → CRM

```
Method: POST
URL: https://<provided-via-private-channel>/functions/v1/admin-api/crm-leads
Headers:
  X-API-Key: your-api-key
  Content-Type: application/json
Body:
{
  "full_name": "{{ $json.name }}",
  "email": "{{ $json.email }}",
  "phone": "{{ $json.phone }}",
  "source": "website_form",
  "notes": "Property interest: {{ $json.property }}"
}
```

### Full Automation Flow

```
Meta/Website → n8n Webhook → POST /crm-leads → Lead in Kanban
                                    ↓
                         n8n can then forward via:
                         - WhatsApp Business API
                         - Email (SendGrid/Mailgun)
                         - Slack notification
```

---

## Pipeline Stages

Stages are parent categories that group lead statuses. Leads move through stages (e.g., Qualification → Negotiation → Closing). Each stage has its own set of lead statuses.

### List All Stages

**Request**
```http
GET /stages
X-API-Key: your-api-key
```

**Response (200 OK)**
```json
{
  "data": [
    {
      "id": "stage-uuid",
      "name": "Qualification",
      "sort_order": 0,
      "is_active": true,
      "created_at": "2026-02-01T10:00:00Z",
      "updated_at": "2026-02-01T10:00:00Z",
      "statuses": [
        {
          "id": "status-uuid",
          "name": "New",
          "color": "#22c55e",
          "sort_order": 0,
          "is_active": true,
          "stage_id": "stage-uuid"
        }
      ]
    }
  ],
  "count": 1
}
```

### Get Single Stage

```http
GET /stages/{stage_id}
X-API-Key: your-api-key
```

### Create Stage

```http
POST /stages
Content-Type: application/json
X-API-Key: your-api-key

{
  "name": "Negotiation",
  "sort_order": 1,
  "is_active": true
}
```

**Required:** `name` | **Optional:** `sort_order` (default: 0), `is_active` (default: true)

### Update Stage

```http
PUT /stages/{stage_id}
Content-Type: application/json
X-API-Key: your-api-key

{ "name": "Updated Name", "is_active": false }
```

### Delete Stage

⚠️ Deleting a stage also deletes all its lead statuses (cascade).

```http
DELETE /stages/{stage_id}
X-API-Key: your-api-key
```

### Get Statuses for a Stage

```http
GET /stages/{stage_id}/statuses
X-API-Key: your-api-key
```

---

## Lead Status Configuration

### List All Statuses

```http
GET /statuses
X-API-Key: your-api-key
```

### Create Status

```http
POST /statuses
Content-Type: application/json
X-API-Key: your-api-key

{
  "name": "Contacted",
  "color": "#3b82f6",
  "sort_order": 1,
  "stage_id": "stage-uuid"
}
```

**Required:** `name` | **Optional:** `color` (default: `#6b7280`), `sort_order` (default: 0), `is_active` (default: true), `stage_id`

### Update Status

```http
PUT /statuses/{status_id}
Content-Type: application/json
X-API-Key: your-api-key

{ "name": "Updated", "color": "#ef4444" }
```

### Delete Status

```http
DELETE /statuses/{status_id}
X-API-Key: your-api-key
```

---

## Users (Legacy — creates auth accounts)

### List All Users

```http
GET /users
X-API-Key: your-api-key
```

Use `GET /users?email=client%40example.com` for an exact, case-insensitive email lookup (not a partial search). The response stays `{ data, count }`. Admin only; requires `users:read`.

### Create User

```http
POST /users
Content-Type: application/json
X-API-Key: your-api-key

{
  "email": "newuser@example.com",
  "password": "securePassword123",
  "full_name": "Jane Smith",
  "role": "user"
}
```

**Required:** `email`, `password`, `full_name` | **Optional:** `phone`, `country_of_residence`, `company_name`, `role` (default: `user`), `parent_id`, `lead_status`, `current_stage_id`

### Update User

```http
PUT /users/{user_id}
```

### Patch Profile

```http
PATCH /users/{user_id}
Content-Type: application/json

{ "name": "Jane Smith", "phone": "+62812345678", "country": "Indonesia", "company": "Example Ltd" }
```

Admin only; requires `users:write` and role write access to Users. Accepts `full_name`, `country_of_residence`, `company_name` as aliases; omitted fields remain unchanged. Use this endpoint for profile fields only; existing `PUT /users/{user_id}` remains supported.

### Change Account Role

```http
POST /users/{user_id}/role
Content-Type: application/json

{ "role": "wahi_agent" }
```

Admin only, `users:write`. Allowed: `user`, `agent` (legacy), `wahi_agent`, `external_agent`, `admin`, `finance`. Roles remain in the protected role store. The Super Admin cannot be demoted; accounts with ambiguous/multiple roles return `409 CONFLICT`.

### Reset Password

```http
POST /users/{user_id}/reset-password
Content-Type: application/json

{ "password": "replace-with-new-password" }
```

Admin only, `users:write`. Minimum 6 characters, plus any hosted password-strength rules enforced when saving. Updates the credential used by Send Account Details and clears its sent marker; sends no email. Passwords never appear in API responses/audit snapshots. Dry-run validates local rules without changing Auth; hosted strength checks cannot be exercised without actually setting the password.

### Send Account Details

```http
POST /users/{user_id}/send-account-details
Content-Type: application/json

{}
```

Admin only, `users:write`. Same GetResponse action as the Users button: sends name/email and stored password to the configured account-details list, updating an existing contact if present. `NO_PASSWORD_STORED` means reset the password first. Dry-run verifies prerequisites but sends nothing to GetResponse. POST actions support `Idempotency-Key`; use a fresh value for each intended action.

All new account actions support `?dry_run=true` with validated before/after previews and no business writes.

### Delete User

```http
DELETE /users/{user_id}
```

### Assign User to Agent

```http
PUT /users/{user_id}/assign
Content-Type: application/json

{ "parent_id": "agent-uuid" }
```

### Update Lead Status

```http
PUT /users/{user_id}/leads-status
Content-Type: application/json

{ "lead_status": "Contacted" }
```

### Move Lead to Stage

```http
PUT /users/{user_id}/move-stage
Content-Type: application/json

{ "stage_id": "target-stage-uuid" }
```

---

## Leads (Legacy — operates on auth users with role=user)

### List / Get / Create / Update / Delete / Move Stage

Same as Users endpoints but at `/leads` path. `POST /leads` auto-sets role to `user`.

> **💡 For new integrations, use `/crm-leads` instead.** The `/leads` endpoint creates auth accounts immediately which is not ideal for lead management workflows.

---

## Properties — Projects

### List Projects

```http
GET /properties
```

Requires key scope `units:read` and role read access to **Properties & Gallery**.
Returns `{ "data": [{ "id": "project-uuid", "name": "Element Residence", "location": "Bali", "status": "Under Construction", "currency": "USD", "currencies": ["USD"] }], "count": 1 }`.
Currency is derived from unit inventory, not assumed: `currency` is `null` when a project has no units or multiple currencies; `currencies` lists the actual currencies.

### List Units in a Project

```http
GET /properties/{project_id}/units?status=available,reserved&limit=100&offset=0
```

Requires `units:read` and role read access to **Units & Buyers**. Returns the same `{ data, pagination: { limit, offset } }` shape as `GET /units`, including `price`, `currency`, `buyers`, and `buyer_user_ids`. Default limit 100, maximum 500; paginate for larger projects. Unknown project returns `404 NOT_FOUND`.

## Gallery Endpoints

### List Gallery Images

```http
GET /properties/{project_id}/gallery
```

### Add Gallery Image

```http
POST /properties/{project_id}/gallery
Content-Type: application/json

{ "url": "https://example.com/image.jpg", "caption": "Optional", "sort_order": 0 }
```

### Update Gallery Image

```http
PUT /gallery/{image_id}
```

### Delete Gallery Image

```http
DELETE /gallery/{image_id}
```

---

## Units — Availability & Buyer Assignment

Manage individual unit inventory: availability status, pricing/specs, and which users own a unit.
Access follows **Units & Buyers** in Settings → API Access by Role, plus key scopes `units:read` / `units:write`.

Every unit price is paired with its `currency` (ISO 4217 code; examples: `USD`, `EUR`, `IDR`). No currency conversion is performed. Always send currency when changing the denomination of a price.

### List Units

```http
GET /admin-api/units?project_id=...&status=available&unit_type_id=...&buyer_user_id=...&unit_code=A-01&limit=100&offset=0
```

`status` accepts a comma-separated list (`available,reserved`). Each unit includes `buyers` (with `is_primary`) and `buyer_user_ids`.

### Get Single Unit

```http
GET /admin-api/units/{unit_id}
```

### Update Unit (availability, price, specs)

```http
PATCH /admin-api/units/{unit_id}
PUT   /admin-api/units/{unit_id}
```

```json
{
  "status": "reserved",
  "price": 250000,
  "currency": "USD",
  "unit_code": "A-01",
  "floor": "2",
  "bedrooms": 2,
  "bathrooms": 2,
  "view": "Ocean",
  "notes": "Held until 30 Aug"
}
```

Allowed `status` values: `available`, `reserved`, `sold`.
Changing status away from `sold` or changing price on a sold unit or a unit with primary/linked buyers returns **409 UNIT_HAS_BUYER** unless the request explicitly sends `"override": true`. An authorized override releasing the unit clears buyers. Query or body dry runs execute the same guard and return the computed before/after without saving, including leaving `updated_at` unchanged.

### Stock Summary

```http
GET /admin-api/units/summary?project_id=...
```

Counts per project and per unit type: `total`, `available`, `reserved`, `sold`. `available_value` remains the legacy raw sum for compatibility; do not use it for mixed currencies. Use `available_value_by_currency` (e.g. `{ "USD": 250000, "IDR": 3000000000 }`). `currency` is the single currency or `null` for mixed currencies, and `currencies` lists all denominations.

### Bulk Update Units (price / status / specs)

```http
POST /admin-api/units/bulk
```

```json
{
  "dry_run": true,
  "lock_sold": true,
  "updates": [
    { "project_id": "...", "unit_code": "3101", "price": 218254, "status": "available" },
    { "id": "unit-uuid", "price": 199000 }
  ]
}
```

- Identify each unit by `id`, or by `project_id` + `unit_code`. Max 500 per request.
- Body `dry_run: true` and **`?dry_run=true`** are equivalent: either true flag enables strictly read-only previews and skips telemetry/idempotency writes, even when the other flag is false.
- `lock_sold` (default `true`): reserved/sold/buyer units keep price, currency and status (reported as `locked`). A per-item `override: true` explicitly unlocks it. With `lock_sold: false`, protected sales still require per-item `override: true` for status release or repricing; otherwise the item reports `UNIT_HAS_BUYER`.
- Bulk responses expose `{ "dry_run": true, "data": { "dry_run": true, "lock_sold": true, "summary": {}, "results": [] } }`; `data.dry_run` is retained for compatibility, and top-level `dry_run` matches other routes. Real writes return both flags as false.
- Each result is `updated` / `would_update` / `unchanged` / `locked` / `not_found` / `error`, with `changes` (from → to).

### Reserve a Unit Without an Account

```http
POST /units/{unit_id}/reserve
Content-Type: application/json
```

```json
{ "reserved_for_name": "Jane Smith", "booking_fee_amount": 2000, "booking_fee_currency": "USD", "note": "Hold for prospect" }
```

Requires `units:write` and role write access to **Units & Buyers**. Name, non-negative numeric fee, and three-letter uppercase currency are required; `note` is optional. The unit must be available without buyers or an active hold. Creates an unpaid booking-fee hold, sets unit status to `reserved`, and does **not** create a user account, contract, payment plan, or send account email.

Response `201`: `{ "data": { "unit": { "id": "unit-uuid", "status": "reserved", "price": 250000, "currency": "USD" }, "reservations": [{ "id": "reservation-uuid", "user_id": null, "prospect_name": "Jane Smith", "booking_fee_amount": 2000, "booking_fee_currency": "USD", "booking_fee_status": "unpaid", "status": "held" }] } }` (additional stored fields included).

### Release a Unit Reservation

```http
POST /units/{unit_id}/release-reservation
Content-Type: application/json
```

Body: `{}` or no body. Requires `units:write`; unit must be `reserved`. Marks all active holds on that unit `released` (history retained), records who released them and when, and returns the unit to `available`. Response `200` uses the same `{ data: { unit, reservations } }` shape. Sold units or units with either a primary buyer or linked buyers return `409 UNIT_HAS_BUYER`; no override bypass exists for these actions. Other invalid states return `409 CONFLICT`.

Both action endpoints support `?dry_run=true`: execute the same validation and return `{ dry_run: true, before: { unit, reservations }, after: { unit, reservations } }` without saving business records. Generated reservation IDs/timestamps are `null` in previews. Writes are transactional and logged with `X-Request-Id`. Use `Idempotency-Key` for retries; dry-run and real-write responses are kept separate.

### Unit Holds (reservations + booking fee)

```http
GET   /admin-api/units/reservations?status=held&project_id=...&unit_id=...
POST  /admin-api/units/reservations
PATCH /admin-api/units/reservations/{reservation_id}
```

Create (unit must be available, becomes `reserved`):
```json
{ "unit_id": "...", "prospect_name": "Jane", "prospect_email": "jane@x.com", "booking_fee_amount": 2000, "booking_fee_currency": "USD", "booking_fee_status": "unpaid" }
```

Update: change `booking_fee_status` (`paid`/`unpaid`), amount, notes, or send `{ "status": "released" }` to release the hold (unit returns to `available`).

All endpoints above follow the **Units & Buyers** permission in Settings → API Access by Role (read = GET, write = everything else).

### List Buyers of a Unit

```http
GET /admin-api/units/{unit_id}/buyers
```

### Assign Buyers (add, keeps existing)

```http
POST /admin-api/units/{unit_id}/buyers
```

```json
{ "user_ids": ["<user_uuid>", "<user_uuid_2>"], "primary_user_id": "<user_uuid>" }
```

A single `"user_id": "<uuid>"` is also accepted. Linking buyers and setting status `sold` run in one transaction with unit locking; repeated links do not duplicate buyers. The successful response remains `{ data: unit }`. Requires `units:write`; agents can only manage buyers assigned to them and cannot alter units containing another agent’s buyers. Supports `?dry_run=true` with validated before/after unit previews.

### Re-assign Buyers (replace full list)

```http
PUT /admin-api/units/{unit_id}/buyers
```

```json
{ "user_ids": ["<new_owner_uuid>"], "primary_user_id": "<new_owner_uuid>" }
```

Buyers not in `user_ids` are removed. Removing existing buyers from a sold/has-buyer unit requires JSON `"override": true`, including dry runs; otherwise returns `409 UNIT_HAS_BUYER`. Sending `{"user_ids": [], "override": true}` clears all buyers and frees the unit.

### Un-assign a Buyer

```http
DELETE /admin-api/units/{unit_id}/buyers/{user_id}   # remove one buyer
DELETE /admin-api/units/{unit_id}/buyers             # remove all buyers
```

For **both** DELETE variants, confirm with JSON `"confirm": true` or query `?confirm=true`; either literal true enables confirmation. Missing/false confirmation returns `422 CONFIRMATION_REQUIRED`. Sold units or units with a primary/linked buyer return `409 UNIT_HAS_BUYER` unless the JSON body also contains `"override": true` (query override is not accepted). This guard also runs in dry runs. Example: `DELETE /units/{unit_id}/buyers?confirm=true&dry_run=true` with body `{ "override": true }`. Removing the last buyer frees a sold unit via the existing synchronization rules; related records follow those same existing rules. DELETE and PUT buyer actions accept query or body dry_run.

All unit changes are recorded in **Change Events** (`unit_status_changed`, `unit_price_changed`, `unit_sold`).

---


## Documents — Private Attachments

Admin/Finance only, using **Properties, Gallery & Documents** role permissions and `documents:write` key scope for all document actions (the current scope set has no documents:read). Existing full-scope keys work unchanged. Documents use a dedicated private `documents` bucket; existing property files are not moved or exposed.

### Upload Document

```http
POST /documents
Content-Type: multipart/form-data
```

Multipart fields: `file` (nonempty PDF/JPG/PNG, maximum **20 MB / 20,971,520 bytes**), `target_type` (`unit`, `user`, `plan`), `target_id` (existing target UUID), `category` (e.g. SPA, receipt, ID, brochure), `title`, `visible_to_investor` (`true` or `false`). The server validates file extension, MIME and file signature. Category/title maximum lengths: 100/255. File paths are generated, never supplied by the caller.

```bash
curl -X POST "$API_BASE/documents" \
  -H "X-API-Key: $API_KEY" \
  -H "Idempotency-Key: unique-upload-id" \
  -F "file=@contract.pdf;type=application/pdf" \
  -F "target_type=unit" -F "target_id=unit-uuid" \
  -F "category=SPA" -F "title=Sale Agreement" \
  -F "visible_to_investor=true"
```

Response `201 { data: document }` contains metadata and a stable storage path, not a public URL. POST supports Idempotency-Key, hashing file content and metadata; changing either with the same key returns IDEMPOTENCY_CONFLICT. `?dry_run=true` validates the file and target but uploads/saves nothing. The visibility flag records the intended audience; it does not grant public access or add these API attachments to the existing investor document screens.

### List and Download Documents

```http
GET /documents?target_type=unit&target_id=unit-uuid
```

Both filters are required. Response `{ data: documents, count }`; each document includes a signed `download_url` valid for **1 hour (3600 seconds)**. Request the list again to refresh expired links. Signing failure returns null with DOWNLOAD_UNAVAILABLE. These bearer links must not be published or logged. This list covers attachments created through this API, not legacy property gallery/personal files.

### Delete Document

```http
DELETE /documents/{document_id}
Content-Type: application/json

{ "confirm": true }
```

Missing/false confirmation returns `422 CONFIRMATION_REQUIRED`. Removes the private object and hides its metadata with a deletion timestamp. `?dry_run=true` previews deletion without changing records/files. Existing signed links should be treated as bearer credentials until expiry even after deletion.

---

## Finance — Payment Plans

Admin or Finance only, controlled by the existing **Finance** role permissions. GET requires `payments:read`; writes require `plans:write` (legacy full-scope keys remain supported). POST/PATCH support `Idempotency-Key` and all writes support validated `?dry_run=true` previews. No account, contract, or payment is created by these actions.

### Create Payment Plan

```http
POST /payment-plans
Content-Type: application/json

{
  "unit_id": "unit-uuid",
  "buyer_user_id": "buyer-uuid",
  "currency": "USD",
  "total_price": 1000,
  "installments": [
    { "label": "Deposit", "amount": 200, "due_date": "2026-10-27" },
    { "label": "Balance", "amount": 800, "due_date": "2027-01-01" }
  ]
}
```

Buyer must already be linked to the unit. Currency: USD/EUR/IDR. Positive total; 1–500 installments with non-negative amounts summing exactly to total_price. Plan and installments are created in one transaction; response `201 { data: plan }` includes installments and buyers. Existing unit pricing is unchanged. Dry-run returns `200 { dry_run, before, after }`.

### List Payment Plans

```http
GET /payment-plans?unit_id=unit-uuid
```

Optional unit filter. Returns `{ data: plans, count }`. Every installment includes amount, currency, paid_amount, remaining_amount, due_date and status. Existing plans without recorded total_price derive it from their installment amounts.

### Update Installment Amount / Due Date

```http
PATCH /payment-plans/{plan_id}/installments/{installment_id}
Content-Type: application/json

{ "amount": 250, "due_date": "2026-10-28" }
```

Only supplied fields change. Installment must belong to this plan. An amount edit does not change the agreed total_price or automatically redistribute other installments. Response `{ data: installment }` includes recalculated balances/status; dry-run validates the same rules without saving.

### Link Additional Plan Buyer

```http
POST /payment-plans/{plan_id}/buyers
Content-Type: application/json

{ "user_id": "additional-buyer-uuid" }
```

Buyer must already own the plan's unit. Links idempotently without replacing the primary buyer or duplicating/splitting installments; invoices and reminders keep the existing primary recipient. Buyers no longer linked to the unit are omitted from the API's additional-buyer list.

### Calendar Dates and Payment Balances

Due dates are strict **YYYY-MM-DD**, never timestamps; impossible dates are rejected. Overdue compares the calendar date to today's UTC calendar date, without shifting the selected day. Existing day-first legacy dates are normalized; unparseable legacy text returns `due_date: null` plus `legacy_due_date` for review, without changing stored data.

Paid amounts sum only verified registrations: expected_amount is the plan-currency allocation when present, otherwise amount is used only if currency matches. Cross-currency registrations without an allocation cannot be converted automatically. Legacy installments marked paid with no registrations count as fully settled; otherwise legacy received_amount is the fallback. FX differences remain separate from plan obligations. Remaining amount is never negative.

Status precedence: **paid** (nothing remaining), **overdue** (outstanding and due before today), **partial** (some paid, not overdue), **unpaid** (nothing paid, not overdue). These are derived API values; existing legacy finance endpoint/status behavior is unchanged.

---

## Finance — Payment Registrations

Incoming payment notifications (bank transfers, gateway callbacks, manual entries) land in a review queue
that Finance/Admin verifies inside the portal (**Finance → Payment Registration**).
Ideal for n8n / automation flows that read bank emails and push payments into the portal.
**Read & write: Admin or Finance** (enable the `finance` scope in Settings → API Access).

Each GET list item additionally includes `proof_download_url` valid for **1 hour** when `proof_url` identifies an object in an approved private documents bucket on this portal’s storage origin. Legacy external proof links remain unchanged and return `proof_download_url: null`; the portal cannot sign another website’s URL. Unrecognized paths or signing failures also return null. To link a new API document as proof, use its bucket/path in the portal storage object URL as the existing `proof_url`.

### List Payment Registrations

```http
GET /admin-api/finance/payment-registrations?status=pending&project_id=...&installment_id=...&source=n8n&limit=100&offset=0
```

Response:

```json
{
  "data": [
    {
      "id": "uuid",
      "installment_id": "uuid",
      "plan_id": "uuid",
      "project_id": "uuid",
      "unit_id": "uuid",
      "user_id": "uuid",
      "payer_name": "John Doe",
      "payer_email": "john@example.com",
      "amount": 25000,
      "currency": "USD",
      "payment_date": "2026-08-18",
      "method": "bank_transfer",
      "bank_name": "BCA",
      "reference": "TRX-99182",
      "proof_url": "https://...",
      "notes": null,
      "source": "n8n",
      "status": "pending",
      "reviewed_by": null,
      "reviewed_at": null,
      "review_note": null,
      "created_at": "2026-08-18T03:00:00Z"
    }
  ],
  "count": 1,
  "limit": 100,
  "offset": 0
}
```

### Get One

```http
GET /admin-api/finance/payment-registrations/{registration_id}
```

### Create a Payment Registration

```http
POST /admin-api/finance/payment-registrations
```

```json
{
  "installment_id": "<installment_uuid>",
  "amount": 25000,
  "currency": "USD",
  "payment_date": "2026-08-18",
  "payer_name": "John Doe",
  "payer_email": "john@example.com",
  "method": "bank_transfer",
  "bank_name": "BCA",
  "reference": "TRX-99182",
  "proof_url": "https://link-to-transfer-proof.jpg",
  "notes": "Parsed from bank email",
  "source": "n8n",
  "raw_payload": { "subject": "Incoming transfer", "body": "..." }
}
```

- Only `amount` is required (positive number). `currency` defaults to `USD`, `source` defaults to `api`.
- When `installment_id` (or `plan_id`) is provided, `plan_id`, `project_id`, `unit_id`, `user_id` and
  `currency` are auto-filled from the payment plan.
- New records default to `status: "pending"` so Finance can review them in the portal.
  Trusted automations may send `"status": "verified"` to settle the installment immediately.

### Review / Update (verify, reject, re-open)

```http
PATCH /admin-api/finance/payment-registrations/{registration_id}
PUT   /admin-api/finance/payment-registrations/{registration_id}
```

```json
{ "status": "verified", "review_note": "Matched with bank statement" }
```

| Status | Effect |
|---|---|
| `verified` | Linked installment is marked **paid** (`paid_at` set) and an entry is written to the payment audit log |
| `rejected` | Registration is dismissed; installment stays unchanged |
| `pending` | Moves the record back into the review queue and clears reviewer info |

Any of the create fields (`amount`, `reference`, `installment_id`, …) can also be corrected with the same call.

### Delete

```http
DELETE /admin-api/finance/payment-registrations/{registration_id}
```

**Admin only.**

---

## Change Events (synced with Finance Board)

Audit log of changes to property pricing, unit status (sold/reserved/available), and payment plans. Use this to drive automations like notifying real estate agencies when a price changes or a unit is sold. **Admin and Finance roles only.**

### List Change Events

```http
GET /admin-api/change-events
```

**Query parameters:**
| Param | Type | Description |
|---|---|---|
| `since` | ISO8601 | Only events at or after this timestamp |
| `until` | ISO8601 | Only events at or before this timestamp |
| `event_type` | string | Comma-separated list. Values: `unit_price_changed`, `unit_status_changed`, `unit_sold`, `unit_type_price_changed`, `payment_plan_created`, `payment_plan_updated`, `payment_plan_deleted`, `timeline_created`, `timeline_updated`, `timeline_deleted`, `timeline_image_added` |
| `project_id` | uuid | Filter by property |
| `unit_id` | uuid | Filter by unit |
| `limit` | int | 1–500, default 50 |
| `cursor` | ISO8601 | Pagination — pass `next_cursor` from previous response |

**Example:**
```bash
curl -H "x-api-key: $API_KEY" \
  "https://<provided-via-private-channel>/functions/v1/admin-api/change-events?event_type=unit_sold,unit_price_changed&since=2026-06-01T00:00:00Z&limit=100"
```

**Response:**
```json
{
  "data": [
    {
      "id": "uuid",
      "event_type": "unit_sold",
      "project_id": "uuid",
      "unit_id": "uuid",
      "unit_type_id": "uuid",
      "payment_plan_id": null,
      "buyer_user_id": "uuid",
      "old_value": null,
      "new_value": { "unit_code": "A-01", "price": 1500000000, "currency": "IDR", "buyer_user_id": "uuid" },
      "changed_fields": null,
      "source": "system",
      "actor_user_id": "uuid",
      "occurred_at": "2026-06-09T10:23:45.000Z",
      "created_at": "2026-06-09T10:23:45.000Z"
    }
  ],
  "pagination": { "limit": 100, "next_cursor": "2026-06-08T14:11:00.000Z" }
}
```

**Timeline event payloads:**

`timeline_created` / `timeline_updated` / `timeline_deleted` carry `new_value` / `old_value` with:
`{ timeline_id, milestone, description, date, progress_percentage, is_completed, image_url, images }`.

`timeline_image_added` carries:
`{ timeline_id, milestone, added_images: string[], new_cover_image: string|null, all_images: string[] }`.

This event fires both when a milestone is created with images and whenever new images are appended to an existing milestone — useful for notifying agencies that new construction progress photos are available.

**Polling pattern for automation (n8n / webhook):**
1. Store the timestamp of your last poll
2. Call `GET /change-events?since=<last_poll>&limit=500`
3. Process each event (notify agencies, sync to CRM, etc.)
4. Save the most recent `occurred_at` as the new `last_poll`

---

## Error Responses


```json
{ "error": "Human-readable message", "code": "ERROR_CODE" }
```

| HTTP Status | Code | Description |
|---|---|---|
| 400 | `VALIDATION_ERROR` | Invalid input data |
| 401 | `UNAUTHORIZED` | Missing or invalid API key |
| 404 | `NOT_FOUND` | Resource not found |
| 405 | `METHOD_NOT_ALLOWED` | HTTP method not supported |
| 429 | `RATE_LIMIT_EXCEEDED` | Too many requests |
| 500 | `INTERNAL_ERROR` | Server error |
| 500 | `DB_ERROR` | Database operation failed |
| 400 | `AUTH_ERROR` | Authentication operation failed |
| 403 | `FORBIDDEN` | Your role does not have access to this endpoint |
| 403 | `SCOPE_FORBIDDEN` | Your API key does not have access to this endpoint group |

---

> API keys are private and are issued from inside the portal. Contact your WAHI Group account manager to request access.


## OpenAPI — Complete Machine-readable Reference

```http
GET /admin-api/openapi.json
X-API-Key: your-api-key
```

Returns OpenAPI **3.1.0** for every supported route/method, with resolved deployment server URL, request schemas/examples, response/error contracts, `x-required-scopes` and independent `x-role-permission` metadata. This endpoint requires a valid, unexpired, non-revoked API key, but no additional key scope. Revoked keys return **401 UNAUTHORIZED**. Public downloadable mirrors: `/openapi.json`, `/swagger.json`, `/api-docs/openapi.json`; their server variable must be filled with the privately approved API origin.

### Scopes and errors

Key scopes: `units:read`, `units:write`, `users:read`, `users:write`, `documents:write`, `plans:write`, `payments:read`, `payments:write`. Legacy NULL/empty scope arrays retain full scope access. Missing scope returns **403 SCOPE_MISSING**; role permission denial retains **SCOPE_FORBIDDEN** / **FORBIDDEN**.

Errors retain flat `code` and `message` alongside the nested envelope:

```json
{"error":{"code":"UNIT_HAS_BUYER","message":"Explicit override is required"},"code":"UNIT_HAS_BUYER","message":"Explicit override is required"}
```

Existing codes such as `SALE_WITHDRAWAL_REQUIRED` and `PLAN_WITHDRAWN` remain unchanged. Default rate limit: **60/minute per key**, with **429 RATE_LIMITED**.

### Production-safe verification and activity

Use GET or dry-run writes only. Every POST/PATCH/PUT/DELETE accepts `?dry_run=true` or JSON body `"dry_run": true`; multipart document uploads accept the form field `dry_run=true`. A true flag in either location wins over a false flag in the other. Both forms never save business records, files, API activity, request logs, key usage or idempotency records, never change `updated_at`, and never send account/receipt emails. Modern unit, buyer, reservation-action, account, plan and document endpoints perform their existing endpoint validation; legacy DELETE routes for users, CRM leads, stages, statuses, leads, gallery images and payment registrations run the same existence/authorization checks as actual deletion and return `404 NOT_FOUND` for missing records or `{ dry_run: true, before, after: null }` for existing records. Other legacy writes still return a submitted-payload preview rather than a full simulated transaction. Do not treat legacy previews as proof that a real mutation will succeed.

Actual POST/PATCH/PUT/DELETE mutations appear in Settings → API Activity with actor key, timestamp, target, request ID and recursively redacted before/after. Dry runs are deliberately excluded because acceptance requires zero writes. Sequential real-write `Idempotency-Key` repeats replay the first response for 24 hours; changed payloads return **409 IDEMPOTENCY_CONFLICT**. Query and body dry runs ignore this header and always recompute their preview.
