Read API v1 reference
Public, read-only routes for a clinic's services, providers, membership plans and open times: the same information the clinic's booking and membership pages already show, with no key. Plus a secret API key for the clinic's own integration, the route that checks one, and lead routes that create a lead and read its progress with it.
Basics
- Host:
https://api.moonshotehr.com(https://api.moonshotclinic.comanswers the same routes). - Clinic, on the public reads:
X-Tenant-Slug: <clinic>. Missing or unknown answers404 tenant_not_found. On keyed routes the key decides the clinic (see Secret API keys). - Success:
{ "data": ..., "next_cursor": null }.next_cursoris a string or null; always null today. - Error:
{ "error": { "code", "message", "request_id" } }. Branch oncode;messageis for people. - Every response carries
X-Request-IdandCache-Control: no-store. Reads answerGETandHEAD; the lead routes also takePOSTandPATCHwith a JSON body of at most 64 KB. A trailing slash is ignored. - An explicit
X-Tenant-Slugthat does not match a clinic is a404; there is no fallback to another clinic. - Dates are
YYYY-MM-DDreal calendar days; anything else is400 invalid_request. Availability is reported from today on, in the clinic's time zone: a past date has no slots and a summary starts no earlier than today. - Full description: openapi-v1.json (OpenAPI 3.1).
GET /api/v1/catalog
The clinic (name, slug, phone, booking_mode), its appointment_types (id, name, duration_minutes, visit_mode, bookable_online), providers (id, display_name, title, appointment_type_ids), memberships.available and membership_plans (product_code, name, price_cents, interval).
bookable_online: falsemarks a video-only service: it is booked through the patient portal and/availabilityrefuses it.booking_mode: requestmeans the clinic takes requests, not online bookings; types and providers are then empty, except for any types the clinic still keeps open online.intervalis usuallymonth,quarteroryear; display any other value as given.
GET /api/v1/availability
Query: appointment_type_id (required), date (required, YYYY-MM-DD in the clinic's time zone), provider_id (optional).
{
"data": {
"date": "2026-11-16",
"duration": 30,
"slots": [
{ "time": "09:00", "end_time": "09:30", "provider_id": "8a2b4c6d-1e3f-4a5b-8c7d-9e0f1a2b3c4d", "provider_name": "Dana Reyes", "provider_title": "NP" }
]
},
"next_cursor": null
}
GET /api/v1/availability/summary
Query: appointment_type_id (required), from and to (required, up to 62 days apart; clamped to start today), provider_id (optional). Open-time counts per day.
{ "data": { "summary": { "2026-11-16": 6, "2026-11-17": 0 } }, "next_cursor": null }
Secret API keys
For the clinic's own integration, called from a server. The clinic's owner creates a key in the EHR (Settings, Developers, API keys), chooses its permissions and how long it lasts (12 months by default, never longer), and copies it once: it is never shown again. A live clinic's key starts mck_live_, a test clinic's mck_test_.
- Send it as
Authorization: Bearer <key>. Keep it on your server; never put it in a web page or an app. - The key decides the clinic.
X-Tenant-Slugis not needed; one that names a different clinic answers403 tenant_mismatch. - A missing, malformed, unknown, expired or revoked key answers
401 unauthorized, the same way every time, withWWW-Authenticate: Bearer. A key without a route's permission answers403 insufficient_scope. - Keyed routes send no CORS headers, so a browser cannot read them.
- To rotate: create a second key, move your integration to it, then have the owner revoke the first. A revoke takes effect on the next request.
- Permissions a key can carry:
leads:writeandleads:read(the lead routes),handoffs:writeandevents:read(for the routes listed under Coming in v1).
GET /api/v1/key
Checks the key you sent: the clinic it belongs to, its prefix (as the clinic's Settings list shows it), its permissions and when it expires. Any valid key may call it; nothing else is returned.
curl -s https://api.moonshotehr.com/api/v1/key -H 'Authorization: Bearer <key>'
{
"data": {
"clinic": "northside",
"key_prefix": "mck_live_3f9a2c1e",
"scopes": ["leads:write"],
"expires_at": "2027-10-05T14:00:00.000Z"
},
"next_cursor": null
}
POST /api/v1/leads
Creates a lead on the clinic's Leads board, the same way the clinic's website form does. Needs leads:write. Body (JSON): email (required), first_name, last_name or name, phone, source, service_interest, notes, attribution (an object of UTM and click-id keys). Full field rules: Send leads.
curl -s https://api.moonshotehr.com/api/v1/leads -H 'Authorization: Bearer <key>' \
-H 'Content-Type: application/json' -H 'Idempotency-Key: form-submission-81723' \
-d '{ "email": "dana@example.com", "first_name": "Dana", "attribution": { "utm_source": "google" } }'
{ "data": { "id": "8d1c2b3a-4e5f-4a6b-9c7d-0e1f2a3b4c5d", "status": "new", "created_at": "2026-11-16T15:04:05.000Z", "duplicate": false, "note_saved": true }, "next_cursor": null }
201for a new lead. A lead with that email address (capitals ignored) from the last 24 hours answers200with"duplicate": trueand its id; no second lead is created.note_saved:truewhen thenotesyou sent are on the lead,falsewhen none were sent or they could not be saved.Idempotency-Key(optional, honored here and ignored onPATCH): the same key and body within 24 hours return the first answer; a different body, or a request while the first is still running, is409 idempotency_conflict.- An unknown or malformed field is
400 invalid_requestand the message names it.
PATCH /api/v1/leads/{id}
Corrects first_name, last_name, email, phone or attribution keys (the keys sent replace those keys; others are kept). Needs leads:write. Status, owner, notes and everything else the clinic's staff own are refused with 400 naming the field. Only leads this API created can be corrected; any other id is 404. Idempotency-Key is ignored here.
{ "data": { "id": "8d1c2b3a-4e5f-4a6b-9c7d-0e1f2a3b4c5d", "updated_at": "2026-11-17T09:30:00.000Z" }, "next_cursor": null }
GET /api/v1/leads/{id}/status
Three yes-or-no answers about the person behind the lead, defined as in the clinic's own conversion report: booked (an appointment booked after the lead came in), onboarding_completed (forms completed, none outstanding), member (a membership started after the lead came in). Needs leads:read. Only for leads this API created (a duplicate of a lead that came in another way answers without status); any other id is 404. No names, contact details, clinical data or amounts.
{ "data": { "id": "8d1c2b3a-4e5f-4a6b-9c7d-0e1f2a3b4c5d", "booked": true, "onboarding_completed": false, "member": false, "updated_at": "2026-11-18T14:00:00.000Z" }, "next_cursor": null }
The lead routes answer 403 phi_access_disabled until the clinic's business associate agreement with Moonshot EHR is in place, and never return an email address or phone number.
Limits
Per visitor IP address, per minute, counted separately for each route and separately from the clinic's own booking page: catalog 30, availability 60, summary 20. Keyed routes count per API key instead, per minute: key check 60, create lead 60, update lead 60, lead status 120. Exactly that many calls succeed; the next gets 429 rate_limited with Retry-After (seconds).
Error codes
| Code | Status | Meaning |
|---|---|---|
invalid_request | 400 | A missing or malformed parameter, or a service that cannot be booked online. |
unauthorized | 401 | Keyed route: the API key is missing, malformed, unknown, expired or revoked. |
online_booking_off | 403 | The clinic takes appointment requests, not online booking. |
insufficient_scope | 403 | Keyed route: the API key does not have the permission this route needs. |
tenant_mismatch | 403 | Keyed route: X-Tenant-Slug names a different clinic than the API key. |
phi_access_disabled | 403 | Lead routes: the clinic's business associate agreement with Moonshot EHR is not in place yet. |
tenant_not_found | 404 | No clinic matches X-Tenant-Slug. |
not_found | 404 | No v1 route at this path, or no lead with this id at the key's clinic. |
method_not_allowed | 405 | Wrong method; see the Allow header. |
idempotency_conflict | 409 | Create lead: the Idempotency-Key was used with a different body, or its first request is still running. |
payload_too_large | 413 | The request body is larger than 64 KB. |
rate_limited | 429 | Over the limit; wait Retry-After seconds. |
not_implemented | 501 | A planned route that is not available yet. |
internal_error | 500 | Our side; retry shortly and quote X-Request-Id. |
From the browser
Only from site addresses the clinic listed with us (see Embed and availability). Errors, including 429, are readable by those sites, and X-Request-Id and Retry-After are exposed to their JavaScript.
const res = await fetch(
'https://api.moonshotehr.com/api/v1/availability?appointment_type_id=<id>&date=2026-11-16',
{ headers: { 'X-Tenant-Slug': '<clinic>' } },
);
const body = await res.json();
if (!res.ok) throw new Error(body.error.code + ': ' + body.error.message);
for (const slot of body.data.slots) console.log(slot.time, slot.provider_name);
From a server:
curl -s https://api.moonshotehr.com/api/v1/catalog -H 'X-Tenant-Slug: <clinic>'
Coming in v1
Available now: the secret key, the route that checks it, and the lead routes (create, correct, read progress). Planned next, on that same key (server to server only): handing a visitor off to the hosted booking, join or intake page, and reading event delivery history. Those routes are not available yet.