WriteUpp API appointment reporting

Source documentation: https://openapi.writeupp.com/docs

Purpose: replace the previous Hazel + downloaded Appointments.csv workflow with a safer API-based report that creates an Obsidian markdown audit of appointment mode patterns.

Previous workflow

A Hazel rule watched for a downloaded WriteUpp Appointments.csv, then ran a shell script which invoked:

/Users/john/workspace/Writeupp Reports/writeupp_week_pattern_report.R

The R script read the CSV, checked appointments from yesterday onwards, and wrote:

~/workspace/Obsidian/Psychotherapy Notes/100 CPD Record/WriteUpp Reports/WriteUpp Appointment Pattern Check.md

Expected pattern:

DayExpected mode
Mondayvideo
Tuesdayface to face
Wednesdayface to face
Thursdayvideo

API documentation notes

From the WriteUpp OpenAPI documentation reviewed on 2026-07-13:

  • The API is intended for server-side integrations that read site data without sharing a staff login.
  • API keys belong to the WriteUpp site.
  • Production API traffic uses HTTPS.
  • API keys can read customer data for the site and must be treated like passwords.
  • API keys should be kept server-side.
  • API keys must not be committed to source control.
  • API keys must not be included in support emails, chat, screenshots, logs, or public forums.
  • WriteUpp support will never ask for the API key.
  • Authentication uses an X-API-Key header.
  • The correct regional API URL must be used; keys are region-bound.
  • A User-Agent header should identify the integration using the format APPLICATION_NAME (APPLICATION_EMAIL).

Confidentiality rule

The API workflow should use only the minimum fields needed for the appointment pattern report.

Preferred data to request/store:

Field typePurpose
appointment start date/timedecide date, weekday, ordering
appointment type/mode/location/categoryclassify video vs face-to-face
clientref / pseudonymous patient referencedistinguish appointments if needed, without full names
statusoptionally exclude cancelled/DNA appointments

Do not request, store, log, or write to Obsidian if avoidable:

DataRule
client full nameavoid entirely; if returned by API, discard immediately
first name / surnameavoid entirely; if returned by API, discard immediately
email addressdo not store/log
phone numberdo not store/log
addressdo not store/log
date of birthonly for the Client Hours creator: request transiently from the individual patient endpoint to derive an integer age; never store, log, or cache it
clinical notesnot needed for this report

If the appointments endpoint does not support field selection and returns patient names, the script must strip those fields before any logging, caching, markdown generation, or error reporting.

Safe API key handling

Do not paste the WriteUpp API key into AI chat, Obsidian notes, screenshots, Git commits, or debug logs.

Preferred options:

  1. Store the key in macOS Keychain and have the script read it at runtime.
  2. Store the key in a local untracked secrets file outside the Obsidian vault and any Git repository, with permissions set to owner-read/write only.
  3. For temporary testing only, set WRITEUPP_API_KEY as an environment variable in the shell running the script.

Recommended local secrets file pattern:

~/.config/writeupp-api.env

Example contents, with placeholder only:

WRITEUPP_API_BASE_URL="https://REGIONAL-API-URL-FROM-WRITEUPP"
WRITEUPP_API_KEY="replace-with-real-key-locally-never-commit"
WRITEUPP_USER_AGENT="Obsidian WriteUpp appointment audit (contact-email@example.com)"

Recommended permissions:

mkdir -p ~/.config
chmod 700 ~/.config
chmod 600 ~/.config/writeupp-api.env

The report script should refuse to run if the key is missing, and should never print the key or full request headers.

Logging rule

Logs may include:

  • timestamp
  • endpoint path, without query secrets
  • HTTP status code
  • number of appointments fetched
  • number of rows checked
  • number of mismatches
  • output markdown path

Logs must not include:

  • X-API-Key
  • full request headers
  • full raw API responses
  • client names or contact details
  • unredacted errors containing returned patient data

Implementation direction

A replacement Python script is probably simpler than the old shell + R pair. It should:

  1. Load API settings from macOS Keychain or an untracked local env file.
  2. Call the WriteUpp appointments endpoint using X-API-Key and User-Agent headers.
  3. Filter appointments from yesterday onwards.
  4. Classify appointment mode as video, face_to_face, or other.
  5. Check only in-scope weekdays and modes.
  6. Write the same Obsidian markdown report.
  7. Avoid writing any identifiable client data.
  8. Log only safe operational metadata.

Server implementation

Implemented on the Linux server on 2026-07-13.

