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.
- package/.claude-plugin/marketplace.json +26 -0
- package/.claude-plugin/plugin.json +13 -0
- package/.mcp.json +11 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/dist/auth.js +79 -0
- package/dist/bundle.js +32350 -0
- package/dist/client.js +177 -0
- package/dist/config.js +58 -0
- package/dist/index.js +26 -0
- package/dist/jsonapi.js +91 -0
- package/dist/tools/account.js +48 -0
- package/dist/tools/appointments.js +54 -0
- package/dist/tools/auth.js +67 -0
- package/dist/tools/billing.js +88 -0
- package/dist/tools/documents.js +94 -0
- package/dist/version.js +5 -0
- package/package.json +48 -0
- package/server.json +34 -0
- package/skills/simplepractice/SKILL.md +74 -0
- package/skills/simplepractice-fpx/SKILL.md +260 -0
- package/skills/simplepractice-fpx/references/requests.md +398 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
|
+
import { asBoolean } from '../jsonapi.js';
|
|
4
|
+
const PAGE_SIZE_MAX = 50;
|
|
5
|
+
/** Statuses that mean the client has nothing left to do. */
|
|
6
|
+
const SETTLED = new Set(['completed', 'locked']);
|
|
7
|
+
export function registerDocumentTools(server, client) {
|
|
8
|
+
server.registerTool('simplepractice_list_document_requests', {
|
|
9
|
+
description: 'Paperwork the practice has sent — consents, questionnaires, contact and insurance forms, Good Faith Estimates, shared files. Use outstandingOnly to see just what still needs the client\'s attention.',
|
|
10
|
+
annotations: toolAnnotations({ readOnly: true }),
|
|
11
|
+
inputSchema: {
|
|
12
|
+
outstandingOnly: z
|
|
13
|
+
.boolean()
|
|
14
|
+
.default(false)
|
|
15
|
+
.describe('Return only requests that are not completed or locked.'),
|
|
16
|
+
pageSize: z.number().int().positive().max(PAGE_SIZE_MAX).default(PAGE_SIZE_MAX),
|
|
17
|
+
includeBody: z
|
|
18
|
+
.boolean()
|
|
19
|
+
.default(false)
|
|
20
|
+
.describe('Include the full document body/questions. Off by default — these are long.'),
|
|
21
|
+
},
|
|
22
|
+
}, async ({ outstandingOnly, pageSize, includeBody }) => {
|
|
23
|
+
const { records, meta } = await client.list('/document-requests', {
|
|
24
|
+
page: { size: pageSize },
|
|
25
|
+
});
|
|
26
|
+
const filtered = outstandingOnly
|
|
27
|
+
? records.filter((r) => !SETTLED.has(String(r.status)))
|
|
28
|
+
: records;
|
|
29
|
+
const items = filtered.map((r) => {
|
|
30
|
+
const base = {
|
|
31
|
+
id: r.id,
|
|
32
|
+
// The subtype IS the type field — documentRequestQuestionnaires etc.
|
|
33
|
+
kind: r.type,
|
|
34
|
+
title: r.documentTitle,
|
|
35
|
+
status: r.status,
|
|
36
|
+
createdAt: r.createdAt,
|
|
37
|
+
updatedAt: r.updatedAt,
|
|
38
|
+
// Arrives as the STRING "true"/"false" on the wire, so a plain
|
|
39
|
+
// truthiness test would report every row as having a PDF.
|
|
40
|
+
hasDocumentPdf: asBoolean(r.hasDocumentPdf) ?? false,
|
|
41
|
+
};
|
|
42
|
+
if (includeBody) {
|
|
43
|
+
base.documentBody = r.documentBody;
|
|
44
|
+
base.templateQuestions = r.templateQuestions;
|
|
45
|
+
base.userAnswers = r.userAnswers;
|
|
46
|
+
}
|
|
47
|
+
return base;
|
|
48
|
+
});
|
|
49
|
+
return textResult({
|
|
50
|
+
count: items.length,
|
|
51
|
+
outstanding: records.filter((r) => !SETTLED.has(String(r.status))).length,
|
|
52
|
+
welcomeText: meta?.welcomeText ?? null,
|
|
53
|
+
documentRequests: items,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
server.registerTool('simplepractice_get_document_request', {
|
|
57
|
+
description: 'One document request in full, including its body or its questions and the answers already given.',
|
|
58
|
+
annotations: toolAnnotations({ readOnly: true }),
|
|
59
|
+
inputSchema: { id: z.string().min(1).describe('The document request id.') },
|
|
60
|
+
}, async ({ id }) => {
|
|
61
|
+
const { records } = await client.list(`/document-requests/${encodeURIComponent(id)}`);
|
|
62
|
+
const record = records[0];
|
|
63
|
+
if (!record)
|
|
64
|
+
return textResult({ found: false, id });
|
|
65
|
+
return textResult({
|
|
66
|
+
...record,
|
|
67
|
+
hasDocumentPdf: asBoolean(record.hasDocumentPdf) ?? false,
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
server.registerTool('simplepractice_list_documents', {
|
|
71
|
+
description: 'Files the practice has shared through the Client Portal.',
|
|
72
|
+
annotations: toolAnnotations({ readOnly: true }),
|
|
73
|
+
inputSchema: {
|
|
74
|
+
pageSize: z.number().int().positive().max(PAGE_SIZE_MAX).default(PAGE_SIZE_MAX),
|
|
75
|
+
},
|
|
76
|
+
}, async ({ pageSize }) => {
|
|
77
|
+
const { records } = await client.list('/documents', { page: { size: pageSize } });
|
|
78
|
+
return textResult({ count: records.length, documents: records });
|
|
79
|
+
});
|
|
80
|
+
server.registerTool('simplepractice_list_announcements', {
|
|
81
|
+
description: 'Announcements the practice has posted to the Client Portal. readAt is null on unread ones.',
|
|
82
|
+
annotations: toolAnnotations({ readOnly: true }),
|
|
83
|
+
inputSchema: {
|
|
84
|
+
pageSize: z.number().int().positive().max(PAGE_SIZE_MAX).default(PAGE_SIZE_MAX),
|
|
85
|
+
},
|
|
86
|
+
}, async ({ pageSize }) => {
|
|
87
|
+
const { records } = await client.list('/announcements', { page: { size: pageSize } });
|
|
88
|
+
return textResult({
|
|
89
|
+
count: records.length,
|
|
90
|
+
unread: records.filter((r) => r.readAt === null || r.readAt === undefined).length,
|
|
91
|
+
announcements: records,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
package/dist/version.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "simplepractice-mcp",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"mcpName": "io.github.chrischall/simplepractice-mcp",
|
|
6
|
+
"description": "SimplePractice Client Portal MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
7
|
+
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/chrischall/simplepractice-mcp.git"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22.5.0"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"simplepractice-mcp": "dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
".claude-plugin",
|
|
22
|
+
"skills",
|
|
23
|
+
".mcp.json",
|
|
24
|
+
"server.json"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc && npm run bundle",
|
|
28
|
+
"bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --banner:js='import { createRequire as __createRequire } from \"module\"; const require = __createRequire(import.meta.url);' --outfile=dist/bundle.js",
|
|
29
|
+
"dev": "node --env-file=.env dist/index.js",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"test:coverage": "vitest run --coverage",
|
|
33
|
+
"test:watch": "vitest"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@chrischall/mcp-utils": "^0.15.0",
|
|
37
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
38
|
+
"dotenv": "^17.4.2",
|
|
39
|
+
"zod": "^4.4.3"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^26.0.0",
|
|
43
|
+
"@vitest/coverage-v8": "^4.1.7",
|
|
44
|
+
"esbuild": "^0.28.0",
|
|
45
|
+
"typescript": "^7.0.2",
|
|
46
|
+
"vitest": "^4.1.7"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.chrischall/simplepractice-mcp",
|
|
4
|
+
"description": "SimplePractice Client Portal — appointments, billing, documents, announcements",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/chrischall/simplepractice-mcp",
|
|
7
|
+
"source": "github"
|
|
8
|
+
},
|
|
9
|
+
"version": "0.0.0",
|
|
10
|
+
"packages": [
|
|
11
|
+
{
|
|
12
|
+
"registryType": "npm",
|
|
13
|
+
"identifier": "simplepractice-mcp",
|
|
14
|
+
"version": "0.0.0",
|
|
15
|
+
"transport": {
|
|
16
|
+
"type": "stdio"
|
|
17
|
+
},
|
|
18
|
+
"environmentVariables": [
|
|
19
|
+
{
|
|
20
|
+
"name": "SIMPLEPRACTICE_PRACTICE",
|
|
21
|
+
"description": "Your practice's Client Portal address — the slug (\"achievebalancetherapy\") or the full host (\"achievebalancetherapy.clientsecure.me\").",
|
|
22
|
+
"isRequired": true,
|
|
23
|
+
"format": "string"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "SIMPLEPRACTICE_SESSION_FILE",
|
|
27
|
+
"description": "Where to persist the signed-in session (default ~/.simplepractice-mcp/session.json, written 0600).",
|
|
28
|
+
"isRequired": false,
|
|
29
|
+
"format": "string"
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: simplepractice
|
|
3
|
+
description: >-
|
|
4
|
+
Read a SimplePractice Client Portal through the simplepractice-mcp server —
|
|
5
|
+
upcoming appointments, invoices/statements/superbills/receipts, balance and
|
|
6
|
+
saved cards, paperwork waiting to be signed, and practice announcements.
|
|
7
|
+
Use when the user asks about their therapy or healthcare appointments,
|
|
8
|
+
what they owe a practice, a superbill for insurance, or forms their
|
|
9
|
+
provider has sent them.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# SimplePractice Client Portal
|
|
13
|
+
|
|
14
|
+
`simplepractice-mcp` reads the Client Portal a practice gives its clients —
|
|
15
|
+
the patient side, not the clinician side. It is **read-only**: nothing here
|
|
16
|
+
cancels an appointment, signs a form, or pays a bill.
|
|
17
|
+
|
|
18
|
+
## Signing in
|
|
19
|
+
|
|
20
|
+
The portal has **no password**. SimplePractice emails a one-time link (or a
|
|
21
|
+
6-digit PIN), and that is the only way in.
|
|
22
|
+
|
|
23
|
+
1. `simplepractice_session_status` — check first; a session persists between
|
|
24
|
+
runs, so most of the time there is nothing to do.
|
|
25
|
+
2. `simplepractice_request_sign_in_link` with the user's portal email. It is
|
|
26
|
+
confirm-gated because it sends a real email and the endpoint is rate-limited
|
|
27
|
+
**per address and per IP** — a retry loop locks the user out of the only
|
|
28
|
+
auth path there is. Ask before sending, and never send twice.
|
|
29
|
+
3. The user opens the email and gives you the link. Pass it whole to
|
|
30
|
+
`simplepractice_verify_sign_in_token` — it takes the token out of the
|
|
31
|
+
fragment itself. Tokens are single-use and last 24 hours.
|
|
32
|
+
|
|
33
|
+
`SIMPLEPRACTICE_PRACTICE` must name the practice's portal — the slug or the
|
|
34
|
+
full `<practice>.clientsecure.me` host, from the link the provider emailed.
|
|
35
|
+
If it is unset, every tool says so on its first call.
|
|
36
|
+
|
|
37
|
+
There is no refresh token. When a session lapses the tools say to sign in
|
|
38
|
+
again; that means another email.
|
|
39
|
+
|
|
40
|
+
## Reading
|
|
41
|
+
|
|
42
|
+
- `simplepractice_get_account` — the practice, the current client, and the
|
|
43
|
+
clients this login covers. **Start here**: one portal login can act for
|
|
44
|
+
several people (a parent for two children), so confirm *whose* record you
|
|
45
|
+
are about to report on before you report on it. It also returns the
|
|
46
|
+
practice's real cancellation policy and the client's feature permissions.
|
|
47
|
+
- `simplepractice_list_appointments` — `status: "scheduled"` for confirmed and
|
|
48
|
+
upcoming, `"requested"` for ones the practice has not confirmed yet.
|
|
49
|
+
- `simplepractice_list_document_requests` — paperwork. `outstandingOnly: true`
|
|
50
|
+
answers "is anything waiting for me?", which is the usual question.
|
|
51
|
+
- `simplepractice_get_billing_overview` — balance due and per-category counts.
|
|
52
|
+
Cheaper than listing the billing collections to find out they are empty.
|
|
53
|
+
- `simplepractice_list_billing_items` — invoices, statements, **superbills**
|
|
54
|
+
(the receipt to claim out-of-network insurance), receipts, or account
|
|
55
|
+
history. Pages by cursor: pass the returned `nextCursor` back as `before`.
|
|
56
|
+
- `simplepractice_list_payment_methods`, `simplepractice_list_documents`,
|
|
57
|
+
`simplepractice_list_announcements`.
|
|
58
|
+
|
|
59
|
+
## Reading the results honestly
|
|
60
|
+
|
|
61
|
+
- **An empty billing list is a real answer.** Plenty of practices invoice
|
|
62
|
+
entirely outside the portal. "No invoices in the portal" is the true
|
|
63
|
+
statement; "you owe nothing" is not one you can make from it.
|
|
64
|
+
- **Do not tell someone they can cancel an appointment.** `isCancellable` and
|
|
65
|
+
the practice's `clientMayCancelAppointments` / `cancellationNoticeHours`
|
|
66
|
+
are what govern it, and cancelling has to happen in the portal anyway.
|
|
67
|
+
- **This is medical information.** Report what was asked. Don't volunteer
|
|
68
|
+
diagnoses, session notes, or a family member's records into a conversation
|
|
69
|
+
that wasn't about them.
|
|
70
|
+
|
|
71
|
+
## The shell alternative
|
|
72
|
+
|
|
73
|
+
`simplepractice-fpx` does the same reads with `curl` and no server, for
|
|
74
|
+
scripts or a machine without the MCP installed.
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: simplepractice-fpx
|
|
3
|
+
description: >-
|
|
4
|
+
Read a SimplePractice Client Portal (`<practice>.clientsecure.me`) from a
|
|
5
|
+
shell — appointments, invoices/statements/superbills/receipts, documents to
|
|
6
|
+
sign, announcements, practice and clinician info — with plain `curl` against
|
|
7
|
+
its JSON:API, instead of running the simplepractice-mcp server. Sign in
|
|
8
|
+
headlessly with an emailed magic link, or capture the session cookie from an
|
|
9
|
+
already-signed-in browser tab with `fpx`. Use when you want Client Portal
|
|
10
|
+
data without the MCP, in a script, or on a machine where the MCP isn't
|
|
11
|
+
installed.
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# SimplePractice Client Portal via curl (+ optional fpx)
|
|
15
|
+
|
|
16
|
+
The Client Portal is an Ember app whose backend is a plain **JSON:API** at
|
|
17
|
+
`https://<practice>.clientsecure.me/client-portal-api`. It has **no bot wall**
|
|
18
|
+
— every endpoint below answers ordinary server-side `curl` once you hold a
|
|
19
|
+
session cookie. So this skill is curl-first; `fpx` appears only as an optional
|
|
20
|
+
one-time way to lift the cookie out of a browser you're already signed into.
|
|
21
|
+
|
|
22
|
+
There is **no password**. Sign-in is passwordless: SimplePractice emails you
|
|
23
|
+
either a magic link or a 6-digit PIN, and you trade that for a session cookie.
|
|
24
|
+
That flow carries **no captcha** (reCAPTCHA guards only the new-client request,
|
|
25
|
+
waitlist and contact forms), so §1 below works headlessly with nothing but
|
|
26
|
+
`curl` and access to your inbox.
|
|
27
|
+
|
|
28
|
+
> This is protected health information — your own therapy/medical record.
|
|
29
|
+
> Treat the cookie jar as a credential: it is a full-access bearer token for
|
|
30
|
+
> the portal. Keep it `chmod 600`, out of git, and off shared machines.
|
|
31
|
+
|
|
32
|
+
## Your practice subdomain
|
|
33
|
+
|
|
34
|
+
Every URL is scoped to one practice. Take the host from the portal link your
|
|
35
|
+
provider sent you and export it once:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
export SP_HOST='achievebalancetherapy.clientsecure.me' # <-- yours
|
|
39
|
+
export SP_API="https://$SP_HOST/client-portal-api"
|
|
40
|
+
export SP_JAR="$HOME/.simplepractice-cookies"
|
|
41
|
+
|
|
42
|
+
# curl creates a cookie jar world-readable (644). This one holds a live
|
|
43
|
+
# session for a medical record, so create it 0600 BEFORE curl ever writes it.
|
|
44
|
+
[ -e "$SP_JAR" ] || ( umask 077; : > "$SP_JAR" )
|
|
45
|
+
chmod 600 "$SP_JAR"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## The four headers — all of them, on every call
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
sp() { curl -s -b "$SP_JAR" -c "$SP_JAR" \
|
|
52
|
+
-H 'Api-Version: 2026-05-25' \
|
|
53
|
+
-H 'Application-Build-Version: 0.0.0' \
|
|
54
|
+
-H 'Application-Platform: web' \
|
|
55
|
+
-H 'Accept: application/vnd.api+json' "$@"; }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Omit `Application-Build-Version` and the API rejects the call with
|
|
59
|
+
`400 {"errors":[{"title":"Application build version is missing"}]}` — verified.
|
|
60
|
+
`Api-Version` is the API's own dated contract version, unrelated to any package
|
|
61
|
+
version; send it as-is.
|
|
62
|
+
|
|
63
|
+
## 1. Sign in with a magic link (no browser)
|
|
64
|
+
|
|
65
|
+
**a. Request the link.** One call, to your own portal address:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
sp -X POST "$SP_API/sign-in-tokens" \
|
|
69
|
+
-H 'Content-Type: application/vnd.api+json' \
|
|
70
|
+
--data '{"data":{"type":"sign-in-tokens","attributes":{"email":"you@example.com","expiresIn":"15 minutes"}}}'
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`202 Accepted` means it was sent. The response echoes `expiresIn: "24 hours"`
|
|
74
|
+
regardless of what you asked for — that is the real token lifetime, and it is
|
|
75
|
+
also what the API returns for an *unknown* email, deliberately, so that a 202
|
|
76
|
+
never reveals whether an address has an account.
|
|
77
|
+
|
|
78
|
+
**Do not retry a failed sign-in.** `429` is a real limit with two distinct
|
|
79
|
+
titles — `Email request limit reached` and `IP request limit reached` — and
|
|
80
|
+
hammering it locks you out of the only auth path there is. Wait it out.
|
|
81
|
+
|
|
82
|
+
**b. Take the token out of the emailed link.** The link looks like
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
https://<practice>.clientsecure.me/sign-in/token/verify#<TOKEN>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The token is the **URL fragment**, after the `#`. Because it is a fragment it
|
|
89
|
+
is never sent to the server by a browser navigation — the app reads it in JS
|
|
90
|
+
and posts it. So you must copy it yourself; following the link with `curl`
|
|
91
|
+
does nothing.
|
|
92
|
+
|
|
93
|
+
**c. Trade it for a session cookie.**
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
sp -X POST "$SP_API/sessions/token" \
|
|
97
|
+
-H 'Content-Type: application/vnd.api+json' \
|
|
98
|
+
--data '{"data":{"type":"sessions","attributes":{"type":"token","token":"'"$TOKEN"'"}}}' \
|
|
99
|
+
| jq '.data.meta.status'
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`"verified"` means the cookie jar is now authenticated. The other statuses are
|
|
103
|
+
`"expired"` and `"merged"`; a `401`/`422` means the token was already used —
|
|
104
|
+
they are single-use.
|
|
105
|
+
|
|
106
|
+
**PIN variant.** If your portal mails a 6-digit code instead of a link, post it
|
|
107
|
+
to `sessions/pin` with the address it was sent to:
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
sp -X POST "$SP_API/sessions/pin" \
|
|
111
|
+
-H 'Content-Type: application/vnd.api+json' \
|
|
112
|
+
--data '{"data":{"type":"sessions","attributes":{"type":"pin","email":"you@example.com","pin":"123456"}}}'
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## 2. Or lift the cookie from a signed-in browser tab (fpx)
|
|
116
|
+
|
|
117
|
+
Only worth it if you're already signed in and would rather not wait on an
|
|
118
|
+
email. Requires the **Transporter** extension and `npm i -g @fetchproxy/cli`.
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
fpx profile add simplepractice --domain clientsecure.me
|
|
122
|
+
fpx profile declare simplepractice \
|
|
123
|
+
--cookie simplepractice-session --cookie client-portal-session \
|
|
124
|
+
--local-storage client-portal-session --local-storage stored-email \
|
|
125
|
+
--capture-header cookie@$SP_HOST
|
|
126
|
+
fpx get "https://$SP_HOST/" -p simplepractice >/dev/null # prints a pair code → approve in Transporter
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Declare **every** scope before that first pairing. Widening it afterwards
|
|
130
|
+
leaves fetches working on the old grant while the new capability errors
|
|
131
|
+
`capability "read_cookies" not granted`, and the fix is to remove the profile
|
|
132
|
+
and re-pair from scratch.
|
|
133
|
+
|
|
134
|
+
Then seed the jar from the browser's cookie:
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
SESSION=$(fpx cookies simplepractice-session -p simplepractice \
|
|
138
|
+
--storage-subdomain "${SP_HOST%%.*}" | jq -r '.["simplepractice-session"]')
|
|
139
|
+
printf '#HttpOnly_%s\tFALSE\t/\tTRUE\t0\tsimplepractice-session\t%s\n' "$SP_HOST" "$SESSION" > "$SP_JAR"
|
|
140
|
+
chmod 600 "$SP_JAR"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`fpx` exit codes: `2` bridge unavailable, `3` bot wall, `4` upstream non-2xx.
|
|
144
|
+
|
|
145
|
+
## 3. Who am I, and which client am I looking at
|
|
146
|
+
|
|
147
|
+
```sh
|
|
148
|
+
sp "$SP_API/environment?include=currentPractice,currentClient,currentClientOptions" | jq '{
|
|
149
|
+
practice: (.included[] | select(.type=="practices") | .attributes.fullName),
|
|
150
|
+
timeZone: (.included[] | select(.type=="practices") | .attributes.timeZone),
|
|
151
|
+
clients: [.included[] | select(.type=="clients") | {id, name: (.attributes.firstName+" "+.attributes.lastName)}]
|
|
152
|
+
}'
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
One portal login is a **client access**, and it can cover more than one client
|
|
156
|
+
— a parent seeing two children, say. `currentClientOptions` is always an array;
|
|
157
|
+
`currentClient` is the one whose data the other endpoints return. Don't assume
|
|
158
|
+
there is exactly one. (On a login that acts for someone else, the client
|
|
159
|
+
record's own `email` is `null` — the sign-in address lives on the access, not
|
|
160
|
+
the client, so don't reach for `clients[].email` to find out who you are.)
|
|
161
|
+
|
|
162
|
+
`401 {"title":"You have no access to this client"}` on any endpoint below means
|
|
163
|
+
the cookie is stale or absent — go back to §1.
|
|
164
|
+
|
|
165
|
+
## 4. Reads
|
|
166
|
+
|
|
167
|
+
All of these are verified live. Collections are JSON:API, so records live under
|
|
168
|
+
`.data[]` with fields under `.attributes`; `include=` pulls related records into
|
|
169
|
+
a sibling `.included[]` array that you join on
|
|
170
|
+
`.relationships.<name>.data.id`.
|
|
171
|
+
|
|
172
|
+
```sh
|
|
173
|
+
# Upcoming appointments (and the requested-but-unconfirmed ones)
|
|
174
|
+
sp "$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=false&page[size]=50&page[number]=1"
|
|
175
|
+
sp "$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=true&page[size]=50&page[number]=1"
|
|
176
|
+
|
|
177
|
+
# Billing — one endpoint, switched by filter[thisType]
|
|
178
|
+
sp "$SP_API/billing-items?filter[thisType]=invoice&page[size]=50"
|
|
179
|
+
sp "$SP_API/billing-items?filter[thisType]=statement&page[size]=50"
|
|
180
|
+
sp "$SP_API/billing-items?filter[thisType]=superbill&page[size]=50"
|
|
181
|
+
sp "$SP_API/billing-items?filter[thisType]=receipt&page[size]=50"
|
|
182
|
+
sp "$SP_API/billing-items?filter[thisType]=billable-item,payment&filter[thisTypeCondition]=unallocated&page[size]=50"
|
|
183
|
+
|
|
184
|
+
# Documents to review or sign, and files shared with you
|
|
185
|
+
sp "$SP_API/document-requests?page[size]=50"
|
|
186
|
+
sp "$SP_API/documents?page[size]=50"
|
|
187
|
+
|
|
188
|
+
# Practice announcements
|
|
189
|
+
sp "$SP_API/announcements?page[size]=50"
|
|
190
|
+
|
|
191
|
+
# Balance summary and saved cards hang off the CLIENT record, not collections
|
|
192
|
+
# of their own — see the warning below.
|
|
193
|
+
CLIENT_ID=$(sp "$SP_API/environment?include=currentClient" \
|
|
194
|
+
| jq -r '.data.relationships.currentClient.data.id')
|
|
195
|
+
sp "$SP_API/clients/$CLIENT_ID?include=clientBillingOverview,cards" \
|
|
196
|
+
| jq '{balance: (.included[] | select(.type=="clientBillingOverviews") | .attributes),
|
|
197
|
+
cards: [.included[] | select(.type=="cards")
|
|
198
|
+
| {brand: .attributes.brand, last4: .attributes.last4,
|
|
199
|
+
expiry: .attributes.expiry, isDefault: .attributes.isDefault}]}'
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
> **A 200 is not proof an endpoint exists.** The portal is a single-page app,
|
|
203
|
+
> so *any* path it does not define comes back as `200 text/html` with the app
|
|
204
|
+
> shell (a constant ~7.5 KB) rather than a 404. `/cards` and
|
|
205
|
+
> `/client-billing-overviews` are the obvious guesses for the two above, and
|
|
206
|
+
> both answer 200 that way — they are not API paths at all. Check the
|
|
207
|
+
> `content-type`, not the status:
|
|
208
|
+
>
|
|
209
|
+
> ```sh
|
|
210
|
+
> sp -o /dev/null -w '%{http_code} %{content_type}\n' "$SP_API/whatever"
|
|
211
|
+
> ```
|
|
212
|
+
>
|
|
213
|
+
> Anything other than `application/vnd.api+json` means the path is wrong.
|
|
214
|
+
|
|
215
|
+
A readable next-appointment line:
|
|
216
|
+
|
|
217
|
+
```sh
|
|
218
|
+
sp "$SP_API/appointments?include=clinician,office&filter[hasPendingConfirmation]=false&page[size]=1&page[number]=1" \
|
|
219
|
+
| jq -r '.data[0] as $a
|
|
220
|
+
| (.included[]? | select(.type=="clinicians")) as $c
|
|
221
|
+
| "\($a.attributes.startTime) \($a.attributes.serviceDescription // "—") with \($c.attributes.firstName) \($c.attributes.lastName)"'
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Pagination — two schemes, don't mix them
|
|
225
|
+
|
|
226
|
+
- **Appointments** page by number: `page[number]=1&page[size]=50`. You're on
|
|
227
|
+
the last page when a page comes back shorter than `page[size]`.
|
|
228
|
+
- **Billing items** page by *cursor*, backwards: `page[size]=50` and then
|
|
229
|
+
`page[before]=<cursorId of the last row you saw>`. The row's `cursorId` is
|
|
230
|
+
the cursor, not its `id`.
|
|
231
|
+
|
|
232
|
+
`50` is the server's max page size; asking for more does not get you more.
|
|
233
|
+
|
|
234
|
+
## Notes
|
|
235
|
+
|
|
236
|
+
- Times come back ISO-8601 with an offset. The practice's own `timeZone`
|
|
237
|
+
(§3) is what its staff schedule in — use it when a date matters.
|
|
238
|
+
- **`permissions` on the client is a JSON string, not an object.** It parses
|
|
239
|
+
to the portal features this client actually has —
|
|
240
|
+
`{"messaging":…,"selfScheduling":…,"billingDocuments":…,"payments":…,"appointments":…}`.
|
|
241
|
+
Read it with `.attributes.permissions | fromjson`; used raw it is a string of
|
|
242
|
+
characters. `billingDocuments` is what gates the whole billing tab.
|
|
243
|
+
- **`hasDocumentPdf` is a string, not a boolean.** It arrives as `"true"` or
|
|
244
|
+
`"false"` — both seen live — so `if (hasDocumentPdf)` and
|
|
245
|
+
`jq 'select(.attributes.hasDocumentPdf)'` are BOTH true for `"false"`.
|
|
246
|
+
Compare against the string: `select(.attributes.hasDocumentPdf == "true")`.
|
|
247
|
+
A card's `isDefault` is the same — so do not assume a JSON boolean anywhere
|
|
248
|
+
in this API without checking the value you actually get back.
|
|
249
|
+
- `billing-items` is polymorphic: `.data[].type` tells you which of
|
|
250
|
+
invoice / statement / superbill / receipt / payment a row actually is, and
|
|
251
|
+
the attribute set differs per type. `.meta.endBalance` accompanies every
|
|
252
|
+
billing query.
|
|
253
|
+
- An empty `.data[]` is a real answer, not a failure — plenty of practices
|
|
254
|
+
bill outside the portal entirely and every billing endpoint returns `200`
|
|
255
|
+
with nothing in it.
|
|
256
|
+
- Everything here is a **read**. Cancelling an appointment, submitting a
|
|
257
|
+
signed document, or paying an invoice are writes this skill deliberately
|
|
258
|
+
does not cover — do those in the portal, where you can see what you're
|
|
259
|
+
agreeing to.
|
|
260
|
+
- This project is developed and maintained by AI (Claude).
|