Quickstart

Wire up @medblocks/connect end to end. Install, initialize a PatientFlow, redirect the patient, read the result.

Six steps, about fifteen minutes. By the end, you will:

  1. Install the @medblocks/connect SDK.
  2. Configure the client with your API key.
  3. Initialize a Connect PatientFlow from a server route and redirect the patient.
  4. Handle the patient’s return and read what got connected.
  5. Mount the handlers on any Fetch-compatible runtime.
  6. Handle failures with typed errors.

The example uses picker mode. Medblocks hosts the EHR picker. The same pattern works for direct mode; see PatientFlow · Direct mode.

Install

pnpm add @medblocks/connect
npm install @medblocks/connect
yarn add @medblocks/connect
bun add @medblocks/connect

Configure

Put your API key in an env var. The SDK reads its constructor argument, but process.env is the canonical place to source it.

.env
MEDBLOCKS_API_KEY="mb_sk_live_..."

Construct the client once per process. The same mb instance handles every call.

src/medblocks.ts
import { Medblocks } from "@medblocks/connect";

const apiKey = process.env.MEDBLOCKS_API_KEY;
if (!apiKey) throw new Error("MEDBLOCKS_API_KEY is not configured");

export const mb = new Medblocks(apiKey);

Initialize A PatientFlow

The server endpoint creates the flow and returns the patient-facing URL; the client redirects.

src/start-connect.ts
import { mb } from "./medblocks";

export async function startConnect(req: Request): Promise<Response> {
  const { patientId, patientEmail } = (await req.json()) as {
    patientId: string;
    patientEmail?: string;
  };

  const flow = await mb.patientFlow.init({
    patient_id: patientId,
    patient_email: patientEmail,
    return_url: "https://app.example.com/connected",
    return_button_label: "Back to Acme Health",
    // Picker mode: surface these EHRs at the top of the Medblocks-hosted
    // search. Omit to show the full catalog with no recommendations.
    recommended_connection_ids: ["fhirsrc_epic_mychart"],
  });

  return Response.json({ url: flow.url });
}
connect-button.ts
async function startConnect(patientId: string) {
  const response = await fetch("/api/start-connect", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ patientId, patientEmail: "jane@example.com" }),
  });

  const { url } = await response.json();
  window.location.href = url;
}

mb.patientFlow.init returns a PatientFlow. id, status, url, connections, plus the input fields echoed back.

Handle The Return

After the patient finishes the hosted flow, Medblocks redirects to your return_url with two ids on the query string: patient_id (your external id) and patient_flow_id. Read the patient from your server to confirm what got connected — patient_id gives you the patient’s cumulative connection state across every flow.

src/connect-status.ts
import { mb } from "./medblocks";

/**
 * Connection-status endpoint the client calls after the patient returns.
 * Reads `patient_id` (forwarded from the return URL) and looks up the
 * patient's cumulative connection state across every flow they've completed.
 */
export async function connectStatus(req: Request): Promise<Response> {
  const url = new URL(req.url);
  const patientId = url.searchParams.get("patient_id");
  if (!patientId) return new Response("missing patient_id", { status: 400 });

  const patient = await mb.patients.retrieve(patientId);
  const active = patient.connections.filter((c) => c.status === "active");

  return Response.json({ connected: active.length });
}
connected-page.tsx
import { parseReturnUrl } from "@medblocks/connect";

export function ConnectedPage() {
  const result = parseReturnUrl();
  if (!result) return <p>Open this page from the Connect flow.</p>;

  return <ConnectStatus patientId={result.patient_id} />;
}

async function ConnectStatus({ patientId }: { patientId: string }) {
  const response = await fetch(`/api/connect-status?patient_id=${patientId}`);
  const { connected } = await response.json();
  return (
    <p>
      {connected} active connection{connected === 1 ? "" : "s"}.
    </p>
  );
}

parseReturnUrl() is the browser-safe helper exported from @medblocks/connect. It reads the current URL (or any URLSearchParams you pass) and returns a typed discriminated union. picker, direct success, direct failure, or null. See Return URL.

Mount The Handlers

The handlers from steps 3 and 4 are standard (Request) => Response functions, so they drop into any runtime that speaks the Fetch API. Bind each to its path and start the server.

src/server.ts
import { startConnect } from "./start-connect";
import { connectStatus } from "./connect-status";
import { webhook } from "./webhook";

Bun.serve({
  port: 3000,
  routes: {
    "/api/start-connect": { POST: startConnect },
    "/api/connect-status": { GET: connectStatus },
    "/api/webhook": { POST: webhook },
  },
});

console.log("listening on http://localhost:3000");

The App Router has no separate “mount” step — each route.ts file is the route binding. Move the handlers from steps 3 and 4 into their own route files.

app/api/start-connect/route.ts
export { startConnect as POST } from "@/lib/start-connect";
app/api/connect-status/route.ts
export { connectStatus as GET } from "@/lib/connect-status";

node:http uses IncomingMessage / ServerResponse instead of Web Request / Response, so the handlers go through a small bridge helper that converts between the two shapes. The handlers themselves stay unchanged.

src/server.ts
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { startConnect } from "./start-connect";
import { connectStatus } from "./connect-status";

async function bridge(
  nreq: IncomingMessage,
  nres: ServerResponse,
  handler: (req: Request) => Promise<Response>,
) {
  const chunks: Buffer[] = [];
  for await (const chunk of nreq) chunks.push(chunk as Buffer);
  const webRes = await handler(
    new Request(`http://${nreq.headers.host}${nreq.url}`, {
      method: nreq.method,
      headers: nreq.headers as Record<string, string>,
      body: chunks.length ? Buffer.concat(chunks) : undefined,
    }),
  );
  nres.writeHead(webRes.status, Object.fromEntries(webRes.headers));
  nres.end(await webRes.text());
}

createServer((req, res) => {
  const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
  if (req.method === "POST" && url.pathname === "/api/start-connect") return bridge(req, res, startConnect);
  if (req.method === "GET" && url.pathname === "/api/connect-status") return bridge(req, res, connectStatus);
  res.writeHead(404).end("not found");
}).listen(3000, () => console.log("listening on http://localhost:3000"));

Hono uses Web Request / Response natively. c.req.raw is the underlying Request, so the handlers from steps 3 and 4 drop in unchanged.

src/index.ts
import { Hono } from "hono";
import { startConnect } from "./start-connect";
import { connectStatus } from "./connect-status";

const app = new Hono();

app.post("/api/start-connect", (c) => startConnect(c.req.raw));
app.get("/api/connect-status", (c) => connectStatus(c.req.raw));

export default app;

Handle Errors

Every non-2xx response is parsed into a typed subclass of MedblocksError. Discriminate with instanceof and log error.requestId.

server/routes/start-connect.ts
import {
  MedblocksConflictError,
  MedblocksInvalidRequestError,
} from "@medblocks/connect";

try {
  await mb.patientFlow.init({ /* ... */ });
} catch (err) {
  if (err instanceof MedblocksInvalidRequestError) {
    console.error("bad request", { param: err.param, requestId: err.requestId });
    return Response.json({ error: err.message }, { status: 400 });
  }
  if (err instanceof MedblocksConflictError) {
    return Response.json({ error: err.message }, { status: 409 });
  }
  throw err;
}

The full hierarchy and a table of what each subclass means is on the Errors page.

Next Steps