highlevel-api 0.2.0

Unofficial Rust SDK for the GoHighLevel (HighLevel) API
Documentation
# highlevel-api — Required Features for Alba PMS

**Audience:** maintainers of the `highlevel-api` crate (separate repo) · **Source plan:** [plan.md](./plan.md)
**Crate version reviewed:** 0.1.0 · **Last updated:** 2026-07-19

Features the PMS needs from the crate, in priority order. P1 items **block** PMS phases; P2 items are needed by the phase noted; P3 are hygiene/nice-to-have.

## Conformance rules (apply to all new code)

- No `unwrap()` / `expect()` / panicking code paths; no `unsafe`.
- Errors via `thiserror` in the existing `error.rs` enum style; never lose upstream status codes.
- Same module layout as existing APIs (`apis/<name>/{mod,client,types}.rs`), same naming conventions.
- `async-trait`, `reqwest` (rustls), `serde`, `chrono` — match existing dependency choices; no new deps without discussion.
- All request/response types `Serialize`/`Deserialize` + `Debug`; optional fields `Option` with `#[serde(default)]` where GHL omits them.

---

## P1-1 · `apis::objects` — Custom Objects (schemas + records + search)

*Blocks PMS Phases 3–7 (rooms and bookings live as custom objects).*

GHL docs: `marketplace.gohighlevel.com/docs/ghl/objects/custom-objects-api`

| Operation | Method & path |
|---|---|
| Get schema by key/id | `GET /objects/{schemaKey}` |
| List schemas for location | `GET /objects/?locationId={id}` |
| Create schema | `POST /objects/` |
| Update schema | `PUT /objects/{schemaKey}` |
| Create record | `POST /objects/{schemaKey}/records` |
| Get record | `GET /objects/{schemaKey}/records/{recordId}` |
| Update record | `PUT /objects/{schemaKey}/records/{recordId}` |
| Delete record | `DELETE /objects/{schemaKey}/records/{recordId}` |
| **Search records** | `POST /objects/{schemaKey}/records/search` |

Notes:
- `schemaKey` is location-specific (assigned at schema creation, e.g. `custom_objects.booking`); the PMS stores keys per location — the client just takes `&str`.
- Record `properties` are schemaless JSON → expose `serde_json::Map<String, Value>` (or a generic `Record<T: DeserializeOwned>`) rather than trying to strongly type user-defined fields.
- Search must support pagination and the documented filter operators; the PMS availability engine depends on date-range filtering (`check_in`/`check_out` overlap) — verify which filter operators the search endpoint actually supports and type them accurately.
- Auth: location-level tokens (Sub-Account user type).

**Acceptance:** create schema → create record → search with date filter → update → delete round-trip against a test location.

## P1-2 · `apis::associations` — Associations + Relations

*Blocks PMS Phase 3 (booking↔contact, booking↔room links).*

GHL docs: `marketplace.gohighlevel.com/docs/ghl/associations/associations-api` (+ Relations subpages)

Needed: list/get/create/delete associations; create/delete/list **relations** between two records (e.g. booking record ↔ contact). Relations are created per record pair — batching is not offered by GHL, so the PMS calls this N times; keep the call cheap (no extra GETs inside the helper).

**Acceptance:** link a custom-object record to a contact and list the relation back.

## P1-3 · OAuth: location-token exchange + auto-refresh

*Blocks PMS Phase 1 (multi-tenant token management).*

1. `OAuthClient::location_token(company_id, location_id)``POST /oauth/locationToken` (agency token in `Authorization` header, `Version` header required) → `TokenData` with `location_id` set.
2. Auto-refresh helper: given a `TokenData` + refresh closure, return a valid access token, refreshing when `TokenData::is_expired()`. Must be **single-flight per location** (concurrent callers share one refresh — e.g. per-key `tokio::sync::Mutex` map) so a burst of requests doesn't trigger N refreshes (refresh tokens rotate on use; parallel refreshes would invalidate each other).
3. `TokenData.issued_at` is currently `#[serde(skip)]` — fine for in-memory, but the PMS persists tokens; make skip behavior explicit/documented or serialize it so restored tokens keep correct expiry.

