← Developers/Praxisv1.0.0
Praxis · REST API

Praxis API

Praxis lets a connected EMR hand patients off, receive clinical updates as signed HL7 FHIR R4 webhooks, and pull the same data on demand for backfill and recovery. This document covers /api/v1 (patient hand-off), /api/fhir/r4 (read-only FHIR), and the partner provisioning endpoint. Internal admin and platform-admin routes are not part of this public contract and are never included here.

The Praxis API lets any EMR connect self-serve: hand patients off, receive clinical updates as signed FHIR R4 webhooks, and pull the same data on demand for backfill and recovery.

Base URLhttps://praxisnp.co

Quickstart

Connect an EMR from the Praxis admin dashboard (Admin > EMR Connections, or Platform Admin > EMR Connections for CoFabri staff): name it, give it an HTTPS webhook URL, and choose which events to subscribe to. Connecting issues an API key and a webhook signing secret -- both are shown exactly once, so copy them immediately.

Send your first patient:

curl -X POST https://praxisnp.co/api/v1/patients \
  -H "Authorization: Bearer prx_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: handoff-1" \
  -d '{
    "externalPatientId": "your-emr-id-123",
    "firstName": "Ada",
    "lastName": "Lovelace",
    "state": "CA",
    "treatmentCategory": "weightLoss"
  }'

A `201` (or `200` on a repeat hand-off of the same patient) returns `praxisPatientId` and the created visit. From here, subscribe to `visit.status_changed` and `note.signed` to receive the first event once a Praxis provider claims and completes the visit.

Use the same `Idempotency-Key` for a retried request -- Praxis returns the original response with `Idempotent-Replayed: true` instead of processing it twice.

Authentication and key rotation

Every request to `/api/v1` and `/api/fhir/r4` (except the unauthenticated `GET /api/fhir/r4/metadata`) carries a Praxis API key:

Authorization: Bearer prx_live_...

A key resolves to exactly one connection, and therefore one practice -- no request ever names a practice explicitly. Keys can be revoked instantly from the admin dashboard, and a connection can be paused (every call then returns `403 connection_paused` until resumed).

To rotate a key with zero downtime: issue a second key from the dashboard, switch your integration over to it, then revoke the old one. Multiple active keys per connection are supported for exactly this overlap window.

A key is shown in full only once, at creation or rotation. Store it in a secrets manager -- Praxis stores only its SHA-256 hash and cannot show it to you again.

Verifying webhook signatures

Every webhook delivery includes `X-Praxis-Signature: t=<unix-seconds>,v1=<hex-hmac>`, an HMAC-SHA256 of `"<t>.<raw-request-body>"` keyed by your connection's webhook signing secret (`whsec_...`, shown once at connect time or rotation). Reject any delivery whose `t` is more than 5 minutes from the current time -- this is replay protection.

const crypto = require('crypto');

function verifyPraxisSignature(rawBody, secret, header, toleranceSeconds = 300) {
  const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header || '');
  if (!match) return false;
  const [, tStr, signature] = match;
  const t = Number(tStr);
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;

  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Test your implementation against this fixed example before going live:
// secret 'whsec_test_0123456789abcdef0123456789abcdef', t=1735689600,
// body '{"resourceType":"Bundle","type":"collection","entry":[]}' ->
// t=1735689600,v1=7d24d3d18fdb2d6edc8e528a1887aed5aef421d03dbb93ac97b426775a269549
import hashlib
import hmac
import re
import time

