Skip to main content

For clinics and their developers

Developer documentation

If your clinic is listed with us, you can push new enquiries into your own systems as they arrive, and read your own enquiries, appointments and totals back out. Both are set up from the Integrations screen in the clinic portal.

What this covers

Two things, and only these two:

  • Webhooks — we POST a small signed message to an address you choose whenever an enquiry arrives or an appointment changes.
  • A read-only API— three GET endpoints returning your own clinic’s enquiries, appointments and totals.

There is no write API. Nothing here can change your listing, your prices or an enquiry’s status — those are all done by a person signed into the portal.

Webhooks and API keys are part of the Pro plan. Deleting a webhook or a key is always available, on any plan.

Authentication

Create a key on the Integrations screen. It is shown once: we store only a scrambled version, so if you lose it you create another and delete the old one. Send it as a bearer token.

Every request

Authorization: Bearer adhd_live_xxxxxxxxxxxxxxxx

The key identifies your clinic. There is no clinic ID in any URL — every response is scoped to the clinic that owns the key, and a key can never read another clinic’s data.

Keep the key out of URLs, browsers and client-side code. Anything with a query string ends up in somebody’s access log; we only accept it in the header for that reason.

Scopes

Each key carries one or more scopes, chosen when you create it. Give a key only what the tool actually needs — you can create a second, narrower one at any time.

ScopeGrants
enquiries:readGET /ext/enquiries. This is the only scope that returns patient contact details and the message they wrote.
bookings:readGET /ext/bookings. Appointment times and status. Deliberately no attendee name or email — join on enquiryId if you need those.
stats:readGET /ext/stats. Counts only. Nothing in it identifies anyone.

A key without the right scope gets 403 from that endpoint, not a filtered response.

Endpoints

Base URL https://adhdprivate.co.uk/api/v1. All three are GET, all return JSON, and the two list endpoints take ?page= (default 1) and ?limit= (default 50, maximum 200). A larger limit is clamped rather than rejected.

GET/api/v1/ext/enquiriesenquiries:read

Your clinic’s enquiries, newest first. Rows we have anonymised at the twelve-month retention point are still returned, with anonymised: trueand their details emptied — so a syncing system can see the row it already knows about has changed, rather than silently diverging from us.

Request

curl -s "https://adhdprivate.co.uk/api/v1/ext/enquiries?limit=2" \
  -H "Authorization: Bearer $ADHD_KEY"

Response 200

{
  "data": [
    {
      "id": 4812,
      "firstName": "Sam",
      "email": "sam@example.com",
      "phone": "07700 900123",
      "who": "myself",
      "service": "assessment",
      "message": "I'd like to book an adult assessment.",
      "stage": "new",
      "createdAt": "2026-08-02T09:14:07Z",
      "anonymised": false
    },
    {
      "id": 4655,
      "firstName": null,
      "email": "",
      "phone": null,
      "who": "my_child",
      "service": "assessment_child",
      "message": null,
      "stage": "assessed",
      "createdAt": "2025-07-30T11:02:55Z",
      "anonymised": true
    }
  ],
  "page": 1,
  "limit": 2,
  "total": 137,
  "hasMore": true
}

stage is one of new, contacted, booked, assessed— the same four columns as the portal pipeline.

GET/api/v1/ext/bookingsbookings:read

Appointments, newest first. enquiryId is null when the appointment did not come from an enquiry through us.

Request

curl -s "https://adhdprivate.co.uk/api/v1/ext/bookings" \
  -H "Authorization: Bearer $ADHD_KEY"

Response 200

{
  "data": [
    {
      "id": 918,
      "enquiryId": 4812,
      "status": "booked",
      "startsAt": "2026-08-14T13:30:00Z",
      "createdAt": "2026-08-02T09:41:12Z"
    }
  ],
  "page": 1,
  "limit": 50,
  "total": 1,
  "hasMore": false
}

status is one of invited, booked, rescheduled, cancelled, attended, no_show, expired.

GET/api/v1/ext/statsstats:read

Counts for your clinic. No pagination, no personal data.

Request

curl -s "https://adhdprivate.co.uk/api/v1/ext/stats" \
  -H "Authorization: Bearer $ADHD_KEY"

Response 200

{
  "enquiries": {
    "total": 137,
    "last30Days": 12,
    "byStage": { "new": 3, "contacted": 5, "booked": 2, "assessed": 127 }
  },
  "bookings": {
    "booked": 41,
    "attended": 36,
    "noShow": 3,
    "cancelled": 2
  }
}

bookings.bookedcounts every appointment that reached a real appointment state — including ones later attended or missed — which is the same definition the no-show screen in the portal uses.

Webhooks

Set one https address on the Integrations screen and we POST to it when something happens. One address per clinic; if you need to fan out to several systems, put something like Zapier in the middle.

What we send — and what we don’t

A webhook is a nudge that says “go and look”. It carries reference numbers, timestamps, categories and status. It never carries the patient’s message, email address or phone number — you already have all three, in the email we send you the moment an enquiry arrives and in your portal inbox. If your system needs them, fetch them with a key carrying enquiries:read.

Events

  • enquiry.received— a patient sent an enquiry through your listing.
  • booking.created— an appointment was created in your connected calendar.
  • booking.outcome_recorded— someone at your clinic recorded whether the patient attended.

