Skip to main content
@photonhealth/client sits underneath Prescribe. It handles the prescriber’s sign-in and tokens, calls the API, and enforces the workflow rules: which drug was meant, when a draft is created, and when an order is ready to send. Use it when you’re building your own UI in another framework, or when your backend wants typed calls. It has no dependencies and runs in browsers and Node.
Building your own prescribing UI on the client, with your own screens for drafting, signing and sending, is part of the Grow plan. On Launch, embed Prescribe for the prescriber, and use the client to build links and make typed calls.
It’s a release candidate of 2.0.0 for now.

Create a client

Try it

The live objects in /integrate, drawn with no framework: sync, sign and send, beside their code.
With clientId, ph.session handles sign-in: start() on load, signIn() from a click, and signOut(). Its status and user show who is signed in. Create one client per signed-in person. On a server, that means one per request, never one shared module-wide. await ph.ping() checks the token without touching a record.

On your backend

Your backend can use the client with its own machine credentials instead of a prescriber’s sign-in:
The client gets a machine token, keeps it until shortly before it expires, and gets a new one if Photon refuses it. A token for any organization other than organizationId is refused with unauthorized. It works only on a server: photon() throws if it’s given a client secret in a browser. A machine token can’t sign or send, so rx.sign() and order.submit() are for a prescriber’s sign-in. ⚠️ Machine credentials aren’t in a published release candidate yet. They arrive after 2.0.0-rc.1.
With clientId, the access token stays in memory and the refresh token is kept in the page’s localStorage, so a reload signs back in. Any script on the page can read it, so treat script injection there as a token leak, and set a Content-Security-Policy. Auth0 rotates the refresh token on every use and revokes it as soon as an old one is reused. How Prescribe is built has the details.

Get, sync, update

ph.patient, ph.prescription and ph.order each have the same three calls:
sync and update return the live object, any candidates when Photon isn’t sure which record you meant, and the changes it reported. The client never picks a candidate for you. Pick one with patient.pick(id), rx.continueDraft(id) for a draft the patient already has in progress, or order.pick(id) for one of the patient’s draft orders. A patient’s lists (allergies, medications, diagnoses and benefits) are null when nothing is recorded, and [] when the patient has none, such as no known drug allergies. See Reading lists.

Live objects

Each record is a live object. state is the record, and status is idle, loading, saving or error. subscribe calls you on every change, and every method waits for the call it makes. There’s one object per record per client, so a patient opened twice is the same object.
The rules live in the objects, so every UI built on them behaves the same:
  • A new prescription without a patient only looks the drug up and never creates anything.
  • The newest response always wins, so a slow early reply can’t overwrite a later one.
  • rx.sign() waits for saves in flight, then signs only what’s stored.
  • order.readyToSend is true once the patient is found, every prescription is signed, and nothing is still saving.
  • order.submit() refuses until it’s ready, and never sets a pharmacy.
  • order.unacknowledgedAlerts lists the screening alerts no one has acknowledged yet: the order’s own, and each unsigned prescription’s. Signing a prescription acknowledges its alerts. Show these by the send button. They never stop a send.

Sig options

rx.loadSigs() looks up sig options for a draft’s drug and patient into rx.snapshot.sigs, and looks them up again whenever the drug or patient changes. It’s separate from saving, so saves don’t wait for it.
sigs is null until the lookup finishes, and stays null without a patient, on a signed prescription, or for a drug without an NDC. When nothing fits, sigs.sigs is empty and sigs.notices says why. ⚠️ Sig options aren’t in a published release candidate yet. They arrive after 2.0.0-rc.1.

Pharmacies

You don’t need a pharmacy to send: the patient chooses one. To choose for them, sync the order with a pharmacy reference. When Photon offers several, order.pharmacyOptions lists them, each with its pharmacy, address and priced offers. order.choose(option) sets one.

Choices

When Photon offers options, such as which of several drugs a name meant, choicesIn(obj.changes) lists them. obj.choose(option) applies one.
The client splits the API’s changes into changes.applied and changes.unapplied. Each option has a path and a parsed value (the API’s argument and value), and id, treatment or pharmacy for the record it picks. Other helpers work on the same changes: A drug option carries its treatment in parts, so you can group drugs by brandName, then form, then strength without parsing displayName. It also has genericName, route, schedule (the DEA schedule) and recommendedDispenseUnits, the values dispense.unit takes. Once picked, the same parts are rx.treatment:

Errors

Anything Photon answers about your data comes back as a result. Only failures that different data can’t fix throw a PhotonError, and its kind says which: url() builds a deep link and frame() builds an iframe URL. Each record’s calls have both, and so does every live object:
  • Links carry Photon ids only (pat_, rx_, ord_, med_, tmp_, org_), in the clear. url() and frame() throw invalid for anything else, such as patient details or a drug’s name. Sync those first, then link by id. See deep links for every parameter.
  • A live object’s link is its record’s id. Before it has a record, the link uses the ids the object has: an order’s patient and lines, or a prescription’s patient and its template or drug. A patient with no record has no link, so url() throws.
  • Links point to the environment’s Prescribe app: rx.neutron.health in the sandbox, or rx.new in production. Pass appUrl to photon() to point them somewhere else.
  • url() is for sharing, so it never carries a client_id. The Prescribe app signs the prescriber in on its own.
  • frame() puts your Application client_id in the URL, from auth: { clientId }, and throws without one. With auth: { getToken }, pass it in the link: ph.frame({ flow, …, auth: { clientId } }).
  • Pass organization to photon(), and every link carries it, so the prescriber signs straight in to that organization. A client with machine credentials uses its organizationId. It can build url() links for a prescriber, but not frame() URLs.

Secrets

A client secret belongs only on your backend. If you pass a clientSecret to photon() in a page, it throws before doing anything else. Treat that secret as leaked and rotate it. Links never carry a secret.

Testing

Two entry points help you test your own code without calling Photon:
  • @photonhealth/client/testing: photonOver(scriptedClient(…)) gives you a client over scripted responses, with fixtures (patient, rx, order and others) to script them.
  • @photonhealth/client/test-utils/msw: photonHandlers(responses) gives you MSW handlers for the GraphQL endpoint, for tests that run a real page or a real photon(). It needs msw installed.
Both may change before 2.0.0.
Last modified on October 29, 2026