What you can import
Care plans, packages and forms are not in the current resource set — see Resources.
Before you write any code
- 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.
- Fetch the ID map. Read each reference resource once and build a lookup from your legacy names to PracticeHub IDs:
- 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).
- 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 ametadata entry carrying its ID in the old system:
- 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 PracticeHubpatient_idwith 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.
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:sextakes the wordsmale,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. dobisYYYY-MM-DD.referral_source_id+referral_source_typeattach a referral source if you migrated those first.- Reads of
/patientsreturnnumbersandaddressembedded, so verifying what landed takes one request.
2. Appointment history
Historical appointments are ordinary creates with a paststarts_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:
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),priceandbillable_item_idare 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, orPOST /billable_items).provider_idis 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:6. Documents
Patient files upload asmultipart/form-data:
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.
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 stampeddata_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, letRetry-Afterguide 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-Keyderived 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 —
DELETEworks 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.