Script:

/home/john/bin/writeupp_appointment_pattern_report.py

Wrapper:

/home/john/bin/run_writeupp_appointment_pattern_report.sh

Local credentials file:

/home/john/.config/writeupp-api.env

The credentials file was created with placeholder values and permissions 600. Replace only the placeholder key/contact email locally on the server; do not paste the key into chat.

Configured base URL:

https://openapi.writeupp.com

Report output:

/srv/obsidian-vault/Psychotherapy/100 CPD Record/WriteUpp Reports/WriteUpp Appointment Pattern Check.md

Log output:

/home/john/.local/state/writeupp-api/appointment-pattern.log

The script has a self-test mode that does not call the API:

/home/john/bin/writeupp_appointment_pattern_report.py --self-test --output-md /tmp/writeupp-test-report.md --logfile /tmp/writeupp-test.log

Run live after editing the credentials file:

/home/john/bin/run_writeupp_appointment_pattern_report.sh

OpenAPI endpoint notes

The downloadable OpenAPI document is at:

https://openapi.writeupp.com/swagger/v1/swagger.json

Relevant endpoints:

EndpointUse in scriptConfidentiality note
/v1/appointmentsappointment date/time, type id, status idappointment payload contains patient_id but no embedded names in the documented schema
/v1/appointment-typesmap appointment type id to type nameneeded to classify video vs face-to-face
/v1/appointment-statusesmap status id to status namenon-client status metadata
/v1/locationsmap location id to location nameused to flag wrong appointment locations, e.g. Phone Call where the pattern expects video/face-to-face

The appointments endpoint supports date filtering with from and to query parameters. The report script uses those parameters, so it does not download the full appointment history. It currently requests from yesterday through 90 days ahead, then paginates only within that date-filtered window. It also fetches the small appointment type/status/location lookup lists so IDs can be rendered as labels.

The appointment-pattern report deliberately does not call /v1/patients, because the documented PatientResponse includes names, contact details, address, date of birth, NHS number/hospital number, comments, and next-of-kin data. The Client Hours creator has the narrowly scoped date-of-birth exception documented below.

The script also fails closed if an appointment response unexpectedly contains blocked sensitive fields such as first_name, surname, email, address, date_of_birth, nhs_number, comments, patient, patients, or next_of_kin. Generic metadata fields called name are allowed for appointment types and statuses, because those endpoint responses use name for non-client labels such as Video Consultation and Booked.

Timezone handling

The WriteUpp API appointment fields observed during testing returned date-time strings without an explicit timezone offset, for example 2026-07-13T13:45:00. These values appear to be UTC/GMT rather than current UK local time.

The server itself is set to Europe/London, but the script must not treat naive WriteUpp API values as local time. It now treats naive appointment times from the API as UTC/GMT, then renders them in Europe/London, including BST when applicable.

Example during BST:

API valueReported as
2026-07-13T13:45:002026-07-13 14:45

API query window times are also sent explicitly in UTC with a Z suffix.

Live test

Live API test completed successfully on 2026-07-13 after the local credentials file was updated.

Result:

  • report written successfully
  • API endpoints returned HTTP 200
  • 18 appointment rows fetched for the test window
  • 9 rows checked
  • 0 mismatches
  • no blocked sensitive field patterns found in the generated report
  • cancelled and non-attended statuses are included; the script does not pass status_id and does not filter appointments by status

Generated report:

/srv/obsidian-vault/Psychotherapy/100 CPD Record/WriteUpp Reports/WriteUpp Appointment Pattern Check.md

Scheduling

Scheduled in the user crontab on 2026-07-13:

# Hermes-managed: WriteUpp appointment pattern report, API version.
30 6 * * * /usr/bin/flock -n /tmp/writeupp-appointment-pattern.lock /home/john/bin/run_writeupp_appointment_pattern_report.sh >> /home/john/.local/state/writeupp-api/cron.log 2>&1

This keeps the report generated on the server without Hazel or downloaded CSV files. Until the real API key and contact email are added to /home/john/.config/writeupp-api.env, the cron run will fail safely and log that the key/contact details are still placeholders.

Status handling

Cancelled and non-attended appointments should be included. The script does not send a status_id filter and does not exclude any appointment status locally. The checked appointments table includes a Status column, daily counts are broken down by status, and the report includes a status-count summary.

Location handling

