API Reference
Field-by-field reference for developers wiring up an integration. For what the API is for and how to get a key, see Integrations & API. This page assumes you already have one.
Authentication
Every request needs a bearer token, generated by an org admin from Admin Panel → API Access. The base URL is https://forzara.ai/api/v1/.
Authorization: Bearer fz_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
A key is scoped to exactly one organization and to whichever resources it was granted (roster.read, roster.read.email, milestones.read, meetings.read) — there is no way to request data outside the org that issued the key. A key may also be IP-restricted and/or set to expire; ask your admin if a request is unexpectedly rejected.
Roster
Every active org member — participants and staff alike. Scope: roster.read.
| Field | Type | Notes |
|---|---|---|
| user_id | integer | Stable identifier — use this to filter /milestones and /meetings. |
| full_name | string | |
| string | Only present if the key also carries roster.read.email — see the note below. | |
| role | string | client, staff, or admin. |
| status | string | active, completed, or exited. |
| coach_id | integer or null | Assigned coach's user_id, if any. |
| joined_at | string | ISO 8601 UTC. |
| updated_at | string | ISO 8601 UTC — last change to this member's row (role, status, coach, or a fresh join). |
roster.read alone returns everything above except email. A key needs the additional roster.read.email scope for email addresses to appear — an integration that only needs headcounts, status, or roles (a BI dashboard, say) can be granted a key that never sees contact info at all. Granting roster.read.email without roster.read does nothing on its own.
{
"data": {
"items": [
{ "user_id": 7, "full_name": "Joe Smith", "email": "joe@example.org",
"role": "client", "status": "active", "coach_id": 2,
"joined_at": "2026-07-31T06:49:36Z", "updated_at": "2026-07-31T06:49:36Z" }
],
"pagination": { "page": 1, "per_page": 100, "total": 1, "total_pages": 1 }
},
"meta": { "resource": "roster", "version": "v1" }
}
(Example shown with roster.read.email granted — omit that scope and the email key is absent from each item.)
Milestones
No single-item form — a milestone is an aggregate of two independent lists, each separately paginated. Filter either or both with ?participant_user_id=. Scope: milestones.read.
Case notes and review notes are never returned by this endpoint — status only, always.
| Field | Where | Notes |
|---|---|---|
| user_id | both | |
| milestone_key | both | |
| completed_on | completions | Calendar date (YYYY-MM-DD), no time component. |
| registered_at | completions | ISO 8601 UTC. |
| status | submissions | pending, approved, or denied. |
| submitted_at / reviewed_at | submissions | ISO 8601 UTC. reviewed_at is null until reviewed. |
{
"data": {
"completions": { "items": [ { "user_id": 7, "milestone_key": "state_id",
"completed_on": "2026-08-01", "registered_at": "2026-08-01T14:02:11Z" } ],
"pagination": { "page": 1, "per_page": 100, "total": 1, "total_pages": 1 } },
"submissions": { "items": [ { "user_id": 7, "milestone_key": "resume",
"status": "approved", "submitted_at": "2026-07-28T09:15:00Z",
"reviewed_at": "2026-07-29T11:40:00Z" } ],
"pagination": { "page": 1, "per_page": 100, "total": 1, "total_pages": 1 } }
},
"meta": { "resource": "milestones", "version": "v1" }
}
Meetings
Milestone review and follow-up meetings. Filter the collection with ?participant_user_id= and/or ?status=scheduled|completed|canceled. Scope: meetings.read.
| Field | Type | Notes |
|---|---|---|
| id | integer | |
| participant_user_id / staff_user_id | integer | |
| scheduled_at / completed_at / canceled_at | string or null | ISO 8601 UTC. |
| duration_minutes | integer | |
| meeting_type | string | milestone_review or followup. |
| status | string | scheduled, completed, or canceled. |
| notes / cancel_reason | string or null | Plain text, not encrypted. |
| updated_at | string | ISO 8601 UTC. |
Pagination
Every collection response is shaped { "items": [...], "pagination": {...} }. Control it with:
| Param | Default | Notes |
|---|---|---|
| page | 1 | 1-indexed. |
| per_page | 100 | Clamped to a max of 500 — a request for more just gets 500. |
pagination.total is a true row count (not items.length), so total_pages is correct even on a partial last page. Milestones' two lists paginate independently — the same ?page=/?per_page= apply to both, but each has its own totals.
Incremental sync
Add ?updated_since= (full ISO 8601, with an explicit offset or Z — e.g. 2026-08-28T00:00:00Z) to any collection to get only rows that changed at or after that instant, instead of a full pull every time. An unparseable value is silently ignored (no filter applied, same as an unrecognized query param elsewhere on this API) rather than an error — double-check the response if a sync looks larger than expected.
For milestone submissions specifically, "changed" means created or reviewed — a submission only ever moves once after creation, so this is exact for that resource, not an approximation.
Errors & rate limits
Every error is { "error": { "code", "message", "status" } }.
| Status | Code | Meaning |
|---|---|---|
| 401 | unauthorized | Missing, invalid, revoked, or expired key. |
| 403 | ip_not_allowed | Key is IP-restricted; this request's IP isn't on the list. |
| 403 | insufficient_scope | Key doesn't carry a scope this resource needs. |
| 404 | not_found | Unknown resource, or an id that doesn't exist in your org. |
| 405 | method_not_allowed | v1 is read-only — anything but GET lands here. |
| 429 | rate_limited | 600 requests/minute per key. Check the Retry-After header (seconds) before retrying. |
| 500 | internal_error | Something broke on our end — safe to retry once, then contact support if it persists. |
Python example
Nothing beyond requests is needed — a nightly incremental pull looks like:
import requests
from datetime import datetime, timezone
API_KEY = "fz_live_..."
BASE = "https://forzara.ai/api/v1"
headers = {"Authorization": f"Bearer {API_KEY}"}
def get_all(path, params=None):
"""Follows pagination until every page has been pulled."""
params = dict(params or {})
page, items = 1, []
while True:
params["page"] = page
resp = requests.get(f"{BASE}/{path}", headers=headers, params=params)
if resp.status_code == 429:
raise RuntimeError(f"rate limited, retry after {resp.headers.get('Retry-After')}s")
resp.raise_for_status()
body = resp.json()["data"]
items.extend(body["items"])
if page >= body["pagination"]["total_pages"]:
return items
page += 1
since = datetime(2026, 8, 1, tzinfo=timezone.utc).isoformat()
roster = get_all("roster", {"updated_since": since})
print(f"{len(roster)} roster changes since {since}")