Hally

API and webhooks

Connect another system to a clinic on Hally: add enquiries, check free times, book appointments and read the diary with the API, and hear about bookings, patients, plans and payments the moment they happen with webhooks. The clinic sets both up itself in Settings → Integrations.

The machine-readable description (OpenAPI 3.1, for Postman, Zapier or GoHighLevel): https://hallyhealth.app/api/v1/openapi.json

Keys

The clinic’s owner makes a key in Settings → Integrations → API keys, chooses what it may do, and copies it (it’s shown once). Send it with every request:

curl https://hallyhealth.app/api/v1/services \
  -H "Authorization: Bearer sk_…"

A key only ever reaches its own clinic. It can be revoked at any time, and then stops working at once. Each key can make 120 requests a minute (the X-RateLimit-* headers say how many are left; past it you get 429 with Retry-After). Every request is recorded in the clinic’s access log.

ScopeLets the key
services:readSee services, practitioners and free times (no patient data)
leads:writeAdd enquiries (leads)
appointments:readSee appointments: times, service, practitioner and patient id only
appointments:writeBook appointments (finds or adds the patient)
patients:readSee patients’ names and contact details
patients:lookupLook up a caller by phone number: name and next appointment only
calls:writeLog phone calls and show incoming calls to staff (your phone system)

Patients’ names and contact details only come back to keys with patients:read; without it, appointments carry the patient’s id only.

Requests

JSON in and out. Times are UTC (ISO 8601, like 2026-11-02T10:00:00Z); dates are the clinic’s own, in UK time (2026-11-02). Money is in whole pence. Errors look like {"error": {"code": "missing_scope", "message": "…"}}, with fields when something you sent was wrong.

GET /services needs services:read

Services, who offers each, and the clinic’s locations.

GET /slots?serviceId=…&from=2026-11-02&to=2026-11-08 needs services:read

Free times, worked out exactly as the clinic’s own booking page does (hours, notice, buffers, other bookings). Optional practitionerId and locationId; up to 31 days at a time.

POST /leads needs leads:write

Adds an enquiry. Reception gets a task to reply within the hour, and a message shows in the clinic’s inbox.

curl -X POST https://hallyhealth.app/api/v1/leads \
  -H "Authorization: Bearer sk_…" -H "Content-Type: application/json" \
  -d '{"name": "Jane Smith", "email": "jane@example.com", "phone": "07700 900123",
       "message": "Do you treat verrucas?"}'

POST /appointments needs appointments:write

Books a free time (check /slots first; a time that isn’t free gets 409). Send patientId, or the patient’s name with an email or phone: someone with the same name and email (or phone) is matched, otherwise they’re added. Nothing is charged; the patient gets the clinic’s usual reminders.

curl -X POST https://hallyhealth.app/api/v1/appointments \
  -H "Authorization: Bearer sk_…" -H "Content-Type: application/json" \
  -d '{"serviceId": "…", "practitionerId": "…", "startsAt": "2026-11-02T10:00:00Z",
       "patient": {"firstName": "Jane", "lastName": "Smith", "email": "jane@example.com"}}'

GET /appointments?from=2026-11-02&to=2026-11-08 needs appointments:read

Appointments starting between two dates (up to 62 days). Optional practitionerId and status.

GET /appointments/{id} needs appointments:read

One appointment.

GET /patients/{id} needs patients:read

A patient’s name, contact details and date of birth.

Phone systems

A clinic’s phone system can show staff who is ringing and file each call on the patient’s record. Make a key with calls:write (and patients:lookup to show names on the phone itself). Numbers can be written any way (07700 900123, +447700900123): they’re matched on their digits.

POST /calls needs calls:write

Log a call. Send it when the call starts (staff signed in see who’s calling within a few seconds), again when it ends, and again with a summary if your system makes one. Requests with the same externalId (your id for the call, and an optional source naming your system) update one call; fields you leave out keep what was sent before. The patient is matched by number unless you send patientId; a number nobody has goes to the clinic’s inbox.

{"externalId": "c-81723", "source": "my-pbx",
 "direction": "inbound",            (or "outbound")
 "number": "07700 900123",          (the caller, or who was rung)
 "startedAt": "2026-11-02T09:14:03Z",
 "answeredAt": "2026-11-02T09:14:09Z",
 "endedAt": "2026-11-02T09:17:40Z",
 "outcome": "answered",             (missed, no_answer, voicemail, busy, failed)
 "summary": "Wants to move Thursday's appointment.",
 "recordingUrl": "https://pbx.example/recordings/c-81723",
 "score": 8}                        (1 to 10, optional)

Answers 201 (new) or 200 (updated) with {"id", "patientId", "matched"}. Summaries and recording links are health information: they’re stored encrypted, and only shown to the clinic’s staff.

GET /patients/lookup?phone=… needs patients:lookup

Who has this number: each patient’s id, first and last name, and next appointment (time, service and practitioner). Nothing else. Several people can share a number.

POST /phone/invoco?key=… needs calls:write

Invoco’s call webhooks, exactly as Invoco sends them (it can only be given an address, so the key goes in it). Calls starting, answered and finished, and the AI summary, score and recording link (call.ai_score). Texts and transfers are acknowledged and ignored; the transcript isn’t kept. The clinic makes this address in Settings → Integrations → Phone system.

Webhooks

The clinic adds your https address in Settings → Integrations → Webhooks, picks the events, and gives you the signing secret. We post JSON like this, usually within five minutes:

POST https://your-system.example/hooks
Webhook-Id: 0193…            (the same on every retry: ignore ones you've seen)
Webhook-Event: booking.created
Webhook-Signature: t=1793613600,v1=5f2b…

{"id": "0193…", "type": "booking.created", "occurredAt": "2026-11-02T09:14:03.000Z",
 "data": {"appointmentId": "…", "patientId": "…"}}
  • booking.created: An appointment is booked
  • booking.cancelled: An appointment is cancelled
  • patient.created: A new patient is added
  • plan.accepted: A treatment plan is accepted
  • payment.succeeded: A payment goes through
  • ping: the clinic pressed Send test

By default a webhook carries ids only: ask the API for anything else you need. If the clinic ticks “include details”, a details object adds the appointment, plan or payment and the patient’s name and contact.

Checking the signature

v1 is the HMAC-SHA256, keyed with the signing secret, of the timestamp, a full stop and the raw body. Refuse anything that doesn’t match, or whose timestamp is more than five minutes old.

import { createHmac, timingSafeEqual } from 'node:crypto'

function verify(secret, header, rawBody) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false
  const want = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
  return want.length === parts.v1?.length && timingSafeEqual(Buffer.from(want), Buffer.from(parts.v1))
}

Answering, and retries

Answer with any 2xx within 10 seconds. Anything else (or no answer) is tried again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, then marked failed; the clinic sees the last result on its Integrations page. Redirects aren’t followed.