# API surface

The grouped inventory below was generated from `php artisan route:list` on **2026-09-18** and can
lag the code; `php artisan route:list --json` reports **371 routes on 2026-10-13**. Current additions
with an external contract are documented in dated sections rather than left implicit.

## How to read it

**Two auth models, and they are not interchangeable.**

- **Panels** are session + cookie. Three of them, one bundle each, separated by HOST rather than by
  path prefix: `ADMIN_DOMAIN`, `ORG_DOMAIN`, `USER_DOMAIN` (see `.env`). A panel route therefore
  looks unprefixed — `/flow/list` is the org panel's, `/booking/create` is the user's — and the
  host is what decides which. Guards: `auth:admin`, `auth:org`, `auth:app_user`.
- **`/api/admin/*` and `/api/org/*`** are Sanctum TOKEN endpoints for machines. Guards:
  `sanctum-admin`, `sanctum-org`. Several org panel routes are deliberate session-guarded *shims*
  over the same controllers, so the panel and an API client share one implementation.

There is no anonymous surface for a PERSON. The `contact` identity, its `sanctum-contact` guard and
the whole `/api/v1/*` widget were removed on 2026-08-06; registration is invitation-only.

- **`/hook/*` is the one unauthenticated write surface**, and it is for MACHINES: an organization's
  own systems pushing a result back into a conversation that is waiting for it. No session, no
  cookie, no CSRF. Authentication is an opaque per-tenant token in the URL plus an HMAC over the
  body — see **Webhooks** at the foot of this file, which is the published contract rather than a
  list of paths.

**Conventions** (`CLAUDE.md`, `docs/coding-style.md`):

- Verb-suffix actions, not REST resources: `/user/info/get`, `/org/flow/publish`.
- Responses are `{ data, message, status }` via `Controller::ok()` / `fail()`.
- Identifiers in payloads are `snake_case`; Mongo ids are 24-char strings and never ints.
- Platform-admin routes are additionally gated per feature by `check_right:<code>`.

**Not every endpoint has a UI caller, and that is not a defect.** Some are API-only by design;
others are UI that has not been built. The audit of which is which is T16 work and is not repeated
here — this file answers "what exists", not "what is reached".

### Platform admin panel — 72 endpoints

```
GET    /
POST   /admin/add
PUT    /admin/edit
GET    /admin/get
GET    /admin/list
GET    /admin_login_log/list
PUT    /admin_right/edit
GET    /admin_right/get
GET    /admin_right/list
POST   /admin_role/add
PUT    /admin_role/edit
GET    /admin_role/list
GET    /admin_setting/get
PUT    /admin_setting/update
POST   /check
GET    /conversation/get
GET    /conversation/list
GET    /csrf-refresh
GET    /dashboard/summary
GET    /flow/get
GET    /flow/list
GET    /flow_version/get
GET    /home
POST   /login
POST   /login/by-code
POST   /login/request-code
POST   /logout
GET    /media/get
POST   /media/upload
POST   /organization/create
PUT    /organization/edit
GET    /organization/get
GET    /organization/list
POST   /organization_detail_config/add
POST   /organization_detail_config/delete
PUT    /organization_detail_config/edit
GET    /organization_detail_config/list
GET    /organization_invitation/list
POST   /organization_invitation/resend
POST   /organization_invitation/revoke
POST   /organization_invitation/send
GET    /organization_login_log/list
POST   /platform_join_link/create
POST   /platform_join_link/delete
GET    /platform_join_link/list
POST   /platform_join_link/regenerate
POST   /platform_join_link/set-expiry
POST   /platform_join_link/toggle
PUT    /profile/change_password
GET    /profile/me
PUT    /profile/save
POST   /score/bump
GET    /score/history
GET    /score/leaderboard
GET    /score/today
GET    /service/get
GET    /service/list
POST   /service_detail_config/add
POST   /service_detail_config/delete
PUT    /service_detail_config/edit
GET    /service_detail_config/list
GET    /system_config/list
PUT    /system_config/save
GET    /timer/list
PUT    /user/edit
GET    /user/get
GET    /user/list
POST   /user_detail_config/add
POST   /user_detail_config/delete
PUT    /user_detail_config/edit
GET    /user_detail_config/list
GET    /user_login_log/list
```

### Org panel — 136 endpoints