The Send a test button delivers a ping event through exactly the same queue and signature, so a successful test proves the real path works.

enquiry.received

POST https://your-system.example.com/adhd-webhook
Content-Type: application/json
X-ADHDPrivate-Signature: t=1785000000,v1=5257a869e7ecebeda32affa62cdca3fa...

{
  "id": "evt_20194",
  "event": "enquiry.received",
  "createdAt": "2026-08-02T09:14:07Z",
  "clinicId": 128,
  "data": {
    "enquiryId": 4812,
    "who": "myself",
    "service": "assessment",
    "stage": "new",
    "createdAt": "2026-08-02T09:14:07Z",
    "portalUrl": "https://adhdprivate.co.uk/portal/enquiries/4812"
  }
}

booking.created and booking.outcome_recorded

{
  "id": "evt_20195",
  "event": "booking.outcome_recorded",
  "createdAt": "2026-08-14T14:35:02Z",
  "clinicId": 128,
  "data": {
    "bookingId": 918,
    "enquiryId": null,
    "status": "attended",
    "startsAt": null,
    "createdAt": "2026-08-14T14:35:02Z",
    "portalUrl": "https://adhdprivate.co.uk/portal/no-shows"
  }
}

Retries, and why you must handle duplicates

Answer with any 2xxas soon as you have stored the message — do the real work afterwards. Anything else (or no answer within eight seconds) is a failure, and we retry after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours before giving up. Redirects are not followed.

Because we retry, the same event can arrive more than once — for example if your server stored it but the reply was lost. Treat id as the deduplication key: it is stable across every retry of the same delivery.

After three consecutive failures the Integrations screen shows a warning with whatever your server last returned. One success clears it. Nothing is lost while an endpoint is down — every enquiry is in your inbox and in the portal regardless.

Verifying a webhook

Every delivery carries an X-ADHDPrivate-Signature header:

X-ADHDPrivate-Signature: t=<unix timestamp>,v1=<hex hmac-sha256>

v1 is HMAC-SHA256 over the string <t>.<raw request body>, keyed with the signing secret shown on your Integrations screen. This is the same scheme Stripe uses, so any Stripe verification example works with two strings changed.

  • Sign the raw body bytes, exactly as received. If your framework parses JSON before you get to it, key order and whitespace will differ and the signature will never match. This is the most common mistake.
  • Check t against your own clock and reject anything older than a few minutes. The timestamp is inside the signature, so this is what stops a captured delivery being replayed at you later.
  • Compare in constant time.

Node (Express)

const express = require('express');
const crypto = require('crypto');

const app = express();
const SECRET = process.env.ADHD_WEBHOOK_SECRET;

// ⚠ express.raw, not express.json — the signature is over the raw bytes.
app.post('/adhd-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-ADHDPrivate-Signature') || '';
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
  if (!parts.t || age > 300) return res.status(400).send('stale');

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(parts.t + '.' + req.body)   // req.body is a Buffer here
    .digest('hex');

  const ok =
    parts.v1 &&
    parts.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  if (!ok) return res.status(400).send('bad signature');

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.id — the same delivery can arrive more than once.
  console.log(event.event, event.id, event.data);

  res.sendStatus(200);   // acknowledge first, do the work afterwards
});

app.listen(3000);

Checking a captured delivery by hand

# BODY must be the exact bytes you received, and T the t= value from the header.
T=1785000000
BODY='{"id":"evt_20194","event":"enquiry.received","clinicId":128}'

printf '%s.%s' "$T" "$BODY" \
  | openssl dgst -sha256 -hmac "$ADHD_WEBHOOK_SECRET" -r \
  | cut -d' ' -f1
# compare with the v1= value in X-ADHDPrivate-Signature

Rate limits

The /ext endpoints allow 60 requests a minute from one IP address. Over that you get 429 with a Retry-After header; wait and try again.

These are read endpoints over data that changes a few times a day at most. Polling every few minutes is plenty — and if you want to know the moment something happens, use the webhook rather than a tight poll.

Errors

Failures return JSON with an error code and a message.

{ "error": "insufficient_scope", "message": "This key doesn't have the enquiries:read scope." }
StatuserrorWhat to do
401missing_keyNo Authorization: Bearer header.
401invalid_keyUnknown or deleted key. Create a new one on the Integrations screen.
403insufficient_scopeThe key is valid but too narrow. Create one with the scope you need.
429rate_limit_exceededToo many requests. Honour Retry-After.
500Our end. Retry with a short backoff; nothing has been changed.

Data protection

Enquiries include health information about identifiable people. When you pull patient details out of the portal through a key, or receive them in your own systems, you are the controller for that copy — its security, its retention and any request a patient makes about it are yours to answer. Our terms say the same thing, and our privacy policy covers what we keep and for how long.

Practical things worth doing: give each tool its own key so you can delete one without breaking the others; use the narrowest scope that works; delete keys for tools you have stopped using (the Integrations screen shows when each was last used).

Something not working?

The Integrations screen shows your recent deliveries and whatever your server last returned, which answers most of it. If it doesn’t, get in touch — tell us the delivery id and we can see what happened.