> ## Documentation Index
> Fetch the complete documentation index at: https://build.practicehub.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Data migration over REST

> Import patients, appointment history, billing history and documents from your previous system over REST

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](/mcp/migrating-your-data) — connectors are read-only during the limited rollout.)

## What you can import

| Data | Resource(s) | Notes |
| - | - | - |
| Patients, contact details, addresses | `patients` (with embedded `numbers` and `address`) | One request per patient carries everything |
| Appointment history | `appointments` | Backdated freely; no clash checks on create |
| Invoices and line items | `invoices`, `line_items` | `invoice_date` backdates; balances computed for you |
| Payments | `payments` | `payment_date` backdates |
| Which payment settled which invoice | `payment_allocations` | Drives paid/unpaid state and patient balances |
| Patient documents (PDFs, images, reports) | `files` | Multipart upload — REST only |
| History and notes entries | `patient_logs` | A dedicated `historical` type exists for exactly this |
| Clinical / treatment notes | `clinical_notes` | `created_at` back-dates the note; SOAP-mapped or single free-text field — see [Clinical notes](/resources-guide/clinical-notes) |
| Referral sources | `referral_sources` | Creatable via the API |

Care plans, packages and forms are not in the current resource set — see [Resources](/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:

```bash theme={null}
BASE=https://your-clinic.your-region.practicehub.io/v3/api
curl -s $BASE/locations        -H "Authorization: Bearer $KEY"
curl -s $BASE/practitioners    -H "Authorization: Bearer $KEY"
curl -s $BASE/appointment_types -H "Authorization: Bearer $KEY"
curl -s $BASE/payment_methods  -H "Authorization: Bearer $KEY"
```

3. **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](#provenance-every-imported-record-is-labelled)).
4. **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:

```json theme={null}
{ "first_name": "Ada", "last_name": "Lovelace", "metadata": { "legacy_id": "P-10442" } }
```

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](/guides/metadata).

For transport-level retry safety, also send an [`Idempotency-Key` header](/guides/writing#retrying-safely-idempotency-keys) 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:

```
reference data (in app)  →  patients  →  appointments  →  invoices  →  payments  →  allocations  →  files / patient_logs / clinical_notes
```

### 1. Patients

One request creates the patient with phone numbers and address embedded:

```bash theme={null}
curl -X POST $BASE/patients \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: import-patient-P-10442" \
  -d '{
    "first_name": "Ada",
    "last_name": "Lovelace",
    "dob": "1985-12-10",
    "sex": "female",
    "email": "ada@example.org",
    "numbers": [
      { "number": "+44 7700 900123", "type": "mobile" },
      { "number": "020 7946 0999", "type": "Home" }
    ],
    "address": {
      "line_1": "12 Analytical Way",
      "city": "London",
      "postal_code": "N1 9GU",
      "country": "GB"
    },
    "metadata": { "legacy_id": "P-10442" }
  }'
```

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](/guides/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:

```bash theme={null}
# Create the past visit
curl -X POST $BASE/appointments \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: import-appt-A-88123" \
  -d '{
    "patient_id": 340,
    "location_id": 1,
    "practitioner_id": 3,
    "appointment_type_id": 9,
    "starts_at": "2024-03-14T10:30:00",
    "ends_at": "2024-03-14T11:00:00",
    "note": "Imported from legacy system",
    "metadata": { "legacy_id": "A-88123" }
  }'

# Mark it attended (or cancelled / missed, per your legacy record)
curl -X PATCH $BASE/appointments/6350 \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "status": "arrived" }'
```

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.

```bash theme={null}
curl -X POST $BASE/invoices \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: import-inv-I-3001" \
  -d '{
    "patient_id": 340,
    "location_id": 1,
    "invoice_date": "2024-03-14",
    "note": "Imported — legacy invoice I-3001",
    "line_items": [
      { "description": "Initial consultation", "quantity": 1, "price": 65.00, "billable_item_id": 17 },
      { "description": "Exercise plan", "quantity": 1, "price": 20.00, "billable_item_id": 31 }
    ],
    "metadata": { "legacy_id": "I-3001" }
  }'
```

* 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

```bash theme={null}
curl -X POST $BASE/payments \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: import-pay-R-5001" \
  -d '{
    "patient_id": 340,
    "amount": 85.00,
    "payment_method_id": 2,
    "location_id": 1,
    "payment_date": "2024-03-14",
    "note": "Imported — legacy receipt R-5001",
    "metadata": { "legacy_id": "R-5001" }
  }'
```

`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:

```bash theme={null}
curl -X POST $BASE/payment_allocations \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "patient_id": 340, "invoice_id": 98, "payment_id": 6497, "amount": 85.00 }'
```

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`:

```bash theme={null}
curl -X POST $BASE/files \
  -H "Authorization: Bearer $KEY" \
  -F "patient_id=340" \
  -F "file_type=generic" \
  -F "description=Referral letter, imported from legacy system" \
  -F "file=@referral-letter.pdf"
```

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:

```bash theme={null}
curl -X POST $BASE/patient_logs \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{
    "patient_id": 340,
    "type": "historical",
    "data": "2019–2024: treated at Riverside Clinic. 42 visits, discharged from care plan March 2024.",
    "metadata": { "legacy_id": "H-10442" }
  }'
```

### 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:

```bash theme={null}
curl -s "$BASE/form_templates?form_type=practitioner" -H "Authorization: Bearer $KEY"
```

`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.

```bash theme={null}
curl -X POST $BASE/clinical_notes \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: import-note-N-77410" \
  -d '{
    "patient_id": 340,
    "form_id": 12,
    "state": "final",
    "created_at": "2024-03-14T11:05:00Z",
    "data": {
      "subjective": "Lower back pain, 6/10, since lifting at work",
      "objective": "Reduced tenderness L4/L5, full range of motion",
      "assessment": "Mechanical lower back pain, improving",
      "plan": "Weekly sessions for 3 weeks, home exercise sheet given"
    },
    "metadata": { "legacy_id": "N-77410" }
  }'
```

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](/resources-guide/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](/guides/querying#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

| Response | Meaning during an import |
| - | - |
| `422` with `errors` | A field failed validation — the body names the field and rule. Log it, fix the row, move on |
| `422 patient_not_found` | Your ID map is wrong or the patient step didn't land — resolve via `metadata[legacy_id]` |
| `422 payment_method_inactive` / `card_payment_not_allowed` | Map that legacy tender to an active, non-card-charging method |
| `409` | The ledger refused (over-allocation, state conflict) — your source data disagrees with itself; investigate rather than retry |
| `429` | Slow down; honour `Retry-After` |

The full catalogue is in [Errors](/guides/errors).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.