Skip to main content
Moving to PracticeHub usually means bringing years of records with you. The PracticeHub API was built with this as a first-class job: every record you import goes through PracticeHub’s own services — the same validation and balance arithmetic the app itself uses — so what lands is consistent, auditable and immediately usable. This is the developer’s route — and currently the only route: export → transform → import with scripts. (Assistant-driven migration over MCP is planned but not available yet — connectors are read-only during the limited rollout.)

What you can import

Care plans, packages and forms are not in the current resource set — see Resources.

Before you write any code

  1. Set up reference data in the app, not the API. Practitioners and modalities are read-only over the API, and locations, appointment types, payment methods and the billable-item catalogue are best set up in Settings — they can be created over the API, but each carries configuration (rotas, licences, billing behaviour) an import shouldn’t guess at. Create them in the app first.
  2. Fetch the ID map. Read each reference resource once and build a lookup from your legacy names to PracticeHub IDs:
  1. Create a read & write API key under Developers → API Keys. Consider a dedicated key named after the migration — every record it writes is attributed to it (see Provenance).
  2. Check the shapes with GET /schema/{resource}. It lists every attribute each resource accepts on create and update, with the exact validation rules. Your import can even assert against it before running.

The legacy-ID pattern

Give every imported record a metadata entry carrying its ID in the old system:
This one habit solves the three problems every migration hits:
  • Idempotency — before creating, ask whether the record already landed: GET /patients?metadata[legacy_id]=eq:P-10442. If it’s there, skip or update. Re-running a crashed import becomes safe.
  • Cross-referencing — when importing an appointment that belongs to legacy patient P-10442, resolve the PracticeHub patient_id with the same query (or from the map your import keeps in memory).
  • Auditability — months later, anyone can trace any migrated record back to its source.
Metadata accepts up to 50 keys per record (keys ≤ 40 chars, values ≤ 500 chars — strings, numbers or booleans) and is filterable on every resource. Details in Metadata. For transport-level retry safety, also send an Idempotency-Key header on every POST — replays within 24 hours return the original result instead of creating a duplicate.

Import order

Dependencies point one way, so import in this order and each step can reference the ones before it:

1. Patients

One request creates the patient with phone numbers and address embedded:
Worth knowing:
  • sex takes the words male, female, other, prefer_not_to_say — map your legacy codes before sending.
  • Every phone number needs a type (mobile, home, work, other). Numbers are normalised on the way in (international format derived where possible), so send them as your old system stored them.
  • dob is YYYY-MM-DD. referral_source_id + referral_source_type attach a referral source if you migrated those first.
  • Reads of /patients return numbers and address embedded, so verifying what landed takes one request.

2. Appointment history

Historical appointments are ordinary creates with a past starts_at. Two facts make this easy:
  • Create does not check the diary for clashes — booking against live availability is a separate, explicit flow (Booking). For history that’s exactly right: the past is what it was.
  • New appointments are created pending. For history, follow the create with a status update:
Status takes pending, arrived, cancelled or missed. There is deliberately no way to set an appointment to processed — in PracticeHub that state means “billed through the app’s own checkout flow”. Migrated billing lives as invoices and payments in the next steps, attached to the patient rather than replayed through processing.

3. Invoices

invoice_date backdates the document; line items ride along in the same request. Balances, totals and the patient ledger are computed server-side — never import a balance.
  • Every invoice needs at least one line item; description, quantity (whole numbers, min 1), price and billable_item_id are required per line — match your legacy services to PracticeHub’s catalogue (GET /billable_items) before importing billing history, creating any missing services or products first (in Settings → Billing, or POST /billable_items). provider_id is optional.
  • Prices are signed decimals with two places; discounts can be negative lines.

4. Payments

payment_method_id must be one of your account’s active payment methods (GET /payment_methods) — and it can’t be a card-charging (Stripe-integrated) method, because recording against one of those means a real card was charged, which a migration never does. Map every legacy tender to a plain method — most accounts keep generic Card and Cash methods for exactly this; add a Legacy import method in Settings if you’d rather keep migrated money visually separate.

5. Allocations — mark what was paid

An invoice is paid when payments are allocated to it. Without this step every imported invoice shows as outstanding and every payment as unallocated credit:
The ledger’s own guards apply: you can’t allocate more than the payment holds or the invoice needs, and patient balances recompute automatically. If your old system only stored “invoice X was paid”, allocate the full amount from the matching payment; if it stored partial payments, allocate each one — the arithmetic will come out exactly as it did in the old system, because it’s derived from the same facts.

6. Documents

Patient files upload as multipart/form-data:
Accepted types include PDFs, images, office documents and CSVs, up to 50 MB per file. The stored filename comes from the upload, so name files meaningfully before sending.

7. History entries

patient_logs is the patient’s activity timeline, and it has a historical type made for migrations — use it for the narrative you want visible on the patient’s record without pretending PracticeHub generated it:

8. Clinical / treatment notes

Unlike the other resources above, notes need one thing set up before you can import them: a note template. There’s no default form the API falls back to — form_id is required on every clinical_notes create. If the account doesn’t already have a template shaped like your legacy note data, create one first in PracticeHub → Settings → Forms (a single free-text field is enough if your export is unstructured; separate subjective/objective/assessment/plan fields if it isn’t), then find its id and field names:
clinical_notes then accepts historical imports the same way as everything else: created_at back-dates the note (and, for a final note, its finalized_at) so it keeps its place in the patient’s chronology instead of landing as if written today. If your export already separates subjective/objective/assessment/plan, map them onto the template’s matching field names so the note renders as structured SOAP content in PracticeHub, not just a paragraph.
An answer key the target template doesn’t define is refused with 422 unknown_field rather than silently dropped, so a schema mismatch surfaces immediately instead of losing content. See Clinical notes for the full field-by-field contract, including how to find a template with GET /form_templates.

Provenance — every imported record is labelled

You don’t need to do anything for this: every record written through the API is stamped data_source: api with a human-readable “created by” naming your API key’s integration (for example API: Legacy import). In the app’s ledgers and activity views, migrated rows are attributed to the integration rather than to a staff member — so there’s never a question about where a record came from.

Verifying the import

  • Counts. Every list response carries meta.total. Compare against your source system per resource, and per patient for the ones that matter: GET /invoices?patient_id=eq:340.
  • Balances. Patient balances are derived from the invoices, payments and allocations you imported. Spot-check a handful of patients whose legacy balance you know — including at least one with credit and one with an outstanding balance.
  • Full sweeps. For large datasets, walk resources with cursor pagination — stable under concurrent writes, no drifting pages.
  • Deletions during cleanup are visible in deleted_entities, so even your fixes leave an audit trail.

Throughput and retries

  • The rate limit is 120 requests per minute, shared by every API key on the account (AI connectors have a separate MCP bucket). A patient with embedded numbers and address is one request; an invoice with all its lines is one request. At a steady pace a 5,000-patient practice with full billing history typically lands within a working day — run steadily rather than in bursts, back off on 429, let Retry-After guide you, and pause any other integrations on the account while the migration runs, since they draw from the same budget.
  • Make every POST carry an Idempotency-Key derived from the legacy ID, and make your loop resumable with the metadata query above. A migration you can re-run from the top without fear is a migration that will go fine.
  • Do a rehearsal first. Run the full import against a developer account (a full PracticeHub account for building integrations — ask for one), check the numbers, then run it for real. Writes are real writes — DELETE works on most resources if you need to unwind a mistake, but not having to is better.

Errors you’ll actually see

The full catalogue is in Errors.