The report includes an appointment Location column. Location names are checked against the appointment type. Phone / Phone Call is flagged as a location issue because the expected pattern only uses video and face-to-face sessions. The script fetches /v1/locations but retains only id-to-name labels for the report; address/postcode/phone metadata from the location response is not written to the report.

Live test on 2026-07-13 correctly flagged the test appointment:

DateTypeStatusLocationIssue
2026-07-13 11:15Video ConsultationDid not attendPhone Calllocation

Client Hours note automation

Implemented on 2026-07-13. Updated on 2026-07-17 after the scheduling and lead-time changes.

Script:

/home/john/bin/writeupp_create_client_hours_notes.py

The deployed script and this operational documentation are also backed up as a small, dedicated private repository at git@git.gay:iwan/writeupp-client-hours-automation-private.git. It contains no API credentials, client records, dates of birth, or appointment responses.

Purpose: create and maintain Obsidian Client Hours notes from appointment metadata, using WriteUpp patient date of birth only transiently to derive a current age; it does not write, log, or retain dates of birth, names, contact details, or patient responses.

Live output path:

/srv/obsidian-vault/Psychotherapy/100 CPD Record/Client Hours/Data/YYYY/YYYY-MM/Client Hours - YYYY-MM-DD HH-MM-SS.md

The filename time uses the appointment start time, converted from WriteUpp UTC/GMT to Europe/London local time.

The script treats naive WriteUpp appointment timestamps as UTC/GMT and then renders them in Europe/London. This is deliberate: it keeps the generated filename/date behaviour correct in both GMT and BST.

The usual generated live note has the following compact frontmatter; a first record adds the supervisor tags and fields described below:

tags:
  - clienthours
where:
clientref:
age: <only when new or changed>
medium:
agency: JD Psychotherapy
current issues: <unpopulated>
hours: 1
date:

The generated body follows the current example note:

___
```dataviewjs
await dv.view("meta/Templates/client-linked-files")
```
## References:
-
[[../100 CPD Record/Client Hours/Mega Report.md]]
[[../030 Literature Notes/Supervision YAML Cheat Sheet.md]]

Mapping rules:

WriteUpp valueClient Hours value
patient_idclientref: WU{patient_id}
Online locationwhere: home
Uplands, Swansea locationwhere: Uplands Therapy Rooms
All Ears Clinic locationwhere: All Ears Clinic
Phone Call locationwhere: home
video appointment typemedium: writeupp-online
face-to-face appointment typemedium: in-person
actual phone appointment/typemedium: phone
cancelled / DNA / did-not-attend statusmedium: cancelled, hours: 0

The script uses patient_id from the appointment response as the suffix of the existing clientref format, e.g. 3742383 becomes WU3742383. Only when a new Client Hours note is eligible for creation, it calls /v1/patients/{id} and immediately derives the whole-years age from date_of_birth for the appointment’s local date. The complete patient response is cleared in memory; neither date of birth nor names/contact details are written, cached, or logged.

The age uses the standard whole-years rule: the year difference is reduced by one until that year’s birthday. Therefore, a note dated on the birthday contains the new age. The script searches the existing Client Hours records for the latest age for that clientref: it writes age: N for a new client or when the calculated age differs, and otherwise omits the field. This keeps the supervisor report’s latest-recorded-age behaviour accurate without adding redundant age fields to every session.

For a client’s first Client Hours note, the script also adds the julia-martin and carolyn-powell tags, alongside clienthours (or clienthours-test in test mode). It creates empty fields ready for completion: gender, issues, frequency, allocated-supervisor, and referred by. On every later new note it checks that client’s existing records; if any of those fields has never appeared, it adds only the missing blank field(s) to that new note. Once a field exists in any prior record, it is not redundantly added again.

If an appointment has no patient_id, the current script fails closed with appointment has no patient_id/clientref suffix rather than guessing a client reference.

Duplicate avoidance: the script writes one file per appointment start time. If the target file already exists, it updates only the permitted YAML fields listed below and leaves the body/current-issues content alone.

Current cron:

# Hermes-managed: WriteUpp Client Hours note creation/update.
5,20,35,50 * * * * /usr/bin/flock -n /tmp/writeupp-client-hours-create.lock /home/john/bin/writeupp_create_client_hours_notes.py >> /home/john/.local/state/writeupp-api/client-hours-create-cron.log 2>&1

The cron runs at 5, 20, 35, and 50 minutes past the hour because appointments are normally on quarter-hour boundaries. This avoids an every-five-minutes API cadence.