```
GET    /
POST   /appointment/cancel
GET    /appointment/list
POST   /appointment/reschedule
POST   /appointment/status
POST   /booking/cancel
GET    /booking/list
GET    /calculation/get
GET    /calculation/list
POST   /check
GET    /conversation/get
GET    /conversation/list
POST   /conversation/messages/{id}
GET    /conversation/participants
POST   /conversation/reject-payment
POST   /conversation/reply
POST   /conversation/send-choice
POST   /conversation/send-invoice
POST   /conversation/send-reschedule
POST   /conversation/verify
POST   /conversation/verify-payment
GET    /csrf-refresh
GET    /dashboard/summary
GET    /detail/get
PUT    /detail/save
POST   /document_template/delete
GET    /document_template/list
GET    /document_template/produced
POST   /document_template/save
POST   /external_form/attach
GET    /external_form/connector
POST   /external_form/dismiss
GET    /external_form/get
POST   /external_form/retry
POST   /external_form/rotate
GET    /external_form/stuck
GET    /external_form/submissions
POST   /external_form/toggle
GET    /external_form/waiting
POST   /external_wait/answer
GET    /external_wait/list
DELETE /flow/archive/{id}
POST   /flow/create
GET    /flow/get/{id}
GET    /flow/list
POST   /flow/publish/{id}
POST   /flow/restore/{id}
POST   /flow/set-kind/{id}
PUT    /flow/update/{id}
POST   /form_template/create
POST   /form_template/delete
PUT    /form_template/edit
POST   /form_template/embed/create
GET    /form_template/embed/list
GET    /form_template/list
GET    /home
POST   /invitation/lookup
POST   /join_link/create
POST   /join_link/delete
GET    /join_link/list
POST   /join_link/regenerate
POST   /join_link/set-expiry
POST   /join_link/toggle
POST   /login
POST   /login/by-code
POST   /login/request-code
POST   /logout
POST   /matching/choose
GET    /matching/list
GET    /matching/records
GET    /member/list
GET    /member_detail/get
PUT    /member_detail/save
POST   /message_template/create
POST   /message_template/delete
PUT    /message_template/edit
GET    /message_template/list
POST   /message_template/media/upload
GET    /message_template/placeholders/list
GET    /message_template/submission/list
POST   /message_template/submission/refresh
POST   /message_template/submission/submit
GET    /notification/list
POST   /notification/read
POST   /notification/read-all
POST   /notification/read-by
GET    /notification/unread-count
GET    /org/me
GET    /payment/list
GET    /payment_info/get
POST   /payment_info/qr/upload
PUT    /payment_info/save
POST   /pin/remove
POST   /pin/save
POST   /register
GET    /render/health
PUT    /resource/availability
POST   /resource/create
POST   /resource/delete
PUT    /resource/edit
GET    /resource/list
PUT    /resource/special_dates
POST   /service/create
PUT    /service/edit
GET    /service/get
GET    /service/list
POST   /service/participant/remove
GET    /service/participants
POST   /service/status/set
GET    /service_detail/get
PUT    /service_detail/save
POST   /template_category/adopt
POST   /template_category/create
POST   /template_category/delete
GET    /template_category/list
PUT    /template_category/rename
POST   /user_detail_config/create
POST   /user_detail_config/delete
PUT    /user_detail_config/edit
GET    /user_detail_config/list
POST   /user_type/assign-user
GET    /user_type/assigned-users
POST   /user_type/create
PUT    /user_type/edit
GET    /user_type/list
GET    /user_type/members
POST   /user_type/toggle
POST   /user_type/unassign-user
GET    /welcome/get
POST   /welcome/seen
POST   /workbook_template/create
POST   /workbook_template/delete
PUT    /workbook_template/edit
GET    /workbook_template/get
GET    /workbook_template/list
```

### User panel — 72 endpoints

