{"id":"24838161-7aa6-4d3e-bcbc-44587fab1ef0","entityType":"agent","slug":"clawhub-chrischall-simplepractice-fpx","name":"simplepractice-fpx","canonicalUrl":"https://www.xpersona.co/agent/clawhub-chrischall-simplepractice-fpx","canonicalPath":"/agent/clawhub-chrischall-simplepractice-fpx","generatedAt":"2026-10-11T10:51:20.073Z","source":"CLAWHUB","claimStatus":"UNCLAIMED","verificationTier":"NONE","summary":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-11T08:11:14.751Z","emptyReason":null},"description":"Read a SimplePractice Client Portal (`<practice>.clientsecure.me`) from a shell — appointments, invoices/statements/superbills/receipts, documents to sign, announcements, practice and clinician info — with plain `curl` against its JSON:API, instead of running the simplepractice-mcp server. Sign in headlessly with an emailed magic link, or capture the session cookie from an already-signed-in browser tab with `fpx`. Use when you want Client Portal data without the MCP, in a script, or on a machine where the MCP isn't installed.","descriptionLabel":"Source description","evidenceSummary":"Capability contract not published. No trust telemetry is available yet. 1.1K downloads reported by the source. Last updated 10/11/2026.","installCommand":"clawhub skill install s17cjx1a349nz5apaqp02vgz4h85728z:simplepractice-fpx","sourceUrl":"https://clawhub.ai/chrischall/simplepractice-fpx","homepage":"https://clawhub.ai/chrischall/skills/simplepractice-fpx","primaryLinks":[{"label":"View on ClawHub","url":"https://clawhub.ai/chrischall/simplepractice-fpx","kind":"source"},{"label":"Homepage","url":"https://clawhub.ai/chrischall/skills/simplepractice-fpx","kind":"homepage"}],"safetyScore":84,"overallRank":62,"popularityScore":61,"trustScore":null,"claimedByName":null,"isOwner":false,"seoDescription":"simplepractice-fpx technical dossier on Xpersona with agent coverage, OPENCLEW support, and live trust metadata."},"coverage":{"evidence":{"source":"public-profile","verified":false,"confidence":"medium","updatedAt":"2026-10-11T08:11:14.751Z","emptyReason":null},"protocols":[{"protocol":"OPENCLEW","label":"OpenClaw","status":"self-declared","notes":"Declared in the public agent profile."}],"capabilities":[],"verifiedCount":0,"selfDeclaredCount":1,"capabilityMatrix":{"rows":[{"key":"OPENCLEW","type":"protocol","support":"unknown","confidenceSource":"profile","notes":"Listed on profile"}],"flattenedTokens":"protocol:OPENCLEW|unknown|profile"}},"adoption":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-11T08:11:14.751Z","emptyReason":null},"stars":null,"forks":null,"downloads":1116,"packageName":null,"latestVersion":"1.2.6","tractionLabel":"1.1K downloads"},"release":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-11T08:11:14.739Z","emptyReason":null},"lastUpdatedAt":"2026-10-11T08:11:14.751Z","lastCrawledAt":"2026-10-11T08:11:14.739Z","lastIndexedAt":null,"nextCrawlAt":"2026-10-12T08:11:14.739Z","lastVerifiedAt":null,"highlights":[{"version":"1.2.6","createdAt":"2026-10-09T23:30:42.026Z","changelog":"- Removed the file skill-card.md. - No changes to functionality or user-facing documentation apart from removing an internal documentation file.","fileCount":4,"zipByteSize":13669},{"version":"1.2.5","createdAt":"2026-10-07T13:38:37.772Z","changelog":"- Removed the file: skill-card.md - No other changes to functionality or documentation. - Version 1.2.5 now contains one less file (skill-card.md).","fileCount":4,"zipByteSize":13645},{"version":"1.2.4","createdAt":"2026-10-05T02:52:19.258Z","changelog":"- Removed the file skill-card.md. - No functional or documentation changes to the skill itself.","fileCount":4,"zipByteSize":13618},{"version":"1.2.3","createdAt":"2026-10-03T01:41:56.621Z","changelog":"- Removed the skill-card.md file. - No changes to skill functionality or documentation.","fileCount":4,"zipByteSize":13781},{"version":"1.2.2","createdAt":"2026-09-30T17:02:10.987Z","changelog":"- Removed the file skill-card.md. - No other changes to code or documentation.","fileCount":4,"zipByteSize":13638},{"version":"1.2.1","createdAt":"2026-09-25T15:55:14.397Z","changelog":"- Removed the skill-card.md file. - No changes made to user-facing commands or documentation in SKILL.md. - No new features or bug fixes in this release.","fileCount":4,"zipByteSize":13628},{"version":"1.2.0","createdAt":"2026-09-24T15:13:00.914Z","changelog":"- Removed the file: skill-card.md - No feature or documentation changes; internal cleanup only","fileCount":4,"zipByteSize":13752},{"version":"1.1.4","createdAt":"2026-09-23T21:42:52.162Z","changelog":"- Removed the file: skill-card.md - No other changes to functionality or documentation","fileCount":4,"zipByteSize":13692}]},"execution":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No published capability contract is available yet."},"installCommand":"clawhub skill install s17cjx1a349nz5apaqp02vgz4h85728z:simplepractice-fpx","setupComplexity":"low","setupSteps":["Install using `clawhub skill install s17cjx1a349nz5apaqp02vgz4h85728z:simplepractice-fpx` in an isolated environment before connecting it to live workloads.","No published capability contract is available yet, so validate auth and request/response behavior manually.","Review the upstream CLAWHUB listing at https://clawhub.ai/chrischall/simplepractice-fpx before using production credentials."],"contract":{"contractStatus":"missing","authModes":[],"requires":[],"forbidden":[],"supportsMcp":false,"supportsA2a":false,"supportsStreaming":false,"inputSchemaRef":null,"outputSchemaRef":null,"dataRegion":null,"contractUpdatedAt":null,"sourceUpdatedAt":null,"freshnessSeconds":null},"invocationGuide":{"preferredApi":{"snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-simplepractice-fpx/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-simplepractice-fpx/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-simplepractice-fpx/trust"},"curlExamples":["curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-simplepractice-fpx/snapshot\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-simplepractice-fpx/contract\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-simplepractice-fpx/trust\""],"jsonRequestTemplate":{"query":"summarize this repo","constraints":{"maxLatencyMs":2000,"protocolPreference":["OPENCLEW"]}},"jsonResponseTemplate":{"ok":true,"result":{"summary":"...","confidence":0.9},"meta":{"source":"CLAWHUB","generatedAt":"2026-10-11T10:51:20.068Z"}},"retryPolicy":{"maxAttempts":3,"backoffMs":[500,1500,3500],"retryableConditions":["HTTP_429","HTTP_503","NETWORK_TIMEOUT"]}},"endpoints":{"dossierUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-simplepractice-fpx/dossier","snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-simplepractice-fpx/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-simplepractice-fpx/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-simplepractice-fpx/trust"}},"reliability":{"evidence":{"source":"runtime-metrics","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No trust, reliability, or runtime telemetry is available."},"trust":{"status":"unavailable","handshakeStatus":"UNKNOWN","verificationFreshnessHours":null,"reputationScore":null,"p95LatencyMs":null,"successRate30d":null,"fallbackRate":null,"attempts30d":null,"trustUpdatedAt":null,"trustConfidence":"unknown","sourceUpdatedAt":null,"freshnessSeconds":null},"decisionGuardrails":{"doNotUseIf":["Contract metadata is missing or unavailable for deterministic execution."],"safeUseWhen":[],"riskFlags":["missing_or_unavailable_contract","trust_data_unavailable","schema_references_missing"],"operationalConfidence":"low"},"executionMetrics":{"observedLatencyMsP50":null,"observedLatencyMsP95":null,"estimatedCostUsd":null,"uptime30d":null,"rateLimitRpm":null,"rateLimitBurst":null,"lastVerifiedAt":null,"verificationSource":null},"runtimeMetrics":{"successRate":null,"avgLatencyMs":null,"avgCostUsd":null,"hallucinationRate":null,"retryRate":null,"disputeRate":null,"p50Latency":null,"p95Latency":null,"lastUpdated":null}},"benchmarks":{"evidence":{"source":"no-benchmark-data","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No benchmark suites or observed failure patterns are available."},"suites":[],"failurePatterns":[]},"artifacts":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-11T08:11:14.751Z","emptyReason":null},"readme":"Skill: simplepractice-fpx\n\nOwner: chrischall\n\nSummary: Read a SimplePractice Client Portal (`<practice>.clientsecure.me`) from a shell — appointments, invoices/statements/superbills/receipts, documents to sign, announcements, practice and clinician info — with plain `curl` against its JSON:API, instead of running the simplepractice-mcp server. Sign in headlessly with an emailed magic link, or capture the session cookie from an already-signed-in browser tab with `fpx`. Use when you want Client Portal data without the MCP, in a script, or on a machine where the MCP isn't installed.\n\nTags: latest:1.2.6\n\nVersion history:\n\nv1.2.6 | 2026-10-09T23:30:42.026Z | auto\n\n- Removed the file skill-card.md.\n- No changes to functionality or user-facing documentation apart from removing an internal documentation file.\n\nv1.2.5 | 2026-10-07T13:38:37.772Z | auto\n\n- Removed the file: skill-card.md\n- No other changes to functionality or documentation.\n- Version 1.2.5 now contains one less file (skill-card.md).\n\nv1.2.4 | 2026-10-05T02:52:19.258Z | auto\n\n- Removed the file skill-card.md.\n- No functional or documentation changes to the skill itself.\n\nv1.2.3 | 2026-10-03T01:41:56.621Z | auto\n\n- Removed the skill-card.md file.\n- No changes to skill functionality or documentation.\n\nv1.2.2 | 2026-09-30T17:02:10.987Z | auto\n\n- Removed the file skill-card.md.\n- No other changes to code or documentation.\n\nv1.2.1 | 2026-09-25T15:55:14.397Z | auto\n\n- Removed the skill-card.md file.\n- No changes made to user-facing commands or documentation in SKILL.md.\n- No new features or bug fixes in this release.\n\nv1.2.0 | 2026-09-24T15:13:00.914Z | auto\n\n- Removed the file: skill-card.md\n- No feature or documentation changes; internal cleanup only\n\nv1.1.4 | 2026-09-23T21:42:52.162Z | auto\n\n- Removed the file: skill-card.md\n- No other changes to functionality or documentation\n\nv1.1.3 | 2026-09-23T15:39:44.133Z | auto\n\n- Removed the file: skill-card.md\n- No user-facing or behavioral changes; documentation and functionality remain the same.\n\nv1.1.2 | 2026-09-21T17:03:02.986Z | auto\n\n- Removed the file `skill-card.md`.\n- No functional or user-facing changes; documentation and features remain unchanged.\n\nv1.1.1 | 2026-09-21T04:12:17.718Z | auto\n\n- Removed redundant skill-card.md file.\n- No functional or documentation changes to the primary SKILL.md content.\n\nv1.1.0 | 2026-09-20T02:53:08.594Z | auto\n\n- Removed the file skill-card.md.\n- No user-facing changes to functionality or documentation.\n- Minor cleanup of repository contents.\n\nv1.0.0 | 2026-09-19T11:18:42.234Z | auto\n\n- Initial release of simplepractice-fpx, enabling shell access to SimplePractice Client Portal data using plain curl.\n- Passwordless, email-based login flow supported; sign in headlessly with a magic link or 6-digit PIN.\n- Optionally extract session credentials from an already-signed-in browser using fpx.\n- Access appointments, billing details, documents, announcements, and practice/clinician info without running the MCP server.\n- No bot wall or captcha for portal API access after authentication.\n- File skill-card.md removed.\n\nv0.4.2 | 2026-09-10T17:51:58.368Z | auto\n\n- Removed the file skill-card.md.\n- No other functional or documentation changes in this release.\n\nv0.4.1 | 2026-09-05T00:51:20.679Z | auto\n\n- Removed the file: skill-card.md.\n- No changes to functionality or usage.\n- Documentation files remain unchanged.\n\nv0.4.0 | 2026-09-04T22:21:20.792Z | auto\n\n- Removed sample file skill-card.md.\n- No other user-facing changes.\n\nv0.3.0 | 2026-09-02T00:16:51.255Z | auto\n\n- Removed the file: skill-card.md\n- No functional or feature changes; this update only deletes an unused documentation file.\n\nv0.2.0 | 2026-08-25T14:00:46.702Z | auto\n\n- Expanded magic link handling details: clarified supported link formats, explained fragment extraction, and added a warning about quoted-printable encoding in emails.\n- Minor corrections and clarifications in authentication and API instructions.\n- Updated documentation in SKILL.md and references/requests.md for greater accuracy and completeness.\n- Removed outdated skill-card.md file.\n\nv0.1.0 | 2026-08-24T22:31:43.894Z | auto\n\nInitial release: access your SimplePractice Client Portal with curl or session cookie extraction.\n\n- Headless sign-in using a magic link or PIN from your email — no password or captcha required.\n- Optional `fpx` method to extract session cookie from a signed-in browser, enabling curl API access without re-authentication.\n- Detailed usage for setting up practice/API endpoints, required headers, and managing session credentials securely.\n- Fetch information on appointments, billing (invoices, statements, superbills, receipts), documents, announcements, practice, and clinician — all via documented curl examples.\n- Emphasizes privacy/security: treat the cookie as a bearer token and protect the credential file accordingly.\n\nArchive index:\n\nArchive v1.2.6: 4 files, 13669 bytes\n\nFiles: references/requests.md (16598b), skill-card.md (1990b), SKILL.md (12336b), _meta.json (137b)\n\nFile v1.2.6:SKILL.md\n\n---\nname: simplepractice-fpx\ndescription: >-\n  Read a SimplePractice Client Portal (`<practice>.clientsecure.me`) from a\n  shell — appointments, invoices/statements/superbills/receipts, documents to\n  sign, announcements, practice and clinician info — with plain `curl` against\n  its JSON:API, instead of running the simplepractice-mcp server. Sign in\n  headlessly with an emailed magic link, or capture the session cookie from an\n  already-signed-in browser tab with `fpx`. Use when you want Client Portal\n  data without the MCP, in a script, or on a machine where the MCP isn't\n  installed.\n---\n\n# SimplePractice Client Portal via curl (+ optional fpx)\n\nThe Client Portal is an Ember app whose backend is a plain **JSON:API** at\n`https://<practice>.clientsecure.me/client-portal-api`. It has **no bot wall**\n— every endpoint below answers ordinary server-side `curl` once you hold a\nsession cookie. So this skill is curl-first; `fpx` appears only as an optional\none-time way to lift the cookie out of a browser you're already signed into.\n\nThere is **no password**. Sign-in is passwordless: SimplePractice emails you\neither a magic link or a 6-digit PIN, and you trade that for a session cookie.\nThat flow carries **no captcha** (reCAPTCHA guards only the new-client request,\nwaitlist and contact forms), so §1 below works headlessly with nothing but\n`curl` and access to your inbox.\n\n> This is protected health information — your own therapy/medical record.\n> Treat the cookie jar as a credential: it is a full-access bearer token for\n> the portal. Keep it `chmod 600`, out of git, and off shared machines.\n\n## Your practice subdomain\n\nEvery URL is scoped to one practice. Take the host from the portal link your\nprovider sent you and export it once:\n\n```sh\nexport SP_HOST='achievebalancetherapy.clientsecure.me'   # <-- yours\nexport SP_API=\"https://$SP_HOST/client-portal-api\"\nexport SP_JAR=\"$HOME/.simplepractice-cookies\"\n\n# curl creates a cookie jar world-readable (644). This one holds a live\n# session for a medical record, so create it 0600 BEFORE curl ever writes it.\n[ -e \"$SP_JAR\" ] || ( umask 077; : > \"$SP_JAR\" )\nchmod 600 \"$SP_JAR\"\n```\n\n## The four headers — all of them, on every call\n\n```sh\nsp() { curl -s -b \"$SP_JAR\" -c \"$SP_JAR\" \\\n  -H 'Api-Version: 2026-05-25' \\\n  -H 'Application-Build-Version: 0.0.0' \\\n  -H 'Application-Platform: web' \\\n  -H 'Accept: application/vnd.api+json' \"$@\"; }\n```\n\nOmit `Application-Build-Version` and the API rejects the call with\n`400 {\"errors\":[{\"title\":\"Application build version is missing\"}]}` — verified.\n`Api-Version` is the API's own dated contract version, unrelated to any package\nversion; send it as-is.\n\n## 1. Sign in with a magic link (no browser)\n\n**a. Request the link.** One call, to your own portal address:\n\n```sh\nsp -X POST \"$SP_API/sign-in-tokens\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sign-in-tokens\",\"attributes\":{\"email\":\"you@example.com\",\"expiresIn\":\"15 minutes\"}}}'\n```\n\n`202 Accepted` means it was sent. The response echoes `expiresIn: \"24 hours\"`\nregardless of what you asked for — that is the real token lifetime, and it is\nalso what the API returns for an *unknown* email, deliberately, so that a 202\nnever reveals whether an address has an account.\n\n**Do not retry a failed sign-in.** `429` is a real limit with two distinct\ntitles — `Email request limit reached` and `IP request limit reached` — and\nhammering it locks you out of the only auth path there is. Wait it out.\n\n**b. Take the token out of the emailed link.** The link looks like\n\n```\nhttps://<practice>.clientsecure.me/sign-in/token#<TOKEN>\n```\n\nSimplePractice also mails a mobile-app variant on the bare apex,\n`https://clientsecure.me/client-portal-api/sign-in/token#<TOKEN>`. Either\nworks — the path is irrelevant, only the fragment matters.\n\nThe token is the **URL fragment**, after the `#` (about 300 characters).\nBecause it is a fragment it is never sent to the server by a browser\nnavigation — the app reads it in JS and posts it. So you must copy it\nyourself; following the link with `curl` does nothing.\n\nIf you are pulling the link out of a raw message rather than clicking it, note\nthe mail is **quoted-printable**: the URL is wrapped across lines with trailing\n`=`, and a naive regex will hand you a silently truncated token. Decode first.\n\n**c. Trade it for a session cookie.**\n\n```sh\nsp -X POST \"$SP_API/sessions/token\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"token\",\"token\":\"'\"$TOKEN\"'\"}}}' \\\n  | jq '.data.meta.status'\n```\n\n`\"verified\"` means the cookie jar is now authenticated. The other statuses are\n`\"expired\"` and `\"merged\"`; a `401`/`422` means the token was already used —\nthey are single-use.\n\n**PIN variant.** If your portal mails a 6-digit code instead of a link, post it\nto `sessions/pin` with the address it was sent to:\n\n```sh\nsp -X POST \"$SP_API/sessions/pin\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"pin\",\"email\":\"you@example.com\",\"pin\":\"123456\"}}}'\n```\n\n## 2. Or lift the cookie from a signed-in browser tab (fpx)\n\nOnly worth it if you're already signed in and would rather not wait on an\nemail. Requires the **Transporter** extension and `npm i -g @fetchproxy/cli`.\n\n```sh\nfpx profile add simplepractice --domain clientsecure.me\nfpx profile declare simplepractice \\\n  --cookie simplepractice-session --cookie client-portal-session \\\n  --local-storage client-portal-session --local-storage stored-email \\\n  --capture-header cookie@$SP_HOST\nfpx get \"https://$SP_HOST/\" -p simplepractice >/dev/null   # prints a pair code → approve in Transporter\n```\n\nDeclare **every** scope before that first pairing. Widening it afterwards\nleaves fetches working on the old grant while the new capability errors\n`capability \"read_cookies\" not granted`, and the fix is to remove the profile\nand re-pair from scratch.\n\nThen seed the jar from the browser's cookie:\n\n```sh\nSESSION=$(fpx cookies simplepractice-session -p simplepractice \\\n            --storage-subdomain \"${SP_HOST%%.*}\" | jq -r '.[\"simplepractice-session\"]')\nprintf '#HttpOnly_%s\\tFALSE\\t/\\tTRUE\\t0\\tsimplepractice-session\\t%s\\n' \"$SP_HOST\" \"$SESSION\" > \"$SP_JAR\"\nchmod 600 \"$SP_JAR\"\n```\n\n`fpx` exit codes: `2` bridge unavailable, `3` bot wall, `4` upstream non-2xx.\n\n## 3. Who am I, and which client am I looking at\n\n```sh\nsp \"$SP_API/environment?include=currentPractice,currentClient,currentClientOptions\" | jq '{\n  practice: (.included[] | select(.type==\"practices\") | .attributes.fullName),\n  timeZone: (.included[] | select(.type==\"practices\") | .attributes.timeZone),\n  clients:  [.included[] | select(.type==\"clients\") | {id, name: (.attributes.firstName+\" \"+.attributes.lastName)}]\n}'\n```\n\nOne portal login is a **client access**, and it can cover more than one client\n— a parent seeing two children, say. `currentClientOptions` is always an array;\n`currentClient` is the one whose data the other endpoints return. Don't assume\nthere is exactly one. (On a login that acts for someone else, the client\nrecord's own `email` is `null` — the sign-in address lives on the access, not\nthe client, so don't reach for `clients[].email` to find out who you are.)\n\n`401 {\"title\":\"You have no access to this client\"}` on any endpoint below means\nthe cookie is stale or absent — go back to §1.\n\n## 4. Reads\n\nAll of these are verified live. Collections are JSON:API, so records live under\n`.data[]` with fields under `.attributes`; `include=` pulls related records into\na sibling `.included[]` array that you join on\n`.relationships.<name>.data.id`.\n\n```sh\n# Upcoming appointments (and the requested-but-unconfirmed ones)\nsp \"$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=false&page[size]=50&page[number]=1\"\nsp \"$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=true&page[size]=50&page[number]=1\"\n\n# Billing — one endpoint, switched by filter[thisType]\nsp \"$SP_API/billing-items?filter[thisType]=invoice&page[size]=50\"\nsp \"$SP_API/billing-items?filter[thisType]=statement&page[size]=50\"\nsp \"$SP_API/billing-items?filter[thisType]=superbill&page[size]=50\"\nsp \"$SP_API/billing-items?filter[thisType]=receipt&page[size]=50\"\nsp \"$SP_API/billing-items?filter[thisType]=billable-item,payment&filter[thisTypeCondition]=unallocated&page[size]=50\"\n\n# Documents to review or sign, and files shared with you\nsp \"$SP_API/document-requests?page[size]=50\"\nsp \"$SP_API/documents?page[size]=50\"\n\n# Practice announcements\nsp \"$SP_API/announcements?page[size]=50\"\n\n# Balance summary and saved cards hang off the CLIENT record, not collections\n# of their own — see the warning below.\nCLIENT_ID=$(sp \"$SP_API/environment?include=currentClient\" \\\n            | jq -r '.data.relationships.currentClient.data.id')\nsp \"$SP_API/clients/$CLIENT_ID?include=clientBillingOverview,cards\" \\\n| jq '{balance: (.included[] | select(.type==\"clientBillingOverviews\") | .attributes),\n       cards:  [.included[] | select(.type==\"cards\")\n                | {brand: .attributes.brand, last4: .attributes.last4,\n                   expiry: .attributes.expiry, isDefault: .attributes.isDefault}]}'\n```\n\n> **A 200 is not proof an endpoint exists.** The portal is a single-page app,\n> so *any* path it does not define comes back as `200 text/html` with the app\n> shell (a constant ~7.5 KB) rather than a 404. `/cards` and\n> `/client-billing-overviews` are the obvious guesses for the two above, and\n> both answer 200 that way — they are not API paths at all. Check the\n> `content-type`, not the status:\n>\n> ```sh\n> sp -o /dev/null -w '%{http_code} %{content_type}\\n' \"$SP_API/whatever\"\n> ```\n>\n> Anything other than `application/vnd.api+json` means the path is wrong.\n\nA readable next-appointment line:\n\n```sh\nsp \"$SP_API/appointments?include=clinician,office&filter[hasPendingConfirmation]=false&page[size]=1&page[number]=1\" \\\n| jq -r '.data[0] as $a\n  | (.included[]? | select(.type==\"clinicians\")) as $c\n  | \"\\($a.attributes.startTime)  \\($a.attributes.serviceDescription // \"—\")  with \\($c.attributes.firstName) \\($c.attributes.lastName)\"'\n```\n\n## Pagination — two schemes, don't mix them\n\n- **Appointments** page by number: `page[number]=1&page[size]=50`. You're on\n  the last page when a page comes back shorter than `page[size]`.\n- **Billing items** page by *cursor*, backwards: `page[size]=50` and then\n  `page[before]=<cursorId of the last row you saw>`. The row's `cursorId` is\n  the cursor, not its `id`.\n\n`50` is the server's max page size; asking for more does not get you more.\n\n## Notes\n\n- Times come back ISO-8601 with an offset. The practice's own `timeZone`\n  (§3) is what its staff schedule in — use it when a date matters.\n- **`permissions` on the client is a JSON string, not an object.** It parses\n  to the portal features this client actually has —\n  `{\"messaging\":…,\"selfScheduling\":…,\"billingDocuments\":…,\"payments\":…,\"appointments\":…}`.\n  Read it with `.attributes.permissions | fromjson`; used raw it is a string of\n  characters. `billingDocuments` is what gates the whole billing tab.\n- **`hasDocumentPdf` is a string, not a boolean.** It arrives as `\"true\"` or\n  `\"false\"` — both seen live — so `if (hasDocumentPdf)` and\n  `jq 'select(.attributes.hasDocumentPdf)'` are BOTH true for `\"false\"`.\n  Compare against the string: `select(.attributes.hasDocumentPdf == \"true\")`.\n  A card's `isDefault` is the same — so do not assume a JSON boolean anywhere\n  in this API without checking the value you actually get back.\n- `billing-items` is polymorphic: `.data[].type` tells you which of\n  invoice / statement / superbill / receipt / payment a row actually is, and\n  the attribute set differs per type. `.meta.endBalance` accompanies every\n  billing query.\n- An empty `.data[]` is a real answer, not a failure — plenty of practices\n  bill outside the portal entirely and every billing endpoint returns `200`\n  with nothing in it.\n- Everything here is a **read**. Cancelling an appointment, submitting a\n  signed document, or paying an invoice are writes this skill deliberately\n  does not cover — do those in the portal, where you can see what you're\n  agreeing to.\n- This project is developed and maintained by AI (Claude).\n\nFile v1.2.6:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"simplepractice-fpx\",\n  \"version\": \"1.2.6\",\n  \"publishedAt\": 1791588642026\n}\n\nFile v1.2.6:references/requests.md\n\n# SimplePractice Client Portal — request reference\n\nBase: `https://<practice>.clientsecure.me/client-portal-api`\n\nEvery shape below was taken from the portal app's own published sourcemaps\n(`widget-cdn.simplepractice.com/assets/*.map`, which ship full\n`sourcesContent`) and then confirmed against a live signed-in portal. Nothing\nhere is guessed. Where a field could not be exercised on the account used for\nverification, it says so.\n\nAssumes the `sp()` helper and `$SP_API` from `SKILL.md`.\n\n---\n\n## 0. Two naming systems — the trap\n\nURLs are **dashed and plural**. JSON:API `type` values are **camelCase and\nplural**. They are not the same string, and one endpoint uses both:\n\n| URL path | `.data[].type` |\n|---|---|\n| `/sign-in-tokens` | `signInTokens` |\n| `/document-requests` | `documentRequestQuestionnaires`, `documentRequestConsentDocuments`, … |\n| `/billing-items` | `invoices`, `statements`, `superbills`, `receipts`, `payments` |\n| `/client-billing-overviews` | `clientBillingOverviews` |\n| `/environment` (singular!) | `environments` |\n\nSo never build a `jq` filter by pluralising the path. Match on the `type`\nstring the response actually carries, or select positionally.\n\n`/environment` is the one singular path in the API.\n\n---\n\n## 1. Auth\n\n### 1.1 Request a magic link — `POST /sign-in-tokens`\n\n```sh\nsp -X POST \"$SP_API/sign-in-tokens\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sign-in-tokens\",\"attributes\":{\"email\":\"you@example.com\",\"expiresIn\":\"15 minutes\"}}}'\n```\n\n`202 Accepted`:\n\n```json\n{\"data\":{\"id\":\"…\",\"type\":\"signInTokens\",\"attributes\":{\"email\":\"you@example.com\",\"expiresIn\":\"24 hours\"}}}\n```\n\n- `expiresIn` in the **response** is the real lifetime (24 hours) whatever you\n  request. The portal app deliberately shows the same \"24 hours\" wording for an\n  address with no account, so that the response cannot be used to test whether\n  an email is registered. A `202` is therefore not proof the address exists.\n- Optional `redirect` attribute: a portal-relative path to land on after\n  verifying (the app uses it for `payment-link/<id>`).\n- **Errors.** `429` with title `Email request limit reached` or\n  `IP request limit reached`; `422` for a malformed address. Do not retry\n  either — this is the only auth path the portal has.\n\n### 1.2 Exchange the token — `POST /sessions/token`\n\nThe emailed link is\n**`https://<practice>.clientsecure.me/sign-in/token#<TOKEN>`** — `/sign-in/token`,\n*not* the `sign-in/token/verify` the app's route tree implies. A second variant,\nsent for the mobile app, points at the bare apex under the API namespace:\n`https://clientsecure.me/client-portal-api/sign-in/token#<TOKEN>`. Either works —\ntake the fragment, ignore the path.\n\nThe token is the **fragment** (303–317 characters observed). A browser never\nsends a fragment to the server; the app reads `location.hash` and posts it.\nFetching the link with `curl` accomplishes nothing — copy the part after `#`.\n\nBoth emails are quoted-printable, so the URL is **wrapped across lines with\ntrailing `=`**. Pulling it out of a raw message with a naive regex silently\ntruncates the token — decode the quoted-printable first.\n\n```sh\nsp -X POST \"$SP_API/sessions/token\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"token\",\"token\":\"'\"$TOKEN\"'\"}}}'\n```\n\nSuccess sets the `simplepractice-session` cookie (Rails/Devise) and returns\n`.data.meta.status`:\n\n| `meta.status` | meaning |\n|---|---|\n| `verified` | signed in; the cookie jar is now good |\n| `expired` | older than 24h — request a new link |\n| `merged` | the account was merged into another; sign in from the new portal |\n\nTokens are single-use — confirmed by replay, which answers\n`401 {\"title\":\"Authorization has already been used or expired\"}`. That is a 401\non a sign-in endpoint, where you have no session yet; it means *get a new\nlink*, not *your session expired*.\n\n### 1.3 PIN variant — `POST /sessions/pin`\n\n```sh\nsp -X POST \"$SP_API/sessions/pin\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"pin\",\"email\":\"you@example.com\",\"pin\":\"123456\"}}}'\n```\n\nThe PIN is exactly 6 digits (client-side regex `^\\d{6}$`) and is likewise\nsingle-use — a wrong or reused code comes back as a validation error on `pin`,\nand `429` is the rate limit.\n\n### 1.4 Session expiry\n\nAny endpoint answers `401 {\"errors\":[{\"title\":\"You have no access to this client\",\"status\":\"401\"}]}`\nonce the cookie lapses. There is no refresh token: re-run §1.1.\n\n---\n\n## 2. Identity — `GET /environment`\n\n```sh\nsp \"$SP_API/environment?include=currentPractice,currentClient,currentClientOptions\"\n```\n\n`.data` is the singleton `environments` record; the interesting part is\n`.data.relationships` + `.included[]`:\n\n| relationship | shape |\n|---|---|\n| `currentPractice` | one `practices` |\n| `currentClient` | one `clients` — whose data every other endpoint returns |\n| `currentClientOptions` | **array** of `clients` this login may switch between |\n| `currentClientAccess` | one `clientAccesses` — the login itself |\n\n```sh\nsp \"$SP_API/environment?include=currentPractice,currentClientOptions\" | jq '{\n  practice: (.included[] | select(.type==\"practices\") | .attributes.fullName),\n  timeZone: (.included[] | select(.type==\"practices\") | .attributes.timeZone),\n  clients: [.included[] | select(.type==\"clients\")\n            | {id, name: ((.attributes.preferredName // .attributes.firstName) + \" \" + .attributes.lastName)}]\n}'\n```\n\nUseful `practices` attributes (67 in all): `fullName`, `timeZone`, `currency`,\n`practiceUrl`, `phoneNumber`, `isGroupPractice`, `telehealthEnabled`,\n`selfSchedulingEnabled`, `isClientAllowedToCancelAppt`,\n`isClientAllowedToConfirmAppt`, `clientCancellableHrs`,\n`announcementsAvailable`, `featureSecureMessagingEmber`.\n\n`isClientAllowedToCancelAppt` and `clientCancellableHrs` are the practice's\nactual cancellation policy — worth reading before assuming an appointment can\nbe cancelled.\n\n`clients` attributes include `firstName`, `lastName`, `preferredName`,\n`nickname`, `birthDate`, `hashedId`, `status`, `billingType`,\n`hasIncompleteDocument`, `hasNewAnnouncements`, `hasInvoicedAppointments`,\n`permissions`, and `relationshipToCurrentClientAccess`.\n\n**`clients[].email` is `null` on a login that acts for someone else** (a parent\nportal, say). The sign-in address belongs to the *access*, not the client.\n\n---\n\n## 3. Appointments — `GET /appointments`\n\n```sh\n# upcoming / confirmed\nsp \"$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=false&page[size]=50&page[number]=1\"\n# requested, awaiting the practice's confirmation\nsp \"$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=true&page[size]=50&page[number]=1\"\n```\n\n`.data[].type` is `appointments`; `.included[]` carries `clinicians`,\n`offices`, `clients`.\n\nAttributes (21 live; from `models/unauthenticated-appointment.js` +\n`models/appointment.js`):\n\n| field | notes |\n|---|---|\n| `startTime`, `endTime` | ISO-8601 with offset |\n| `serviceDescription` | e.g. the CPT service name |\n| `confirmationStatus`, `clientConfirmationStatus` | practice-side vs client-side |\n| `isCancellable` | boolean — respects the practice's own policy |\n| `cancelReason`, `visitReason`, `visitTherapyReasons` | |\n| `videoRoomUrl` | telehealth link, when the appointment is video |\n| `icalUrl`, `gcalendarUrl` | ready-made calendar links |\n| `fee`, `uninvoicedFee`, `billableDescription`, `cptCodes`, `units` | |\n| `channel`, `schedulingSource`, `source` | how it was booked |\n| `files` | attachments |\n\nRelationships: `clinician`, `office`, `client`, `card`, `superbill`,\n`invoiceItems`, `appointmentClient`.\n\n`offices` carry `name`, `street`, `city`, `state`, `zip`, `phone`, `isVideo`,\n`geolocation` — `isVideo: true` is a telehealth \"room\", not an address.\n\nJoined one-liner:\n\n```sh\nsp \"$SP_API/appointments?include=clinician,office&filter[hasPendingConfirmation]=false&page[size]=50&page[number]=1\" \\\n| jq -r '\n  (.included // []) as $inc\n  | .data[]\n  | . as $a\n  | ($inc[]? | select(.type==\"clinicians\" and .id==$a.relationships.clinician.data.id)) as $c\n  | ($inc[]? | select(.type==\"offices\"    and .id==$a.relationships.office.data.id))    as $o\n  | [$a.attributes.startTime,\n     ($a.attributes.serviceDescription // \"—\"),\n     \"\\($c.attributes.firstName) \\($c.attributes.lastName)\",\n     (if $o.attributes.isVideo then \"telehealth\" else ($o.attributes.name // \"—\") end)\n    ] | @tsv'\n```\n\n**Pagination: by number.** `page[number]` / `page[size]`, max size 50. A short\npage is the last page.\n\n---\n\n## 4. Billing — `GET /billing-items`\n\nOne polymorphic collection, switched by `filter[thisType]`:\n\n| `filter[thisType]` | `.data[].type` | key attributes |\n|---|---|---|\n| `invoice` | `invoices` | `displayName`, `displayStatus`, `invoiceDate`, `totalAmount`, `remainingAmount`, `isNewForClient` |\n| `statement` | `statements` | `displayName`, `createdAt`, `isNewForClient` |\n| `superbill` | `superbills` | `displayName`, `createdAt`, `totalAmount`, `isNewForClient` |\n| `receipt` | `receipts` | `displayName`, `createdAt`, `isNewForClient` |\n| `billable-item,payment` (+ `filter[thisTypeCondition]=unallocated`) | mixed | account history |\n\n```sh\nsp \"$SP_API/billing-items?filter[thisType]=invoice&page[size]=50\" \\\n| jq '{balance: .meta.endBalance,\n       rows: [.data[] | {type, id, name: .attributes.displayName,\n                         status: .attributes.displayStatus,\n                         total: .attributes.totalAmount,\n                         due: .attributes.remainingAmount}]}'\n```\n\nEvery billing query returns `.meta.endBalance`.\n\nOptional `filter[timeRange]` narrows by date; the portal sends it as a\n`{start,end}` object, which `curl` writes as\n`filter[timeRange][start]=…&filter[timeRange][end]=…`. Omit it for everything.\n\n**Pagination: by cursor, backwards.** `page[size]=50`, then\n`page[before]=<the last row's cursorId>`. The cursor is the row's `cursorId`\nattribute, **not** its `id`. A short page is the last page.\n\n```sh\nsp \"$SP_API/billing-items?filter[thisType]=invoice&page[size]=50\" | jq -r '.data[-1].attributes.cursorId'\n```\n\nAn empty `.data[]` here is a normal, correct answer — many practices invoice\nentirely outside the portal. All five filters were confirmed to return `200`\nwith `meta.endBalance` on the account used for verification, which had no\nportal billing rows.\n\n### 4.1 Balance summary and saved cards live ON the client record\n\nThere is **no** `/client-billing-overviews` collection and **no** `/cards`\ncollection. Both are `include`-able relationships of `/clients/<id>`:\n\n```sh\nCLIENT_ID=$(sp \"$SP_API/environment?include=currentClient\" \\\n            | jq -r '.data.relationships.currentClient.data.id')\nsp \"$SP_API/clients/$CLIENT_ID?include=clientBillingOverview,cards\"\n```\n\n- `clientBillingOverviews` — `balanceDue`, `unallocatedPaymentAmount`, and the\n  counts `invoicesCount` / `statementsCount` / `superbillsCount` /\n  `receiptsCount` / `insuranceInfoCount`. Cheaper than paging the collections\n  just to see whether anything is there.\n- `cards` — `brand`, `last4`, `expiry` (e.g. `\"07 / 30\"`), `expMonth`,\n  `expYear`, `isDefault` (a **string** `\"true\"`/`\"false\"`), plus the Stripe\n  identifiers `paymentMethodId` / `customStripeCardId` /\n  `customStripeCustomerId`. No full card number.\n\n> Guessing `/cards` and `/client-billing-overviews` is the natural first move,\n> and both return **HTTP 200** — with `text/html` and the app shell, because\n> the SPA catch-all swallows every undefined path. They read as working,\n> empty endpoints. This cost a full debugging round during this build; check\n> `content-type`, never status, when an endpoint returns suspiciously nothing.\n\n---\n\n## 5. Documents — `GET /document-requests`\n\nPaperwork the practice has sent you to read, complete or sign.\n\n```sh\nsp \"$SP_API/document-requests?page[size]=50\" \\\n| jq -r '.data[] | [.attributes.status, .type, .attributes.documentTitle] | @tsv'\n```\n\n`.data[].type` is the *subtype*, and the attribute set varies with it:\n\n| `type` | what it is |\n|---|---|\n| `documentRequestConsentDocuments` | a consent form to sign |\n| `documentRequestQuestionnaires` | a questionnaire — `templateQuestions`, `userAnswers` |\n| `documentRequestContactInfos` | demographics/contact form |\n| `documentRequestInsuranceInfos` | insurance details |\n| `documentRequestCreditCardInfos` | card on file — `cardAttributes` |\n| `documentRequestStoredDocuments` | a file shared with you |\n| `documentRequestNotes` | a note |\n| `documentRequestGoodFaithEstimates` | a Good Faith Estimate |\n| `documentRequestPostSessionSummaries` | post-session summary |\n\n`status` ∈ `sent` · `viewed` · `reviewing` · `completed` · `locked`\n(`completed`, `sent` and `viewed` seen live). Anything not `completed`/`locked`\nis outstanding:\n\n```sh\nsp \"$SP_API/document-requests?page[size]=50\" \\\n| jq -r '[.data[] | select(.attributes.status | IN(\"completed\",\"locked\") | not)\n          | .attributes.documentTitle] | \"outstanding: \\(length)\\n\" + join(\"\\n\")'\n```\n\nCommon attributes: `documentTitle`, `status`, `createdAt`, `updatedAt`,\n`hasDocumentPdf`.\n\n> **`hasDocumentPdf` is a JSON string**, `\"true\"` or `\"false\"` — not a boolean,\n> despite `models/document-request.js` declaring `@attr('boolean')` (Ember casts\n> it client-side; the wire value is a string). Both values were seen live. So\n> `select(.attributes.hasDocumentPdf)` matches every row, including the ones\n> with no PDF. Always compare to the string:\n>\n> ```sh\n> jq -r '.data[] | select(.attributes.hasDocumentPdf == \"true\") | .attributes.documentTitle'\n> ```\n>\n> It is not the only one: a saved card's `isDefault` arrives as `\"true\"` /\n> `\"false\"` too. The declared type in the model is not evidence of the wire\n> type — check any boolean you come to depend on, with a real response. Subtype-specific: `documentType`, `documentExt`,\n`documentMimeType`, `documentBody`, `templateQuestions`, `userAnswers`,\n`cardAttributes`, `mixpanelType`.\n\nCollection `.meta` carries `hasDocumentsIntro` and `welcomeText`.\n\nSingle request: `GET /document-requests/<id>`.\n\n`hasDocumentPdf: true` means a rendered PDF exists. The portal fetches it\nthrough the same authenticated origin; treat the URL as session-scoped.\n\n`GET /documents` is the separate \"files shared with you\" list —\n`documentName`, `documentExt`, `thisType`, `createdAt`.\n\n---\n\n## 6. Announcements — `GET /announcements`\n\n```sh\nsp \"$SP_API/announcements?page[size]=50\" \\\n| jq -r '.data[] | [(.attributes.readAt // \"UNREAD\"), .attributes.title] | @tsv'\n```\n\nAttributes: `title`, `message`, `fromLabel`, `createdAt`, `readAt`,\n`isDeleted`. `clients[].hasNewAnnouncements` (§2) is the cheap \"is there\nanything new\" flag.\n\nThere is a `POST /announcements/read-announcements` that marks them all read —\na write, so out of scope here; it is listed only so you recognise it.\n\n---\n\n## 7. Secure messaging\n\nMessaging is **not** on this API. It lives at\n`https://messaging-api.simplepractice.com` (`messagingApiUrl` in the portal's\nconfig) with models `messagingConversation` / `messagingMessage` /\n`messagingContact` / `messagingProfile` / `messagingUser`, and is gated by the\npractice's `featureSecureMessagingEmber` flag.\n\nIts request shapes were **not** captured for this skill. If you need messages,\nread them in the portal, or capture the host's calls first — don't guess them.\n\n---\n\n## 8. Errors\n\n| status | meaning |\n|---|---|\n| `400` `Application build version is missing` | you dropped `Application-Build-Version` |\n| `401` `You have no access to this client` | cookie stale/absent → re-auth (§1) |\n| `422` | validation — the body names the offending field |\n| `429` | rate limit; on auth calls the title says email- or IP-scoped. Do not retry |\n\nErrors are JSON:API: `.errors[] | {title, code, status}`.\n\n---\n\n## Appendix — where these shapes came from\n\nThe portal serves public sourcemaps with full original sources:\n\n```sh\ncurl -s https://widget-cdn.simplepractice.com/assets/<chunk>.js.map | gunzip > map.json\nnode -e 'const m=require(\"./map.json\");m.sources.forEach((s,i)=>{/* write m.sourcesContent[i] */})'\n```\n\nThe chunk filenames are hashed per deploy — read them out of the portal HTML's\n`<script src>` tags. `adapters/application.js` defines the namespace and the\nrequired headers; `models/*.js` define every attribute; `routes/site/**` show\nwhich filters each screen sends. When SimplePractice ships a new build, that is\nthe authoritative place to re-check a shape.\n\nFile v1.2.6:skill-card.md\n\n## Description:\n\nGuides an agent in reading a signed-in SimplePractice Client Portal from the shell to retrieve appointments, billing records, documents, and practice information.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nPortal users and their authorized agents use this skill to retrieve their SimplePractice appointment, billing, document, and practice information without installing a separate portal server. It covers reading data, not making payments or changing appointments.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: A live portal session cookie can grant access to sensitive health and billing records.\n\nMitigation: Use a trusted personal machine, restrict the cookie jar to the account owner, keep it out of git and logs, and delete it when finished.\n\nRisk: Portal responses may expose protected health information in shared output or saved files.\n\nMitigation: Avoid printing or saving health and billing responses in shared locations; review any agent output before sharing.\n\n## Reference(s):\n\n- [SimplePractice FPX release](https://clawhub.ai/chrischall/skills/simplepractice-fpx)\n- [SimplePractice Client Portal request reference](references/requests.md)\n\n## Skill Output:\n\n**Output Type(s):** [Guidance, Shell commands, Text]\n\n**Output Format:** [Markdown with shell examples and portal response summaries]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Read-only portal guidance; results may contain sensitive health and billing information.]\n\n## Skill Version(s):\n\n1.2.6 (source: server-resolved release metadata)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.2.5: 4 files, 13645 bytes\n\nFiles: references/requests.md (16598b), skill-card.md (1875b), SKILL.md (12336b), _meta.json (137b)\n\nFile v1.2.5:SKILL.md\n\n---\nname: simplepractice-fpx\ndescription: >-\n  Read a SimplePractice Client Portal (`<practice>.clientsecure.me`) from a\n  shell — appointments, invoices/statements/superbills/receipts, documents to\n  sign, announcements, practice and clinician info — with plain `curl` against\n  its JSON:API, instead of running the simplepractice-mcp server. Sign in\n  headlessly with an emailed magic link, or capture the session cookie from an\n  already-signed-in browser tab with `fpx`. Use when you want Client Portal\n  data without the MCP, in a script, or on a machine where the MCP isn't\n  installed.\n---\n\n# SimplePractice Client Portal via curl (+ optional fpx)\n\nThe Client Portal is an Ember app whose backend is a plain **JSON:API** at\n`https://<practice>.clientsecure.me/client-portal-api`. It has **no bot wall**\n— every endpoint below answers ordinary server-side `curl` once you hold a\nsession cookie. So this skill is curl-first; `fpx` appears only as an optional\none-time way to lift the cookie out of a browser you're already signed into.\n\nThere is **no password**. Sign-in is passwordless: SimplePractice emails you\neither a magic link or a 6-digit PIN, and you trade that for a session cookie.\nThat flow carries **no captcha** (reCAPTCHA guards only the new-client request,\nwaitlist and contact forms), so §1 below works headlessly with nothing but\n`curl` and access to your inbox.\n\n> This is protected health information — your own therapy/medical record.\n> Treat the cookie jar as a credential: it is a full-access bearer token for\n> the portal. Keep it `chmod 600`, out of git, and off shared machines.\n\n## Your practice subdomain\n\nEvery URL is scoped to one practice. Take the host from the portal link your\nprovider sent you and export it once:\n\n```sh\nexport SP_HOST='achievebalancetherapy.clientsecure.me'   # <-- yours\nexport SP_API=\"https://$SP_HOST/client-portal-api\"\nexport SP_JAR=\"$HOME/.simplepractice-cookies\"\n\n# curl creates a cookie jar world-readable (644). This one holds a live\n# session for a medical record, so create it 0600 BEFORE curl ever writes it.\n[ -e \"$SP_JAR\" ] || ( umask 077; : > \"$SP_JAR\" )\nchmod 600 \"$SP_JAR\"\n```\n\n## The four headers — all of them, on every call\n\n```sh\nsp() { curl -s -b \"$SP_JAR\" -c \"$SP_JAR\" \\\n  -H 'Api-Version: 2026-05-25' \\\n  -H 'Application-Build-Version: 0.0.0' \\\n  -H 'Application-Platform: web' \\\n  -H 'Accept: application/vnd.api+json' \"$@\"; }\n```\n\nOmit `Application-Build-Version` and the API rejects the call with\n`400 {\"errors\":[{\"title\":\"Application build version is missing\"}]}` — verified.\n`Api-Version` is the API's own dated contract version, unrelated to any package\nversion; send it as-is.\n\n## 1. Sign in with a magic link (no browser)\n\n**a. Request the link.** One call, to your own portal address:\n\n```sh\nsp -X POST \"$SP_API/sign-in-tokens\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sign-in-tokens\",\"attributes\":{\"email\":\"you@example.com\",\"expiresIn\":\"15 minutes\"}}}'\n```\n\n`202 Accepted` means it was sent. The response echoes `expiresIn: \"24 hours\"`\nregardless of what you asked for — that is the real token lifetime, and it is\nalso what the API returns for an *unknown* email, deliberately, so that a 202\nnever reveals whether an address has an account.\n\n**Do not retry a failed sign-in.** `429` is a real limit with two distinct\ntitles — `Email request limit reached` and `IP request limit reached` — and\nhammering it locks you out of the only auth path there is. Wait it out.\n\n**b. Take the token out of the emailed link.** The link looks like\n\n```\nhttps://<practice>.clientsecure.me/sign-in/token#<TOKEN>\n```\n\nSimplePractice also mails a mobile-app variant on the bare apex,\n`https://clientsecure.me/client-portal-api/sign-in/token#<TOKEN>`. Either\nworks — the path is irrelevant, only the fragment matters.\n\nThe token is the **URL fragment**, after the `#` (about 300 characters).\nBecause it is a fragment it is never sent to the server by a browser\nnavigation — the app reads it in JS and posts it. So you must copy it\nyourself; following the link with `curl` does nothing.\n\nIf you are pulling the link out of a raw message rather than clicking it, note\nthe mail is **quoted-printable**: the URL is wrapped across lines with trailing\n`=`, and a naive regex will hand you a silently truncated token. Decode first.\n\n**c. Trade it for a session cookie.**\n\n```sh\nsp -X POST \"$SP_API/sessions/token\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"token\",\"token\":\"'\"$TOKEN\"'\"}}}' \\\n  | jq '.data.meta.status'\n```\n\n`\"verified\"` means the cookie jar is now authenticated. The other statuses are\n`\"expired\"` and `\"merged\"`; a `401`/`422` means the token was already used —\nthey are single-use.\n\n**PIN variant.** If your portal mails a 6-digit code instead of a link, post it\nto `sessions/pin` with the address it was sent to:\n\n```sh\nsp -X POST \"$SP_API/sessions/pin\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"pin\",\"email\":\"you@example.com\",\"pin\":\"123456\"}}}'\n```\n\n## 2. Or lift the cookie from a signed-in browser tab (fpx)\n\nOnly worth it if you're already signed in and would rather not wait on an\nemail. Requires the **Transporter** extension and `npm i -g @fetchproxy/cli`.\n\n```sh\nfpx profile add simplepractice --domain clientsecure.me\nfpx profile declare simplepractice \\\n  --cookie simplepractice-session --cookie client-portal-session \\\n  --local-storage client-portal-session --local-storage stored-email \\\n  --capture-header cookie@$SP_HOST\nfpx get \"https://$SP_HOST/\" -p simplepractice >/dev/null   # prints a pair code → approve in Transporter\n```\n\nDeclare **every** scope before that first pairing. Widening it afterwards\nleaves fetches working on the old grant while the new capability errors\n`capability \"read_cookies\" not granted`, and the fix is to remove the profile\nand re-pair from scratch.\n\nThen seed the jar from the browser's cookie:\n\n```sh\nSESSION=$(fpx cookies simplepractice-session -p simplepractice \\\n            --storage-subdomain \"${SP_HOST%%.*}\" | jq -r '.[\"simplepractice-session\"]')\nprintf '#HttpOnly_%s\\tFALSE\\t/\\tTRUE\\t0\\tsimplepractice-session\\t%s\\n' \"$SP_HOST\" \"$SESSION\" > \"$SP_JAR\"\nchmod 600 \"$SP_JAR\"\n```\n\n`fpx` exit codes: `2` bridge unavailable, `3` bot wall, `4` upstream non-2xx.\n\n## 3. Who am I, and which client am I looking at\n\n```sh\nsp \"$SP_API/environment?include=currentPractice,currentClient,currentClientOptions\" | jq '{\n  practice: (.included[] | select(.type==\"practices\") | .attributes.fullName),\n  timeZone: (.included[] | select(.type==\"practices\") | .attributes.timeZone),\n  clients:  [.included[] | select(.type==\"clients\") | {id, name: (.attributes.firstName+\" \"+.attributes.lastName)}]\n}'\n```\n\nOne portal login is a **client access**, and it can cover more than one client\n— a parent seeing two children, say. `currentClientOptions` is always an array;\n`currentClient` is the one whose data the other endpoints return. Don't assume\nthere is exactly one. (On a login that acts for someone else, the client\nrecord's own `email` is `null` — the sign-in address lives on the access, not\nthe client, so don't reach for `clients[].email` to find out who you are.)\n\n`401 {\"title\":\"You have no access to this client\"}` on any endpoint below means\nthe cookie is stale or absent — go back to §1.\n\n## 4. Reads\n\nAll of these are verified live. Collections are JSON:API, so records live under\n`.data[]` with fields under `.attributes`; `include=` pulls related records into\na sibling `.included[]` array that you join on\n`.relationships.<name>.data.id`.\n\n```sh\n# Upcoming appointments (and the requested-but-unconfirmed ones)\nsp \"$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=false&page[size]=50&page[number]=1\"\nsp \"$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=true&page[size]=50&page[number]=1\"\n\n# Billing — one endpoint, switched by filter[thisType]\nsp \"$SP_API/billing-items?filter[thisType]=invoice&page[size]=50\"\nsp \"$SP_API/billing-items?filter[thisType]=statement&page[size]=50\"\nsp \"$SP_API/billing-items?filter[thisType]=superbill&page[size]=50\"\nsp \"$SP_API/billing-items?filter[thisType]=receipt&page[size]=50\"\nsp \"$SP_API/billing-items?filter[thisType]=billable-item,payment&filter[thisTypeCondition]=unallocated&page[size]=50\"\n\n# Documents to review or sign, and files shared with you\nsp \"$SP_API/document-requests?page[size]=50\"\nsp \"$SP_API/documents?page[size]=50\"\n\n# Practice announcements\nsp \"$SP_API/announcements?page[size]=50\"\n\n# Balance summary and saved cards hang off the CLIENT record, not collections\n# of their own — see the warning below.\nCLIENT_ID=$(sp \"$SP_API/environment?include=currentClient\" \\\n            | jq -r '.data.relationships.currentClient.data.id')\nsp \"$SP_API/clients/$CLIENT_ID?include=clientBillingOverview,cards\" \\\n| jq '{balance: (.included[] | select(.type==\"clientBillingOverviews\") | .attributes),\n       cards:  [.included[] | select(.type==\"cards\")\n                | {brand: .attributes.brand, last4: .attributes.last4,\n                   expiry: .attributes.expiry, isDefault: .attributes.isDefault}]}'\n```\n\n> **A 200 is not proof an endpoint exists.** The portal is a single-page app,\n> so *any* path it does not define comes back as `200 text/html` with the app\n> shell (a constant ~7.5 KB) rather than a 404. `/cards` and\n> `/client-billing-overviews` are the obvious guesses for the two above, and\n> both answer 200 that way — they are not API paths at all. Check the\n> `content-type`, not the status:\n>\n> ```sh\n> sp -o /dev/null -w '%{http_code} %{content_type}\\n' \"$SP_API/whatever\"\n> ```\n>\n> Anything other than `application/vnd.api+json` means the path is wrong.\n\nA readable next-appointment line:\n\n```sh\nsp \"$SP_API/appointments?include=clinician,office&filter[hasPendingConfirmation]=false&page[size]=1&page[number]=1\" \\\n| jq -r '.data[0] as $a\n  | (.included[]? | select(.type==\"clinicians\")) as $c\n  | \"\\($a.attributes.startTime)  \\($a.attributes.serviceDescription // \"—\")  with \\($c.attributes.firstName) \\($c.attributes.lastName)\"'\n```\n\n## Pagination — two schemes, don't mix them\n\n- **Appointments** page by number: `page[number]=1&page[size]=50`. You're on\n  the last page when a page comes back shorter than `page[size]`.\n- **Billing items** page by *cursor*, backwards: `page[size]=50` and then\n  `page[before]=<cursorId of the last row you saw>`. The row's `cursorId` is\n  the cursor, not its `id`.\n\n`50` is the server's max page size; asking for more does not get you more.\n\n## Notes\n\n- Times come back ISO-8601 with an offset. The practice's own `timeZone`\n  (§3) is what its staff schedule in — use it when a date matters.\n- **`permissions` on the client is a JSON string, not an object.** It parses\n  to the portal features this client actually has —\n  `{\"messaging\":…,\"selfScheduling\":…,\"billingDocuments\":…,\"payments\":…,\"appointments\":…}`.\n  Read it with `.attributes.permissions | fromjson`; used raw it is a string of\n  characters. `billingDocuments` is what gates the whole billing tab.\n- **`hasDocumentPdf` is a string, not a boolean.** It arrives as `\"true\"` or\n  `\"false\"` — both seen live — so `if (hasDocumentPdf)` and\n  `jq 'select(.attributes.hasDocumentPdf)'` are BOTH true for `\"false\"`.\n  Compare against the string: `select(.attributes.hasDocumentPdf == \"true\")`.\n  A card's `isDefault` is the same — so do not assume a JSON boolean anywhere\n  in this API without checking the value you actually get back.\n- `billing-items` is polymorphic: `.data[].type` tells you which of\n  invoice / statement / superbill / receipt / payment a row actually is, and\n  the attribute set differs per type. `.meta.endBalance` accompanies every\n  billing query.\n- An empty `.data[]` is a real answer, not a failure — plenty of practices\n  bill outside the portal entirely and every billing endpoint returns `200`\n  with nothing in it.\n- Everything here is a **read**. Cancelling an appointment, submitting a\n  signed document, or paying an invoice are writes this skill deliberately\n  does not cover — do those in the portal, where you can see what you're\n  agreeing to.\n- This project is developed and maintained by AI (Claude).\n\nFile v1.2.5:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"simplepractice-fpx\",\n  \"version\": \"1.2.5\",\n  \"publishedAt\": 1791380317772\n}\n\nFile v1.2.5:references/requests.md\n\n# SimplePractice Client Portal — request reference\n\nBase: `https://<practice>.clientsecure.me/client-portal-api`\n\nEvery shape below was taken from the portal app's own published sourcemaps\n(`widget-cdn.simplepractice.com/assets/*.map`, which ship full\n`sourcesContent`) and then confirmed against a live signed-in portal. Nothing\nhere is guessed. Where a field could not be exercised on the account used for\nverification, it says so.\n\nAssumes the `sp()` helper and `$SP_API` from `SKILL.md`.\n\n---\n\n## 0. Two naming systems — the trap\n\nURLs are **dashed and plural**. JSON:API `type` values are **camelCase and\nplural**. They are not the same string, and one endpoint uses both:\n\n| URL path | `.data[].type` |\n|---|---|\n| `/sign-in-tokens` | `signInTokens` |\n| `/document-requests` | `documentRequestQuestionnaires`, `documentRequestConsentDocuments`, … |\n| `/billing-items` | `invoices`, `statements`, `superbills`, `receipts`, `payments` |\n| `/client-billing-overviews` | `clientBillingOverviews` |\n| `/environment` (singular!) | `environments` |\n\nSo never build a `jq` filter by pluralising the path. Match on the `type`\nstring the response actually carries, or select positionally.\n\n`/environment` is the one singular path in the API.\n\n---\n\n## 1. Auth\n\n### 1.1 Request a magic link — `POST /sign-in-tokens`\n\n```sh\nsp -X POST \"$SP_API/sign-in-tokens\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sign-in-tokens\",\"attributes\":{\"email\":\"you@example.com\",\"expiresIn\":\"15 minutes\"}}}'\n```\n\n`202 Accepted`:\n\n```json\n{\"data\":{\"id\":\"…\",\"type\":\"signInTokens\",\"attributes\":{\"email\":\"you@example.com\",\"expiresIn\":\"24 hours\"}}}\n```\n\n- `expiresIn` in the **response** is the real lifetime (24 hours) whatever you\n  request. The portal app deliberately shows the same \"24 hours\" wording for an\n  address with no account, so that the response cannot be used to test whether\n  an email is registered. A `202` is therefore not proof the address exists.\n- Optional `redirect` attribute: a portal-relative path to land on after\n  verifying (the app uses it for `payment-link/<id>`).\n- **Errors.** `429` with title `Email request limit reached` or\n  `IP request limit reached`; `422` for a malformed address. Do not retry\n  either — this is the only auth path the portal has.\n\n### 1.2 Exchange the token — `POST /sessions/token`\n\nThe emailed link is\n**`https://<practice>.clientsecure.me/sign-in/token#<TOKEN>`** — `/sign-in/token`,\n*not* the `sign-in/token/verify` the app's route tree implies. A second variant,\nsent for the mobile app, points at the bare apex under the API namespace:\n`https://clientsecure.me/client-portal-api/sign-in/token#<TOKEN>`. Either works —\ntake the fragment, ignore the path.\n\nThe token is the **fragment** (303–317 characters observed). A browser never\nsends a fragment to the server; the app reads `location.hash` and posts it.\nFetching the link with `curl` accomplishes nothing — copy the part after `#`.\n\nBoth emails are quoted-printable, so the URL is **wrapped across lines with\ntrailing `=`**. Pulling it out of a raw message with a naive regex silently\ntruncates the token — decode the quoted-printable first.\n\n```sh\nsp -X POST \"$SP_API/sessions/token\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"token\",\"token\":\"'\"$TOKEN\"'\"}}}'\n```\n\nSuccess sets the `simplepractice-session` cookie (Rails/Devise) and returns\n`.data.meta.status`:\n\n| `meta.status` | meaning |\n|---|---|\n| `verified` | signed in; the cookie jar is now good |\n| `expired` | older than 24h — request a new link |\n| `merged` | the account was merged into another; sign in from the new portal |\n\nTokens are single-use — confirmed by replay, which answers\n`401 {\"title\":\"Authorization has already been used or expired\"}`. That is a 401\non a sign-in endpoint, where you have no session yet; it means *get a new\nlink*, not *your session expired*.\n\n### 1.3 PIN variant — `POST /sessions/pin`\n\n```sh\nsp -X POST \"$SP_API/sessions/pin\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"pin\",\"email\":\"you@example.com\",\"pin\":\"123456\"}}}'\n```\n\nThe PIN is exactly 6 digits (client-side regex `^\\d{6}$`) and is likewise\nsingle-use — a wrong or reused code comes back as a validation error on `pin`,\nand `429` is the rate limit.\n\n### 1.4 Session expiry\n\nAny endpoint answers `401 {\"errors\":[{\"title\":\"You have no access to this client\",\"status\":\"401\"}]}`\nonce the cookie lapses. There is no refresh token: re-run §1.1.\n\n---\n\n## 2. Identity — `GET /environment`\n\n```sh\nsp \"$SP_API/environment?include=currentPractice,currentClient,currentClientOptions\"\n```\n\n`.data` is the singleton `environments` record; the interesting part is\n`.data.relationships` + `.included[]`:\n\n| relationship | shape |\n|---|---|\n| `currentPractice` | one `practices` |\n| `currentClient` | one `clients` — whose data every other endpoint returns |\n| `currentClientOptions` | **array** of `clients` this login may switch between |\n| `currentClientAccess` | one `clientAccesses` — the login itself |\n\n```sh\nsp \"$SP_API/environment?include=currentPractice,currentClientOptions\" | jq '{\n  practice: (.included[] | select(.type==\"practices\") | .attributes.fullName),\n  timeZone: (.included[] | select(.type==\"practices\") | .attributes.timeZone),\n  clients: [.included[] | select(.type==\"clients\")\n            | {id, name: ((.attributes.preferredName // .attributes.firstName) + \" \" + .attributes.lastName)}]\n}'\n```\n\nUseful `practices` attributes (67 in all): `fullName`, `timeZone`, `currency`,\n`practiceUrl`, `phoneNumber`, `isGroupPractice`, `telehealthEnabled`,\n`selfSchedulingEnabled`, `isClientAllowedToCancelAppt`,\n`isClientAllowedToConfirmAppt`, `clientCancellableHrs`,\n`announcementsAvailable`, `featureSecureMessagingEmber`.\n\n`isClientAllowedToCancelAppt` and `clientCancellableHrs` are the practice's\nactual cancellation policy — worth reading before assuming an appointment can\nbe cancelled.\n\n`clients` attributes include `firstName`, `lastName`, `preferredName`,\n`nickname`, `birthDate`, `hashedId`, `status`, `billingType`,\n`hasIncompleteDocument`, `hasNewAnnouncements`, `hasInvoicedAppointments`,\n`permissions`, and `relationshipToCurrentClientAccess`.\n\n**`clients[].email` is `null` on a login that acts for someone else** (a parent\nportal, say). The sign-in address belongs to the *access*, not the client.\n\n---\n\n## 3. Appointments — `GET /appointments`\n\n```sh\n# upcoming / confirmed\nsp \"$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=false&page[size]=50&page[number]=1\"\n# requested, awaiting the practice's confirmation\nsp \"$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=true&page[size]=50&page[number]=1\"\n```\n\n`.data[].type` is `appointments`; `.included[]` carries `clinicians`,\n`offices`, `clients`.\n\nAttributes (21 live; from `models/unauthenticated-appointment.js` +\n`models/appointment.js`):\n\n| field | notes |\n|---|---|\n| `startTime`, `endTime` | ISO-8601 with offset |\n| `serviceDescription` | e.g. the CPT service name |\n| `confirmationStatus`, `clientConfirmationStatus` | practice-side vs client-side |\n| `isCancellable` | boolean — respects the practice's own policy |\n| `cancelReason`, `visitReason`, `visitTherapyReasons` | |\n| `videoRoomUrl` | telehealth link, when the appointment is video |\n| `icalUrl`, `gcalendarUrl` | ready-made calendar links |\n| `fee`, `uninvoicedFee`, `billableDescription`, `cptCodes`, `units` | |\n| `channel`, `schedulingSource`, `source` | how it was booked |\n| `files` | attachments |\n\nRelationships: `clinician`, `office`, `client`, `card`, `superbill`,\n`invoiceItems`, `appointmentClient`.\n\n`offices` carry `name`, `street`, `city`, `state`, `zip`, `phone`, `isVideo`,\n`geolocation` — `isVideo: true` is a telehealth \"room\", not an address.\n\nJoined one-liner:\n\n```sh\nsp \"$SP_API/appointments?include=clinician,office&filter[hasPendingConfirmation]=false&page[size]=50&page[number]=1\" \\\n| jq -r '\n  (.included // []) as $inc\n  | .data[]\n  | . as $a\n  | ($inc[]? | select(.type==\"clinicians\" and .id==$a.relationships.clinician.data.id)) as $c\n  | ($inc[]? | select(.type==\"offices\"    and .id==$a.relationships.office.data.id))    as $o\n  | [$a.attributes.startTime,\n     ($a.attributes.serviceDescription // \"—\"),\n     \"\\($c.attributes.firstName) \\($c.attributes.lastName)\",\n     (if $o.attributes.isVideo then \"telehealth\" else ($o.attributes.name // \"—\") end)\n    ] | @tsv'\n```\n\n**Pagination: by number.** `page[number]` / `page[size]`, max size 50. A short\npage is the last page.\n\n---\n\n## 4. Billing — `GET /billing-items`\n\nOne polymorphic collection, switched by `filter[thisType]`:\n\n| `filter[thisType]` | `.data[].type` | key attributes |\n|---|---|---|\n| `invoice` | `invoices` | `displayName`, `displayStatus`, `invoiceDate`, `totalAmount`, `remainingAmount`, `isNewForClient` |\n| `statement` | `statements` | `displayName`, `createdAt`, `isNewForClient` |\n| `superbill` | `superbills` | `displayName`, `createdAt`, `totalAmount`, `isNewForClient` |\n| `receipt` | `receipts` | `displayName`, `createdAt`, `isNewForClient` |\n| `billable-item,payment` (+ `filter[thisTypeCondition]=unallocated`) | mixed | account history |\n\n```sh\nsp \"$SP_API/billing-items?filter[thisType]=invoice&page[size]=50\" \\\n| jq '{balance: .meta.endBalance,\n       rows: [.data[] | {type, id, name: .attributes.displayName,\n                         status: .attributes.displayStatus,\n                         total: .attributes.totalAmount,\n                         due: .attributes.remainingAmount}]}'\n```\n\nEvery billing query returns `.meta.endBalance`.\n\nOptional `filter[timeRange]` narrows by date; the portal sends it as a\n`{start,end}` object, which `curl` writes as\n`filter[timeRange][start]=…&filter[timeRange][end]=…`. Omit it for everything.\n\n**Pagination: by cursor, backwards.** `page[size]=50`, then\n`page[before]=<the last row's cursorId>`. The cursor is the row's `cursorId`\nattribute, **not** its `id`. A short page is the last page.\n\n```sh\nsp \"$SP_API/billing-items?filter[thisType]=invoice&page[size]=50\" | jq -r '.data[-1].attributes.cursorId'\n```\n\nAn empty `.data[]` here is a normal, correct answer — many practices invoice\nentirely outside the portal. All five filters were confirmed to return `200`\nwith `meta.endBalance` on the account used for verification, which had no\nportal billing rows.\n\n### 4.1 Balance summary and saved cards live ON the client record\n\nThere is **no** `/client-billing-overviews` collection and **no** `/cards`\ncollection. Both are `include`-able relationships of `/clients/<id>`:\n\n```sh\nCLIENT_ID=$(sp \"$SP_API/environment?include=currentClient\" \\\n            | jq -r '.data.relationships.currentClient.data.id')\nsp \"$SP_API/clients/$CLIENT_ID?include=clientBillingOverview,cards\"\n```\n\n- `clientBillingOverviews` — `balanceDue`, `unallocatedPaymentAmount`, and the\n  counts `invoicesCount` / `statementsCount` / `superbillsCount` /\n  `receiptsCount` / `insuranceInfoCount`. Cheaper than paging the collections\n  just to see whether anything is there.\n- `cards` — `brand`, `last4`, `expiry` (e.g. `\"07 / 30\"`), `expMonth`,\n  `expYear`, `isDefault` (a **string** `\"true\"`/`\"false\"`), plus the Stripe\n  identifiers `paymentMethodId` / `customStripeCardId` /\n  `customStripeCustomerId`. No full card number.\n\n> Guessing `/cards` and `/client-billing-overviews` is the natural first move,\n> and both return **HTTP 200** — with `text/html` and the app shell, because\n> the SPA catch-all swallows every undefined path. They read as working,\n> empty endpoints. This cost a full debugging round during this build; check\n> `content-type`, never status, when an endpoint returns suspiciously nothing.\n\n---\n\n## 5. Documents — `GET /document-requests`\n\nPaperwork the practice has sent you to read, complete or sign.\n\n```sh\nsp \"$SP_API/document-requests?page[size]=50\" \\\n| jq -r '.data[] | [.attributes.status, .type, .attributes.documentTitle] | @tsv'\n```\n\n`.data[].type` is the *subtype*, and the attribute set varies with it:\n\n| `type` | what it is |\n|---|---|\n| `documentRequestConsentDocuments` | a consent form to sign |\n| `documentRequestQuestionnaires` | a questionnaire — `templateQuestions`, `userAnswers` |\n| `documentRequestContactInfos` | demographics/contact form |\n| `documentRequestInsuranceInfos` | insurance details |\n| `documentRequestCreditCardInfos` | card on file — `cardAttributes` |\n| `documentRequestStoredDocuments` | a file shared with you |\n| `documentRequestNotes` | a note |\n| `documentRequestGoodFaithEstimates` | a Good Faith Estimate |\n| `documentRequestPostSessionSummaries` | post-session summary |\n\n`status` ∈ `sent` · `viewed` · `reviewing` · `completed` · `locked`\n(`completed`, `sent` and `viewed` seen live). Anything not `completed`/`locked`\nis outstanding:\n\n```sh\nsp \"$SP_API/document-requests?page[size]=50\" \\\n| jq -r '[.data[] | select(.attributes.status | IN(\"completed\",\"locked\") | not)\n          | .attributes.documentTitle] | \"outstanding: \\(length)\\n\" + join(\"\\n\")'\n```\n\nCommon attributes: `documentTitle`, `status`, `createdAt`, `updatedAt`,\n`hasDocumentPdf`.\n\n> **`hasDocumentPdf` is a JSON string**, `\"true\"` or `\"false\"` — not a boolean,\n> despite `models/document-request.js` declaring `@attr('boolean')` (Ember casts\n> it client-side; the wire value is a string). Both values were seen live. So\n> `select(.attributes.hasDocumentPdf)` matches every row, including the ones\n> with no PDF. Always compare to the string:\n>\n> ```sh\n> jq -r '.data[] | select(.attributes.hasDocumentPdf == \"true\") | .attributes.documentTitle'\n> ```\n>\n> It is not the only one: a saved card's `isDefault` arrives as `\"true\"` /\n> `\"false\"` too. The declared type in the model is not evidence of the wire\n> type — check any boolean you come to depend on, with a real response. Subtype-specific: `documentType`, `documentExt`,\n`documentMimeType`, `documentBody`, `templateQuestions`, `userAnswers`,\n`cardAttributes`, `mixpanelType`.\n\nCollection `.meta` carries `hasDocumentsIntro` and `welcomeText`.\n\nSingle request: `GET /document-requests/<id>`.\n\n`hasDocumentPdf: true` means a rendered PDF exists. The portal fetches it\nthrough the same authenticated origin; treat the URL as session-scoped.\n\n`GET /documents` is the separate \"files shared with you\" list —\n`documentName`, `documentExt`, `thisType`, `createdAt`.\n\n---\n\n## 6. Announcements — `GET /announcements`\n\n```sh\nsp \"$SP_API/announcements?page[size]=50\" \\\n| jq -r '.data[] | [(.attributes.readAt // \"UNREAD\"), .attributes.title] | @tsv'\n```\n\nAttributes: `title`, `message`, `fromLabel`, `createdAt`, `readAt`,\n`isDeleted`. `clients[].hasNewAnnouncements` (§2) is the cheap \"is there\nanything new\" flag.\n\nThere is a `POST /announcements/read-announcements` that marks them all read —\na write, so out of scope here; it is listed only so you recognise it.\n\n---\n\n## 7. Secure messaging\n\nMessaging is **not** on this API. It lives at\n`https://messaging-api.simplepractice.com` (`messagingApiUrl` in the portal's\nconfig) with models `messagingConversation` / `messagingMessage` /\n`messagingContact` / `messagingProfile` / `messagingUser`, and is gated by the\npractice's `featureSecureMessagingEmber` flag.\n\nIts request shapes were **not** captured for this skill. If you need messages,\nread them in the portal, or capture the host's calls first — don't guess them.\n\n---\n\n## 8. Errors\n\n| status | meaning |\n|---|---|\n| `400` `Application build version is missing` | you dropped `Application-Build-Version` |\n| `401` `You have no access to this client` | cookie stale/absent → re-auth (§1) |\n| `422` | validation — the body names the offending field |\n| `429` | rate limit; on auth calls the title says email- or IP-scoped. Do not retry |\n\nErrors are JSON:API: `.errors[] | {title, code, status}`.\n\n---\n\n## Appendix — where these shapes came from\n\nThe portal serves public sourcemaps with full original sources:\n\n```sh\ncurl -s https://widget-cdn.simplepractice.com/assets/<chunk>.js.map | gunzip > map.json\nnode -e 'const m=require(\"./map.json\");m.sources.forEach((s,i)=>{/* write m.sourcesContent[i] */})'\n```\n\nThe chunk filenames are hashed per deploy — read them out of the portal HTML's\n`<script src>` tags. `adapters/application.js` defines the namespace and the\nrequired headers; `models/*.js` define every attribute; `routes/site/**` show\nwhich filters each screen sends. When SimplePractice ships a new build, that is\nthe authoritative place to re-check a shape.\n\nFile v1.2.5:skill-card.md\n\n## Description:\n\nGuides users through read-only access to their authorized SimplePractice Client Portal records using curl, with optional browser-session capture through fpx.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nAuthorized SimplePractice portal users and developers use this skill to read appointments, billing records, documents, and practice information from a shell without running an MCP server.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: Session cookies, magic-link tokens, and PINs grant access to sensitive portal records.\n\nMitigation: Use only your own authorized portal; restrict cookie-file permissions and keep credentials off shared machines, logs, shell history, and repositories.\n\nRisk: Downloaded documents and command output may expose medical or billing information.\n\nMitigation: Handle PDFs and output as sensitive data; avoid storing or sharing them in unsecured locations.\n\n## Reference(s):\n\n- [SimplePractice FPX on ClawHub](https://clawhub.ai/chrischall/skills/simplepractice-fpx)\n- [SimplePractice Client Portal request reference](references/requests.md)\n\n## Skill Output:\n\n**Output Type(s):** [Shell commands, Guidance]\n\n**Output Format:** [Markdown with curl and jq examples]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Read-only portal guidance; command results can contain sensitive medical and billing information.]\n\n## Skill Version(s):\n\n1.2.5 (source: ClawHub release metadata)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.2.4: 4 files, 13618 bytes\n\nFiles: references/requests.md (16598b), skill-card.md (1805b), SKILL.md (12336b), _meta.json (137b)\n\nFile v1.2.4:SKILL.md\n\n---\nname: simplepractice-fpx\ndescription: >-\n  Read a SimplePractice Client Portal (`<practice>.clientsecure.me`) from a\n  shell — appointments, invoices/statements/superbills/receipts, documents to\n  sign, announcements, practice and clinician info — with plain `curl` against\n  its JSON:API, instead of running the simplepractice-mcp server. Sign in\n  headlessly with an emailed magic link, or capture the session cookie from an\n  already-signed-in browser tab with `fpx`. Use when you want Client Portal\n  data without the MCP, in a script, or on a machine where the MCP isn't\n  installed.\n---\n\n# SimplePractice Client Portal via curl (+ optional fpx)\n\nThe Client Portal is an Ember app whose backend is a plain **JSON:API** at\n`https://<practice>.clientsecure.me/client-portal-api`. It has **no bot wall**\n— every endpoint below answers ordinary server-side `curl` once you hold a\nsession cookie. So this skill is curl-first; `fpx` appears only as an optional\none-time way to lift the cookie out of a browser you're already signed into.\n\nThere is **no password**. Sign-in is passwordless: SimplePractice emails you\neither a magic link or a 6-digit PIN, and you trade that for a session cookie.\nThat flow carries **no captcha** (reCAPTCHA guards only the new-client request,\nwaitlist and contact forms), so §1 below works headlessly with nothing but\n`curl` and access to your inbox.\n\n> This is protected health information — your own therapy/medical record.\n> Treat the cookie jar as a credential: it is a full-access bearer token for\n> the portal. Keep it `chmod 600`, out of git, and off shared machines.\n\n## Your practice subdomain\n\nEvery URL is scoped to one practice. Take the host from the portal link your\nprovider sent you and export it once:\n\n```sh\nexport SP_HOST='achievebalancetherapy.clientsecure.me'   # <-- yours\nexport SP_API=\"https://$SP_HOST/client-portal-api\"\nexport SP_JAR=\"$HOME/.simplepractice-cookies\"\n\n# curl creates a cookie jar world-readable (644). This one holds a live\n# session for a medical record, so create it 0600 BEFORE curl ever writes it.\n[ -e \"$SP_JAR\" ] || ( umask 077; : > \"$SP_JAR\" )\nchmod 600 \"$SP_JAR\"\n```\n\n## The four headers — all of them, on every call\n\n```sh\nsp() { curl -s -b \"$SP_JAR\" -c \"$SP_JAR\" \\\n  -H 'Api-Version: 2026-05-25' \\\n  -H 'Application-Build-Version: 0.0.0' \\\n  -H 'Application-Platform: web' \\\n  -H 'Accept: application/vnd.api+json' \"$@\"; }\n```\n\nOmit `Application-Build-Version` and the API rejects the call with\n`400 {\"errors\":[{\"title\":\"Application build version is missing\"}]}` — verified.\n`Api-Version` is the API's own dated contract version, unrelated to any package\nversion; send it as-is.\n\n## 1. Sign in with a magic link (no browser)\n\n**a. Request the link.** One call, to your own portal address:\n\n```sh\nsp -X POST \"$SP_API/sign-in-tokens\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sign-in-tokens\",\"attributes\":{\"email\":\"you@example.com\",\"expiresIn\":\"15 minutes\"}}}'\n```\n\n`202 Accepted` means it was sent. The response echoes `expiresIn: \"24 hours\"`\nregardless of what you asked for — that is the real token lifetime, and it is\nalso what the API returns for an *unknown* email, deliberately, so that a 202\nnever reveals whether an address has an account.\n\n**Do not retry a failed sign-in.** `429` is a real limit with two distinct\ntitles — `Email request limit reached` and `IP request limit reached` — and\nhammering it locks you out of the only auth path there is. Wait it out.\n\n**b. Take the token out of the emailed link.** The link looks like\n\n```\nhttps://<practice>.clientsecure.me/sign-in/token#<TOKEN>\n```\n\nSimplePractice also mails a mobile-app variant on the bare apex,\n`https://clientsecure.me/client-portal-api/sign-in/token#<TOKEN>`. Either\nworks — the path is irrelevant, only the fragment matters.\n\nThe token is the **URL fragment**, after the `#` (about 300 characters).\nBecause it is a fragment it is never sent to the server by a browser\nnavigation — the app reads it in JS and posts it. So you must copy it\nyourself; following the link with `curl` does nothing.\n\nIf you are pulling the link out of a raw message rather than clicking it, note\nthe mail is **quoted-printable**: the URL is wrapped across lines with trailing\n`=`, and a naive regex will hand you a silently truncated token. Decode first.\n\n**c. Trade it for a session cookie.**\n\n```sh\nsp -X POST \"$SP_API/sessions/token\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"token\",\"token\":\"'\"$TOKEN\"'\"}}}' \\\n  | jq '.data.meta.status'\n```\n\n`\"verified\"` means the cookie jar is now authenticated. The other statuses are\n`\"expired\"` and `\"merged\"`; a `401`/`422` means the token was already used —\nthey are single-use.\n\n**PIN variant.** If your portal mails a 6-digit code instead of a link, post it\nto `sessions/pin` with the address it was sent to:\n\n```sh\nsp -X POST \"$SP_API/sessions/pin\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"pin\",\"email\":\"you@example.com\",\"pin\":\"123456\"}}}'\n```\n\n## 2. Or lift the cookie from a signed-in browser tab (fpx)\n\nOnly worth it if you're already signed in and would rather not wait on an\nemail. Requires the **Transporter** extension and `npm i -g @fetchproxy/cli`.\n\n```sh\nfpx profile add simplepractice --domain clientsecure.me\nfpx profile declare simplepractice \\\n  --cookie simplepractice-session --cookie client-portal-session \\\n  --local-storage client-portal-session --local-storage stored-email \\\n  --capture-header cookie@$SP_HOST\nfpx get \"https://$SP_HOST/\" -p simplepractice >/dev/null   # prints a pair code → approve in Transporter\n```\n\nDeclare **every** scope before that first pairing. Widening it afterwards\nleaves fetches working on the old grant while the new capability errors\n`capability \"read_cookies\" not granted`, and the fix is to remove the profile\nand re-pair from scratch.\n\nThen seed the jar from the browser's cookie:\n\n```sh\nSESSION=$(fpx cookies simplepractice-session -p simplepractice \\\n            --storage-subdomain \"${SP_HOST%%.*}\" | jq -r '.[\"simplepractice-session\"]')\nprintf '#HttpOnly_%s\\tFALSE\\t/\\tTRUE\\t0\\tsimplepractice-session\\t%s\\n' \"$SP_HOST\" \"$SESSION\" > \"$SP_JAR\"\nchmod 600 \"$SP_JAR\"\n```\n\n`fpx` exit codes: `2` bridge unavailable, `3` bot wall, `4` upstream non-2xx.\n\n## 3. Who am I, and which client am I looking at\n\n```sh\nsp \"$SP_API/environment?include=currentPractice,currentClient,currentClientOptions\" | jq '{\n  practice: (.included[] | select(.type==\"practices\") | .attributes.fullName),\n  timeZone: (.included[] | select(.type==\"practices\") | .attributes.timeZone),\n  clients:  [.included[] | select(.type==\"clients\") | {id, name: (.attributes.firstName+\" \"+.attributes.lastName)}]\n}'\n```\n\nOne portal login is a **client access**, and it can cover more than one client\n— a parent seeing two children, say. `currentClientOptions` is always an array;\n`currentClient` is the one whose data the other endpoints return. Don't assume\nthere is exactly one. (On a login that acts for someone else, the client\nrecord's own `email` is `null` — the sign-in address lives on the access, not\nthe client, so don't reach for `clients[].email` to find out who you are.)\n\n`401 {\"title\":\"You have no access to this client\"}` on any endpoint below means\nthe cookie is stale or absent — go back to §1.\n\n## 4. Reads\n\nAll of these are verified live. Collections are JSON:API, so records live under\n`.data[]` with fields under `.attributes`; `include=` pulls related records into\na sibling `.included[]` array that you join on\n`.relationships.<name>.data.id`.\n\n```sh\n# Upcoming appointments (and the requested-but-unconfirmed ones)\nsp \"$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=false&page[size]=50&page[number]=1\"\nsp \"$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=true&page[size]=50&page[number]=1\"\n\n# Billing — one endpoint, switched by filter[thisType]\nsp \"$SP_API/billing-items?filter[thisType]=invoice&page[size]=50\"\nsp \"$SP_API/billing-items?filter[thisType]=statement&page[size]=50\"\nsp \"$SP_API/billing-items?filter[thisType]=superbill&page[size]=50\"\nsp \"$SP_API/billing-items?filter[thisType]=receipt&page[size]=50\"\nsp \"$SP_API/billing-items?filter[thisType]=billable-item,payment&filter[thisTypeCondition]=unallocated&page[size]=50\"\n\n# Documents to review or sign, and files shared with you\nsp \"$SP_API/document-requests?page[size]=50\"\nsp \"$SP_API/documents?page[size]=50\"\n\n# Practice announcements\nsp \"$SP_API/announcements?page[size]=50\"\n\n# Balance summary and saved cards hang off the CLIENT record, not collections\n# of their own — see the warning below.\nCLIENT_ID=$(sp \"$SP_API/environment?include=currentClient\" \\\n            | jq -r '.data.relationships.currentClient.data.id')\nsp \"$SP_API/clients/$CLIENT_ID?include=clientBillingOverview,cards\" \\\n| jq '{balance: (.included[] | select(.type==\"clientBillingOverviews\") | .attributes),\n       cards:  [.included[] | select(.type==\"cards\")\n                | {brand: .attributes.brand, last4: .attributes.last4,\n                   expiry: .attributes.expiry, isDefault: .attributes.isDefault}]}'\n```\n\n> **A 200 is not proof an endpoint exists.** The portal is a single-page app,\n> so *any* path it does not define comes back as `200 text/html` with the app\n> shell (a constant ~7.5 KB) rather than a 404. `/cards` and\n> `/client-billing-overviews` are the obvious guesses for the two above, and\n> both answer 200 that way — they are not API paths at all. Check the\n> `content-type`, not the status:\n>\n> ```sh\n> sp -o /dev/null -w '%{http_code} %{content_type}\\n' \"$SP_API/whatever\"\n> ```\n>\n> Anything other than `application/vnd.api+json` means the path is wrong.\n\nA readable next-appointment line:\n\n```sh\nsp \"$SP_API/appointments?include=clinician,office&filter[hasPendingConfirmation]=false&page[size]=1&page[number]=1\" \\\n| jq -r '.data[0] as $a\n  | (.included[]? | select(.type==\"clinicians\")) as $c\n  | \"\\($a.attributes.startTime)  \\($a.attributes.serviceDescription // \"—\")  with \\($c.attributes.firstName) \\($c.attributes.lastName)\"'\n```\n\n## Pagination — two schemes, don't mix them\n\n- **Appointments** page by number: `page[number]=1&page[size]=50`. You're on\n  the last page when a page comes back shorter than `page[size]`.\n- **Billing items** page by *cursor*, backwards: `page[size]=50` and then\n  `page[before]=<cursorId of the last row you saw>`. The row's `cursorId` is\n  the cursor, not its `id`.\n\n`50` is the server's max page size; asking for more does not get you more.\n\n## Notes\n\n- Times come back ISO-8601 with an offset. The practice's own `timeZone`\n  (§3) is what its staff schedule in — use it when a date matters.\n- **`permissions` on the client is a JSON string, not an object.** It parses\n  to the portal features this client actually has —\n  `{\"messaging\":…,\"selfScheduling\":…,\"billingDocuments\":…,\"payments\":…,\"appointments\":…}`.\n  Read it with `.attributes.permissions | fromjson`; used raw it is a string of\n  characters. `billingDocuments` is what gates the whole billing tab.\n- **`hasDocumentPdf` is a string, not a boolean.** It arrives as `\"true\"` or\n  `\"false\"` — both seen live — so `if (hasDocumentPdf)` and\n  `jq 'select(.attributes.hasDocumentPdf)'` are BOTH true for `\"false\"`.\n  Compare against the string: `select(.attributes.hasDocumentPdf == \"true\")`.\n  A card's `isDefault` is the same — so do not assume a JSON boolean anywhere\n  in this API without checking the value you actually get back.\n- `billing-items` is polymorphic: `.data[].type` tells you which of\n  invoice / statement / superbill / receipt / payment a row actually is, and\n  the attribute set differs per type. `.meta.endBalance` accompanies every\n  billing query.\n- An empty `.data[]` is a real answer, not a failure — plenty of practices\n  bill outside the portal entirely and every billing endpoint returns `200`\n  with nothing in it.\n- Everything here is a **read**. Cancelling an appointment, submitting a\n  signed document, or paying an invoice are writes this skill deliberately\n  does not cover — do those in the portal, where you can see what you're\n  agreeing to.\n- This project is developed and maintained by AI (Claude).\n\nFile v1.2.4:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"simplepractice-fpx\",\n  \"version\": \"1.2.4\",\n  \"publishedAt\": 1791168739258\n}\n\nFile v1.2.4:references/requests.md\n\n# SimplePractice Client Portal — request reference\n\nBase: `https://<practice>.clientsecure.me/client-portal-api`\n\nEvery shape below was taken from the portal app's own published sourcemaps\n(`widget-cdn.simplepractice.com/assets/*.map`, which ship full\n`sourcesContent`) and then confirmed against a live signed-in portal. Nothing\nhere is guessed. Where a field could not be exercised on the account used for\nverification, it says so.\n\nAssumes the `sp()` helper and `$SP_API` from `SKILL.md`.\n\n---\n\n## 0. Two naming systems — the trap\n\nURLs are **dashed and plural**. JSON:API `type` values are **camelCase and\nplural**. They are not the same string, and one endpoint uses both:\n\n| URL path | `.data[].type` |\n|---|---|\n| `/sign-in-tokens` | `signInTokens` |\n| `/document-requests` | `documentRequestQuestionnaires`, `documentRequestConsentDocuments`, … |\n| `/billing-items` | `invoices`, `statements`, `superbills`, `receipts`, `payments` |\n| `/client-billing-overviews` | `clientBillingOverviews` |\n| `/environment` (singular!) | `environments` |\n\nSo never build a `jq` filter by pluralising the path. Match on the `type`\nstring the response actually carries, or select positionally.\n\n`/environment` is the one singular path in the API.\n\n---\n\n## 1. Auth\n\n### 1.1 Request a magic link — `POST /sign-in-tokens`\n\n```sh\nsp -X POST \"$SP_API/sign-in-tokens\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sign-in-tokens\",\"attributes\":{\"email\":\"you@example.com\",\"expiresIn\":\"15 minutes\"}}}'\n```\n\n`202 Accepted`:\n\n```json\n{\"data\":{\"id\":\"…\",\"type\":\"signInTokens\",\"attributes\":{\"email\":\"you@example.com\",\"expiresIn\":\"24 hours\"}}}\n```\n\n- `expiresIn` in the **response** is the real lifetime (24 hours) whatever you\n  request. The portal app deliberately shows the same \"24 hours\" wording for an\n  address with no account, so that the response cannot be used to test whether\n  an email is registered. A `202` is therefore not proof the address exists.\n- Optional `redirect` attribute: a portal-relative path to land on after\n  verifying (the app uses it for `payment-link/<id>`).\n- **Errors.** `429` with title `Email request limit reached` or\n  `IP request limit reached`; `422` for a malformed address. Do not retry\n  either — this is the only auth path the portal has.\n\n### 1.2 Exchange the token — `POST /sessions/token`\n\nThe emailed link is\n**`https://<practice>.clientsecure.me/sign-in/token#<TOKEN>`** — `/sign-in/token`,\n*not* the `sign-in/token/verify` the app's route tree implies. A second variant,\nsent for the mobile app, points at the bare apex under the API namespace:\n`https://clientsecure.me/client-portal-api/sign-in/token#<TOKEN>`. Either works —\ntake the fragment, ignore the path.\n\nThe token is the **fragment** (303–317 characters observed). A browser never\nsends a fragment to the server; the app reads `location.hash` and posts it.\nFetching the link with `curl` accomplishes nothing — copy the part after `#`.\n\nBoth emails are quoted-printable, so the URL is **wrapped across lines with\ntrailing `=`**. Pulling it out of a raw message with a naive regex silently\ntruncates the token — decode the quoted-printable first.\n\n```sh\nsp -X POST \"$SP_API/sessions/token\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"token\",\"token\":\"'\"$TOKEN\"'\"}}}'\n```\n\nSuccess sets the `simplepractice-session` cookie (Rails/Devise) and returns\n`.data.meta.status`:\n\n| `meta.status` | meaning |\n|---|---|\n| `verified` | signed in; the cookie jar is now good |\n| `expired` | older than 24h — request a new link |\n| `merged` | the account was merged into another; sign in from the new portal |\n\nTokens are single-use — confirmed by replay, which answers\n`401 {\"title\":\"Authorization has already been used or expired\"}`. That is a 401\non a sign-in endpoint, where you have no session yet; it means *get a new\nlink*, not *your session expired*.\n\n### 1.3 PIN variant — `POST /sessions/pin`\n\n```sh\nsp -X POST \"$SP_API/sessions/pin\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"pin\",\"email\":\"you@example.com\",\"pin\":\"123456\"}}}'\n```\n\nThe PIN is exactly 6 digits (client-side regex `^\\d{6}$`) and is likewise\nsingle-use — a wrong or reused code comes back as a validation error on `pin`,\nand `429` is the rate limit.\n\n### 1.4 Session expiry\n\nAny endpoint answers `401 {\"errors\":[{\"title\":\"You have no access to this client\",\"status\":\"401\"}]}`\nonce the cookie lapses. There is no refresh token: re-run §1.1.\n\n---\n\n## 2. Identity — `GET /environment`\n\n```sh\nsp \"$SP_API/environment?include=currentPractice,currentClient,currentClientOptions\"\n```\n\n`.data` is the singleton `environments` record; the interesting part is\n`.data.relationships` + `.included[]`:\n\n| relationship | shape |\n|---|---|\n| `currentPractice` | one `practices` |\n| `currentClient` | one `clients` — whose data every other endpoint returns |\n| `currentClientOptions` | **array** of `clients` this login may switch between |\n| `currentClientAccess` | one `clientAccesses` — the login itself |\n\n```sh\nsp \"$SP_API/environment?include=currentPractice,currentClientOptions\" | jq '{\n  practice: (.included[] | select(.type==\"practices\") | .attributes.fullName),\n  timeZone: (.included[] | select(.type==\"practices\") | .attributes.timeZone),\n  clients: [.included[] | select(.type==\"clients\")\n            | {id, name: ((.attributes.preferredName // .attributes.firstName) + \" \" + .attributes.lastName)}]\n}'\n```\n\nUseful `practices` attributes (67 in all): `fullName`, `timeZone`, `currency`,\n`practiceUrl`, `phoneNumber`, `isGroupPractice`, `telehealthEnabled`,\n`selfSchedulingEnabled`, `isClientAllowedToCancelAppt`,\n`isClientAllowedToConfirmAppt`, `clientCancellableHrs`,\n`announcementsAvailable`, `featureSecureMessagingEmber`.\n\n`isClientAllowedToCancelAppt` and `clientCancellableHrs` are the practice's\nactual cancellation policy — worth reading before assuming an appointment can\nbe cancelled.\n\n`clients` attributes include `firstName`, `lastName`, `preferredName`,\n`nickname`, `birthDate`, `hashedId`, `status`, `billingType`,\n`hasIncompleteDocument`, `hasNewAnnouncements`, `hasInvoicedAppointments`,\n`permissions`, and `relationshipToCurrentClientAccess`.\n\n**`clients[].email` is `null` on a login that acts for someone else** (a parent\nportal, say). The sign-in address belongs to the *access*, not the client.\n\n---\n\n## 3. Appointments — `GET /appointments`\n\n```sh\n# upcoming / confirmed\nsp \"$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=false&page[size]=50&page[number]=1\"\n# requested, awaiting the practice's confirmation\nsp \"$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=true&page[size]=50&page[number]=1\"\n```\n\n`.data[].type` is `appointments`; `.included[]` carries `clinicians`,\n`offices`, `clients`.\n\nAttributes (21 live; from `models/unauthenticated-appointment.js` +\n`models/appointment.js`):\n\n| field | notes |\n|---|---|\n| `startTime`, `endTime` | ISO-8601 with offset |\n| `serviceDescription` | e.g. the CPT service name |\n| `confirmationStatus`, `clientConfirmationStatus` | practice-side vs client-side |\n| `isCancellable` | boolean — respects the practice's own policy |\n| `cancelReason`, `visitReason`, `visitTherapyReasons` | |\n| `videoRoomUrl` | telehealth link, when the appointment is video |\n| `icalUrl`, `gcalendarUrl` | ready-made calendar links |\n| `fee`, `uninvoicedFee`, `billableDescription`, `cptCodes`, `units` | |\n| `channel`, `schedulingSource`, `source` | how it was booked |\n| `files` | attachments |\n\nRelationships: `clinician`, `office`, `client`, `card`, `superbill`,\n`invoiceItems`, `appointmentClient`.\n\n`offices` carry `name`, `street`, `city`, `state`, `zip`, `phone`, `isVideo`,\n`geolocation` — `isVideo: true` is a telehealth \"room\", not an address.\n\nJoined one-liner:\n\n```sh\nsp \"$SP_API/appointments?include=clinician,office&filter[hasPendingConfirmation]=false&page[size]=50&page[number]=1\" \\\n| jq -r '\n  (.included // []) as $inc\n  | .data[]\n  | . as $a\n  | ($inc[]? | select(.type==\"clinicians\" and .id==$a.relationships.clinician.data.id)) as $c\n  | ($inc[]? | select(.type==\"offices\"    and .id==$a.relationships.office.data.id))    as $o\n  | [$a.attributes.startTime,\n     ($a.attributes.serviceDescription // \"—\"),\n     \"\\($c.attributes.firstName) \\($c.attributes.lastName)\",\n     (if $o.attributes.isVideo then \"telehealth\" else ($o.attributes.name // \"—\") end)\n    ] | @tsv'\n```\n\n**Pagination: by number.** `page[number]` / `page[size]`, max size 50. A short\npage is the last page.\n\n---\n\n## 4. Billing — `GET /billing-items`\n\nOne polymorphic collection, switched by `filter[thisType]`:\n\n| `filter[thisType]` | `.data[].type` | key attributes |\n|---|---|---|\n| `invoice` | `invoices` | `displayName`, `displayStatus`, `invoiceDate`, `totalAmount`, `remainingAmount`, `isNewForClient` |\n| `statement` | `statements` | `displayName`, `createdAt`, `isNewForClient` |\n| `superbill` | `superbills` | `displayName`, `createdAt`, `totalAmount`, `isNewForClient` |\n| `receipt` | `receipts` | `displayName`, `createdAt`, `isNewForClient` |\n| `billable-item,payment` (+ `filter[thisTypeCondition]=unallocated`) | mixed | account history |\n\n```sh\nsp \"$SP_API/billing-items?filter[thisType]=invoice&page[size]=50\" \\\n| jq '{balance: .meta.endBalance,\n       rows: [.data[] | {type, id, name: .attributes.displayName,\n                         status: .attributes.displayStatus,\n                         total: .attributes.totalAmount,\n                         due: .attributes.remainingAmount}]}'\n```\n\nEvery billing query returns `.meta.endBalance`.\n\nOptional `filter[timeRange]` narrows by date; the portal sends it as a\n`{start,end}` object, which `curl` writes as\n`filter[timeRange][start]=…&filter[timeRange][end]=…`. Omit it for everything.\n\n**Pagination: by cursor, backwards.** `page[size]=50`, then\n`page[before]=<the last row's cursorId>`. The cursor is the row's `cursorId`\nattribute, **not** its `id`. A short page is the last page.\n\n```sh\nsp \"$SP_API/billing-items?filter[thisType]=invoice&page[size]=50\" | jq -r '.data[-1].attributes.cursorId'\n```\n\nAn empty `.data[]` here is a normal, correct answer — many practices invoice\nentirely outside the portal. All five filters were confirmed to return `200`\nwith `meta.endBalance` on the account used for verification, which had no\nportal billing rows.\n\n### 4.1 Balance summary and saved cards live ON the client record\n\nThere is **no** `/client-billing-overviews` collection and **no** `/cards`\ncollection. Both are `include`-able relationships of `/clients/<id>`:\n\n```sh\nCLIENT_ID=$(sp \"$SP_API/environment?include=currentClient\" \\\n            | jq -r '.data.relationships.currentClient.data.id')\nsp \"$SP_API/clients/$CLIENT_ID?include=clientBillingOverview,cards\"\n```\n\n- `clientBillingOverviews` — `balanceDue`, `unallocatedPaymentAmount`, and the\n  counts `invoicesCount` / `statementsCount` / `superbillsCount` /\n  `receiptsCount` / `insuranceInfoCount`. Cheaper than paging the collections\n  just to see whether anything is there.\n- `cards` — `brand`, `last4`, `expiry` (e.g. `\"07 / 30\"`), `expMonth`,\n  `expYear`, `isDefault` (a **string** `\"true\"`/`\"false\"`), plus the Stripe\n  identifiers `paymentMethodId` / `customStripeCardId` /\n  `customStripeCustomerId`. No full card number.\n\n> Guessing `/cards` and `/client-billing-overviews` is the natural first move,\n> and both return **HTTP 200** — with `text/html` and the app shell, because\n> the SPA catch-all swallows every undefined path. They read as working,\n> empty endpoints. This cost a full debugging round during this build; check\n> `content-type`, never status, when an endpoint returns suspiciously nothing.\n\n---\n\n## 5. Documents — `GET /document-requests`\n\nPaperwork the practice has sent you to read, complete or sign.\n\n```sh\nsp \"$SP_API/document-requests?page[size]=50\" \\\n| jq -r '.data[] | [.attributes.status, .type, .attributes.documentTitle] | @tsv'\n```\n\n`.data[].type` is the *subtype*, and the attribute set varies with it:\n\n| `type` | what it is |\n|---|---|\n| `documentRequestConsentDocuments` | a consent form to sign |\n| `documentRequestQuestionnaires` | a questionnaire — `templateQuestions`, `userAnswers` |\n| `documentRequestContactInfos` | demographics/contact form |\n| `documentRequestInsuranceInfos` | insurance details |\n| `documentRequestCreditCardInfos` | card on file — `cardAttributes` |\n| `documentRequestStoredDocuments` | a file shared with you |\n| `documentRequestNotes` | a note |\n| `documentRequestGoodFaithEstimates` | a Good Faith Estimate |\n| `documentRequestPostSessionSummaries` | post-session summary |\n\n`status` ∈ `sent` · `viewed` · `reviewing` · `completed` · `locked`\n(`completed`, `sent` and `viewed` seen live). Anything not `completed`/`locked`\nis outstanding:\n\n```sh\nsp \"$SP_API/document-requests?page[size]=50\" \\\n| jq -r '[.data[] | select(.attributes.status | IN(\"completed\",\"locked\") | not)\n          | .attributes.documentTitle] | \"outstanding: \\(length)\\n\" + join(\"\\n\")'\n```\n\nCommon attributes: `documentTitle`, `status`, `createdAt`, `updatedAt`,\n`hasDocumentPdf`.\n\n> **`hasDocumentPdf` is a JSON string**, `\"true\"` or `\"false\"` — not a boolean,\n> despite `models/document-request.js` declaring `@attr('boolean')` (Ember casts\n> it client-side; the wire value is a string). Both values were seen live. So\n> `select(.attributes.hasDocumentPdf)` matches every row, including the ones\n> with no PDF. Always compare to the string:\n>\n> ```sh\n> jq -r '.data[] | select(.attributes.hasDocumentPdf == \"true\") | .attributes.documentTitle'\n> ```\n>\n> It is not the only one: a saved card's `isDefault` arrives as `\"true\"` /\n> `\"false\"` too. The declared type in the model is not evidence of the wire\n> type — check any boolean you come to depend on, with a real response. Subtype-specific: `documentType`, `documentExt`,\n`documentMimeType`, `documentBody`, `templateQuestions`, `userAnswers`,\n`cardAttributes`, `mixpanelType`.\n\nCollection `.meta` carries `hasDocumentsIntro` and `welcomeText`.\n\nSingle request: `GET /document-requests/<id>`.\n\n`hasDocumentPdf: true` means a rendered PDF exists. The portal fetches it\nthrough the same authenticated origin; treat the URL as session-scoped.\n\n`GET /documents` is the separate \"files shared with you\" list —\n`documentName`, `documentExt`, `thisType`, `createdAt`.\n\n---\n\n## 6. Announcements — `GET /announcements`\n\n```sh\nsp \"$SP_API/announcements?page[size]=50\" \\\n| jq -r '.data[] | [(.attributes.readAt // \"UNREAD\"), .attributes.title] | @tsv'\n```\n\nAttributes: `title`, `message`, `fromLabel`, `createdAt`, `readAt`,\n`isDeleted`. `clients[].hasNewAnnouncements` (§2) is the cheap \"is there\nanything new\" flag.\n\nThere is a `POST /announcements/read-announcements` that marks them all read —\na write, so out of scope here; it is listed only so you recognise it.\n\n---\n\n## 7. Secure messaging\n\nMessaging is **not** on this API. It lives at\n`https://messaging-api.simplepractice.com` (`messagingApiUrl` in the portal's\nconfig) with models `messagingConversation` / `messagingMessage` /\n`messagingContact` / `messagingProfile` / `messagingUser`, and is gated by the\npractice's `featureSecureMessagingEmber` flag.\n\nIts request shapes were **not** captured for this skill. If you need messages,\nread them in the portal, or capture the host's calls first — don't guess them.\n\n---\n\n## 8. Errors\n\n| status | meaning |\n|---|---|\n| `400` `Application build version is missing` | you dropped `Application-Build-Version` |\n| `401` `You have no access to this client` | cookie stale/absent → re-auth (§1) |\n| `422` | validation — the body names the offending field |\n| `429` | rate limit; on auth calls the title says email- or IP-scoped. Do not retry |\n\nErrors are JSON:API: `.errors[] | {title, code, status}`.\n\n---\n\n## Appendix — where these shapes came from\n\nThe portal serves public sourcemaps with full original sources:\n\n```sh\ncurl -s https://widget-cdn.simplepractice.com/assets/<chunk>.js.map | gunzip > map.json\nnode -e 'const m=require(\"./map.json\");m.sources.forEach((s,i)=>{/* write m.sourcesContent[i] */})'\n```\n\nThe chunk filenames are hashed per deploy — read them out of the portal HTML's\n`<script src>` tags. `adapters/application.js` defines the namespace and the\nrequired headers; `models/*.js` define every attribute; `routes/site/**` show\nwhich filters each screen sends. When SimplePractice ships a new build, that is\nthe authoritative place to re-check a shape.\n\nFile v1.2.4:skill-card.md\n\n## Description:\n\nGuides users through signing in to their own SimplePractice Client Portal and reading appointments, billing records, documents, announcements, and practice information with shell commands.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nPortal users and their authorized assistants can retrieve their own appointments, billing records, documents, and practice details without running an MCP server. The skill offers shell-based sign-in and read-only request examples.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: Magic links, PINs, session cookies, and browser profiles can grant access to sensitive medical records if exposed.\n\nMitigation: Keep credentials off shared machines and out of git and logs; restrict the cookie file to the account owner and remove the cookie and browser profile when no longer needed.\n\n## Reference(s):\n\n- [SimplePractice Client Portal request reference](references/requests.md)\n- [ClawHub skill release](https://clawhub.ai/chrischall/skills/simplepractice-fpx)\n\n## Skill Output:\n\n**Output Type(s):** [Shell commands, Guidance]\n\n**Output Format:** [Markdown with shell examples and JSON:API response guidance]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Read-only portal guidance; returned records may contain sensitive health information.]\n\n## Skill Version(s):\n\n1.2.4 (source: ClawHub release metadata)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.2.3: 4 files, 13781 bytes\n\nFiles: references/requests.md (16598b), skill-card.md (2197b), SKILL.md (12336b), _meta.json (137b)\n\nFile v1.2.3:SKILL.md\n\n---\nname: simplepractice-fpx\ndescription: >-\n  Read a SimplePractice Client Portal (`<practice>.clientsecure.me`) from a\n  shell — appointments, invoices/statements/superbills/receipts, documents to\n  sign, announcements, practice and clinician info — with plain `curl` against\n  its JSON:API, instead of running the simplepractice-mcp server. Sign in\n  headlessly with an emailed magic link, or capture the session cookie from an\n  already-signed-in browser tab with `fpx`. Use when you want Client Portal\n  data without the MCP, in a script, or on a machine where the MCP isn't\n  installed.\n---\n\n# SimplePractice Client Portal via curl (+ optional fpx)\n\nThe Client Portal is an Ember app whose backend is a plain **JSON:API** at\n`https://<practice>.clientsecure.me/client-portal-api`. It has **no bot wall**\n— every endpoint below answers ordinary server-side `curl` once you hold a\nsession cookie. So this skill is curl-first; `fpx` appears only as an optional\none-time way to lift the cookie out of a browser you're already signed into.\n\nThere is **no password**. Sign-in is passwordless: SimplePractice emails you\neither a magic link or a 6-digit PIN, and you trade that for a session cookie.\nThat flow carries **no captcha** (reCAPTCHA guards only the new-client request,\nwaitlist and contact forms), so §1 below works headlessly with nothing but\n`curl` and access to your inbox.\n\n> This is protected health information — your own therapy/medical record.\n> Treat the cookie jar as a credential: it is a full-access bearer token for\n> the portal. Keep it `chmod 600`, out of git, and off shared machines.\n\n## Your practice subdomain\n\nEvery URL is scoped to one practice. Take the host from the portal link your\nprovider sent you and export it once:\n\n```sh\nexport SP_HOST='achievebalancetherapy.clientsecure.me'   # <-- yours\nexport SP_API=\"https://$SP_HOST/client-portal-api\"\nexport SP_JAR=\"$HOME/.simplepractice-cookies\"\n\n# curl creates a cookie jar world-readable (644). This one holds a live\n# session for a medical record, so create it 0600 BEFORE curl ever writes it.\n[ -e \"$SP_JAR\" ] || ( umask 077; : > \"$SP_JAR\" )\nchmod 600 \"$SP_JAR\"\n```\n\n## The four headers — all of them, on every call\n\n```sh\nsp() { curl -s -b \"$SP_JAR\" -c \"$SP_JAR\" \\\n  -H 'Api-Version: 2026-05-25' \\\n  -H 'Application-Build-Version: 0.0.0' \\\n  -H 'Application-Platform: web' \\\n  -H 'Accept: application/vnd.api+json' \"$@\"; }\n```\n\nOmit `Application-Build-Version` and the API rejects the call with\n`400 {\"errors\":[{\"title\":\"Application build version is missing\"}]}` — verified.\n`Api-Version` is the API's own dated contract version, unrelated to any package\nversion; send it as-is.\n\n## 1. Sign in with a magic link (no browser)\n\n**a. Request the link.** One call, to your own portal address:\n\n```sh\nsp -X POST \"$SP_API/sign-in-tokens\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sign-in-tokens\",\"attributes\":{\"email\":\"you@example.com\",\"expiresIn\":\"15 minutes\"}}}'\n```\n\n`202 Accepted` means it was sent. The response echoes `expiresIn: \"24 hours\"`\nregardless of what you asked for — that is the real token lifetime, and it is\nalso what the API returns for an *unknown* email, deliberately, so that a 202\nnever reveals whether an address has an account.\n\n**Do not retry a failed sign-in.** `429` is a real limit with two distinct\ntitles — `Email request limit reached` and `IP request limit reached` — and\nhammering it locks you out of the only auth path there is. Wait it out.\n\n**b. Take the token out of the emailed link.** The link looks like\n\n```\nhttps://<practice>.clientsecure.me/sign-in/token#<TOKEN>\n```\n\nSimplePractice also mails a mobile-app variant on the bare apex,\n`https://clientsecure.me/client-portal-api/sign-in/token#<TOKEN>`. Either\nworks — the path is irrelevant, only the fragment matters.\n\nThe token is the **URL fragment**, after the `#` (about 300 characters).\nBecause it is a fragment it is never sent to the server by a browser\nnavigation — the app reads it in JS and posts it. So you must copy it\nyourself; following the link with `curl` does nothing.\n\nIf you are pulling the link out of a raw message rather than clicking it, note\nthe mail is **quoted-printable**: the URL is wrapped across lines with trailing\n`=`, and a naive regex will hand you a silently truncated token. Decode first.\n\n**c. Trade it for a session cookie.**\n\n```sh\nsp -X POST \"$SP_API/sessions/token\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"token\",\"token\":\"'\"$TOKEN\"'\"}}}' \\\n  | jq '.data.meta.status'\n```\n\n`\"verified\"` means the cookie jar is now authenticated. The other statuses are\n`\"expired\"` and `\"merged\"`; a `401`/`422` means the token was already used —\nthey are single-use.\n\n**PIN variant.** If your portal mails a 6-digit code instead of a link, post it\nto `sessions/pin` with the address it was sent to:\n\n```sh\nsp -X POST \"$SP_API/sessions/pin\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"pin\",\"email\":\"you@example.com\",\"pin\":\"123456\"}}}'\n```\n\n## 2. Or lift the cookie from a signed-in browser tab (fpx)\n\nOnly worth it if you're already signed in and would rather not wait on an\nemail. Requires the **Transporter** extension and `npm i -g @fetchproxy/cli`.\n\n```sh\nfpx profile add simplepractice --domain clientsecure.me\nfpx profile declare simplepractice \\\n  --cookie simplepractice-session --cookie client-portal-session \\\n  --local-storage client-portal-session --local-storage stored-email \\\n  --capture-header cookie@$SP_HOST\nfpx get \"https://$SP_HOST/\" -p simplepractice >/dev/null   # prints a pair code → approve in Transporter\n```\n\nDeclare **every** scope before that first pairing. Widening it afterwards\nleaves fetches working on the old grant while the new capability errors\n`capability \"read_cookies\" not granted`, and the fix is to remove the profile\nand re-pair from scratch.\n\nThen seed the jar from the browser's cookie:\n\n```sh\nSESSION=$(fpx cookies simplepractice-session -p simplepractice \\\n            --storage-subdomain \"${SP_HOST%%.*}\" | jq -r '.[\"simplepractice-session\"]')\nprintf '#HttpOnly_%s\\tFALSE\\t/\\tTRUE\\t0\\tsimplepractice-session\\t%s\\n' \"$SP_HOST\" \"$SESSION\" > \"$SP_JAR\"\nchmod 600 \"$SP_JAR\"\n```\n\n`fpx` exit codes: `2` bridge unavailable, `3` bot wall, `4` upstream non-2xx.\n\n## 3. Who am I, and which client am I looking at\n\n```sh\nsp \"$SP_API/environment?include=currentPractice,currentClient,currentClientOptions\" | jq '{\n  practice: (.included[] | select(.type==\"practices\") | .attributes.fullName),\n  timeZone: (.included[] | select(.type==\"practices\") | .attributes.timeZone),\n  clients:  [.included[] | select(.type==\"clients\") | {id, name: (.attributes.firstName+\" \"+.attributes.lastName)}]\n}'\n```\n\nOne portal login is a **client access**, and it can cover more than one client\n— a parent seeing two children, say. `currentClientOptions` is always an array;\n`currentClient` is the one whose data the other endpoints return. Don't assume\nthere is exactly one. (On a login that acts for someone else, the client\nrecord's own `email` is `null` — the sign-in address lives on the access, not\nthe client, so don't reach for `clients[].email` to find out who you are.)\n\n`401 {\"title\":\"You have no access to this client\"}` on any endpoint below means\nthe cookie is stale or absent — go back to §1.\n\n## 4. Reads\n\nAll of these are verified live. Collections are JSON:API, so records live under\n`.data[]` with fields under `.attributes`; `include=` pulls related records into\na sibling `.included[]` array that you join on\n`.relationships.<name>.data.id`.\n\n```sh\n# Upcoming appointments (and the requested-but-unconfirmed ones)\nsp \"$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=false&page[size]=50&page[number]=1\"\nsp \"$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=true&page[size]=50&page[number]=1\"\n\n# Billing — one endpoint, switched by filter[thisType]\nsp \"$SP_API/billing-items?filter[thisType]=invoice&page[size]=50\"\nsp \"$SP_API/billing-items?filter[thisType]=statement&page[size]=50\"\nsp \"$SP_API/billing-items?filter[thisType]=superbill&page[size]=50\"\nsp \"$SP_API/billing-items?filter[thisType]=receipt&page[size]=50\"\nsp \"$SP_API/billing-items?filter[thisType]=billable-item,payment&filter[thisTypeCondition]=unallocated&page[size]=50\"\n\n# Documents to review or sign, and files shared with you\nsp \"$SP_API/document-requests?page[size]=50\"\nsp \"$SP_API/documents?page[size]=50\"\n\n# Practice announcements\nsp \"$SP_API/announcements?page[size]=50\"\n\n# Balance summary and saved cards hang off the CLIENT record, not collections\n# of their own — see the warning below.\nCLIENT_ID=$(sp \"$SP_API/environment?include=currentClient\" \\\n            | jq -r '.data.relationships.currentClient.data.id')\nsp \"$SP_API/clients/$CLIENT_ID?include=clientBillingOverview,cards\" \\\n| jq '{balance: (.included[] | select(.type==\"clientBillingOverviews\") | .attributes),\n       cards:  [.included[] | select(.type==\"cards\")\n                | {brand: .attributes.brand, last4: .attributes.last4,\n                   expiry: .attributes.expiry, isDefault: .attributes.isDefault}]}'\n```\n\n> **A 200 is not proof an endpoint exists.** The portal is a single-page app,\n> so *any* path it does not define comes back as `200 text/html` with the app\n> shell (a constant ~7.5 KB) rather than a 404. `/cards` and\n> `/client-billing-overviews` are the obvious guesses for the two above, and\n> both answer 200 that way — they are not API paths at all. Check the\n> `content-type`, not the status:\n>\n> ```sh\n> sp -o /dev/null -w '%{http_code} %{content_type}\\n' \"$SP_API/whatever\"\n> ```\n>\n> Anything other than `application/vnd.api+json` means the path is wrong.\n\nA readable next-appointment line:\n\n```sh\nsp \"$SP_API/appointments?include=clinician,office&filter[hasPendingConfirmation]=false&page[size]=1&page[number]=1\" \\\n| jq -r '.data[0] as $a\n  | (.included[]? | select(.type==\"clinicians\")) as $c\n  | \"\\($a.attributes.startTime)  \\($a.attributes.serviceDescription // \"—\")  with \\($c.attributes.firstName) \\($c.attributes.lastName)\"'\n```\n\n## Pagination — two schemes, don't mix them\n\n- **Appointments** page by number: `page[number]=1&page[size]=50`. You're on\n  the last page when a page comes back shorter than `page[size]`.\n- **Billing items** page by *cursor*, backwards: `page[size]=50` and then\n  `page[before]=<cursorId of the last row you saw>`. The row's `cursorId` is\n  the cursor, not its `id`.\n\n`50` is the server's max page size; asking for more does not get you more.\n\n## Notes\n\n- Times come back ISO-8601 with an offset. The practice's own `timeZone`\n  (§3) is what its staff schedule in — use it when a date matters.\n- **`permissions` on the client is a JSON string, not an object.** It parses\n  to the portal features this client actually has —\n  `{\"messaging\":…,\"selfScheduling\":…,\"billingDocuments\":…,\"payments\":…,\"appointments\":…}`.\n  Read it with `.attributes.permissions | fromjson`; used raw it is a string of\n  characters. `billingDocuments` is what gates the whole billing tab.\n- **`hasDocumentPdf` is a string, not a boolean.** It arrives as `\"true\"` or\n  `\"false\"` — both seen live — so `if (hasDocumentPdf)` and\n  `jq 'select(.attributes.hasDocumentPdf)'` are BOTH true for `\"false\"`.\n  Compare against the string: `select(.attributes.hasDocumentPdf == \"true\")`.\n  A card's `isDefault` is the same — so do not assume a JSON boolean anywhere\n  in this API without checking the value you actually get back.\n- `billing-items` is polymorphic: `.data[].type` tells you which of\n  invoice / statement / superbill / receipt / payment a row actually is, and\n  the attribute set differs per type. `.meta.endBalance` accompanies every\n  billing query.\n- An empty `.data[]` is a real answer, not a failure — plenty of practices\n  bill outside the portal entirely and every billing endpoint returns `200`\n  with nothing in it.\n- Everything here is a **read**. Cancelling an appointment, submitting a\n  signed document, or paying an invoice are writes this skill deliberately\n  does not cover — do those in the portal, where you can see what you're\n  agreeing to.\n- This project is developed and maintained by AI (Claude).\n\nFile v1.2.3:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"simplepractice-fpx\",\n  \"version\": \"1.2.3\",\n  \"publishedAt\": 1790991716621\n}\n\nFile v1.2.3:references/requests.md\n\n# SimplePractice Client Portal — request reference\n\nBase: `https://<practice>.clientsecure.me/client-portal-api`\n\nEvery shape below was taken from the portal app's own published sourcemaps\n(`widget-cdn.simplepractice.com/assets/*.map`, which ship full\n`sourcesContent`) and then confirmed against a live signed-in portal. Nothing\nhere is guessed. Where a field could not be exercised on the account used for\nverification, it says so.\n\nAssumes the `sp()` helper and `$SP_API` from `SKILL.md`.\n\n---\n\n## 0. Two naming systems — the trap\n\nURLs are **dashed and plural**. JSON:API `type` values are **camelCase and\nplural**. They are not the same string, and one endpoint uses both:\n\n| URL path | `.data[].type` |\n|---|---|\n| `/sign-in-tokens` | `signInTokens` |\n| `/document-requests` | `documentRequestQuestionnaires`, `documentRequestConsentDocuments`, … |\n| `/billing-items` | `invoices`, `statements`, `superbills`, `receipts`, `payments` |\n| `/client-billing-overviews` | `clientBillingOverviews` |\n| `/environment` (singular!) | `environments` |\n\nSo never build a `jq` filter by pluralising the path. Match on the `type`\nstring the response actually carries, or select positionally.\n\n`/environment` is the one singular path in the API.\n\n---\n\n## 1. Auth\n\n### 1.1 Request a magic link — `POST /sign-in-tokens`\n\n```sh\nsp -X POST \"$SP_API/sign-in-tokens\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sign-in-tokens\",\"attributes\":{\"email\":\"you@example.com\",\"expiresIn\":\"15 minutes\"}}}'\n```\n\n`202 Accepted`:\n\n```json\n{\"data\":{\"id\":\"…\",\"type\":\"signInTokens\",\"attributes\":{\"email\":\"you@example.com\",\"expiresIn\":\"24 hours\"}}}\n```\n\n- `expiresIn` in the **response** is the real lifetime (24 hours) whatever you\n  request. The portal app deliberately shows the same \"24 hours\" wording for an\n  address with no account, so that the response cannot be used to test whether\n  an email is registered. A `202` is therefore not proof the address exists.\n- Optional `redirect` attribute: a portal-relative path to land on after\n  verifying (the app uses it for `payment-link/<id>`).\n- **Errors.** `429` with title `Email request limit reached` or\n  `IP request limit reached`; `422` for a malformed address. Do not retry\n  either — this is the only auth path the portal has.\n\n### 1.2 Exchange the token — `POST /sessions/token`\n\nThe emailed link is\n**`https://<practice>.clientsecure.me/sign-in/token#<TOKEN>`** — `/sign-in/token`,\n*not* the `sign-in/token/verify` the app's route tree implies. A second variant,\nsent for the mobile app, points at the bare apex under the API namespace:\n`https://clientsecure.me/client-portal-api/sign-in/token#<TOKEN>`. Either works —\ntake the fragment, ignore the path.\n\nThe token is the **fragment** (303–317 characters observed). A browser never\nsends a fragment to the server; the app reads `location.hash` and posts it.\nFetching the link with `curl` accomplishes nothing — copy the part after `#`.\n\nBoth emails are quoted-printable, so the URL is **wrapped across lines with\ntrailing `=`**. Pulling it out of a raw message with a naive regex silently\ntruncates the token — decode the quoted-printable first.\n\n```sh\nsp -X POST \"$SP_API/sessions/token\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"token\",\"token\":\"'\"$TOKEN\"'\"}}}'\n```\n\nSuccess sets the `simplepractice-session` cookie (Rails/Devise) and returns\n`.data.meta.status`:\n\n| `meta.status` | meaning |\n|---|---|\n| `verified` | signed in; the cookie jar is now good |\n| `expired` | older than 24h — request a new link |\n| `merged` | the account was merged into another; sign in from the new portal |\n\nTokens are single-use — confirmed by replay, which answers\n`401 {\"title\":\"Authorization has already been used or expired\"}`. That is a 401\non a sign-in endpoint, where you have no session yet; it means *get a new\nlink*, not *your session expired*.\n\n### 1.3 PIN variant — `POST /sessions/pin`\n\n```sh\nsp -X POST \"$SP_API/sessions/pin\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"pin\",\"email\":\"you@example.com\",\"pin\":\"123456\"}}}'\n```\n\nThe PIN is exactly 6 digits (client-side regex `^\\d{6}$`) and is likewise\nsingle-use — a wrong or reused code comes back as a validation error on `pin`,\nand `429` is the rate limit.\n\n### 1.4 Session expiry\n\nAny endpoint answers `401 {\"errors\":[{\"title\":\"You have no access to this client\",\"status\":\"401\"}]}`\nonce the cookie lapses. There is no refresh token: re-run §1.1.\n\n---\n\n## 2. Identity — `GET /environment`\n\n```sh\nsp \"$SP_API/environment?include=currentPractice,currentClient,currentClientOptions\"\n```\n\n`.data` is the singleton `environments` record; the interesting part is\n`.data.relationships` + `.included[]`:\n\n| relationship | shape |\n|---|---|\n| `currentPractice` | one `practices` |\n| `currentClient` | one `clients` — whose data every other endpoint returns |\n| `currentClientOptions` | **array** of `clients` this login may switch between |\n| `currentClientAccess` | one `clientAccesses` — the login itself |\n\n```sh\nsp \"$SP_API/environment?include=currentPractice,currentClientOptions\" | jq '{\n  practice: (.included[] | select(.type==\"practices\") | .attributes.fullName),\n  timeZone: (.included[] | select(.type==\"practices\") | .attributes.timeZone),\n  clients: [.included[] | select(.type==\"clients\")\n            | {id, name: ((.attributes.preferredName // .attributes.firstName) + \" \" + .attributes.lastName)}]\n}'\n```\n\nUseful `practices` attributes (67 in all): `fullName`, `timeZone`, `currency`,\n`practiceUrl`, `phoneNumber`, `isGroupPractice`, `telehealthEnabled`,\n`selfSchedulingEnabled`, `isClientAllowedToCancelAppt`,\n`isClientAllowedToConfirmAppt`, `clientCancellableHrs`,\n`announcementsAvailable`, `featureSecureMessagingEmber`.\n\n`isClientAllowedToCancelAppt` and `clientCancellableHrs` are the practice's\nactual cancellation policy — worth reading before assuming an appointment can\nbe cancelled.\n\n`clients` attributes include `firstName`, `lastName`, `preferredName`,\n`nickname`, `birthDate`, `hashedId`, `status`, `billingType`,\n`hasIncompleteDocument`, `hasNewAnnouncements`, `hasInvoicedAppointments`,\n`permissions`, and `relationshipToCurrentClientAccess`.\n\n**`clients[].email` is `null` on a login that acts for someone else** (a parent\nportal, say). The sign-in address belongs to the *access*, not the client.\n\n---\n\n## 3. Appointments — `GET /appointments`\n\n```sh\n# upcoming / confirmed\nsp \"$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=false&page[size]=50&page[number]=1\"\n# requested, awaiting the practice's confirmation\nsp \"$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=true&page[size]=50&page[number]=1\"\n```\n\n`.data[].type` is `appointments`; `.included[]` carries `clinicians`,\n`offices`, `clients`.\n\nAttributes (21 live; from `models/unauthenticated-appointment.js` +\n`models/appointment.js`):\n\n| field | notes |\n|---|---|\n| `startTime`, `endTime` | ISO-8601 with offset |\n| `serviceDescription` | e.g. the CPT service name |\n| `confirmationStatus`, `clientConfirmationStatus` | practice-side vs client-side |\n| `isCancellable` | boolean — respects the practice's own policy |\n| `cancelReason`, `visitReason`, `visitTherapyReasons` | |\n| `videoRoomUrl` | telehealth link, when the appointment is video |\n| `icalUrl`, `gcalendarUrl` | ready-made calendar links |\n| `fee`, `uninvoicedFee`, `billableDescription`, `cptCodes`, `units` | |\n| `channel`, `schedulingSource`, `source` | how it was booked |\n| `files` | attachments |\n\nRelationships: `clinician`, `office`, `client`, `card`, `superbill`,\n`invoiceItems`, `appointmentClient`.\n\n`offices` carry `name`, `street`, `city`, `state`, `zip`, `phone`, `isVideo`,\n`geolocation` — `isVideo: true` is a telehealth \"room\", not an address.\n\nJoined one-liner:\n\n```sh\nsp \"$SP_API/appointments?include=clinician,office&filter[hasPendingConfirmation]=false&page[size]=50&page[number]=1\" \\\n| jq -r '\n  (.included // []) as $inc\n  | .data[]\n  | . as $a\n  | ($inc[]? | select(.type==\"clinicians\" and .id==$a.relationships.clinician.data.id)) as $c\n  | ($inc[]? | select(.type==\"offices\"    and .id==$a.relationships.office.data.id))    as $o\n  | [$a.attributes.startTime,\n     ($a.attributes.serviceDescription // \"—\"),\n     \"\\($c.attributes.firstName) \\($c.attributes.lastName)\",\n     (if $o.attributes.isVideo then \"telehealth\" else ($o.attributes.name // \"—\") end)\n    ] | @tsv'\n```\n\n**Pagination: by number.** `page[number]` / `page[size]`, max size 50. A short\npage is the last page.\n\n---\n\n## 4. Billing — `GET /billing-items`\n\nOne polymorphic collection, switched by `filter[thisType]`:\n\n| `filter[thisType]` | `.data[].type` | key attributes |\n|---|---|---|\n| `invoice` | `invoices` | `displayName`, `displayStatus`, `invoiceDate`, `totalAmount`, `remainingAmount`, `isNewForClient` |\n| `statement` | `statements` | `displayName`, `createdAt`, `isNewForClient` |\n| `superbill` | `superbills` | `displayName`, `createdAt`, `totalAmount`, `isNewForClient` |\n| `receipt` | `receipts` | `displayName`, `createdAt`, `isNewForClient` |\n| `billable-item,payment` (+ `filter[thisTypeCondition]=unallocated`) | mixed | account history |\n\n```sh\nsp \"$SP_API/billing-items?filter[thisType]=invoice&page[size]=50\" \\\n| jq '{balance: .meta.endBalance,\n       rows: [.data[] | {type, id, name: .attributes.displayName,\n                         status: .attributes.displayStatus,\n                         total: .attributes.totalAmount,\n                         due: .attributes.remainingAmount}]}'\n```\n\nEvery billing query returns `.meta.endBalance`.\n\nOptional `filter[timeRange]` narrows by date; the portal sends it as a\n`{start,end}` object, which `curl` writes as\n`filter[timeRange][start]=…&filter[timeRange][end]=…`. Omit it for everything.\n\n**Pagination: by cursor, backwards.** `page[size]=50`, then\n`page[before]=<the last row's cursorId>`. The cursor is the row's `cursorId`\nattribute, **not** its `id`. A short page is the last page.\n\n```sh\nsp \"$SP_API/billing-items?filter[thisType]=invoice&page[size]=50\" | jq -r '.data[-1].attributes.cursorId'\n```\n\nAn empty `.data[]` here is a normal, correct answer — many practices invoice\nentirely outside the portal. All five filters were confirmed to return `200`\nwith `meta.endBalance` on the account used for verification, which had no\nportal billing rows.\n\n### 4.1 Balance summary and saved cards live ON the client record\n\nThere is **no** `/client-billing-overviews` collection and **no** `/cards`\ncollection. Both are `include`-able relationships of `/clients/<id>`:\n\n```sh\nCLIENT_ID=$(sp \"$SP_API/environment?include=currentClient\" \\\n            | jq -r '.data.relationships.currentClient.data.id')\nsp \"$SP_API/clients/$CLIENT_ID?include=clientBillingOverview,cards\"\n```\n\n- `clientBillingOverviews` — `balanceDue`, `unallocatedPaymentAmount`, and the\n  counts `invoicesCount` / `statementsCount` / `superbillsCount` /\n  `receiptsCount` / `insuranceInfoCount`. Cheaper than paging the collections\n  just to see whether anything is there.\n- `cards` — `brand`, `last4`, `expiry` (e.g. `\"07 / 30\"`), `expMonth`,\n  `expYear`, `isDefault` (a **string** `\"true\"`/`\"false\"`), plus the Stripe\n  identifiers `paymentMethodId` / `customStripeCardId` /\n  `customStripeCustomerId`. No full card number.\n\n> Guessing `/cards` and `/client-billing-overviews` is the natural first move,\n> and both return **HTTP 200** — with `text/html` and the app shell, because\n> the SPA catch-all swallows every undefined path. They read as working,\n> empty endpoints. This cost a full debugging round during this build; check\n> `content-type`, never status, when an endpoint returns suspiciously nothing.\n\n---\n\n## 5. Documents — `GET /document-requests`\n\nPaperwork the practice has sent you to read, complete or sign.\n\n```sh\nsp \"$SP_API/document-requests?page[size]=50\" \\\n| jq -r '.data[] | [.attributes.status, .type, .attributes.documentTitle] | @tsv'\n```\n\n`.data[].type` is the *subtype*, and the attribute set varies with it:\n\n| `type` | what it is |\n|---|---|\n| `documentRequestConsentDocuments` | a consent form to sign |\n| `documentRequestQuestionnaires` | a questionnaire — `templateQuestions`, `userAnswers` |\n| `documentRequestContactInfos` | demographics/contact form |\n| `documentRequestInsuranceInfos` | insurance details |\n| `documentRequestCreditCardInfos` | card on file — `cardAttributes` |\n| `documentRequestStoredDocuments` | a file shared with you |\n| `documentRequestNotes` | a note |\n| `documentRequestGoodFaithEstimates` | a Good Faith Estimate |\n| `documentRequestPostSessionSummaries` | post-session summary |\n\n`status` ∈ `sent` · `viewed` · `reviewing` · `completed` · `locked`\n(`completed`, `sent` and `viewed` seen live). Anything not `completed`/`locked`\nis outstanding:\n\n```sh\nsp \"$SP_API/document-requests?page[size]=50\" \\\n| jq -r '[.data[] | select(.attributes.status | IN(\"completed\",\"locked\") | not)\n          | .attributes.documentTitle] | \"outstanding: \\(length)\\n\" + join(\"\\n\")'\n```\n\nCommon attributes: `documentTitle`, `status`, `createdAt`, `updatedAt`,\n`hasDocumentPdf`.\n\n> **`hasDocumentPdf` is a JSON string**, `\"true\"` or `\"false\"` — not a boolean,\n> despite `models/document-request.js` declaring `@attr('boolean')` (Ember casts\n> it client-side; the wire value is a string). Both values were seen live. So\n> `select(.attributes.hasDocumentPdf)` matches every row, including the ones\n> with no PDF. Always compare to the string:\n>\n> ```sh\n> jq -r '.data[] | select(.attributes.hasDocumentPdf == \"true\") | .attributes.documentTitle'\n> ```\n>\n> It is not the only one: a saved card's `isDefault` arrives as `\"true\"` /\n> `\"false\"` too. The declared type in the model is not evidence of the wire\n> type — check any boolean you come to depend on, with a real response. Subtype-specific: `documentType`, `documentExt`,\n`documentMimeType`, `documentBody`, `templateQuestions`, `userAnswers`,\n`cardAttributes`, `mixpanelType`.\n\nCollection `.meta` carries `hasDocumentsIntro` and `welcomeText`.\n\nSingle request: `GET /document-requests/<id>`.\n\n`hasDocumentPdf: true` means a rendered PDF exists. The portal fetches it\nthrough the same authenticated origin; treat the URL as session-scoped.\n\n`GET /documents` is the separate \"files shared with you\" list —\n`documentName`, `documentExt`, `thisType`, `createdAt`.\n\n---\n\n## 6. Announcements — `GET /announcements`\n\n```sh\nsp \"$SP_API/announcements?page[size]=50\" \\\n| jq -r '.data[] | [(.attributes.readAt // \"UNREAD\"), .attributes.title] | @tsv'\n```\n\nAttributes: `title`, `message`, `fromLabel`, `createdAt`, `readAt`,\n`isDeleted`. `clients[].hasNewAnnouncements` (§2) is the cheap \"is there\nanything new\" flag.\n\nThere is a `POST /announcements/read-announcements` that marks them all read —\na write, so out of scope here; it is listed only so you recognise it.\n\n---\n\n## 7. Secure messaging\n\nMessaging is **not** on this API. It lives at\n`https://messaging-api.simplepractice.com` (`messagingApiUrl` in the portal's\nconfig) with models `messagingConversation` / `messagingMessage` /\n`messagingContact` / `messagingProfile` / `messagingUser`, and is gated by the\npractice's `featureSecureMessagingEmber` flag.\n\nIts request shapes were **not** captured for this skill. If you need messages,\nread them in the portal, or capture the host's calls first — don't guess them.\n\n---\n\n## 8. Errors\n\n| status | meaning |\n|---|---|\n| `400` `Application build version is missing` | you dropped `Application-Build-Version` |\n| `401` `You have no access to this client` | cookie stale/absent → re-auth (§1) |\n| `422` | validation — the body names the offending field |\n| `429` | rate limit; on auth calls the title says email- or IP-scoped. Do not retry |\n\nErrors are JSON:API: `.errors[] | {title, code, status}`.\n\n---\n\n## Appendix — where these shapes came from\n\nThe portal serves public sourcemaps with full original sources:\n\n```sh\ncurl -s https://widget-cdn.simplepractice.com/assets/<chunk>.js.map | gunzip > map.json\nnode -e 'const m=require(\"./map.json\");m.sources.forEach((s,i)=>{/* write m.sourcesContent[i] */})'\n```\n\nThe chunk filenames are hashed per deploy — read them out of the portal HTML's\n`<script src>` tags. `adapters/application.js` defines the namespace and the\nrequired headers; `models/*.js` define every attribute; `routes/site/**` show\nwhich filters each screen sends. When SimplePractice ships a new build, that is\nthe authoritative place to re-check a shape.\n\nFile v1.2.3:skill-card.md\n\n## Description:\n\nGuides users in reading their SimplePractice Client Portal appointments, billing records, documents, announcements, and practice details from a shell using curl, with optional browser-session access through fpx.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nPeople accessing their own SimplePractice Client Portal, and developers assisting them, use this skill to retrieve appointments, billing information, documents, and practice details without running an MCP server. It provides read-focused shell instructions for signing in and querying the portal.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: Portal responses may disclose sensitive medical and billing records.\n\nMitigation: Use only for your own authorized portal access on a trusted personal machine; keep responses out of shared files and chat logs.\n\nRisk: Magic links, PINs, and session cookies can grant access to the portal if exposed.\n\nMitigation: Protect the cookie jar with restricted permissions, avoid sharing credentials or command output, and remove the cookie jar or fpx profile when finished.\n\nRisk: Using the wrong practice host could send authentication data to an unintended destination.\n\nMitigation: Verify the practice's SP_HOST against the provider's portal link before running commands.\n\n## Reference(s):\n\n- [SimplePractice FPX release](https://clawhub.ai/chrischall/skills/simplepractice-fpx)\n- [Client Portal request reference](references/requests.md)\n\n## Skill Output:\n\n**Output Type(s):** [Shell commands, Guidance]\n\n**Output Format:** [Markdown with shell commands]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Read-focused; retrieved records can contain protected health information.]\n\n## Skill Version(s):\n\n1.2.3 (source: ClawHub release)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.2.2: 4 files, 13638 bytes\n\nFiles: references/requests.md (16598b), skill-card.md (1955b), SKILL.md (12336b), _meta.json (137b)\n\nFile v1.2.2:SKILL.md\n\n---\nname: simplepractice-fpx\ndescription: >-\n  Read a SimplePractice Client Portal (`<practice>.clientsecure.me`) from a\n  shell — appointments, invoices/statements/superbills/receipts, documents to\n  sign, announcements, practice and clinician info — with plain `curl` against\n  its JSON:API, instead of running the simplepractice-mcp server. Sign in\n  headlessly with an emailed magic link, or capture the session cookie from an\n  already-signed-in browser tab with `fpx`. Use when you want Client Portal\n  data without the MCP, in a script, or on a machine where the MCP isn't\n  installed.\n---\n\n# SimplePractice Client Portal via curl (+ optional fpx)\n\nThe Client Portal is an Ember app whose backend is a plain **JSON:API** at\n`https://<practice>.clientsecure.me/client-portal-api`. It has **no bot wall**\n— every endpoint below answers ordinary server-side `curl` once you hold a\nsession cookie. So this skill is curl-first; `fpx` appears only as an optional\none-time way to lift the cookie out of a browser you're already signed into.\n\nThere is **no password**. Sign-in is passwordless: SimplePractice emails you\neither a magic link or a 6-digit PIN, and you trade that for a session cookie.\nThat flow carries **no captcha** (reCAPTCHA guards only the new-client request,\nwaitlist and contact forms), so §1 below works headlessly with nothing but\n`curl` and access to your inbox.\n\n> This is protected health information — your own therapy/medical record.\n> Treat the cookie jar as a credential: it is a full-access bearer token for\n> the portal. Keep it `chmod 600`, out of git, and off shared machines.\n\n## Your practice subdomain\n\nEvery URL is scoped to one practice. Take the host from the portal link your\nprovider sent you and export it once:\n\n```sh\nexport SP_HOST='achievebalancetherapy.clientsecure.me'   # <-- yours\nexport SP_API=\"https://$SP_HOST/client-portal-api\"\nexport SP_JAR=\"$HOME/.simplepractice-cookies\"\n\n# curl creates a cookie jar world-readable (644). This one holds a live\n# session for a medical record, so create it 0600 BEFORE curl ever writes it.\n[ -e \"$SP_JAR\" ] || ( umask 077; : > \"$SP_JAR\" )\nchmod 600 \"$SP_JAR\"\n```\n\n## The four headers — all of them, on every call\n\n```sh\nsp() { curl -s -b \"$SP_JAR\" -c \"$SP_JAR\" \\\n  -H 'Api-Version: 2026-05-25' \\\n  -H 'Application-Build-Version: 0.0.0' \\\n  -H 'Application-Platform: web' \\\n  -H 'Accept: application/vnd.api+json' \"$@\"; }\n```\n\nOmit `Application-Build-Version` and the API rejects the call with\n`400 {\"errors\":[{\"title\":\"Application build version is missing\"}]}` — verified.\n`Api-Version` is the API's own dated contract version, unrelated to any package\nversion; send it as-is.\n\n## 1. Sign in with a magic link (no browser)\n\n**a. Request the link.** One call, to your own portal address:\n\n```sh\nsp -X POST \"$SP_API/sign-in-tokens\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sign-in-tokens\",\"attributes\":{\"email\":\"you@example.com\",\"expiresIn\":\"15 minutes\"}}}'\n```\n\n`202 Accepted` means it was sent. The response echoes `expiresIn: \"24 hours\"`\nregardless of what you asked for — that is the real token lifetime, and it is\nalso what the API returns for an *unknown* email, deliberately, so that a 202\nnever reveals whether an address has an account.\n\n**Do not retry a failed sign-in.** `429` is a real limit with two distinct\ntitles — `Email request limit reached` and `IP request limit reached` — and\nhammering it locks you out of the only auth path there is. Wait it out.\n\n**b. Take the token out of the emailed link.** The link looks like\n\n```\nhttps://<practice>.clientsecure.me/sign-in/token#<TOKEN>\n```\n\nSimplePractice also mails a mobile-app variant on the bare apex,\n`https://clientsecure.me/client-portal-api/sign-in/token#<TOKEN>`. Either\nworks — the path is irrelevant, only the fragment matters.\n\nThe token is the **URL fragment**, after the `#` (about 300 characters).\nBecause it is a fragment it is never sent to the server by a browser\nnavigation — the app reads it in JS and posts it. So you must copy it\nyourself; following the link with `curl` does nothing.\n\nIf you are pulling the link out of a raw message rather than clicking it, note\nthe mail is **quoted-printable**: the URL is wrapped across lines with trailing\n`=`, and a naive regex will hand you a silently truncated token. Decode first.\n\n**c. Trade it for a session cookie.**\n\n```sh\nsp -X POST \"$SP_API/sessions/token\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"token\",\"token\":\"'\"$TOKEN\"'\"}}}' \\\n  | jq '.data.meta.status'\n```\n\n`\"verified\"` means the cookie jar is now authenticated. The other statuses are\n`\"expired\"` and `\"merged\"`; a `401`/`422` means the token was already used —\nthey are single-use.\n\n**PIN variant.** If your portal mails a 6-digit code instead of a link, post it\nto `sessions/pin` with the address it was sent to:\n\n```sh\nsp -X POST \"$SP_API/sessions/pin\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"pin\",\"email\":\"you@example.com\",\"pin\":\"123456\"}}}'\n```\n\n## 2. Or lift the cookie from a signed-in browser tab (fpx)\n\nOnly worth it if you're already signed in and would rather not wait on an\nemail. Requires the **Transporter** extension and `npm i -g @fetchproxy/cli`.\n\n```sh\nfpx profile add simplepractice --domain clientsecure.me\nfpx profile declare simplepractice \\\n  --cookie simplepractice-session --cookie client-portal-session \\\n  --local-storage client-portal-session --local-storage stored-email \\\n  --capture-header cookie@$SP_HOST\nfpx get \"https://$SP_HOST/\" -p simplepractice >/dev/null   # prints a pair code → approve in Transporter\n```\n\nDeclare **every** scope before that first pairing. Widening it afterwards\nleaves fetches working on the old grant while the new capability errors\n`capability \"read_cookies\" not granted`, and the fix is to remove the profile\nand re-pair from scratch.\n\nThen seed the jar from the browser's cookie:\n\n```sh\nSESSION=$(fpx cookies simplepractice-session -p simplepractice \\\n            --storage-subdomain \"${SP_HOST%%.*}\" | jq -r '.[\"simplepractice-session\"]')\nprintf '#HttpOnly_%s\\tFALSE\\t/\\tTRUE\\t0\\tsimplepractice-session\\t%s\\n' \"$SP_HOST\" \"$SESSION\" > \"$SP_JAR\"\nchmod 600 \"$SP_JAR\"\n```\n\n`fpx` exit codes: `2` bridge unavailable, `3` bot wall, `4` upstream non-2xx.\n\n## 3. Who am I, and which client am I looking at\n\n```sh\nsp \"$SP_API/environment?include=currentPractice,currentClient,currentClientOptions\" | jq '{\n  practice: (.included[] | select(.type==\"practices\") | .attributes.fullName),\n  timeZone: (.included[] | select(.type==\"practices\") | .attributes.timeZone),\n  clients:  [.included[] | select(.type==\"clients\") | {id, name: (.attributes.firstName+\" \"+.attributes.lastName)}]\n}'\n```\n\nOne portal login is a **client access**, and it can cover more than one client\n— a parent seeing two children, say. `currentClientOptions` is always an array;\n`currentClient` is the one whose data the other endpoints return. Don't assume\nthere is exactly one. (On a login that acts for someone else, the client\nrecord's own `email` is `null` — the sign-in address lives on the access, not\nthe client, so don't reach for `clients[].email` to find out who you are.)\n\n`401 {\"title\":\"You have no access to this client\"}` on any endpoint below means\nthe cookie is stale or absent — go back to §1.\n\n## 4. Reads\n\nAll of these are verified live. Collections are JSON:API, so records live under\n`.data[]` with fields under `.attributes`; `include=` pulls related records into\na sibling `.included[]` array that you join on\n`.relationships.<name>.data.id`.\n\n```sh\n# Upcoming appointments (and the requested-but-unconfirmed ones)\nsp \"$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=false&page[size]=50&page[number]=1\"\nsp \"$SP_API/appointments?include=clinician,office,client&filter[hasPendingConfirmation]=true&page[size]=50&page[number]=1\"\n\n# Billing — one endpoint, switched by filter[thisType]\nsp \"$SP_API/billing-items?filter[thisType]=invoice&page[size]=50\"\nsp \"$SP_API/billing-items?filter[thisType]=statement&page[size]=50\"\nsp \"$SP_API/billing-items?filter[thisType]=superbill&page[size]=50\"\nsp \"$SP_API/billing-items?filter[thisType]=receipt&page[size]=50\"\nsp \"$SP_API/billing-items?filter[thisType]=billable-item,payment&filter[thisTypeCondition]=unallocated&page[size]=50\"\n\n# Documents to review or sign, and files shared with you\nsp \"$SP_API/document-requests?page[size]=50\"\nsp \"$SP_API/documents?page[size]=50\"\n\n# Practice announcements\nsp \"$SP_API/announcements?page[size]=50\"\n\n# Balance summary and saved cards hang off the CLIENT record, not collections\n# of their own — see the warning below.\nCLIENT_ID=$(sp \"$SP_API/environment?include=currentClient\" \\\n            | jq -r '.data.relationships.currentClient.data.id')\nsp \"$SP_API/clients/$CLIENT_ID?include=clientBillingOverview,cards\" \\\n| jq '{balance: (.included[] | select(.type==\"clientBillingOverviews\") | .attributes),\n       cards:  [.included[] | select(.type==\"cards\")\n                | {brand: .attributes.brand, last4: .attributes.last4,\n                   expiry: .attributes.expiry, isDefault: .attributes.isDefault}]}'\n```\n\n> **A 200 is not proof an endpoint exists.** The portal is a single-page app,\n> so *any* path it does not define comes back as `200 text/html` with the app\n> shell (a constant ~7.5 KB) rather than a 404. `/cards` and\n> `/client-billing-overviews` are the obvious guesses for the two above, and\n> both answer 200 that way — they are not API paths at all. Check the\n> `content-type`, not the status:\n>\n> ```sh\n> sp -o /dev/null -w '%{http_code} %{content_type}\\n' \"$SP_API/whatever\"\n> ```\n>\n> Anything other than `application/vnd.api+json` means the path is wrong.\n\nA readable next-appointment line:\n\n```sh\nsp \"$SP_API/appointments?include=clinician,office&filter[hasPendingConfirmation]=false&page[size]=1&page[number]=1\" \\\n| jq -r '.data[0] as $a\n  | (.included[]? | select(.type==\"clinicians\")) as $c\n  | \"\\($a.attributes.startTime)  \\($a.attributes.serviceDescription // \"—\")  with \\($c.attributes.firstName) \\($c.attributes.lastName)\"'\n```\n\n## Pagination — two schemes, don't mix them\n\n- **Appointments** page by number: `page[number]=1&page[size]=50`. You're on\n  the last page when a page comes back shorter than `page[size]`.\n- **Billing items** page by *cursor*, backwards: `page[size]=50` and then\n  `page[before]=<cursorId of the last row you saw>`. The row's `cursorId` is\n  the cursor, not its `id`.\n\n`50` is the server's max page size; asking for more does not get you more.\n\n## Notes\n\n- Times come back ISO-8601 with an offset. The practice's own `timeZone`\n  (§3) is what its staff schedule in — use it when a date matters.\n- **`permissions` on the client is a JSON string, not an object.** It parses\n  to the portal features this client actually has —\n  `{\"messaging\":…,\"selfScheduling\":…,\"billingDocuments\":…,\"payments\":…,\"appointments\":…}`.\n  Read it with `.attributes.permissions | fromjson`; used raw it is a string of\n  characters. `billingDocuments` is what gates the whole billing tab.\n- **`hasDocumentPdf` is a string, not a boolean.** It arrives as `\"true\"` or\n  `\"false\"` — both seen live — so `if (hasDocumentPdf)` and\n  `jq 'select(.attributes.hasDocumentPdf)'` are BOTH true for `\"false\"`.\n  Compare against the string: `select(.attributes.hasDocumentPdf == \"true\")`.\n  A card's `isDefault` is the same — so do not assume a JSON boolean anywhere\n  in this API without checking the value you actually get back.\n- `billing-items` is polymorphic: `.data[].type` tells you which of\n  invoice / statement / superbill / receipt / payment a row actually is, and\n  the attribute set differs per type. `.meta.endBalance` accompanies every\n  billing query.\n- An empty `.data[]` is a real answer, not a failure — plenty of practices\n  bill outside the portal entirely and every billing endpoint returns `200`\n  with nothing in it.\n- Everything here is a **read**. Cancelling an appointment, submitting a\n  signed document, or paying an invoice are writes this skill deliberately\n  does not cover — do those in the portal, where you can see what you're\n  agreeing to.\n- This project is developed and maintained by AI (Claude).\n\nFile v1.2.2:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"simplepractice-fpx\",\n  \"version\": \"1.2.2\",\n  \"publishedAt\": 1790787730987\n}\n\nFile v1.2.2:references/requests.md\n\n# SimplePractice Client Portal — request reference\n\nBase: `https://<practice>.clientsecure.me/client-portal-api`\n\nEvery shape below was taken from the portal app's own published sourcemaps\n(`widget-cdn.simplepractice.com/assets/*.map`, which ship full\n`sourcesContent`) and then confirmed against a live signed-in portal. Nothing\nhere is guessed. Where a field could not be exercised on the account used for\nverification, it says so.\n\nAssumes the `sp()` helper and `$SP_API` from `SKILL.md`.\n\n---\n\n## 0. Two naming systems — the trap\n\nURLs are **dashed and plural**. JSON:API `type` values are **camelCase and\nplural**. They are not the same string, and one endpoint uses both:\n\n| URL path | `.data[].type` |\n|---|---|\n| `/sign-in-tokens` | `signInTokens` |\n| `/document-requests` | `documentRequestQuestionnaires`, `documentRequestConsentDocuments`, … |\n| `/billing-items` | `invoices`, `statements`, `superbills`, `receipts`, `payments` |\n| `/client-billing-overviews` | `clientBillingOverviews` |\n| `/environment` (singular!) | `environments` |\n\nSo never build a `jq` filter by pluralising the path. Match on the `type`\nstring the response actually carries, or select positionally.\n\n`/environment` is the one singular path in the API.\n\n---\n\n## 1. Auth\n\n### 1.1 Request a magic link — `POST /sign-in-tokens`\n\n```sh\nsp -X POST \"$SP_API/sign-in-tokens\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sign-in-tokens\",\"attributes\":{\"email\":\"you@example.com\",\"expiresIn\":\"15 minutes\"}}}'\n```\n\n`202 Accepted`:\n\n```json\n{\"data\":{\"id\":\"…\",\"type\":\"signInTokens\",\"attributes\":{\"email\":\"you@example.com\",\"expiresIn\":\"24 hours\"}}}\n```\n\n- `expiresIn` in the **response** is the real lifetime (24 hours) whatever you\n  request. The portal app deliberately shows the same \"24 hours\" wording for an\n  address with no account, so that the response cannot be used to test whether\n  an email is registered. A `202` is therefore not proof the address exists.\n- Optional `redirect` attribute: a portal-relative path to land on after\n  verifying (the app uses it for `payment-link/<id>`).\n- **Errors.** `429` with title `Email request limit reached` or\n  `IP request limit reached`; `422` for a malformed address. Do not retry\n  either — this is the only auth path the portal has.\n\n### 1.2 Exchange the token — `POST /sessions/token`\n\nThe emailed link is\n**`https://<practice>.clientsecure.me/sign-in/token#<TOKEN>`** — `/sign-in/token`,\n*not* the `sign-in/token/verify` the app's route tree implies. A second variant,\nsent for the mobile app, points at the bare apex under the API namespace:\n`https://clientsecure.me/client-portal-api/sign-in/token#<TOKEN>`. Either works —\ntake the fragment, ignore the path.\n\nThe token is the **fragment** (303–317 characters observed). A browser never\nsends a fragment to the server; the app reads `location.hash` and posts it.\nFetching the link with `curl` accomplishes nothing — copy the part after `#`.\n\nBoth emails are quoted-printable, so the URL is **wrapped across lines with\ntrailing `=`**. Pulling it out of a raw message with a naive regex silently\ntruncates the token — decode the quoted-printable first.\n\n```sh\nsp -X POST \"$SP_API/sessions/token\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"token\",\"token\":\"'\"$TOKEN\"'\"}}}'\n```\n\nSuccess sets the `simplepractice-session` cookie (Rails/Devise) and returns\n`.data.meta.status`:\n\n| `meta.status` | meaning |\n|---|---|\n| `verified` | signed in; the cookie jar is now good |\n| `expired` | older than 24h — request a new link |\n| `merged` | the account was merged into another; sign in from the new portal |\n\nTokens are single-use — confirmed by replay, which answers\n`401 {\"title\":\"Authorization has already been used or expired\"}`. That is a 401\non a sign-in endpoint, where you have no session yet; it means *get a new\nlink*, not *your session expired*.\n\n### 1.3 PIN variant — `POST /sessions/pin`\n\n```sh\nsp -X POST \"$SP_API/sessions/pin\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"pin\",\"email\"\n\nArchive v1.2.1: 4 files, 13628 bytes\n\nFiles: references/requests.md (16598b), skill-card.md (1797b), SKILL.md (12336b), _meta.json (137b)\n\nArchive v1.2.0: 4 files, 13752 bytes\n\nFiles: references/requests.md (16598b), skill-card.md (2137b), SKILL.md (12336b), _meta.json (137b)\n\nArchive v1.1.4: 4 files, 13692 bytes\n\nFiles: references/requests.md (16598b), skill-card.md (1948b), SKILL.md (12336b), _meta.json (137b)\n\nArchive v1.1.3: 4 files, 13884 bytes\n\nFiles: references/requests.md (16598b), skill-card.md (2392b), SKILL.md (12336b), _meta.json (137b)\n\nArchive v1.1.2: 4 files, 13813 bytes\n\nFiles: references/requests.md (16598b), skill-card.md (2353b), SKILL.md (12336b), _meta.json (137b)","readmeExcerpt":"Skill: simplepractice-fpx Owner: chrischall Summary: Read a SimplePractice Client Portal (<practice>.clientsecure.me) from a shell — appointments, invoices/statements/superbills/receipts, documents to sign, announcements, practice and clinician info — with plain curl against its JSON:API, instead of running the simplepractice-mcp server. Sign in headlessly with an emailed magic link, or capture the session cookie fro","codeSnippets":[],"executableExamples":[{"language":"sh","snippet":"export SP_HOST='achievebalancetherapy.clientsecure.me'   # <-- yours\nexport SP_API=\"https://$SP_HOST/client-portal-api\"\nexport SP_JAR=\"$HOME/.simplepractice-cookies\"\n\n# curl creates a cookie jar world-readable (644). This one holds a live\n# session for a medical record, so create it 0600 BEFORE curl ever writes it.\n[ -e \"$SP_JAR\" ] || ( umask 077; : > \"$SP_JAR\" )\nchmod 600 \"$SP_JAR\""},{"language":"sh","snippet":"sp() { curl -s -b \"$SP_JAR\" -c \"$SP_JAR\" \\\n  -H 'Api-Version: 2026-05-25' \\\n  -H 'Application-Build-Version: 0.0.0' \\\n  -H 'Application-Platform: web' \\\n  -H 'Accept: application/vnd.api+json' \"$@\"; }"},{"language":"sh","snippet":"sp -X POST \"$SP_API/sign-in-tokens\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sign-in-tokens\",\"attributes\":{\"email\":\"you@example.com\",\"expiresIn\":\"15 minutes\"}}}'"},{"language":"text","snippet":"https://<practice>.clientsecure.me/sign-in/token#<TOKEN>"},{"language":"sh","snippet":"sp -X POST \"$SP_API/sessions/token\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"token\",\"token\":\"'\"$TOKEN\"'\"}}}' \\\n  | jq '.data.meta.status'"},{"language":"sh","snippet":"sp -X POST \"$SP_API/sessions/pin\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sessions\",\"attributes\":{\"type\":\"pin\",\"email\":\"you@example.com\",\"pin\":\"123456\"}}}'"}],"parameters":null,"dependencies":[],"permissions":[],"extractedFiles":[{"path":"SKILL.md","content":"---\nname: simplepractice-fpx\ndescription: >-\n  Read a SimplePractice Client Portal (`<practice>.clientsecure.me`) from a\n  shell — appointments, invoices/statements/superbills/receipts, documents to\n  sign, announcements, practice and clinician info — with plain `curl` against\n  its JSON:API, instead of running the simplepractice-mcp server. Sign in\n  headlessly with an emailed magic link, or capture the session cookie from an\n  already-signed-in browser tab with `fpx`. Use when you want Client Portal\n  data without the MCP, in a script, or on a machine where the MCP isn't\n  installed.\n---\n\n# SimplePractice Client Portal via curl (+ optional fpx)\n\nThe Client Portal is an Ember app whose backend is a plain **JSON:API** at\n`https://<practice>.clientsecure.me/client-portal-api`. It has **no bot wall**\n— every endpoint below answers ordinary server-side `curl` once you hold a\nsession cookie. So this skill is curl-first; `fpx` appears only as an optional\none-time way to lift the cookie out of a browser you're already signed into.\n\nThere is **no password**. Sign-in is passwordless: SimplePractice emails you\neither a magic link or a 6-digit PIN, and you trade that for a session cookie.\nThat flow carries **no captcha** (reCAPTCHA guards only the new-client request,\nwaitlist and contact forms), so §1 below works headlessly with nothing but\n`curl` and access to your inbox.\n\n> This is protected health information — your own therapy/medical record.\n> Treat the cookie jar as a credential: it is a full-access bearer token for\n> the portal. Keep it `chmod 600`, out of git, and off shared machines.\n\n## Your practice subdomain\n\nEvery URL is scoped to one practice. Take the host from the portal link your\nprovider sent you and export it once:\n\n```sh\nexport SP_HOST='achievebalancetherapy.clientsecure.me'   # <-- yours\nexport SP_API=\"https://$SP_HOST/client-portal-api\"\nexport SP_JAR=\"$HOME/.simplepractice-cookies\"\n\n# curl creates a cookie jar world-readable (644). This one holds a live\n# session for a medical record, so create it 0600 BEFORE curl ever writes it.\n[ -e \"$SP_JAR\" ] || ( umask 077; : > \"$SP_JAR\" )\nchmod 600 \"$SP_JAR\"\n```\n\n## The four headers — all of them, on every call\n\n```sh\nsp() { curl -s -b \"$SP_JAR\" -c \"$SP_JAR\" \\\n  -H 'Api-Version: 2026-05-25' \\\n  -H 'Application-Build-Version: 0.0.0' \\\n  -H 'Application-Platform: web' \\\n  -H 'Accept: application/vnd.api+json' \"$@\"; }\n```\n\nOmit `Application-Build-Version` and the API rejects the call with\n`400 {\"errors\":[{\"title\":\"Application build version is missing\"}]}` — verified.\n`Api-Version` is the API's own dated contract version, unrelated to any package\nversion; send it as-is.\n\n## 1. Sign in with a magic link (no browser)\n\n**a. Request the link.** One call, to your own portal address:\n\n```sh\nsp -X POST \"$SP_API/sign-in-tokens\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sign-in-tokens\",\"attributes\":{\"email\":\"you@example.com\",\"expiresIn\":\"15 minutes\"}}}'\n```\n\n`202 Accepted` "},{"path":"_meta.json","content":"{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"simplepractice-fpx\",\n  \"version\": \"1.2.6\",\n  \"publishedAt\": 1791588642026\n}"},{"path":"references/requests.md","content":"# SimplePractice Client Portal — request reference\n\nBase: `https://<practice>.clientsecure.me/client-portal-api`\n\nEvery shape below was taken from the portal app's own published sourcemaps\n(`widget-cdn.simplepractice.com/assets/*.map`, which ship full\n`sourcesContent`) and then confirmed against a live signed-in portal. Nothing\nhere is guessed. Where a field could not be exercised on the account used for\nverification, it says so.\n\nAssumes the `sp()` helper and `$SP_API` from `SKILL.md`.\n\n---\n\n## 0. Two naming systems — the trap\n\nURLs are **dashed and plural**. JSON:API `type` values are **camelCase and\nplural**. They are not the same string, and one endpoint uses both:\n\n| URL path | `.data[].type` |\n|---|---|\n| `/sign-in-tokens` | `signInTokens` |\n| `/document-requests` | `documentRequestQuestionnaires`, `documentRequestConsentDocuments`, … |\n| `/billing-items` | `invoices`, `statements`, `superbills`, `receipts`, `payments` |\n| `/client-billing-overviews` | `clientBillingOverviews` |\n| `/environment` (singular!) | `environments` |\n\nSo never build a `jq` filter by pluralising the path. Match on the `type`\nstring the response actually carries, or select positionally.\n\n`/environment` is the one singular path in the API.\n\n---\n\n## 1. Auth\n\n### 1.1 Request a magic link — `POST /sign-in-tokens`\n\n```sh\nsp -X POST \"$SP_API/sign-in-tokens\" \\\n  -H 'Content-Type: application/vnd.api+json' \\\n  --data '{\"data\":{\"type\":\"sign-in-tokens\",\"attributes\":{\"email\":\"you@example.com\",\"expiresIn\":\"15 minutes\"}}}'\n```\n\n`202 Accepted`:\n\n```json\n{\"data\":{\"id\":\"…\",\"type\":\"signInTokens\",\"attributes\":{\"email\":\"you@example.com\",\"expiresIn\":\"24 hours\"}}}\n```\n\n- `expiresIn` in the **response** is the real lifetime (24 hours) whatever you\n  request. The portal app deliberately shows the same \"24 hours\" wording for an\n  address with no account, so that the response cannot be used to test whether\n  an email is registered. A `202` is therefore not proof the address exists.\n- Optional `redirect` attribute: a portal-relative path to land on after\n  verifying (the app uses it for `payment-link/<id>`).\n- **Errors.** `429` with title `Email request limit reached` or\n  `IP request limit reached`; `422` for a malformed address. Do not retry\n  either — this is the only auth path the portal has.\n\n### 1.2 Exchange the token — `POST /sessions/token`\n\nThe emailed link is\n**`https://<practice>.clientsecure.me/sign-in/token#<TOKEN>`** — `/sign-in/token`,\n*not* the `sign-in/token/verify` the app's route tree implies. A second variant,\nsent for the mobile app, points at the bare apex under the API namespace:\n`https://clientsecure.me/client-portal-api/sign-in/token#<TOKEN>`. Either works —\ntake the fragment, ignore the path.\n\nThe token is the **fragment** (303–317 characters observed). A browser never\nsends a fragment to the server; the app reads `location.hash` and posts it.\nFetching the link with `curl` accomplishes nothing — copy the part after `#`.\n\nBoth emails are quoted-printable, so the URL i"},{"path":"skill-card.md","content":"## Description:\n\nGuides an agent in reading a signed-in SimplePractice Client Portal from the shell to retrieve appointments, billing records, documents, and practice information.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nPortal users and their authorized agents use this skill to retrieve their SimplePractice appointment, billing, document, and practice information without installing a separate portal server. It covers reading data, not making payments or changing appointments.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: A live portal session cookie can grant access to sensitive health and billing records.\n\nMitigation: Use a trusted personal machine, restrict the cookie jar to the account owner, keep it out of git and logs, and delete it when finished.\n\nRisk: Portal responses may expose protected health information in shared output or saved files.\n\nMitigation: Avoid printing or saving health and billing responses in shared locations; review any agent output before sharing.\n\n## Reference(s):\n\n- [SimplePractice FPX release](https://clawhub.ai/chrischall/skills/simplepractice-fpx)\n- [SimplePractice Client Portal request reference](references/requests.md)\n\n## Skill Output:\n\n**Output Type(s):** [Guidance, Shell commands, Text]\n\n**Output Format:** [Markdown with shell examples and portal response summaries]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Read-only portal guidance; results may contain sensitive health and billing information.]\n\n## Skill Version(s):\n\n1.2.6 (source: server-resolved release metadata)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment."}],"languages":[],"docsSourceLabel":"CLAWHUB","editorialOverview":null,"editorialQuality":{"score":100,"threshold":65,"status":"thin","wordCount":1546,"uniquenessScore":43,"reasons":["uniqueness-below-45"]}},"media":{"evidence":{"source":"no-media","verified":false,"confidence":"low","updatedAt":"2026-10-11T08:11:14.751Z","emptyReason":"No screenshots, media assets, or demo links are available."},"primaryImageUrl":null,"mediaAssetCount":0,"assets":[],"demoUrl":null},"ownerResources":{"evidence":{"source":"unclaimed","verified":false,"confidence":"low","updatedAt":"2026-10-11T08:11:14.751Z","emptyReason":"This page has not been claimed by the agent owner."},"hasCustomPage":false,"customPageUpdatedAt":null,"customLinks":[],"structuredLinks":{"docsUrl":null,"demoUrl":null,"supportUrl":null,"pricingUrl":null,"statusUrl":null},"customPage":null},"relatedAgents":{"evidence":{"source":"protocol-neighbors","verified":false,"confidence":"medium","updatedAt":"2026-10-11T10:51:20.073Z","emptyReason":null},"items":[{"id":"8ebccd8e-3863-4187-8355-c3f14e1f9edf","entityType":"agent","canonicalPath":"/agent/iofficeai-aionui","slug":"iofficeai-aionui","name":"AionUi","description":"Free, local, open-source 24/7 Cowork app and OpenClaw for Gemini CLI, Claude Code, Codex, OpenCode, Qwen Code, Goose CLI, Auggie, and more | 🌟 Star if you like it!","url":"https://github.com/iOfficeAI/AionUi","homepage":"https://www.aionui.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-10-09T19:11:12.944Z","createdAt":"2026-02-25T03:38:16.584Z","downloads":null},{"id":"b917f68a-ebff-438e-84f8-3f4b2494c0bc","entityType":"agent","canonicalPath":"/agent/activepieces-activepieces","slug":"activepieces-activepieces","name":"activepieces","description":"AI Agents & MCPs & AI Workflow Automation • (~400 MCP servers for AI agents) • AI Automation / AI Agent with MCPs • AI Workflows & AI Agents • MCPs for AI Agents","url":"https://github.com/activepieces/activepieces","homepage":"https://www.activepieces.com","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-15T02:22:12.426Z","createdAt":"2026-02-25T03:38:12.412Z","downloads":null},{"id":"5cb26759-3a39-483f-94cf-276a98c13bb8","entityType":"agent","canonicalPath":"/agent/cherryhq-cherry-studio","slug":"cherryhq-cherry-studio","name":"cherry-studio","description":"AI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs","url":"https://github.com/CherryHQ/cherry-studio","homepage":"https://cherry-ai.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-11T14:38:40.986Z","createdAt":"2026-02-25T03:38:19.379Z","downloads":null},{"id":"6f6582d0-5d76-4f0f-b81d-86520247950b","entityType":"agent","canonicalPath":"/agent/copilotkit-copilotkit","slug":"copilotkit-copilotkit","name":"CopilotKit","description":"The Frontend for Agents & Generative UI. React + Angular","url":"https://github.com/CopilotKit/CopilotKit","homepage":"https://docs.copilotkit.ai","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-03-25T09:50:57.846Z","createdAt":"2026-02-25T03:39:14.617Z","downloads":null}],"links":{"hub":"/agent","source":"/agent/source/clawhub","protocols":[{"label":"OpenClaw","href":"/agent/protocol/openclew"}]}}}