def verify_praxis_signature(raw_body: bytes, secret: str, header: str, tolerance_seconds: int = 300) -> bool:
    match = re.match(r'^t=(\d+),v1=([0-9a-f]{64})$', header or '')
    if not match:
        return False
    t, signature = int(match.group(1)), match.group(2)
    if abs(time.time() - t) > tolerance_seconds:
        return False

    expected = hmac.new(secret.encode(), f'{t}.'.encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

# Same fixed example as the Node.js version above -- both must verify it as valid.
Compute the signature over the exact raw request body bytes, before any JSON parsing -- re-serializing a parsed body can reorder keys or change whitespace and break the comparison.

Webhook events

Subscribed per connection at connect time (editable later). Every event fires only for patients your connection handed off. The machine-readable payload shape for each event is in the "webhooks" section of the OpenAPI document above; this table is the conceptual summary. Text messaging has its own `message.outbound` event, which is not in this table and is not a FHIR bundle: see "Text messaging" below.

EventFires whenPrimary resource
visit.status_changedA visit moves between queued/assigned/in_progress/completed/cancelled.Encounter
note.signedA visit is completed via Confirm & Sign. A draft save never fires this.DocumentReference
script.createdA prescription is written.MedicationRequest
script.status_changedA script's status changes.MedicationRequest
script.shipment_updatedShipping or tracking information updates.MedicationRequest + Task
lab_order.createdA lab order is created.ServiceRequest
lab_order.status_changedA lab order's status changes.ServiceRequest

Delivery is at-least-once, not strictly ordered. A payload is a FHIR `Bundle` (`type: collection`); `entry[0]` is the primary resource, followed by related `Patient`/`Practitioner`/`Encounter` resources so you never need a follow-up call. Every resource carries `meta.versionId` and `meta.lastUpdated` -- discard a delivery that is older than one you already have for the same resource.

Missed or out-of-order delivery? Use the read-only FHIR API below to pull current state directly instead of waiting for a redelivery.

Text messaging

Praxis providers can text the patients you handed off. Praxis never sends the text itself: it hands each message to your EMR, which sends it from your own number and reports back through the API. Turn it on with the **Messaging** switch on your connection (Admin > EMR Connections). Until it is on, every messaging endpoint returns `403 messaging_disabled`. The switch is the subscription: you do not tick a separate event.

When a provider sends a message, Praxis delivers a `message.outbound` webhook. It uses the same signature and retry rules as every other event, with `X-Praxis-Event: message.outbound`, but the body is plain JSON, not a FHIR bundle:

{
  "eventId": "9b2f1c0e-...",
  "messageId": "4c6a77d2-...",
  "externalPatientId": "your-emr-id-123",
  "body": "Your labs are ready to review.",
  "sentAt": "2026-10-07T16:04:59.000Z",
  "provider": { "id": "c1a8...", "displayName": "Jordan Rivera, NP" }
}
Deduplicate on `eventId`. It stays the same across Praxis's automatic redeliveries of one event. A provider can also resend a message that failed: that arrives with a new `eventId` and a `messageId` you have already seen. Send it again; do not drop it as a duplicate.

Send the text, then report what happened:

EndpointUse it to
POST /api/v1/messages/{messageId}/statusReport `sent`, `delivered`, or `failed` (with an optional `reason`, up to 200 characters, which the provider sees).
POST /api/v1/patients/{externalPatientId}/messagesRecord a patient reply: `body`, plus optional `receivedAt` and `externalMessageId`.
GET /api/v1/patients/{externalPatientId}/messagesRead the thread and the opt-out flag, for backfill or recovery.
PUT /api/v1/patients/{externalPatientId}/messagingSet `smsOptOut` to `true` or `false`.
curl -X POST https://praxisnp.co/api/v1/patients/your-emr-id-123/messages \
  -H "Authorization: Bearer prx_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: reply-8841" \
  -d '{ "body": "Thanks, I will come in Friday.", "externalMessageId": "sms-8841" }'

Status only moves forward: `queued`, then `sent`, then `delivered`. A late or repeated update is ignored and still returns `200` with the current status. `delivered` is final. A `failed` message can still become `sent` or `delivered` if you report it later, because your EMR knows what really happened. A reply is stored once per `externalMessageId`, whatever `Idempotency-Key` you use, and a new reply returns `201` while a repeat returns `200`.

Messages are limited to 1,600 characters, counted as Unicode code points, so an emoji counts as one. A longer reply returns `422 message_too_long`. A `receivedAt` more than 5 minutes in the future is clamped to now.

Opt-outs are your EMR's job: detect `STOP` in your own inbound handling and call `PUT .../messaging` with `{ "smsOptOut": true }`. While a patient is opted out, Praxis blocks providers from sending. Replies that still arrive are recorded.

If a message cannot be delivered to you, Praxis marks it `failed` and the provider sees a Retry button. That happens when your webhook keeps failing after the usual retries, and also when the connection is paused or Messaging is turned off while a message is still waiting. While paused, your calls return `403 connection_paused`. With Messaging off, they return `403 messaging_disabled`.

FHIR mapping and identifier systems

Every `Patient` carries an identifier so you can match it back to your own record:

{ "system": "urn:cofabri:praxis:external-patient-id", "value": "<the externalPatientId you sent>" }
Praxis conceptFHIR resourceStatus mapping
VisitEncounterqueued/assigned -> planned, in_progress -> in-progress, completed -> finished, cancelled -> cancelled
Signed SOAP noteDocumentReferencestatus: current, docStatus: final. Inline text/plain, base64, labelled S/O/A/P sections. Unsigned (draft) notes have no FHIR representation.
PrescriptionMedicationRequestpending/processing/verified/ready -> active, on_hold_pending_pa/awaiting_payment -> on-hold, completed -> completed, cancelled -> cancelled, rejected -> cancelled (statusReason: rejected). The raw Praxis status always survives in extension `urn:cofabri:praxis:script-status`.
Shipment updateTask (linked to the MedicationRequest)Carries tracking details alongside the MedicationRequest.
Lab orderServiceRequestordered -> active (see the generated spec for the complete set in use).
PrescriberPractitionerName plus NPI, when on file.

Errors, rate limits, retries, idempotency

/api/v1 errors are always `{ "error": { "code", "message", "details"? } }`; /api/fhir/r4 errors are always a FHIR `OperationOutcome`. Branch on `code` (or `issue[].code`), never on the message text -- see the generated schemas above for the full enum of each.

Status/api/v1 code/api/fhir/r4 issue code
401invalid_api_keysecurity
403connection_paused / messaging_disabledforbidden
404not_foundnot-found
409conflict--
413payload_too_large--
422validation_failed / idempotency_key_reuse / message_too_longinvalid
429rate_limitedthrottled

Rate limit: 120 requests per 60 seconds per API key, shared across /api/v1 and /api/fhir/r4 (same key). A `429` includes `Retry-After` in seconds.

A webhook delivery retries on any non-2xx response or timeout, with backoff of 1m, 5m, 30m, 2h, 6h, then 24h (7 attempts total) before being marked failed. After 3 consecutive failed *events* (not failed attempts of one event), the connection auto-pauses and stops attempting further deliveries. There is currently no email or push notification when this happens -- check the connection's delivery log in the admin dashboard, which also has a Redeliver action to retry a specific failed event by hand. Use `Idempotency-Key` on `POST /api/v1/patients` to make retries of your own requests safe.

Versioning policy

Within `v1`, changes are additive only: new optional request fields, new response fields, new event types, new error codes. A breaking change ships as `/v2` -- your integration keeps working on `/v1` indefinitely once you're on it. `/api/fhir/r4` follows the same policy relative to the fixed FHIR R4 resource shapes it already implements.

DocumentReference

2 endpoints
get/api/fhir/r4/DocumentReferenceSearch DocumentReference

Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.

Parameters
patientoptional
queryPraxis patient id. Omit to search every patient this connection handed off.
_lastUpdatedoptional
queryOnly the `gt` prefix is supported, e.g. `gt2026-01-01T00:00:00Z`.
_countoptional
queryPage size, 1-200 (default 50).
cursoroptional
queryOpaque pagination cursor from a previous response's `link` entry with `relation: "next"`.
Responses
200A searchset Bundle of signed notes. A draft (unsigned) note never appears, even once its text is autosaved.
401Missing, unknown, or revoked API key (issue code `security`).
403Connection paused, either by a Praxis administrator or automatically after 3 consecutive fully-failed webhook deliveries (issue code `forbidden`).
422Malformed search input (bad `_lastUpdated`, out-of-range `_count`, or invalid `cursor`), as an `OperationOutcome` with issue code `invalid`.
429Rate limit exceeded, includes a `Retry-After` header (issue code `throttled`).
500Unexpected server error (issue code `exception`).
get/api/fhir/r4/DocumentReference/{id}Get a single DocumentReference

Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.

Parameters
idrequired
pathDocumentReference id.
Responses
200The DocumentReference.
401Missing, unknown, or revoked API key (issue code `security`).
403Connection paused, either by a Praxis administrator or automatically after 3 consecutive fully-failed webhook deliveries (issue code `forbidden`).
404No DocumentReference with that id for this connection, or the visit's note is still a draft (unsigned notes have no FHIR representation).
429Rate limit exceeded, includes a `Retry-After` header (issue code `throttled`).
500Unexpected server error (issue code `exception`).

Encounter

2 endpoints
get/api/fhir/r4/EncounterSearch Encounter

Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.

Parameters
patientoptional
queryPraxis patient id. Omit to search every patient this connection handed off.
_lastUpdatedoptional
queryOnly the `gt` prefix is supported, e.g. `gt2026-01-01T00:00:00Z`.
_countoptional
queryPage size, 1-200 (default 50).
cursoroptional
queryOpaque pagination cursor from a previous response's `link` entry with `relation: "next"`.
Responses
200A searchset Bundle of Encounters, newest-updated last within a page.
401Missing, unknown, or revoked API key (issue code `security`).
403Connection paused, either by a Praxis administrator or automatically after 3 consecutive fully-failed webhook deliveries (issue code `forbidden`).
422Malformed search input (bad `_lastUpdated`, out-of-range `_count`, or invalid `cursor`), as an `OperationOutcome` with issue code `invalid`.
429Rate limit exceeded, includes a `Retry-After` header (issue code `throttled`).
500Unexpected server error (issue code `exception`).
get/api/fhir/r4/Encounter/{id}Get a single Encounter

Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.

Parameters
idrequired
pathEncounter id.
Responses
200The Encounter.
401Missing, unknown, or revoked API key (issue code `security`).
403Connection paused, either by a Praxis administrator or automatically after 3 consecutive fully-failed webhook deliveries (issue code `forbidden`).
404No Encounter with that id for this connection.
429Rate limit exceeded, includes a `Retry-After` header (issue code `throttled`).
500Unexpected server error (issue code `exception`).

MedicationRequest

2 endpoints
get/api/fhir/r4/MedicationRequestSearch MedicationRequest

Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.

Parameters
patientoptional
queryPraxis patient id. Omit to search every patient this connection handed off.
_lastUpdatedoptional
queryOnly the `gt` prefix is supported, e.g. `gt2026-01-01T00:00:00Z`.
_countoptional
queryPage size, 1-200 (default 50).
cursoroptional
queryOpaque pagination cursor from a previous response's `link` entry with `relation: "next"`.
Responses
200A searchset Bundle of MedicationRequests. The raw Praxis script status is preserved in an extension (`urn:cofabri:praxis:script-status`) so no granularity is lost by the FHIR status mapping -- see the FHIR mapping guide.
401Missing, unknown, or revoked API key (issue code `security`).
403Connection paused, either by a Praxis administrator or automatically after 3 consecutive fully-failed webhook deliveries (issue code `forbidden`).
422Malformed search input (bad `_lastUpdated`, out-of-range `_count`, or invalid `cursor`), as an `OperationOutcome` with issue code `invalid`.
429Rate limit exceeded, includes a `Retry-After` header (issue code `throttled`).
500Unexpected server error (issue code `exception`).
get/api/fhir/r4/MedicationRequest/{id}Get a single MedicationRequest

Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.

Parameters
idrequired
pathMedicationRequest id.
Responses
200The MedicationRequest.
401Missing, unknown, or revoked API key (issue code `security`).
403Connection paused, either by a Praxis administrator or automatically after 3 consecutive fully-failed webhook deliveries (issue code `forbidden`).
404No MedicationRequest with that id for this connection.
429Rate limit exceeded, includes a `Retry-After` header (issue code `throttled`).
500Unexpected server error (issue code `exception`).

Discovery

1 endpoint
get/api/fhir/r4/metadataCapabilityStatement

The one unauthenticated route under /api/fhir/r4 -- an EMR discovers how to authenticate before it has anything to authenticate with. No authentication required.

Responses
200A FHIR CapabilityStatement.

Patient

2 endpoints
get/api/fhir/r4/PatientSearch Patient by identifier

The only supported Patient search. Never paginates (there is at most one match). Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.

Parameters
identifierrequired
queryIn the form `system|value`, e.g. `urn:cofabri:praxis:external-patient-id|<externalPatientId>`.
Responses
200A searchset Bundle with zero or one entry.
401Missing, unknown, or revoked API key (issue code `security`).
403Connection paused, either by a Praxis administrator or automatically after 3 consecutive fully-failed webhook deliveries (issue code `forbidden`).
422The `identifier` parameter is missing or not in `system|value` form (issue code `invalid`).
429Rate limit exceeded, includes a `Retry-After` header (issue code `throttled`).
500Unexpected server error (issue code `exception`).
get/api/fhir/r4/Patient/{id}Get a single Patient

Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.

Parameters
idrequired
pathPatient id.
Responses
200The Patient.
401Missing, unknown, or revoked API key (issue code `security`).
403Connection paused, either by a Praxis administrator or automatically after 3 consecutive fully-failed webhook deliveries (issue code `forbidden`).
404No Patient with that id for this connection.
429Rate limit exceeded, includes a `Retry-After` header (issue code `throttled`).
500Unexpected server error (issue code `exception`).

Practitioner

1 endpoint
get/api/fhir/r4/Practitioner/{id}Get a single Practitioner

Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.

Parameters
idrequired
pathPractitioner id.
Responses
200The Practitioner.
401Missing, unknown, or revoked API key (issue code `security`).
403Connection paused, either by a Praxis administrator or automatically after 3 consecutive fully-failed webhook deliveries (issue code `forbidden`).
404No Practitioner with that id visible to this connection.
429Rate limit exceeded, includes a `Retry-After` header (issue code `throttled`).
500Unexpected server error (issue code `exception`).

ServiceRequest

2 endpoints
get/api/fhir/r4/ServiceRequestSearch ServiceRequest

Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.

Parameters
patientoptional
queryPraxis patient id. Omit to search every patient this connection handed off.
_lastUpdatedoptional
queryOnly the `gt` prefix is supported, e.g. `gt2026-01-01T00:00:00Z`.
_countoptional
queryPage size, 1-200 (default 50).
cursoroptional
queryOpaque pagination cursor from a previous response's `link` entry with `relation: "next"`.
Responses
200A searchset Bundle of ServiceRequests (lab orders).
401Missing, unknown, or revoked API key (issue code `security`).
403Connection paused, either by a Praxis administrator or automatically after 3 consecutive fully-failed webhook deliveries (issue code `forbidden`).
422Malformed search input (bad `_lastUpdated`, out-of-range `_count`, or invalid `cursor`), as an `OperationOutcome` with issue code `invalid`.
429Rate limit exceeded, includes a `Retry-After` header (issue code `throttled`).
500Unexpected server error (issue code `exception`).
get/api/fhir/r4/ServiceRequest/{id}Get a single ServiceRequest

Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.

Parameters
idrequired
pathServiceRequest id.
Responses
200The ServiceRequest.
401Missing, unknown, or revoked API key (issue code `security`).
403Connection paused, either by a Praxis administrator or automatically after 3 consecutive fully-failed webhook deliveries (issue code `forbidden`).
404No ServiceRequest with that id for this connection.
429Rate limit exceeded, includes a `Retry-After` header (issue code `throttled`).
500Unexpected server error (issue code `exception`).

Messages

4 endpoints
post/api/v1/messages/{messageId}/statusReport delivery status of an outbound message

Reports how an outbound message (one Praxis sent you in a `message.outbound` webhook) fared. Status advances `queued` → `sent` → `delivered`, and `failed` can be reported any time before `delivered`. `delivered` is final. A `failed` message can still become `sent` or `delivered` (your later report wins), so a failure report is not final. A late or out-of-order update (for example `sent` after `delivered`) is accepted with 200 and leaves the current status unchanged. Requires messaging to be enabled for the connection. Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.

Parameters
messageIdrequired
pathThe `messageId` from the `message.outbound` webhook payload.
Request body (required)
status"sent" | "delivered" | "failed"required
reasonstringoptional

Why delivery failed. Only used with `failed`.

Responses
200The message's status after this update.
messageIdstringrequired
status"queued" | "sent" | "delivered" | "failed"required
401`invalid_api_key` -- the API key is missing, unknown, or revoked.
EmrError
403`connection_paused` -- either a Praxis administrator paused this connection, or it was paused automatically after 3 consecutive fully-failed webhook deliveries. Also returned when: `messaging_disabled` -- messaging is not enabled for this connection. (Also `connection_paused`, see above.)
EmrError
404`not_found` -- no outbound message with that id for this connection (an inbound message, another connection's message, or a malformed id).
EmrError
413`payload_too_large` -- request body over 65,536 characters.
EmrError
422`validation_failed` -- `status` must be `sent`, `delivered`, or `failed`; `reason` at most 200 characters.
EmrError
429`rate_limited` -- 120 requests per 60 seconds per key. Includes a `Retry-After` header.
EmrError
500`internal_error` -- unexpected server error.
EmrError
post/api/v1/patients/{externalPatientId}/messagesRecord a patient reply

Records a text message the patient sent back, so the care team sees it in Praxis. Requires messaging to be enabled for the connection. A reply is recorded even if the patient has opted out of texts. Include `externalMessageId` (your own id for the message) to make delivery retries safe: a repeat with the same `externalMessageId` returns the original `messageId` with 200 and never creates a second message. The patient must already have been handed off by this connection; this call never creates a patient. Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.

Parameters
externalPatientIdrequired
pathThe externalPatientId this connection used in POST /api/v1/patients.
Idempotency-Keyoptional
header1-255 printable ASCII characters, no spaces. A repeat call with the same key and body within 24 hours returns the original response with `Idempotent-Replayed: true`. Reusing a key with a *different* body returns 422 `idempotency_key_reuse`.
Request body (required)
bodystringrequired

The message text. Leading and trailing whitespace is trimmed; must not be blank. At most 1,600 characters (Unicode code points).

receivedAtstringoptional

When the patient sent it (ISO 8601). Defaults to now.

externalMessageIdstringoptional

Your own id for this message (1-255 characters). Makes retries idempotent.

Responses
200A message with this `externalMessageId` was already recorded; returns the original ids. Nothing new is created.
messageIdstringrequired
threadIdstringrequired
201Reply recorded.
messageIdstringrequired
threadIdstringrequired
401`invalid_api_key` -- the API key is missing, unknown, or revoked.
EmrError
403`connection_paused` -- either a Praxis administrator paused this connection, or it was paused automatically after 3 consecutive fully-failed webhook deliveries. Also returned when: `messaging_disabled` -- messaging is not enabled for this connection. (Also `connection_paused`, see above.)
EmrError
404`not_found` -- no patient with that `externalPatientId` was handed off by this connection (a patient handed off by a different connection is indistinguishable by design).
EmrError
413`payload_too_large` -- request body over 65,536 characters.
EmrError
422`validation_failed` (field-level `details`), `message_too_long` (body over 1,600 characters), or `idempotency_key_reuse`.
EmrError
429`rate_limited` -- 120 requests per 60 seconds per key. Includes a `Retry-After` header.
EmrError
500`internal_error` -- unexpected server error.
EmrError
get/api/v1/patients/{externalPatientId}/messagesRead a patient message thread

Returns the message thread for a patient this connection handed off, oldest first (the most recent 200 messages). A patient with no messages yet returns an empty list. Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.

Parameters
externalPatientIdrequired
pathThe externalPatientId this connection used in POST /api/v1/patients.
Responses
200The thread.
smsOptOutbooleanrequired

True when the patient has opted out of texts; Praxis then refuses to send them new messages.

messagesobject[]required
401`invalid_api_key` -- the API key is missing, unknown, or revoked.
EmrError
403`connection_paused` -- either a Praxis administrator paused this connection, or it was paused automatically after 3 consecutive fully-failed webhook deliveries. Also returned when: `messaging_disabled` -- messaging is not enabled for this connection. (Also `connection_paused`, see above.)
EmrError
404`not_found` -- no patient with that `externalPatientId` was handed off by this connection (a patient handed off by a different connection is indistinguishable by design).
EmrError
429`rate_limited` -- 120 requests per 60 seconds per key. Includes a `Retry-After` header.
EmrError
500`internal_error` -- unexpected server error.
EmrError
put/api/v1/patients/{externalPatientId}/messagingSet a patient's text opt-out

Tells Praxis whether the patient has opted out of texts (for example after replying STOP). While opted out, Praxis refuses to send the patient new messages. Creates the patient's message thread if it does not exist yet. Requires messaging to be enabled for the connection. Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.

Parameters
externalPatientIdrequired
pathThe externalPatientId this connection used in POST /api/v1/patients.
Request body (required)
smsOptOutbooleanrequired

true to opt the patient out, false to clear the opt-out.

Responses
200The opt-out state now in effect.
smsOptOutbooleanrequired
401`invalid_api_key` -- the API key is missing, unknown, or revoked.
EmrError
403`connection_paused` -- either a Praxis administrator paused this connection, or it was paused automatically after 3 consecutive fully-failed webhook deliveries. Also returned when: `messaging_disabled` -- messaging is not enabled for this connection. (Also `connection_paused`, see above.)
EmrError
404`not_found` -- no patient with that `externalPatientId` was handed off by this connection.
EmrError
413`payload_too_large` -- request body over 65,536 characters.
EmrError
422`validation_failed` -- `smsOptOut` must be a boolean.
EmrError
429`rate_limited` -- 120 requests per 60 seconds per key. Includes a `Retry-After` header.
EmrError
500`internal_error` -- unexpected server error.
EmrError

Partner

1 endpoint
post/api/v1/partner/connectionsProvision a partner tenant connection

Creates a practice and a custom_emr connection through the same code path as the admin's "Connect an EMR" UI. Platform-level: gated by a single bootstrap key, not a per-connection API key. Idempotent on tenantIdentifier -- a repeat call for an already-provisioned tenant returns 409 conflict rather than reissuing credentials; reissuing goes through the normal per-connection key-rotation flow. Requires the `x-provision-bootstrap-key` header (a separate, high-privilege platform provisioning key -- not a per-connection API key).

Request body (required)
tenantIdentifierstringrequired

The partner's own identifier for this tenant, at most 200 characters. Unique across all custom_emr connections; a repeat value returns 409 conflict.

namestringrequired

Practice / connection display name.

webhookUrlstringrequired

Must be HTTPS and resolve to a public address (checked at save time and at every delivery); rejected otherwise.

Responses
201Connection provisioned. Credentials are returned once, here, and never again.
practiceIdstringrequired
connectionIdstringrequired
apiKeyobjectrequired
idstringrequired
plaintextstringrequired

Shown only in this response.

prefixstringrequired
webhookSecretstringrequired

Shown only in this response.

401`invalid_api_key` -- missing or invalid `x-provision-bootstrap-key` header.
EmrError
409`conflict` -- a connection for this `tenantIdentifier` is already provisioned.
EmrError
413`payload_too_large` -- body over 8,192 characters.
EmrError
422`validation_failed` -- invalid JSON, a missing or too-long field (`tenantIdentifier`, `name`, `webhookUrl`), or a `webhookUrl` that is not HTTPS or resolves to a non-public address.
EmrError
500`internal_error` -- an unexpected server error.
EmrError

Patient Edits

1 endpoint
post/api/v1/patient-edits/{editId}Accept or reject a patient edit

Answers a `patient.update` webhook: Praxis sent you a change a clinical support user made to one patient field. Apply it in your system and send `accept`, or send `reject` with a short reason. Praxis updates its own copy of the patient only when you accept. Answer within 24 hours (the webhook's `expiresAt`): after that the edit is timed out in Praxis and a late `accept` is not applied there. The response `status` is the edit's state in Praxis after your call. A repeat answer is a 200 no-op that returns the existing state (`applied: false`), so if `status` differs from your decision, Praxis did not take it. Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.

Parameters
editIdrequired
pathThe `editId` from the `patient.update` webhook payload.
Request body (required)
decision"accept" | "reject"required
reasonstringoptional

Why the change was rejected. Shown to the support user. Only used with `reject`.

Responses
200The edit's state after this call.
editIdstringrequired
status"accepted" | "rejected" | "timed_out"required
appliedbooleanrequired

True only for the call that accepted the edit and updated Praxis's copy of the patient.

401`invalid_api_key` -- the API key is missing, unknown, or revoked.
EmrError
403`connection_paused` -- either a Praxis administrator paused this connection, or it was paused automatically after 3 consecutive fully-failed webhook deliveries.
EmrError
404`not_found` -- no patient edit with that id for this connection (another connection's edit, or a malformed id).
EmrError
413`payload_too_large` -- request body over 65,536 characters.
EmrError
422`validation_failed` -- `decision` must be `accept` or `reject`; `reason` at most 200 characters.
EmrError
429`rate_limited` -- 120 requests per 60 seconds per key. Includes a `Retry-After` header.
EmrError
500`internal_error` -- unexpected server error.
EmrError

Patients

5 endpoints
post/api/v1/patientsHand off a patient

Hands a patient off from a connected EMR to Praxis and creates their visit. Upserts on `externalPatientId` within this connection's practice -- re-sending never duplicates an active visit. Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.

Parameters
Idempotency-Keyoptional
header1-255 printable ASCII characters, no spaces. A repeat call with the same key and body within 24 hours returns the original response with `Idempotent-Replayed: true`. Reusing a key with a *different* body returns 422 `idempotency_key_reuse`.
Request body (required)
externalPatientIdstringrequired

This EMR's own id for the patient. Unique per practice per connection.

firstNamestringrequired
lastNamestringrequired
statestringrequired

Full US state name or two-letter abbreviation.

treatmentCategorystringrequired

Free text, stored verbatim. Known keys: weightLoss, longevity, sexualHealth -- any other non-empty string is also accepted.

dateOfBirthstringoptional
emailstringoptional
phonestringoptional
addressLine1stringoptional
addressLine2stringoptional
citystringoptional
zipstringoptional
requiresSyncVisitbooleanoptional
Responses
200Existing patient updated. Returns their current active visit, or a newly created one if they had none active. Never creates a duplicate active visit.
praxisPatientIdstringrequired
visitobjectrequired
idstringoptional
statusstringoptional
201New patient handed off and visit created.
praxisPatientIdstringrequired
visitobjectrequired
idstringoptional
statusstringoptional
401`invalid_api_key` -- the API key is missing, unknown, or revoked.
EmrError
403`connection_paused` -- either a Praxis administrator paused this connection, or it was paused automatically after 3 consecutive fully-failed webhook deliveries.
EmrError
409`conflict` -- `externalPatientId` is already used in this practice by a different connection.
EmrError
413`payload_too_large` -- body over 65,536 characters (the real `MAX_BODY_CHARS` check counts UTF-16 code units, not bytes).
EmrError
422`validation_failed` (field-level `details`) or `idempotency_key_reuse`.
EmrError
429`rate_limited` -- 120 requests per 60 seconds per key. Includes a `Retry-After` header.
EmrError
500`internal_error` -- unexpected server error.
EmrError
get/api/v1/patients/{externalPatientId}Get current patient state

Current state of a patient this connection handed off: patient, active visit, and latest script status. Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.

Parameters
externalPatientIdrequired
pathThe externalPatientId this connection used in POST /api/v1/patients.
Responses
200The patient's current state.
praxisPatientIdstringoptional
externalPatientIdstringoptional
firstNamestringoptional
lastNamestringoptional
statestringnulloptional
treatmentCategorystringnulloptional
visitobjectnulloptional

The current non-terminal visit (queued/assigned/in_progress), or null.

idstringoptional
statusstringoptional
latestScriptobjectnulloptional
idstringoptional
statusstringoptional
medicationNamestringoptional
401`invalid_api_key` -- the API key is missing, unknown, or revoked.
EmrError
403`connection_paused` -- either a Praxis administrator paused this connection, or it was paused automatically after 3 consecutive fully-failed webhook deliveries.
EmrError
404`not_found` -- unknown `externalPatientId`, or a patient handed off by a different connection (indistinguishable by design).
EmrError
429`rate_limited` -- 120 requests per 60 seconds per key. Includes a `Retry-After` header.
EmrError
500`internal_error` -- unexpected server error.
EmrError
put/api/v1/patients/{externalPatientId}/identity-verificationSet a patient's ID verification result

Pushes the current ID verification snapshot for a patient so Praxis providers and admins can see it, read-only, on the patient chart. Praxis stores exactly one current snapshot per patient: the call is an idempotent upsert, and an update whose `updatedAt` is older (or equal) than the stored one is ignored (200) so out-of-order delivery is safe. Send only the fields below; ID images, ID numbers and anything else are rejected. A status of `none` stores the snapshot but shows no card. Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.

Parameters
externalPatientIdrequired
pathThe externalPatientId this connection used in POST /api/v1/patients.
Request body (required)
status"none" | "awaiting_start" | "pending" | "needs_visual" | "failed" | "provider_error" | "passed" | "manually_verified" | "refunded"required
level"passive" | "visual_always" | nullrequired
visualAttemptsintegerrequired
heldbooleanrequired
failureReasonsstring[]required

At most 20. Machine keys such as `dob_mismatch`, `name_mismatch`, `id_expired`.

warningsbooleanrequired

The provider flagged the verification for review.

verifiedFirstNamestringnullrequired
verifiedLastNamestringnullrequired
verifiedDobstringnullrequired

YYYY-MM-DD.

idTypestringnullrequired

For example `drivers-license`.

idStatestringnullrequired
idExpiresstringnullrequired

YYYY-MM-DD.

releasedAtstringnullrequired

ISO 8601 timestamp the order was released, or null.

manualReasonstringnullrequired
requestedByStaffbooleanrequired
updatedAtstringrequired

ISO 8601 timestamp of this snapshot in the sender's system; used to ignore out-of-order updates.

Responses
200Ignored: a snapshot with the same or a newer `updatedAt` is already stored. No body.
204The snapshot was stored. No body.
401`invalid_api_key` -- the API key is missing, unknown, or revoked.
EmrError
403`connection_paused` -- either a Praxis administrator paused this connection, or it was paused automatically after 3 consecutive fully-failed webhook deliveries.
EmrError
404`not_found` -- no patient with that `externalPatientId` was handed off by this connection.
EmrError
413`payload_too_large` -- request body over 65,536 characters.
EmrError
422`validation_failed` -- a field is missing, mistyped, over its limit, or unknown. `details` lists each field (values are never echoed).
EmrError
429`rate_limited` -- 120 requests per 60 seconds per key. Includes a `Retry-After` header.
EmrError
500`internal_error` -- unexpected server error.
EmrError
post/api/v1/patients/{externalPatientId}/refill-checkinsSubmit a refill check-in

Creates a queued refill visit for a patient this connection already handed off, carrying the check-in flags for the provider. If the patient has an open refill visit with no check-in, the check-in attaches to it. If the patient has any other open visit (for example a new-patient visit) or the open visit already carries a different check-in, the call is rejected with 409. Idempotent on `checkinRef`: a repeat returns the same visit with 200. The patient must already have been handed off by this connection; this call never creates a patient. Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.

Parameters
externalPatientIdrequired
pathThe externalPatientId this connection used in POST /api/v1/patients.
Idempotency-Keyoptional
header1-255 printable ASCII characters, no spaces. A repeat call with the same key and body within 24 hours returns the original response with `Idempotent-Replayed: true`. Reusing a key with a *different* body returns 422 `idempotency_key_reuse`.
Request body (required)
checkinRefstringrequired

Your unique id for this check-in (1-200 characters). Makes retries safe.

program"glp1" | "nad" | "sermorelin"required
dueDatestringrequired

When the refill is due, YYYY-MM-DD.

stateConfirmedstringrequired

The state the patient confirmed in the check-in (up to 50 characters).

hardStopsstring[]required

Answers that block a refill (at most 50 items, 300 characters each).

reviewFlagsstring[]required

Answers the provider should review (at most 50 items, 300 characters each).

subscriptionPeriodDaysintegeroptional

Optional; null or omitted both mean unknown. The length in days of the patient's subscription billing period (1-400). Used to cap the days supply of the refill prescription.

Responses
200A visit already exists for this `checkinRef`, or the check-in attached to the patient's open refill visit. No new visit created.
visitobjectrequired
idstringrequired
statusstringrequired
201Refill visit created.
visitobjectrequired
idstringrequired
statusstringrequired
401`invalid_api_key` -- the API key is missing, unknown, or revoked.
EmrError
403`connection_paused` -- either a Praxis administrator paused this connection, or it was paused automatically after 3 consecutive fully-failed webhook deliveries.
EmrError
404`not_found` -- no patient with that `externalPatientId` was handed off by this connection (a patient handed off by a different connection is indistinguishable by design).
EmrError
409`conflict` -- either `details[0].message` is `checkin_ref_in_use` (the `checkinRef` is already used for a different patient in this practice) or `open_visit_has_checkin` (the patient's open visit is not a refill visit or already carries a different check-in; nothing was changed -- retry after that visit completes).
EmrError
413`payload_too_large` -- body over 65,536 characters.
EmrError
422`validation_failed` (field-level `details`) or `idempotency_key_reuse`.
EmrError
429`rate_limited` -- 120 requests per 60 seconds per key. Includes a `Retry-After` header.
EmrError
500`internal_error` -- unexpected server error.
EmrError
post/api/v1/refill-checkins/{checkinRef}/payment-confirmedConfirm payment for a refill check-in

Tells Praxis the patient has paid for an approved refill. Only then is the provider's approved prescription released to the pharmacy. Idempotent: calling it again returns `already_released` and sends nothing. If the pharmacy network rejects the prescription for another reason (a 4xx answer) the call fails with 500 `internal_error` and the same call can safely be retried. If the prescription is permanently rejected by a pharmacy limit (the days supply is over the product limit, or the medication is unavailable or no longer matches), the call fails with 422 `validation_failed`, message `release_rejected`, and `details[0].message` carries the machine code (for example `days_supply_exceeds_cap`); do not retry: the prescription stays awaiting payment and a human must re-approve it. If the outcome is uncertain (the pharmacy network answered 5xx, or the connection failed after the prescription was sent), the call fails with 500 `internal_error` and message `release_needs_review`: nothing is sent again automatically, this does not resolve by itself, and a Praxis administrator must check with the pharmacy network; keep retrying or alert. Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.

Parameters
checkinRefrequired
pathThe `checkinRef` this connection used when submitting the refill check-in.
Request body (required)
paymentRefstringoptional

Optional. Your own payment reference (up to 200 characters). Accepted for your records; not otherwise used.

Responses
200`released` -- the prescription was just sent to the pharmacy. `already_released` -- it was sent earlier; nothing new was sent. `nothing_pending` -- no approved prescription is waiting for this check-in. `not_approved` -- the provider has not approved this refill (held, discontinued, or no decision yet).
status"released" | "already_released" | "nothing_pending" | "not_approved"required
401`invalid_api_key` -- the API key is missing, unknown, or revoked.
EmrError
403`connection_paused` -- either a Praxis administrator paused this connection, or it was paused automatically after 3 consecutive fully-failed webhook deliveries.
EmrError
404`not_found` -- no check-in with that `checkinRef` was submitted by this connection (a ref from another connection is indistinguishable by design).
EmrError
413`payload_too_large` -- body over 65,536 characters.
EmrError
422`validation_failed` -- the body is not a JSON object, or `paymentRef` is not a string of at most 200 characters. Also `release_rejected` (message) -- the pharmacy limit permanently rejected the approved prescription; `details: [{ field: "script", message: <code> }]`. Permanent: do not retry; a human must re-approve.
EmrError
429`rate_limited` -- 120 requests per 60 seconds per key. Includes a `Retry-After` header.
EmrError
500`internal_error` -- unexpected server error.
EmrError