Contacts
Contacts are studio members for a location. Create them with POST /api/v2/contacts (manage scope), list the directory with GET /api/v2/contacts (summary scope), update email/name with PATCH /api/v2/contacts/{id} (manage scope), or let a grant create them on first payment. Per-member read endpoints require the summary scope and take location_id as a query parameter. List endpoints accept optional ?limit= (default 20, max 100).
The path {id} is the member’s external contact key — the same value you send as external_contact_id or ghl_contact_id (not Kotally’s internal contact.id). Responses include both external_contact_id and ghl_contact_id (same value) plus the internal id.
Create a member
Section titled “Create a member”/api/v2/contacts Requires the manage scope. Idempotent via request_id.
| Field | Type | Required | Notes |
|---|---|---|---|
location_id | string | yes | Must match the API client’s location |
request_id | string | yes | Idempotency key |
email | string | yes | Member email |
name | string | no | Display name |
external_contact_id | string | no | Your CRM / booking id (preferred) |
ghl_contact_id | string | no | Alias; external_contact_id wins if both are set |
If neither contact id is sent, Kotally generates a kota-native:… id.
curl -X POST "https://app.<your-domain>/api/v2/contacts" \ -H "Authorization: Bearer ktly_<your-token>" \ -H "Content-Type: application/json" \ -d '{ "location_id": "loc_1", "request_id": "member-crm_123", "email": "[email protected]", "name": "Member Example", "external_contact_id": "crm_123" }'const res = await fetch("https://app.<your-domain>/api/v2/contacts", { method: "POST", headers: { Authorization: `Bearer ${process.env.KOTALLY_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ location_id: "loc_1", request_id: "member-crm_123", name: "Member Example", external_contact_id: "crm_123", }),});const data = await res.json();// data.contact.id, data.contact.ghl_contact_idResponse (200)
Section titled “Response (200)”{ "ok": true, "reason_code": "contact_created", "correlation_id": "a1b2c3d4-...", "contact": { "id": "kotally-contact-uuid", "ghl_contact_id": "crm_123", "external_contact_id": "crm_123", "name": "Member Example" }}Failure reason codes
Section titled “Failure reason codes”reason_code | HTTP | Meaning |
|---|---|---|
CONFLICT | 409 | A member with this contact id already exists for the location |
VALIDATION_ERROR | 400 | Missing email / location / request_id |
BILLING_SUSPENDED | 403 | Workspace billing is suspended |
List members
Section titled “List members”/api/v2/contacts Requires the summary scope. Pass location_id as a query parameter. Optional ?q= searches name, email, or external id. Pagination uses an opaque ?cursor= from next_cursor in the previous response.
curl "https://app.<your-domain>/api/v2/contacts?location_id=loc_1&q=jane" \ -H "Authorization: Bearer ktly_<your-token>"const url = "https://app.<your-domain>/api/v2/contacts?location_id=loc_1&q=jane";const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.KOTALLY_TOKEN}` },});const data = await res.json();// data.contacts[], data.next_cursorResponse (200)
Section titled “Response (200)”{ "ok": true, "location_id": "loc_1", "contacts": [ { "id": "kotally-contact-uuid", "external_contact_id": "crm_123", "ghl_contact_id": "crm_123", "name": "Member Example", "created_at": "2026-04-01T00:00:00.000Z", "updated_at": "2026-04-16T00:00:00.000Z" } ], "next_cursor": null}Update a member
Section titled “Update a member”/api/v2/contacts/{id} Requires the manage scope. Path {id} is the external contact key. Send location_id in the body. Update email and/or name only — the external id cannot be changed. Optional request_id enables idempotent retries.
| Field | Type | Required | Notes |
|---|---|---|---|
location_id | string | yes | Must match the API client’s location |
email | string | no | New email |
name | string | no | New display name |
request_id | string | no | Idempotency key for safe retries |
curl -X PATCH "https://app.<your-domain>/api/v2/contacts/crm_123" \ -H "Authorization: Bearer ktly_<your-token>" \ -H "Content-Type: application/json" \ -d '{ "location_id": "loc_1", "email": "[email protected]", "name": "Member Renamed" }'Response (200)
Section titled “Response (200)”{ "ok": true, "reason_code": "contact_updated", "correlation_id": "a1b2c3d4-...", "contact": { "id": "kotally-contact-uuid", "external_contact_id": "crm_123", "ghl_contact_id": "crm_123", "name": "Member Renamed", "created_at": "2026-04-01T00:00:00.000Z", "updated_at": "2026-04-16T12:00:00.000Z" }}Failure reason codes
Section titled “Failure reason codes”reason_code | HTTP | Meaning |
|---|---|---|
NOT_FOUND | 404 | No member with this external id for the location |
VALIDATION_ERROR | 400 | Missing location_id, no fields to update, or attempt to change external id |
BILLING_SUSPENDED | 403 | Workspace billing is suspended |
Summary
Section titled “Summary”/api/v2/contacts/{id}/summary Returns a snapshot of credits, entitlements, recent ledger, appointments, and payments.
curl "https://app.<your-domain>/api/v2/contacts/crm_123/summary?location_id=loc_1" \ -H "Authorization: Bearer ktly_<your-token>"const url = "https://app.<your-domain>/api/v2/contacts/crm_123/summary?location_id=loc_1";const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.KOTALLY_TOKEN}` },});const data = await res.json();// data.summary.credits_available, data.summary.entitlementsResponse (200)
Section titled “Response (200)”{ "ok": true, "reason_code": "summary_loaded", "correlation_id": "a1b2c3d4-...", "contact": { "id": "kotally-contact-uuid", "external_contact_id": "crm_123", "ghl_contact_id": "crm_123", "name": "Member Example" }, "summary": { "credits_available": 9, "last_paid_at": "2026-04-16T00:00:00.000Z", "lifetime_value_cents": 9900, "payment_events_count": 1, "entitlements": [ { "id": "kotally-entitlement-uuid", "product_config_id": "pc_package_1", "status": "active", "credits_remaining": 9, "expires_at": null } ], "recent_ledger": [], "recent_appointments": [], "recent_payments": [] }}All read endpoints
Section titled “All read endpoints”Every endpoint below takes ?location_id= and requires the summary scope. List endpoints also accept ?limit=.
| Method | Path | Returns |
|---|---|---|
GET | /api/v2/contacts | Member directory for the location (see List members) |
GET | /api/v2/contacts/{id} | Contact profile |
GET | /api/v2/contacts/{id}/summary | The combined snapshot above |
GET | /api/v2/contacts/{id}/credits | Credit balance and entitlement status |
GET | /api/v2/contacts/{id}/entitlements | All entitlements with full detail |
GET | /api/v2/contacts/{id}/appointments/upcoming | Upcoming appointments |
GET | /api/v2/contacts/{id}/appointments/past | Past appointments |
GET | /api/v2/contacts/{id}/ledger | Credit ledger entries |
GET | /api/v2/contacts/{id}/payments | Payment events |
GET | /api/v2/contacts/{id}/timeline | Combined chronological timeline |
Read failure reason codes
Section titled “Read failure reason codes”reason_code | Meaning |
|---|---|
NOT_FOUND | No contact found for the given id and location_id |
See the Overview for HTTP status codes and the full reason-code list.