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.
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.
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.
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=7d24d3d18fdb2d6edc8e528a1887aed5aef421d03dbb93ac97b426775a269549import 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.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.
| Event | Fires when | Primary resource |
|---|---|---|
| visit.status_changed | A visit moves between queued/assigned/in_progress/completed/cancelled. | Encounter |
| note.signed | A visit is completed via Confirm & Sign. A draft save never fires this. | DocumentReference |
| script.created | A prescription is written. | MedicationRequest |
| script.status_changed | A script's status changes. | MedicationRequest |
| script.shipment_updated | Shipping or tracking information updates. | MedicationRequest + Task |
| lab_order.created | A lab order is created. | ServiceRequest |
| lab_order.status_changed | A 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.
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" }
}Send the text, then report what happened:
| Endpoint | Use it to |
|---|---|
| POST /api/v1/messages/{messageId}/status | Report `sent`, `delivered`, or `failed` (with an optional `reason`, up to 200 characters, which the provider sees). |
| POST /api/v1/patients/{externalPatientId}/messages | Record a patient reply: `body`, plus optional `receivedAt` and `externalMessageId`. |
| GET /api/v1/patients/{externalPatientId}/messages | Read the thread and the opt-out flag, for backfill or recovery. |
| PUT /api/v1/patients/{externalPatientId}/messaging | Set `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.
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 concept | FHIR resource | Status mapping |
|---|---|---|
| Visit | Encounter | queued/assigned -> planned, in_progress -> in-progress, completed -> finished, cancelled -> cancelled |
| Signed SOAP note | DocumentReference | status: current, docStatus: final. Inline text/plain, base64, labelled S/O/A/P sections. Unsigned (draft) notes have no FHIR representation. |
| Prescription | MedicationRequest | pending/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 update | Task (linked to the MedicationRequest) | Carries tracking details alongside the MedicationRequest. |
| Lab order | ServiceRequest | ordered -> active (see the generated spec for the complete set in use). |
| Prescriber | Practitioner | Name 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 |
|---|---|---|
| 401 | invalid_api_key | security |
| 403 | connection_paused / messaging_disabled | forbidden |
| 404 | not_found | not-found |
| 409 | conflict | -- |
| 413 | payload_too_large | -- |
| 422 | validation_failed / idempotency_key_reuse / message_too_long | invalid |
| 429 | rate_limited | throttled |
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 endpointsRequires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.
Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.
Encounter
2 endpointsRequires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.
Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.
MedicationRequest
2 endpointsRequires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.
Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.
Discovery
1 endpointThe one unauthenticated route under /api/fhir/r4 -- an EMR discovers how to authenticate before it has anything to authenticate with. No authentication required.
Patient
2 endpointsThe 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.
Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.
Practitioner
1 endpointRequires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.
ServiceRequest
2 endpointsRequires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.
Requires `Authorization: Bearer <Praxis API key>` (`prx_live_...`). The key resolves to exactly one connection, and therefore one practice -- no request names a practice.
Messages
4 endpointsReports 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.
Why delivery failed. Only used with `failed`.
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.
The message text. Leading and trailing whitespace is trimmed; must not be blank. At most 1,600 characters (Unicode code points).
When the patient sent it (ISO 8601). Defaults to now.
Your own id for this message (1-255 characters). Makes retries idempotent.
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.
True when the patient has opted out of texts; Praxis then refuses to send them new messages.
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.
true to opt the patient out, false to clear the opt-out.
Partner
1 endpointCreates 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).
The partner's own identifier for this tenant, at most 200 characters. Unique across all custom_emr connections; a repeat value returns 409 conflict.
Practice / connection display name.
Must be HTTPS and resolve to a public address (checked at save time and at every delivery); rejected otherwise.
Shown only in this response.
Shown only in this response.
Patient Edits
1 endpointAnswers 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.
Why the change was rejected. Shown to the support user. Only used with `reject`.
True only for the call that accepted the edit and updated Praxis's copy of the patient.
Patients
5 endpointsHands 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.
This EMR's own id for the patient. Unique per practice per connection.
Full US state name or two-letter abbreviation.
Free text, stored verbatim. Known keys: weightLoss, longevity, sexualHealth -- any other non-empty string is also accepted.
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.
The current non-terminal visit (queued/assigned/in_progress), or null.
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.
At most 20. Machine keys such as `dob_mismatch`, `name_mismatch`, `id_expired`.
The provider flagged the verification for review.
YYYY-MM-DD.
For example `drivers-license`.
YYYY-MM-DD.
ISO 8601 timestamp the order was released, or null.
ISO 8601 timestamp of this snapshot in the sender's system; used to ignore out-of-order updates.
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.
Your unique id for this check-in (1-200 characters). Makes retries safe.
When the refill is due, YYYY-MM-DD.
The state the patient confirmed in the check-in (up to 50 characters).
Answers that block a refill (at most 50 items, 300 characters each).
Answers the provider should review (at most 50 items, 300 characters each).
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.
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.
Optional. Your own payment reference (up to 200 characters). Accepted for your records; not otherwise used.