@yourera/canvas-client

The one HTTP client every service uses to talk to Canvas FHIR. Shared package, not a service. Lives in packages/canvas-client/ in the platform monorepo.

Shipped in Wave A2 · 2026-04-21 · commit bd48cfd.

1065 unit tests passing. Imported by every Canvas-touching service: intake-gateway, patient-service, webhook-receiver, orchestrator, organization-service, pharmacy-router. Direct Canvas HTTP calls outside this package are a code-review fail.

Overview

Canvas-client is a single package, not a service. Every sidecar that needs to read or write Canvas FHIR imports it and speaks to Canvas through a typed client instance. This gives us one place to manage OAuth, one place to apply retry / rate-limit / timeout policy, one error taxonomy to match on, and one source of truth for the FHIR resource shapes we actually use.

Rule of thumb. If a service has a fetch() call pointing at /core/api/* or /fhir/*, it's a bug. That logic belongs here.

What's in it

What's NOT in it

Status

FieldValue
WaveA2 — Shared infrastructure
Shipped2026-04-21
Commitbd48cfd
Tests1065 unit
Pathpackages/canvas-client/
PublishedWorkspace package only — not on npm
TypeScriptStrict mode, no any, all public types exported from index.ts

Install & Construct

It's a workspace package. Add it to the consumer's package.json:

{
  "dependencies": {
    "@yourera/canvas-client": "workspace:*"
  }
}

Construct once per service, inject via DI or factory:

import { createCanvasClient } from '@yourera/canvas-client';

const canvas = createCanvasClient({
  baseUrl: process.env.CANVAS_BASE_URL!,
  clientId: process.env.CANVAS_CLIENT_ID!,
  clientSecret: process.env.CANVAS_CLIENT_SECRET!,
  timeoutMs: 10_000,
  maxRetries: 3,
});

const patient = await canvas.patient.create({
  name: [{ given: ['Jane'], family: 'Doe' }],
  telecom: [{ system: 'email', value: 'jane@example.com' }],
  identifier: [{ system: 'https://yourera.com/mrn', value: 'YR-A1B2C3D4E5' }],
  managingOrganization: { reference: 'Organization/abc-123' },
});

OAuth & Token Cache

Canvas uses OAuth2 client_credentials. The client maintains an in-memory token cache keyed by client_id. Tokens are refreshed when they're within 60 seconds of expiry. Concurrent requests hitting an expired token trigger a single-flight refresh — all waiters receive the same refreshed token, and exactly one network call is made.

401 refresh-retry. If Canvas returns 401 despite our token being apparently valid (clock skew, revocation), the transport forces one token refresh and retries the original request once. A second 401 surfaces as CanvasAuthError.

HTTP Transport

Every request goes through a shared transport layer that applies:

ConcernPolicy
TimeoutAbortController with timeoutMs (default 10s). Surfaces as CanvasTransientError.
401Refresh token once, retry once, then fail as CanvasAuthError.
404Fails immediately as CanvasNotFoundError. No retry.
409 / 422CanvasValidationError or CanvasConflictError depending on payload shape. No retry.
429Surfaces as CanvasRateLimitError carrying retryAfterSeconds parsed from the Retry-After header. Caller decides whether to back off and retry.
5xxExponential backoff with jitter, up to maxRetries. After exhaustion, CanvasTransientError.
Network errorsTreated as transient; retried with backoff.

Error Taxonomy

Seven concrete error classes. All extend a shared CanvasError base so callers can instanceof-branch.

ClassRetryable?Meaning
CanvasAuthErrorNo401 after refresh. Credentials are wrong or revoked.
CanvasNotFoundErrorNo404. The referenced resource does not exist.
CanvasValidationErrorNo422 or structured validation reject. The payload is malformed.
CanvasConflictErrorNo409. Duplicate identifier or concurrent-write conflict.
CanvasRateLimitErrorCaller's choice429. Carries retryAfterSeconds.
CanvasTransientErrorYesTimeout, network drop, or 5xx after retry exhaustion.
CanvasUnexpectedErrorNoShape we didn't anticipate. File a bug.
Terminal vs retryable is a caller concern for 429s. The client will NOT auto-retry 429. Callers like the orchestrator saga and webhook-receiver decide whether to sleep and retry or let the background worker re-deliver. See the webhook-receiver egress handling for the canonical pattern.

FHIR Primitives

Typed helpers for the FHIR datatypes we actually use. Reduces boilerplate and makes lint enforce shape consistency.

TypeUsed by
ReferenceEverywhere — Patient.managingOrganization, MedicationRequest.subject, etc.
IdentifierMRN, external IDs
CodeableConceptConditions, allergies, reasons
HumanNamePatient, Practitioner
ContactPointEmail, phone
AddressShipping-service reads this live from Canvas per label
PeriodEffective ranges on Conditions, MedicationRequests
QuantityObservation values, dosages
AttachmentDocumentReference payloads (intake PDFs, ID photos)
AnnotationClinical notes
MetaversionId, lastUpdated — used by webhook-receiver for idempotency

Resource Wrappers

Sixteen resource wrappers. Each exposes the subset of operations we use (usually create, get, update, and sometimes search).

ResourcePrimary callersTypical ops
Patientpatient-service, intake-gateway, webhook-receivercreate, get, update, search by identifier
Organizationorganization-servicecreate, get, update
Practitionerphysician-registrycreate, get, update
PractitionerRolephysician-registry, org membershipscreate, update
Observationintake-gateway (vitals), orchestratorcreate, search
Conditionintake-gateway (medical history)create
AllergyIntoleranceintake-gatewaycreate
MedicationRequestorchestrator (read), webhook-receiver (notify)get, update status
MedicationDispensepharmacy-routercreate on dispense callback
MedicationStatementintake-gateway (current meds)create
QuestionnaireResponseintake-gatewaycreate
Consentintake-gatewaycreate
DocumentReferenceintake-gateway (ID photos), document uploadscreate, get
TaskCanvas plugins, orchestrator (provider review routing)get, update
Communicationnotification-service (inbound log)create
Appointmentpatient-portal schedulingcreate, get, search

Webhook Verifier

Canvas signs outbound webhook POSTs with HMAC-SHA256 over the raw body. The client exports a verifyCanvasWebhook(rawBody, signatureHeader, secret) helper that performs a timing-safe comparison and returns a boolean. Used exclusively by webhook-receiver.

import { verifyCanvasWebhook } from '@yourera/canvas-client';

const ok = verifyCanvasWebhook(rawBody, req.headers['x-canvas-signature'], secret);
if (!ok) {
  // log verbatim for forensics, respond 401, DO NOT dispatch
}
Always log the raw body first. Webhook-receiver persists the raw body with signature_valid = false before short-circuiting. That's a deliberate forensic trail. The verifier returns false; it does not throw.

Testing

1065 unit tests under packages/canvas-client/src/**/*.test.ts. Coverage areas:

We deliberately avoid mocking Canvas at the HTTP layer in integration tests; consumers use MSW or Nock to simulate. The canvas-client's own tests use a small in-package transport stub.

TODOs & Future Waves