@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.
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.
fetch() call pointing at /core/api/* or /fhir/*,
it's a bug. That logic belongs here.
What's in it
- OAuth2 client_credentials flow with token cache and single-flight refresh.
- HTTP transport with 401 refresh-retry, 429 rate-limit surfacing, 5xx exponential backoff with jitter, and AbortController-based timeouts.
- Error taxonomy of seven typed errors, each carrying the Canvas response body where relevant.
- FHIR primitives — typed helpers for the datatypes that show up everywhere (
Reference,Identifier,CodeableConcept, etc.). - Resource wrappers for the 16 Canvas resources we read or write.
- Webhook verifier — HMAC-SHA256 signature check with timing-safe compare, used by webhook-receiver.
What's NOT in it
- Caching of Canvas data. The client caches tokens, not responses. Sidecars that need hot reads build materialized views fed by Canvas webhooks (see webhook-receiver).
- Business logic. No MRN generation, no atomicity semantics, no saga state. Those live in the caller.
- Service discovery. The base URL is injected at construction; no ambient service locator.
Status
| Field | Value |
|---|---|
| Wave | A2 — Shared infrastructure |
| Shipped | 2026-04-21 |
| Commit | bd48cfd |
| Tests | 1065 unit |
| Path | packages/canvas-client/ |
| Published | Workspace package only — not on npm |
| TypeScript | Strict 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.
CanvasAuthError.
HTTP Transport
Every request goes through a shared transport layer that applies:
| Concern | Policy |
|---|---|
| Timeout | AbortController with timeoutMs (default 10s). Surfaces as CanvasTransientError. |
| 401 | Refresh token once, retry once, then fail as CanvasAuthError. |
| 404 | Fails immediately as CanvasNotFoundError. No retry. |
| 409 / 422 | CanvasValidationError or CanvasConflictError depending on payload shape. No retry. |
| 429 | Surfaces as CanvasRateLimitError carrying retryAfterSeconds parsed from the Retry-After header. Caller decides whether to back off and retry. |
| 5xx | Exponential backoff with jitter, up to maxRetries. After exhaustion, CanvasTransientError. |
| Network errors | Treated as transient; retried with backoff. |
Error Taxonomy
Seven concrete error classes. All extend a shared CanvasError base so callers can instanceof-branch.
| Class | Retryable? | Meaning |
|---|---|---|
CanvasAuthError | No | 401 after refresh. Credentials are wrong or revoked. |
CanvasNotFoundError | No | 404. The referenced resource does not exist. |
CanvasValidationError | No | 422 or structured validation reject. The payload is malformed. |
CanvasConflictError | No | 409. Duplicate identifier or concurrent-write conflict. |
CanvasRateLimitError | Caller's choice | 429. Carries retryAfterSeconds. |
CanvasTransientError | Yes | Timeout, network drop, or 5xx after retry exhaustion. |
CanvasUnexpectedError | No | Shape we didn't anticipate. File a bug. |
FHIR Primitives
Typed helpers for the FHIR datatypes we actually use. Reduces boilerplate and makes lint enforce shape consistency.
| Type | Used by |
|---|---|
Reference | Everywhere — Patient.managingOrganization, MedicationRequest.subject, etc. |
Identifier | MRN, external IDs |
CodeableConcept | Conditions, allergies, reasons |
HumanName | Patient, Practitioner |
ContactPoint | Email, phone |
Address | Shipping-service reads this live from Canvas per label |
Period | Effective ranges on Conditions, MedicationRequests |
Quantity | Observation values, dosages |
Attachment | DocumentReference payloads (intake PDFs, ID photos) |
Annotation | Clinical notes |
Meta | versionId, 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).
| Resource | Primary callers | Typical ops |
|---|---|---|
Patient | patient-service, intake-gateway, webhook-receiver | create, get, update, search by identifier |
Organization | organization-service | create, get, update |
Practitioner | physician-registry | create, get, update |
PractitionerRole | physician-registry, org memberships | create, update |
Observation | intake-gateway (vitals), orchestrator | create, search |
Condition | intake-gateway (medical history) | create |
AllergyIntolerance | intake-gateway | create |
MedicationRequest | orchestrator (read), webhook-receiver (notify) | get, update status |
MedicationDispense | pharmacy-router | create on dispense callback |
MedicationStatement | intake-gateway (current meds) | create |
QuestionnaireResponse | intake-gateway | create |
Consent | intake-gateway | create |
DocumentReference | intake-gateway (ID photos), document uploads | create, get |
Task | Canvas plugins, orchestrator (provider review routing) | get, update |
Communication | notification-service (inbound log) | create |
Appointment | patient-portal scheduling | create, 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
}
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:
- Token cache behavior (cold start, near-expiry refresh, single-flight concurrency).
- Every retry path, including jitter bounds and max-retry exhaustion.
- Every error class from every status code + response-body shape we've seen in the wild.
- FHIR primitive builders (round-trip tests through
JSON.parse). - Every resource wrapper op (create, get, update, search where applicable).
- Webhook verifier: known-vector match, tamper rejection, timing-attack resistance via statistical equal-time test.
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
- Bulk export.
$exportand async FHIR bulk APIs are not wired. Compliance tooling currently paginates; a bulk wrapper is a Wave C item. - Subscription resource. Not used yet — we rely on Canvas console-configured webhooks. When we move to programmatic subscription management we'll add a
Subscriptionwrapper. - SMART-on-FHIR user tokens. Only client_credentials today. Patient-token flows are out of scope until we need end-user scoping (not foreseeable in current roadmap).
- Structured log redaction. Responses are logged at debug level with no PHI redaction. Fine for staging, but we'll want field-level redaction before production log forwarding.
- OpenTelemetry tracing. Context propagation is manual; a proper OTel layer with Canvas as a span boundary is deferred.