Webhook Receiver

Canvas FHIR bridge. Bidirectional. Receives Canvas webhooks on one side, pushes back to Canvas on the other. The only service with Canvas webhooks pointed at it.

Shipped in Wave A4 · 2026-04-21 · commit 31c7367.

841 tests passing (535 unit + 302 integration + 9 real-Postgres-skipped). First egress handler is patient.archivedPatient.active = false push to Canvas. Additional handlers land per unit as we build them out.

Overview

Webhook-receiver is the Canvas-platform boundary. It does two jobs:

One service, two directions. The decision to colocate ingress and egress was deliberate: both sides need the same canvas-client configuration, the same Canvas OAuth credential, and the same operational concepts around idempotency and retry. Keeping them together keeps the Canvas boundary centralized in one place.

Status

FieldValue
WaveA4 — Canvas bridge
Shipped2026-04-21
Commit31c7367
Tests841 (535 unit + 302 integration + 9 real-Postgres-skipped)
Ingress endpointPOST /webhooks/canvas (public, HMAC-authed)
Egress dispatcherCanvasSyncWorker, SQS consumer
Phase 1 egress handlerspatient.archivedPatient.active=false

Ingress: Canvas → us

The POST /webhooks/canvas endpoint is the sole Canvas-facing entrypoint.

Flow

  1. Canvas POSTs to /webhooks/canvas with header X-Canvas-Signature.
  2. We read the raw body (before any JSON parsing).
  3. We INSERT a canvas_webhook_events row immediately — before deciding whether the signature is valid. This is a deliberate forensic choice: even invalid payloads are captured.
  4. HMAC verify via @yourera/canvas-client's verifyCanvasWebhook. signature_valid is persisted as the result.
  5. If signature_valid = false: log a structured warn (CloudWatch Metric Filter hook) and return 401. Do NOT dispatch. The row is retained for forensics.
  6. If valid but malformed JSON: persist, log, return 400. No dispatch.
  7. Otherwise: parse, normalize into the canonical envelope, INSERT an outbox row with the normalized event, update canvas_webhook_events.dispatched_outbox_id to link them.
  8. Return 200. Outbox worker ships the event to EventBridge.
Always log verbatim, even on failure. Whether the signature is bad, the JSON is malformed, or the payload shape is unknown — we still write canvas_webhook_events. This gives ops a complete record when Canvas changes a payload schema or an attacker probes the endpoint.

Event naming

Canvas webhooks carry a FHIR resource type and an action. We emit internal events as canvas.{resource_snake}.{action}:

Canvas eventEmitted as
MedicationRequest activatedcanvas.medication_request.activated
MedicationDispense completedcanvas.medication_dispense.completed
Appointment bookedcanvas.appointment.booked
Patient updatedcanvas.patient.updated
Task completedcanvas.task.completed

Consumers filter via EventBridge rules on source=canvas + detail-type. No fan-out is done at the emitter side — webhook-receiver just publishes; rules route.

Egress: us → Canvas

Internal events (e.g. patient.archived) fan out via EventBridge. An SQS queue owned by webhook-receiver has an EventBridge rule subscription for the events that need to write back to Canvas. The CanvasSyncWorker consumes the queue.

Flow

  1. Worker polls SQS. Receives a message with the envelope.
  2. Computes an idempotency key (see Idempotency Key).
  3. UPSERT into canvas_sync_attempts keyed by the idempotency key. If existing row is terminal (succeeded, failed, abandoned), ack and move on.
  4. Atomically bump attempts++ via SQL expression (race-safe; two workers cannot double-count).
  5. Dispatch to the appropriate handler. For Phase 1, the only handler is patient.archivedcanvas-client.patient.update(id, { active: false }).
  6. Disposition based on the result (see Error Disposition).
Phase 1 scope. Only patient.archived is wired today. Additional handlers (e.g. Canvas task assignment on physician membership grant, MedicationRequest cancellation on saga abort) land per unit in subsequent waves.

Database Schema

canvas_webhook_events (ingress log)

Immutable audit log of every Canvas webhook we received, valid or not.

ColumnTypeNotes
iduuidPK.
received_attimestamptzRequest arrival.
signature_validbooleanResult of HMAC verify.
raw_bodybyteaVerbatim bytes. Contains PHI. Access gated to superadmin.
signature_headertextThe signature string we received.
resource_typetextParsed from body if valid JSON; else null.
resource_idtextParsed. Null if unparseable.
canvas_event_nametexte.g. MedicationRequest.activated.
canvas_event_timestamptimestamptzCanvas-side event time.
dispatched_outbox_iduuidNull if not dispatched (e.g. invalid signature).