**Acceptance:** two concurrent expired-token callers cause exactly one refresh request (test with `wiremock`).

## P1-4 · Rate limiting + 429 resilience

*Blocks PMS Phase 1 (all traffic flows through the client). GHL: 100 req/10 s burst, 200k/day per location per app.*

1. Opt-in per-token/per-location token-bucket limiter on `HttpClient` (configurable rate; default just under 100/10 s). A small internal leaky bucket is fine; do not add a heavy middleware stack.
2. On HTTP 429: exponential backoff with jitter, max 3 retries, honor `Retry-After` when present. After N consecutive 429s, open a short circuit breaker and fail fast with a distinct `Error::RateLimited` variant (new) so the PMS can map it to `503 upstream_unavailable`.
3. Surface rate-limit response headers (if GHL returns any) on a per-request basis — e.g. return them in a response wrapper or expose via a hook — so the PMS can maintain its daily budget counter.

**Acceptance:** `wiremock` test: 429,429,200 → success after 2 retries; sustained 429 → `Error::RateLimited` without further calls while breaker is open.

---

## P2-1 · `products`: price support (verify & complete)

*Needed by PMS Phase 5 (extras are GHL Products with prices on invoices).*

0.1.0's products module has no visible price fields/types. GHL products carry prices (and a prices sub-resource on some API versions). Verify the actual response shape and add typed price fields (`price`, `currency`, `compare_at_price`, …) plus list/create of prices if exposed.

## P2-2 · `invoices`: schedules (deposit/balance split)

*Needed by PMS Phase 6 (deposit payments).*

0.1.0 has base invoice CRUD + send + record_payment. Add the invoice **schedule** endpoints (create/list schedules for split payments: deposit now + balance on date) per `docs/ghl/invoices/schedule`. If the schedule API turns out not to support hotel-style deposits, report back — the PMS fallback is two invoices.

## P2-3 · Webhook signature verification helper

*Needed by PMS Phase 4 (payment webhooks) and Phase 2 (install webhook is used from Phase 1 — bump if easy).*

Helper to verify marketplace webhook authenticity per GHL's webhook integration guide (shared-secret/HMAC), using constant-time comparison. Signature: `verify(payload: &[u8], signature_header: &str, secret: &str) -> bool`. Document which header GHL sends; if GHL offers no signing for app-level webhooks, document that and provide an HMAC-of-shared-path-token pattern instead.

---

## P3-1 · Remove `expect()` in `auth/oauth.rs`

`authorization_url()` calls `Url::parse(AUTH_BASE_URL).expect(...)`. Replace with a lazily-parsed base URL stored in `OAuthClient` (parse once at construction, return `Result` from the constructor) or return `Result<String>` from the method. Keeps the crate consistent with the no-panic rule the PMS enforces via `#![deny(clippy::unwrap_used, clippy::expect_used)]`.

## P3-2 · `storage::sqlite::SqliteStorage`

`SessionStorage` impl over `sqlx` 0.9 (sqlite, rustls) mirroring `MemoryStorage` semantics. May live in the PMS repo instead if you'd rather keep the crate dependency-light — decide and document. PMS needs SQLite anyway; low priority.

## P3-3 · Misc small gaps (fold into above where convenient)

- `users.me()` exists — verify it works with Location tokens (needed to resolve installing user on INSTALL).
- Contacts `upsert` exists — verify it accepts custom fields in the same single call (PMS mirrors ID-verification fields; must stay one call).
- Consider exposing raw `serde_json::Value` escape-hatch methods (`get_raw`, `post_raw`) so the PMS isn't blocked if a GHL endpoint ships before the typed wrapper.

---

## Implementation order suggestion

1. P1-4 rate limiting (protects everything else during development too)
2. P1-3 OAuth location token + auto-refresh
3. P1-1 objects → 4. P1-2 associations (unblocks PMS Phase 3)
5. P2-3 webhook verify → 6. P2-1 product prices → 7. P2-2 invoice schedules
8. P3 hygiene items any time

Each item ships with: types + client + unit tests against `wiremock` fixtures captured from real GHL responses (redact IDs), and a doc example in the module header.