Current lead-time setting in the script:

CREATE_NOTES_BEFORE_START_MINUTES = 4

The live creation gate is:

starts - CREATE_NOTES_BEFORE_START_MINUTES <= now <= ends

Rationale for the current timing:

  • With quarter-hour appointments and cron at 5,20,35,50, a four-minute lead time means the previous cron run is usually too early and the next cron run is five minutes after the appointment start.
  • This is intentional. The user does not want a note for the appointment that is about to happen to be present alongside the note for the appointment that has just happened, because that increases the risk of opening and writing in the wrong note.
  • The intended workflow is that the note is ready to fill in by the time the appointment has finished, not necessarily before it starts.
  • If this preference changes and pre-start creation is wanted later, either increase the lead time so the previous cron run is inside the creation window, or move the cron to a before-start pattern such as 10,25,40,55 with an appropriate lead time.

The API query window starts two days back so recent WriteUpp status changes can update existing notes. It also looks a little ahead of the current run time so near-future appointments can be seen before applying the local creation gate.

Test mode:

/home/john/bin/writeupp_create_client_hours_notes.py --test-future --future-days 7

Test notes are clearly separated here and use tags: clienthours-test so they do not get confused with live Client Hours notes:

/srv/obsidian-vault/Psychotherapy/100 CPD Record/Client Hours/TEST Future Generated/

On 2026-07-13, test mode created 9 clearly flagged future test notes. A second run created 0 notes and skipped all 9, confirming duplicate avoidance.

Post-appointment status correction

Added on 2026-07-13.

The Client Hours automation also updates existing generated notes when WriteUpp appointment metadata changes after the appointment has happened. This is intended for cases where an appointment is changed to a cancellation or non-attendance status up to a day or two later.

Scheduled live mode fetches appointments from the previous two days through the near-future query window. For each fetched appointment:

  1. Compute the expected Client Hours filename from the appointment start time.
  2. If the note already exists, update only these YAML fields:
    • where
    • clientref
    • medium
    • agency
    • hours
    • date
  3. Leave current issues untouched.
  4. Leave the body untouched.
  5. Preserve age and any other non-automation YAML fields already present; it does not retrospectively alter historical session ages.
  6. The conditional age field is considered only when creating a new note, using the appointment date and WriteUpp date_of_birth as described above.

Cancellation / DNA rule:

WriteUpp statusClient Hours update
any status containing Cancelmedium: cancelled, hours: 0
Did not attendmedium: cancelled, hours: 0
status containing DNAmedium: cancelled, hours: 0

This means a note originally created as an attended online or in-person session can later be corrected to medium: cancelled and hours: 0 if WriteUpp is updated after the fact.

Duplicate/update test:

  • A clearly flagged future test note was deliberately changed to incorrect values (medium: writeupp-online, hours: 9).
  • Dry run correctly reported it would update only medium and hours.
  • Live test run corrected it back to medium: in-person, hours: 1.
  • current issues: <unpopulated> was preserved.

Clinical hours versus booked-slot length

Added on 2026-07-13.

WriteUpp may book a one-hour clinical appointment into a 1.25 hour calendar slot to allow protected note-writing time. Client Hours should record the clinical contact time, not the whole booked slot.

Rule now implemented in /home/john/bin/writeupp_create_client_hours_notes.py:

WriteUpp booked slotClient Hours hours
approximately 1.25 hours1
approximately 1 hour1
type name containing 1.51.5
type name containing 2 and hr2
cancellation / DNA / did-not-attend0

The script still preserves genuinely unusual durations as compact decimals rather than silently inventing a duration.

Live correction test:

/srv/obsidian-vault/Psychotherapy/100 CPD Record/Client Hours/Data/2026/2026-07/Client Hours - 2026-07-13 16-30-00.md

WriteUpp metadata for that appointment showed a booked slot of 16:30-17:45, i.e. 1.25 hours. The script corrected the Client Hours note from:

medium: phone
hours: 1.25

to:

medium: writeupp-online
hours: 1

current issues and the note body were preserved.

On-demand Statement of Hours letters

Added 2026-07-26.

/home/john/bin/writeupp_create_statement_of_hours.py creates a new, dated copy of the Active or Former Client Statement of Hours template and a PDF in 170 Letter Templates. It is deliberately on-demand rather than scheduled: run it only after the client has requested the letter and enter the intended WriteUpp client reference.

