simplepractice-mcp 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,398 @@
1
+ # SimplePractice Client Portal — request reference
2
+
3
+ Base: `https://<practice>.clientsecure.me/client-portal-api`
4
+
5
+ Every shape below was taken from the portal app's own published sourcemaps
6
+ (`widget-cdn.simplepractice.com/assets/*.map`, which ship full
7
+ `sourcesContent`) and then confirmed against a live signed-in portal. Nothing
8
+ here is guessed. Where a field could not be exercised on the account used for
9
+ verification, it says so.
10
+
11
+ Assumes the `sp()` helper and `$SP_API` from `SKILL.md`.
12
+
13
+ ---
14
+
15
+ ## 0. Two naming systems — the trap
16
+
17
+ URLs are **dashed and plural**. JSON:API `type` values are **camelCase and
18
+ plural**. They are not the same string, and one endpoint uses both:
19
+
20
+ | URL path | `.data[].type` |
21
+ |---|---|
22
+ | `/sign-in-tokens` | `signInTokens` |
23
+ | `/document-requests` | `documentRequestQuestionnaires`, `documentRequestConsentDocuments`, … |
24
+ | `/billing-items` | `invoices`, `statements`, `superbills`, `receipts`, `payments` |
25
+ | `/client-billing-overviews` | `clientBillingOverviews` |
26
+ | `/environment` (singular!) | `environments` |
27
+
28
+ So never build a `jq` filter by pluralising the path. Match on the `type`
29
+ string the response actually carries, or select positionally.
30
+
31
+ `/environment` is the one singular path in the API.
32
+
33
+ ---
34
+
35
+ ## 1. Auth
36
+
37
+ ### 1.1 Request a magic link — `POST /sign-in-tokens`
38
+
39
+ ```sh
40
+ sp -X POST "$SP_API/sign-in-tokens" \
41
+ -H 'Content-Type: application/vnd.api+json' \
42
+ --data '{"data":{"type":"sign-in-tokens","attributes":{"email":"you@example.com","expiresIn":"15 minutes"}}}'
43
+ ```
44
+
45
+ `202 Accepted`:
46
+
47
+ ```json
48
+ {"data":{"id":"…","type":"signInTokens","attributes":{"email":"you@example.com","expiresIn":"24 hours"}}}
49
+ ```
50
+
51
+ - `expiresIn` in the **response** is the real lifetime (24 hours) whatever you
52
+ request. The portal app deliberately shows the same "24 hours" wording for an
53
+ address with no account, so that the response cannot be used to test whether
54
+ an email is registered. A `202` is therefore not proof the address exists.
55
+ - Optional `redirect` attribute: a portal-relative path to land on after
56
+ verifying (the app uses it for `payment-link/<id>`).
57
+ - **Errors.** `429` with title `Email request limit reached` or
58
+ `IP request limit reached`; `422` for a malformed address. Do not retry
59
+ either — this is the only auth path the portal has.
60
+
61
+ ### 1.2 Exchange the token — `POST /sessions/token`
62
+
63
+ The emailed link is `https://<practice>.clientsecure.me/sign-in/token/verify#<TOKEN>`.
64
+ The token is the **fragment**. A browser never sends a fragment to the server;
65
+ the Ember app reads `location.hash` and posts it. Fetching the link with `curl`
66
+ accomplishes nothing — copy the part after `#`.
67
+
68
+ ```sh
69
+ sp -X POST "$SP_API/sessions/token" \
70
+ -H 'Content-Type: application/vnd.api+json' \
71
+ --data '{"data":{"type":"sessions","attributes":{"type":"token","token":"'"$TOKEN"'"}}}'
72
+ ```
73
+
74
+ Success sets the `simplepractice-session` cookie (Rails/Devise) and returns
75
+ `.data.meta.status`:
76
+
77
+ | `meta.status` | meaning |
78
+ |---|---|
79
+ | `verified` | signed in; the cookie jar is now good |
80
+ | `expired` | older than 24h — request a new link |
81
+ | `merged` | the account was merged into another; sign in from the new portal |
82
+
83
+ Tokens are single-use; replaying one gives `401`/`422`.
84
+
85
+ > Not exercised live during this build: the exchange call itself. §1.1 was
86
+ > confirmed against a real account (`202`), and the shape above comes from
87
+ > `routes/sign-in/token/verify.js` + `services/sign-in.js`, but reading the
88
+ > emailed token was out of scope for the agent that wrote this. Treat
89
+ > `meta.status` handling as source-derived until you've run it once.
90
+
91
+ ### 1.3 PIN variant — `POST /sessions/pin`
92
+
93
+ ```sh
94
+ sp -X POST "$SP_API/sessions/pin" \
95
+ -H 'Content-Type: application/vnd.api+json' \
96
+ --data '{"data":{"type":"sessions","attributes":{"type":"pin","email":"you@example.com","pin":"123456"}}}'
97
+ ```
98
+
99
+ The PIN is exactly 6 digits (client-side regex `^\d{6}$`) and is likewise
100
+ single-use — a wrong or reused code comes back as a validation error on `pin`,
101
+ and `429` is the rate limit.
102
+
103
+ ### 1.4 Session expiry
104
+
105
+ Any endpoint answers `401 {"errors":[{"title":"You have no access to this client","status":"401"}]}`
106
+ once the cookie lapses. There is no refresh token: re-run §1.1.
107
+
108
+ ---
109
+
110
+ ## 2. Identity — `GET /environment`
111
+
112
+ ```sh
113
+ sp "$SP_API/environment?include=currentPractice,currentClient,currentClientOptions"
114
+ ```
115
+
116
+ `.data` is the singleton `environments` record; the interesting part is
117
+ `.data.relationships` + `.included[]`:
118
+
119
+ | relationship | shape |
120
+ |---|---|
121
+ | `currentPractice` | one `practices` |
122
+ | `currentClient` | one `clients` — whose data every other endpoint returns |
123
+ | `currentClientOptions` | **array** of `clients` this login may switch between |
124
+ | `currentClientAccess` | one `clientAccesses` — the login itself |
125
+
126
+ ```sh
127
+ sp "$SP_API/environment?include=currentPractice,currentClientOptions" | jq '{
128
+ practice: (.included[] | select(.type=="practices") | .attributes.fullName),
129
+ timeZone: (.included[] | select(.type=="practices") | .attributes.timeZone),
130
+ clients: [.included[] | select(.type=="clients")
131
+ | {id, name: ((.attributes.preferredName // .attributes.firstName) + " " + .attributes.lastName)}]
132
+ }'
133
+ ```
134
+
135
+ Useful `practices` attributes (67 in all): `fullName`, `timeZone`, `currency`,
136
+ `practiceUrl`, `phoneNumber`, `isGroupPractice`, `telehealthEnabled`,
137
+ `selfSchedulingEnabled`, `isClientAllowedToCancelAppt`,
138
+ `isClientAllowedToConfirmAppt`, `clientCancellableHrs`,
139
+ `announcementsAvailable`, `featureSecureMessagingEmber`.
140
+
141
+ `isClientAllowedToCancelAppt` and `clientCancellableHrs` are the practice's
142
+ actual cancellation policy — worth reading before assuming an appointment can
143
+ be cancelled.
144
+
145
+ `clients` attributes include `firstName`, `lastName`, `preferredName`,
146
+ `nickname`, `birthDate`, `hashedId`, `status`, `billingType`,
147
+ `hasIncompleteDocument`, `hasNewAnnouncements`, `hasInvoicedAppointments`,
148
+ `permissions`, and `relationshipToCurrentClientAccess`.
149
+
150
+ **`clients[].email` is `null` on a login that acts for someone else** (a parent
151
+ portal, say). The sign-in address belongs to the *access*, not the client.
152
+
153
+ ---
154
+
155
+ ## 3. Appointments — `GET /appointments`
156
+
157
+ ```sh
158
+ # upcoming / confirmed
159
+ sp "$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=false&page[size]=50&page[number]=1"
160
+ # requested, awaiting the practice's confirmation
161
+ sp "$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=true&page[size]=50&page[number]=1"
162
+ ```
163
+
164
+ `.data[].type` is `appointments`; `.included[]` carries `clinicians`,
165
+ `offices`, `clients`.
166
+
167
+ Attributes (21 live; from `models/unauthenticated-appointment.js` +
168
+ `models/appointment.js`):
169
+
170
+ | field | notes |
171
+ |---|---|
172
+ | `startTime`, `endTime` | ISO-8601 with offset |
173
+ | `serviceDescription` | e.g. the CPT service name |
174
+ | `confirmationStatus`, `clientConfirmationStatus` | practice-side vs client-side |
175
+ | `isCancellable` | boolean — respects the practice's own policy |
176
+ | `cancelReason`, `visitReason`, `visitTherapyReasons` | |
177
+ | `videoRoomUrl` | telehealth link, when the appointment is video |
178
+ | `icalUrl`, `gcalendarUrl` | ready-made calendar links |
179
+ | `fee`, `uninvoicedFee`, `billableDescription`, `cptCodes`, `units` | |
180
+ | `channel`, `schedulingSource`, `source` | how it was booked |
181
+ | `files` | attachments |
182
+
183
+ Relationships: `clinician`, `office`, `client`, `card`, `superbill`,
184
+ `invoiceItems`, `appointmentClient`.
185
+
186
+ `offices` carry `name`, `street`, `city`, `state`, `zip`, `phone`, `isVideo`,
187
+ `geolocation` — `isVideo: true` is a telehealth "room", not an address.
188
+
189
+ Joined one-liner:
190
+
191
+ ```sh
192
+ sp "$SP_API/appointments?include=clinician,office&filter[hasPendingConfirmation]=false&page[size]=50&page[number]=1" \
193
+ | jq -r '
194
+ (.included // []) as $inc
195
+ | .data[]
196
+ | . as $a
197
+ | ($inc[]? | select(.type=="clinicians" and .id==$a.relationships.clinician.data.id)) as $c
198
+ | ($inc[]? | select(.type=="offices" and .id==$a.relationships.office.data.id)) as $o
199
+ | [$a.attributes.startTime,
200
+ ($a.attributes.serviceDescription // "—"),
201
+ "\($c.attributes.firstName) \($c.attributes.lastName)",
202
+ (if $o.attributes.isVideo then "telehealth" else ($o.attributes.name // "—") end)
203
+ ] | @tsv'
204
+ ```
205
+
206
+ **Pagination: by number.** `page[number]` / `page[size]`, max size 50. A short
207
+ page is the last page.
208
+
209
+ ---
210
+
211
+ ## 4. Billing — `GET /billing-items`
212
+
213
+ One polymorphic collection, switched by `filter[thisType]`:
214
+
215
+ | `filter[thisType]` | `.data[].type` | key attributes |
216
+ |---|---|---|
217
+ | `invoice` | `invoices` | `displayName`, `displayStatus`, `invoiceDate`, `totalAmount`, `remainingAmount`, `isNewForClient` |
218
+ | `statement` | `statements` | `displayName`, `createdAt`, `isNewForClient` |
219
+ | `superbill` | `superbills` | `displayName`, `createdAt`, `totalAmount`, `isNewForClient` |
220
+ | `receipt` | `receipts` | `displayName`, `createdAt`, `isNewForClient` |
221
+ | `billable-item,payment` (+ `filter[thisTypeCondition]=unallocated`) | mixed | account history |
222
+
223
+ ```sh
224
+ sp "$SP_API/billing-items?filter[thisType]=invoice&page[size]=50" \
225
+ | jq '{balance: .meta.endBalance,
226
+ rows: [.data[] | {type, id, name: .attributes.displayName,
227
+ status: .attributes.displayStatus,
228
+ total: .attributes.totalAmount,
229
+ due: .attributes.remainingAmount}]}'
230
+ ```
231
+
232
+ Every billing query returns `.meta.endBalance`.
233
+
234
+ Optional `filter[timeRange]` narrows by date; the portal sends it as a
235
+ `{start,end}` object, which `curl` writes as
236
+ `filter[timeRange][start]=…&filter[timeRange][end]=…`. Omit it for everything.
237
+
238
+ **Pagination: by cursor, backwards.** `page[size]=50`, then
239
+ `page[before]=<the last row's cursorId>`. The cursor is the row's `cursorId`
240
+ attribute, **not** its `id`. A short page is the last page.
241
+
242
+ ```sh
243
+ sp "$SP_API/billing-items?filter[thisType]=invoice&page[size]=50" | jq -r '.data[-1].attributes.cursorId'
244
+ ```
245
+
246
+ An empty `.data[]` here is a normal, correct answer — many practices invoice
247
+ entirely outside the portal. All five filters were confirmed to return `200`
248
+ with `meta.endBalance` on the account used for verification, which had no
249
+ portal billing rows.
250
+
251
+ ### 4.1 Balance summary and saved cards live ON the client record
252
+
253
+ There is **no** `/client-billing-overviews` collection and **no** `/cards`
254
+ collection. Both are `include`-able relationships of `/clients/<id>`:
255
+
256
+ ```sh
257
+ CLIENT_ID=$(sp "$SP_API/environment?include=currentClient" \
258
+ | jq -r '.data.relationships.currentClient.data.id')
259
+ sp "$SP_API/clients/$CLIENT_ID?include=clientBillingOverview,cards"
260
+ ```
261
+
262
+ - `clientBillingOverviews` — `balanceDue`, `unallocatedPaymentAmount`, and the
263
+ counts `invoicesCount` / `statementsCount` / `superbillsCount` /
264
+ `receiptsCount` / `insuranceInfoCount`. Cheaper than paging the collections
265
+ just to see whether anything is there.
266
+ - `cards` — `brand`, `last4`, `expiry` (e.g. `"07 / 30"`), `expMonth`,
267
+ `expYear`, `isDefault` (a **string** `"true"`/`"false"`), plus the Stripe
268
+ identifiers `paymentMethodId` / `customStripeCardId` /
269
+ `customStripeCustomerId`. No full card number.
270
+
271
+ > Guessing `/cards` and `/client-billing-overviews` is the natural first move,
272
+ > and both return **HTTP 200** — with `text/html` and the app shell, because
273
+ > the SPA catch-all swallows every undefined path. They read as working,
274
+ > empty endpoints. This cost a full debugging round during this build; check
275
+ > `content-type`, never status, when an endpoint returns suspiciously nothing.
276
+
277
+ ---
278
+
279
+ ## 5. Documents — `GET /document-requests`
280
+
281
+ Paperwork the practice has sent you to read, complete or sign.
282
+
283
+ ```sh
284
+ sp "$SP_API/document-requests?page[size]=50" \
285
+ | jq -r '.data[] | [.attributes.status, .type, .attributes.documentTitle] | @tsv'
286
+ ```
287
+
288
+ `.data[].type` is the *subtype*, and the attribute set varies with it:
289
+
290
+ | `type` | what it is |
291
+ |---|---|
292
+ | `documentRequestConsentDocuments` | a consent form to sign |
293
+ | `documentRequestQuestionnaires` | a questionnaire — `templateQuestions`, `userAnswers` |
294
+ | `documentRequestContactInfos` | demographics/contact form |
295
+ | `documentRequestInsuranceInfos` | insurance details |
296
+ | `documentRequestCreditCardInfos` | card on file — `cardAttributes` |
297
+ | `documentRequestStoredDocuments` | a file shared with you |
298
+ | `documentRequestNotes` | a note |
299
+ | `documentRequestGoodFaithEstimates` | a Good Faith Estimate |
300
+ | `documentRequestPostSessionSummaries` | post-session summary |
301
+
302
+ `status` ∈ `sent` · `viewed` · `reviewing` · `completed` · `locked`
303
+ (`completed`, `sent` and `viewed` seen live). Anything not `completed`/`locked`
304
+ is outstanding:
305
+
306
+ ```sh
307
+ sp "$SP_API/document-requests?page[size]=50" \
308
+ | jq -r '[.data[] | select(.attributes.status | IN("completed","locked") | not)
309
+ | .attributes.documentTitle] | "outstanding: \(length)\n" + join("\n")'
310
+ ```
311
+
312
+ Common attributes: `documentTitle`, `status`, `createdAt`, `updatedAt`,
313
+ `hasDocumentPdf`.
314
+
315
+ > **`hasDocumentPdf` is a JSON string**, `"true"` or `"false"` — not a boolean,
316
+ > despite `models/document-request.js` declaring `@attr('boolean')` (Ember casts
317
+ > it client-side; the wire value is a string). Both values were seen live. So
318
+ > `select(.attributes.hasDocumentPdf)` matches every row, including the ones
319
+ > with no PDF. Always compare to the string:
320
+ >
321
+ > ```sh
322
+ > jq -r '.data[] | select(.attributes.hasDocumentPdf == "true") | .attributes.documentTitle'
323
+ > ```
324
+ >
325
+ > It is not the only one: a saved card's `isDefault` arrives as `"true"` /
326
+ > `"false"` too. The declared type in the model is not evidence of the wire
327
+ > type — check any boolean you come to depend on, with a real response. Subtype-specific: `documentType`, `documentExt`,
328
+ `documentMimeType`, `documentBody`, `templateQuestions`, `userAnswers`,
329
+ `cardAttributes`, `mixpanelType`.
330
+
331
+ Collection `.meta` carries `hasDocumentsIntro` and `welcomeText`.
332
+
333
+ Single request: `GET /document-requests/<id>`.
334
+
335
+ `hasDocumentPdf: true` means a rendered PDF exists. The portal fetches it
336
+ through the same authenticated origin; treat the URL as session-scoped.
337
+
338
+ `GET /documents` is the separate "files shared with you" list —
339
+ `documentName`, `documentExt`, `thisType`, `createdAt`.
340
+
341
+ ---
342
+
343
+ ## 6. Announcements — `GET /announcements`
344
+
345
+ ```sh
346
+ sp "$SP_API/announcements?page[size]=50" \
347
+ | jq -r '.data[] | [(.attributes.readAt // "UNREAD"), .attributes.title] | @tsv'
348
+ ```
349
+
350
+ Attributes: `title`, `message`, `fromLabel`, `createdAt`, `readAt`,
351
+ `isDeleted`. `clients[].hasNewAnnouncements` (§2) is the cheap "is there
352
+ anything new" flag.
353
+
354
+ There is a `POST /announcements/read-announcements` that marks them all read —
355
+ a write, so out of scope here; it is listed only so you recognise it.
356
+
357
+ ---
358
+
359
+ ## 7. Secure messaging
360
+
361
+ Messaging is **not** on this API. It lives at
362
+ `https://messaging-api.simplepractice.com` (`messagingApiUrl` in the portal's
363
+ config) with models `messagingConversation` / `messagingMessage` /
364
+ `messagingContact` / `messagingProfile` / `messagingUser`, and is gated by the
365
+ practice's `featureSecureMessagingEmber` flag.
366
+
367
+ Its request shapes were **not** captured for this skill. If you need messages,
368
+ read them in the portal, or capture the host's calls first — don't guess them.
369
+
370
+ ---
371
+
372
+ ## 8. Errors
373
+
374
+ | status | meaning |
375
+ |---|---|
376
+ | `400` `Application build version is missing` | you dropped `Application-Build-Version` |
377
+ | `401` `You have no access to this client` | cookie stale/absent → re-auth (§1) |
378
+ | `422` | validation — the body names the offending field |
379
+ | `429` | rate limit; on auth calls the title says email- or IP-scoped. Do not retry |
380
+
381
+ Errors are JSON:API: `.errors[] | {title, code, status}`.
382
+
383
+ ---
384
+
385
+ ## Appendix — where these shapes came from
386
+
387
+ The portal serves public sourcemaps with full original sources:
388
+
389
+ ```sh
390
+ curl -s https://widget-cdn.simplepractice.com/assets/<chunk>.js.map | gunzip > map.json
391
+ node -e 'const m=require("./map.json");m.sources.forEach((s,i)=>{/* write m.sourcesContent[i] */})'
392
+ ```
393
+
394
+ The chunk filenames are hashed per deploy — read them out of the portal HTML's
395
+ `<script src>` tags. `adapters/application.js` defines the namespace and the
396
+ required headers; `models/*.js` define every attribute; `routes/site/**` show
397
+ which filters each screen sends. When SimplePractice ships a new build, that is
398
+ the authoritative place to re-check a shape.