```
GET    /
PUT    /account/password/save
GET    /appointment/list
GET    /availability/list
POST   /booking/create
GET    /booking/list
POST   /booking/reschedule
POST   /check
GET    /conversation/flows
GET    /conversation/get
GET    /conversation/list
POST   /conversation/end
POST   /conversation/help
POST   /conversation/start
POST   /conversation/submit
POST   /conversation/upload
GET    /csrf-refresh
GET    /dashboard/summary
GET    /detail/get
PUT    /detail/save
GET    /embed/continue/{token}
POST   /embed/form/check
POST   /embed/form/get
POST   /embed/form/request-code
POST   /embed/form/submit
GET    /embed/form/{token}
GET    /home
POST   /invitation/accept
POST   /invitation/check-email
POST   /invitation/login-and-attach
POST   /invitation/lookup
POST   /invitation/request-code
POST   /invitation/verify-and-register
POST   /login
POST   /login/by-code
POST   /login/request-code
POST   /logout
GET    /matching/candidates
POST   /matching/choose
GET    /matching/list
POST   /matching/take
GET    /notification/list
POST   /notification/read
POST   /notification/read-all
POST   /notification/read-by
GET    /notification/unread-count
GET    /org_detail/get
GET    /org_detail/list
PUT    /org_detail/save
GET    /payment/list
POST   /payment/upload-receipt
GET    /receive-channel/list
PUT    /receive-channel/save
GET    /service/get
POST   /service/join
POST   /service/leave
GET    /service/list
GET    /service/resources
GET    /sign-in/{token}
GET    /thread/get
POST   /thread/leave
GET    /thread/list
POST   /thread/pin
POST   /thread/respond-choice
POST   /thread/send
POST   /thread/unpin
GET    /timetable/get
PUT    /timetable/row/remove
PUT    /timetable/row/save
PUT    /timetable/save
GET    /user/me
GET    /welcome/get
POST   /welcome/seen
```

### Platform API (token) — 6 endpoints

```
GET    /api/admin/health
POST   /api/admin/organization/create
GET    /api/admin/organization/get
GET    /api/admin/organization/list
POST   /api/admin/organization/reactivate
POST   /api/admin/organization/suspend
```

### Org API (token) — 12 endpoints

```
GET    /api/org/conversation/get
GET    /api/org/conversation/list
POST   /api/org/conversation/send-message
POST   /api/org/conversation/verify
POST   /api/org/flow/create
POST   /api/org/flow/delete
POST   /api/org/flow/edit
GET    /api/org/flow/get
GET    /api/org/flow/list
POST   /api/org/flow/publish
POST   /api/org/flow/restore
POST   /api/org/flow/set-kind
```

### Shared / framework — 7 endpoints

```
GET    /api/common/{option}
GET    /api/health
POST   /hook/external-form/{token}
POST   /hook/external-form/{token}/document
GET    /sanctum/csrf-cookie
GET    /storage/{path}
GET    /up
```

---

## Organization-owned Stripe gateway and reconciliation — 2026-10-13 (FP-T7/T8)

These are test-mode endpoints until FP-T24. Each organization owns its Stripe gateway and gives
Plantoo the credentials needed to integrate it. Keys are tenant-scoped, encrypted at rest and
write-only; they are never returned by an API or sent to the customer browser.

| Caller | Method and path | Purpose |
|---|---|---|
| Org session | `POST /stripe/account/save` | Save/write-only credentials and verify them against Stripe |
| User session | `POST /payment/stripe-intent/status` | Re-read whether this invoice's organization currently offers Stripe |
| User session | `POST /payment/stripe-intent/create` | Create/reuse a PaymentIntent in the organization's account |
| User session | `POST /payment/stripe-intent/confirm` | Read the PaymentIntent back from Stripe after browser confirmation |
| Org session | `GET /payment/reconciliation/get` | Compare Stripe reports, local payments, bookings and recent events |
| Org session | `POST /payment/webhook/simulate` | Test-mode success/failure event through the real idempotent pipeline |
| Admin session + `organization_management` | `GET /organization/payment/reconciliation/get` | Read the same report for a named organization |
| Public, Stripe-signed | `POST /hook/stripe/{token}` | Organization-specific signed Stripe events |

`POST /payment/stripe-intent/create` takes `{ "payment_id": 123 }`. The payment must belong to the
signed-in user. It returns only what Stripe.js is designed to receive:

```jsonc
{ "data": {
    "payment_id": 123,
    "publishable_key": "pk_test_…",
    "client_secret": "pi_…_secret_…"
} }
```

The server authenticates with the owning organization's encrypted secret key. It sends no
`transfer_data`, destination, `on_behalf_of` or `application_fee_amount`. The browser initializes
`loadStripe(publishableKey)` with the owning organization's publishable key.

### Organization webhook

The Payments screen gives each organization its own opaque `/hook/stripe/{token}` URL. Add that URL
to the organization's Stripe test dashboard and save that endpoint's signing secret back in
Payments. Plantoo resolves the tenant from the URL before trusting the body, then verifies the raw
body against `Stripe-Signature` using that tenant's encrypted signing secret.

The event must carry Stripe's normal envelope. PaymentIntent events are matched first by the
tenant-scoped intent id, then by Plantoo's metadata:

```jsonc
{
  "id": "evt_…",
  "type": "payment_intent.succeeded",
  "livemode": false,
  "data": { "object": {
    "id": "pi_…", "amount": 1000, "currency": "hkd", "status": "succeeded",
    "metadata": { "plantoo_payment_id": "123", "plantoo_organization_id": "7" }
  } }
}
```

`event.id` is unique in MySQL. The endpoint returns after recording and queueing; the job verifies
amount and currency, commits the relational payment, then resumes the Mongo flow with a chat dedupe
key derived from the same event id. A replay therefore returns `200` with `data.created=false` and
does not queue or append anything twice. Invalid signature is `401`; malformed/unknown/live account
events are `422`; missing test configuration is `503`.

---

## Signed-in channel verification — 2026-10-13 (FP-T4)

These routes let an email-first member link their existing account to an organization's ready
WhatsApp or Messenger channel. They return no channel token, app secret or verification secret.

| Caller | Method and path | Purpose |
|---|---|---|
| User session | `GET /channel-verification/list` | List ready channels belonging to the member's organizations and whether each identity is already linked |
| User session | `POST /channel-verification/create` | Create a short-lived one-tap `wa.me` or `m.me` verification link for one listed channel |

`POST /channel-verification/create` takes `{ "organization_channel_id": 123 }`. The channel must belong to an
organization the signed-in user belongs to, must be ready, and must not already be linked to that
user. A channel from another tenant is returned as `404`; an unavailable or already-linked channel
is `422`.

---

## Webhooks — the inbound contract (FP-T2)

**Published, and therefore a promise.** Once an organization's system runs against this, its
versioning and back-compatibility are ours to carry. Everything below is the contract, and **any
caller that follows it is accepted** — there is no allowlist of senders. Two kinds of caller exist:

- **Google Forms + our script.** `resources/connector/plantoo-external-form.gs` speaks the contract,
  and we ship it rather than asking each org to implement it.
- **The organization's own system** (06/10/2026). Their own https page receives the customer at
  `{url}?{ref_param}={reference}`, their backend does its work and posts the result back, signing
  it itself. The org panel's External forms › Connector › *Your own system* shows the addresses,
  the signing rule, curl/Node/PHP examples and a signature checker
  (`POST /external_form/signature/check`, org-scoped, `throttle:signature-check`, calls nobody and
  never returns the secret). Which of the two an org picked is `org_integration.connector_type`; it
  changes the instructions shown and nothing about what is accepted.

**Plantoo never calls out — on a link step.** Every hop is inbound, started by the org. We do not
poll a form, read a Drive, or fetch a document. `tests/Unit/NoOutboundHttpTest.php` is that rule,
written as a test. The one exception is a step whose `trigger` is `server` (v0.37, below): there the
customer's press makes our server TELL their system, once, from `App\Jobs\AskOrgSystem` — the only
external-form file the test lets call out.

```
POST /hook/external-form/{integration_token}            a submission came back
POST /hook/external-form/{integration_token}/document    the document that followed it
```

Both are on **every host** — the caller is a server or a script and has no idea which panel it
belongs to — and both are throttled (`throttle:webhook`, keyed on the token). Both always answer in
JSON, whether or not the caller sends `Accept: application/json` (06/10/2026 — a validation failure
used to be a 302 for a caller that did not).

**Headers**

```
X-Plantoo-Timestamp   required; unix SECONDS; refused if more than 300s from ours
X-Plantoo-Signature   required; sha256=<hex hmac of "{timestamp}.{raw body}" keyed with the org's secret>
                      (the "sha256=" prefix is optional, hex case does not matter)
X-Plantoo-Connector   optional; a free label for what is calling — our script sends its version,
                      an org's own backend may send e.g. "ddh-backend/1.0" or nothing. Shown in the
                      panel; absent keeps the last one seen
```

Signed over the timestamp AND the body together: a captured signature cannot be replayed onto a
different body, and a captured body cannot be replayed at a different time. The rule lives in
`App\Support\WebhookSignature`, used by both the webhook and the panel's checker.

**The link the customer is sent.** The step's `url` (any https address) with
`{ref_param}={reference}` appended to its query string — before any `#fragment`, keeping existing
parameters. For a Google Form `ref_param` is the reference question's `entry.NNN`; for an org's own
page it is whatever query parameter that page reads.

**Body — `/hook/external-form/{token}`**

```jsonc
{ "event_id":  "2hJ…",                 // THEIR id for this delivery; idempotency is on it
  "reference": "K7R4M-9WXNP-2F8HJ-QTVD3",
  "answers":   { "Final pay": "12400" },
  "documents": [                          // optional (06/10/2026) — the files WITH the result, one call
    { "name": "statement.pdf", "mime": "application/pdf", "content": "<base64>", "sha256": "<hex>" } ] }
```