/home/john/bin/writeupp_create_statement_of_hours.py WU1234567 --active
/home/john/bin/writeupp_create_statement_of_hours.py WU1234567 --former

The output names are, for example:

Statement of Hours - Active Client - 2026-07-26 - WU1234567.md
Statement of Hours - Active Client - 2026-07-26 - WU1234567.pdf

Before writing anything, it retrieves only that client’s appointments using WriteUpp’s patient_id query parameter. It calculates:

  • therapy_start: earliest non-cancelled appointment with a duration greater than 30 minutes that has started by the time the letter is generated (future appointments are excluded);
  • number_sessions: non-cancelled/non-DNA WriteUpp appointments longer than 30 minutes that have started by the generation time (future appointments are excluded), counted in nearest whole clinical-hour units so a 135-minute booked double session counts as two sessions (the usual 60–75-minute booking counts as one);
  • therapy_end (former client): latest non-cancelled appointment longer than 30 minutes that has started by the generation time;
  • name_practice: the name and postal address of the WriteUpp location attached to the latest qualifying (non-cancelled, >30-minute) session, formatted as Location name (street address, postcode). Any line breaks in the clinic address or postcode are converted to , ;
  • therapy_frequency: the latest non-empty frequency recorded against that clientref in Client Hours. It accepts a scalar (for example frequency: w) or Obsidian’s one-item YAML-list form (frequency: followed by - w). It reads the Client Hours form used by the supervisor report (P2W2w) and then writes letter prose: w/1wweekly, f/2wfortnightly, mmonthly, and 3wthree weekly.

It then calls that one patient’s endpoint only in memory to obtain the four client_* YAML fields required by the letter: full name formed from the WriteUpp first_name and surname fields, first_name, DOB, and assembled postal address. Any line breaks in the WriteUpp address are converted to , before it is placed in the letter. The full response is immediately cleared; raw responses and those values are never logged. The generated letter and PDF necessarily contain the information needed for the requested correspondence.

The command refuses to overwrite an existing same-day pair unless --overwrite is supplied. With that explicit flag it replaces both the Markdown and PDF; PDF production is atomic, so a failed conversion leaves the prior PDF intact. It fails rather than guessing a missing/blank Client Hours frequency. A dry run makes no letter or PDF:

/home/john/bin/writeupp_create_statement_of_hours.py WU1234567 --active --dry-run

After manually amending an already-created intermediary Markdown letter, regenerate the PDF without querying WriteUpp or altering the Markdown. Give the client reference and select --former only for a former-client letter:

/home/john/bin/writeupp_create_statement_of_hours.py WU1234567 --active --pdf-only

The script infers the letter filename from its type and clientref; where several historic matches exist, it uses the most recently modified Markdown. --pdf-only requires that the inferred file has no unresolved {{placeholders}} and replaces its corresponding .pdf file.

If the appointment-count rules have changed but you need to retain manual amendments to the intermediary Markdown, refresh only number_sessions and the standard attendance sentence before recreating the PDF:

/home/john/bin/writeupp_create_statement_of_hours.py WU1234567 --active --refresh-session-count

This queries only that patient’s appointments and statuses. It changes exactly the number_sessions YAML field and the matching they have attended … and … sentence; it aborts without changing the Markdown if that standard sentence has been manually rewritten.

To deliberately regenerate an existing same-day Markdown/PDF pair from WriteUpp, use:

/home/john/bin/writeupp_create_statement_of_hours.py WU1234567 --active --overwrite

Offline regression check (uses only synthetic data):

/home/john/bin/writeupp_create_statement_of_hours.py --self-test

PDF conversion follows the existing Pandoc/XeLaTeX instruction. Before calling Pandoc, the script expands every letter placeholder directly in the duplicated Markdown file, so no Lua filter is used and personal metadata is never printed to the terminal. The source templates retain Helvetica Neue; the server-generated duplicate uses Noto Sans because Helvetica Neue is not installed on this Linux server. NotoSans-Bold is explicitly selected for bold text: this avoids the malformed Nimbus Sans bold font that could omit characters during XeLaTeX PDF output. PDF dates are rendered as d MMM YYYY (for example 4 Jan 1911) from the unaltered date values in the Markdown/YAML.

Prerequisite: pandoc and xelatex must be installed. Both were present when the workflow was verified on 2026-07-26. If they are absent on another machine, install XeLaTeX with:

sudo apt install texlive-xetex

The script preflights this before making an API request or output file, so a missing PDF dependency cannot leave an incomplete letter behind.