canvas_sync_attempts (egress ledger)

Per-egress-work-unit ledger with idempotency key.

ColumnTypeNotes
iduuidPK.
idempotency_keytextUnique index. See below.
internal_event_nametexte.g. patient.archived.
target_resource_typetextCanvas resource we're updating.
target_resource_idtextCanvas resource id.
statusenumpending / succeeded / failed / abandoned.
attemptsintIncremented atomically via SQL expression.
canvas_response_statusintLast Canvas HTTP status. Operational metadata, no PHI.
last_errortextClass + short message. No payload.
created_attimestamptz
updated_attimestamptz

Also present: outbox and audit_events, shared-pattern tables.

HTTP Endpoints

POST /webhooks/canvas

Public. Canvas ingress. HMAC auth only (no JWT). Always logs verbatim, even on invalid signature or malformed JSON. See Ingress for the full flow.

GET /webhook-events

Superadmin only. Contains PHI in raw_body. List + filter.

GET /webhook-events/:id

Superadmin only. Full verbatim payload for incident review.

GET /canvas-sync-attempts

Support + superadmin (M-5). Operational metadata only — no PHI. Support can read to triage stuck syncs.

GET /canvas-sync-attempts/:id

Support + superadmin. Single attempt detail.

POST /canvas-sync-attempts/:id/retry

Superadmin only. Flips failed or abandoned back to pending and kicks worker.tick() best-effort. Used when a Canvas-side fix (e.g. unstuck resource) makes a previously terminal error now recoverable.

POST /admin/workers/outbox/tick

Superadmin only. Synchronous outbox tick for incident response.

POST /admin/workers/canvas-sync/tick

Superadmin only. Synchronous egress tick for incident response.

Event Contracts

Defined in @yourera/contracts/events/canvas.ts. The canonical envelope for every Canvas-origin event we emit:

export const canvasEventEnvelopeSchema = z.object({
  webhookEventId: z.string().uuid(),
  canvasEventName: z.string(),      // e.g. "MedicationRequest.activated"
  resourceType: z.string(),         // e.g. "MedicationRequest"
  resourceId: z.string(),
  canvasEventTimestamp: z.string().datetime(),
  receivedAt: z.string().datetime(),
  resource: z.unknown(),             // the FHIR resource body as received
});

Consumers use EventBridge rules with detail-type = canvas.{resource_snake}.{action}. For example, orchestrator subscribes to canvas.medication_request.activated; pharmacy-router subscribes to canvas.medication_dispense.completed.

Idempotency Key

The egress idempotency key is deterministic from (internal event name, target resource id, intent). For patient.archived the shape is:

canvas-sync:patient.archived:canvasPatientId:<canvasPatientId>:active=false

The canvas_sync_attempts.idempotency_key column has a unique index. Two concurrent workers receiving the same SQS message (or a re-delivery after an ack timeout) collide on INSERT and short-circuit.

Race-safe attempts counter. attempts is incremented via SQL expression (UPDATE … SET attempts = attempts + 1), never read-modify-write at the application level. This matters when SQS visibility-timeout expires and two workers pick up the same message.

Error Disposition

The CanvasSyncWorker branches on the error class returned from canvas-client:

ErrorDispositionReason
CanvasNotFoundErrorTerminal — status=failed + ACK SQSTarget doesn't exist. Retrying will never succeed.
CanvasValidationErrorTerminal — status=failed + ACK SQSPayload is wrong. Bug to fix, not retry.
CanvasConflictErrorTerminal — status=failed + ACK SQSConcurrent write won. Human must reconcile.
CanvasAuthErrorTerminal — status=failed + ACK SQSCredentials broken. Page ops. Retrying doesn't help.
CanvasTransientErrorLeave status=pending, do NOT ACK SQSSQS redelivers after visibility timeout.
CanvasRateLimitErrorLeave status=pending, change SQS visibility to retryAfterSecondsRespect Canvas backoff guidance.
Canvas 5xx after client retriesLeave status=pending, do NOT ACK SQSTransient; SQS redelivers.
Terminal means human-triggered only. A failed attempt does not retry automatically. Ops reviews via /canvas-sync-attempts, fixes the root cause, then calls /canvas-sync-attempts/:id/retry to flip back to pending.

Testing

841 tests:

TODOs & Future Waves