`documents` takes the same shape and limits as `…/document` (up to 5). The files are checked before
anything is recorded — one that fails its checksum refuses the whole call with `422` — and are then
filed as their own delivery (`event_id` + `#files`), so a repeated call files nothing twice. The
answer adds `data.documents.result`. `…/document` remains for files that are only ready later.

**Size limits, both calls:** 10 MB a file and 25 MB a call (decoded bytes), checked before anything
is stored — over either is a `422` whose `message` says which. The web server in front must allow
at least ~35 MB request bodies (base64 is a third larger), e.g. nginx `client_max_body_size 40m;`.

**The form brings ANSWERS and nothing else.** An optional `calculation` block — figures an org's own
Apps Script had worked out — was accepted here until 16/09/2026, alongside a per-service switch
deciding whose figure a customer could be told. Both went with the C1 → C2 → D2 ladder they were
built for: **a figure a chat quotes is worked out by a workbook kept here**, by a `form` step that
names it, run in our own worker. The shipped connector never sent a `calculation`, so no
organization's script has to change. Extra keys are ignored rather than refused, and every figure
still lands in `calculation_record` with its inputs, outputs and workbook version.

**Body — `…/document`**

```jsonc
{ "event_id": "2hJ…-doc", "reference": "K7R4M-…",
  "name": "statement.pdf", "mime": "application/pdf",
  "content": "<base64>", "sha256": "<hex>" }     // base64, not multipart: the signature is over the raw body
```

**What comes back**

| Status | When |
|---|---|
| 200 | Recorded. `data.result` is `resumed`, `unmatched`, `attached`, `received` or `failed`, with `data.reason` when it is not `resumed` |
| 200, same `event_id` again | The first answer, unchanged. Nothing happens twice — a caller may retry after a timeout |
| 401 | Unknown or disabled token, bad signature, or a timestamp outside the window. Nothing is recorded |
| 422 | A required field is missing (Laravel's `{message, errors}`), or the document did not match its checksum — a structured refusal the caller can fix and retry |
| 429 | Over the per-token rate; `Retry-After` says when |

**200 for a submission we could not place is deliberate.** Google's trigger does not retry and
Google disables triggers that keep failing, so a non-200 costs an org their integration — and an
org's own backend gets the same answer, so it never retries something already queued. Anything
unplaceable becomes a queue item in the org panel under **External forms**, never a refusal.

**The reference is the whole security model.** Long, random, expiring, single-use, scoped to one
run and one step — the standard a booking code and a magic link already meet. A matched live
reference resumes the run on the spot; an author who wants a human to read the answers first puts a
`verify` step after it. Unknown, spent, expired, another tenant's, or pointing at a case that has
already moved on are five different sentences and all of them are the unmatched queue.

### The outbound tell — `external_form` with `trigger: "server"` (v0.37, 06/10/2026)

No link: the customer presses a button and our SERVER posts the case to the step's `url` (https; a
local http address only on a local environment). Signed with the org's **external-form secret** —
the same one their server signs results with — so one secret covers both directions.

```
POST {step url}
Content-Type: application/json
X-Plantoo-Timestamp:  unix seconds
X-Plantoo-Signature:  sha256=<hex hmac of "{timestamp}.{raw body}" keyed with the external-form secret>
X-Plantoo-Request-Id: same as body.request_id

{ "reference": "K7R4M-9WXNP-2F8HJ-QTVD3", "request_id": "<run>-<try>-<card>", "attempt": 1,
  "fields": { "contract_no": "C-2026-0419", "email": "…", "whatsapp": "+852…" } }
```

`fields` is the step's own list, filled in as `fetch_documents` fills its own. `request_id` is one
per press and stays the same if one press is delivered twice.

| Their answer | The step |
|---|---|
| any `2xx` | accepted — the button becomes "Waiting for their system…"; the run stays parked until the result arrives on `/hook/external-form/{token}` with the same `reference`, exactly as a link step's does |
| `404`/`422` with `"found": false` | no such case — their `message` is shown; `on_not_found` if the step names one, else the button comes back |
| anything else, a timeout, a refused signature | "Their system didn't answer. Please try again." — after `retry.max_attempts` (default 3) the step is handed to staff, as `fetch_documents` is |

The step's reminder and `on_timeout` still end a wait that was accepted and never answered; the
customer is only chased while the button is theirs.
