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:
- Install the
@medblocks/connectSDK. - Configure the client with your API key.
- Initialize a Connect PatientFlow from a server route and redirect the patient.
- Handle the patient’s return and read what got connected.
- Mount the handlers on any Fetch-compatible runtime.
- 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/connectnpm install @medblocks/connectyarn add @medblocks/connectbun add @medblocks/connectConfigure
Put your API key in an env var. The SDK reads its constructor argument, but process.env is the canonical place to source it.
MEDBLOCKS_API_KEY="mb_sk_live_..."Construct the client once per process. The same mb instance handles every call.
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.
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 });
}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.
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 });
}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.
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.
export { startConnect as POST } from "@/lib/start-connect";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.
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.
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.
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
- Build a custom EHR picker with Direct mode and
mb.connections.list. - Skip polling and subscribe to
patient_flow.completedinstead. - Read Advanced for timeouts, retries, AbortSignal, and the injectable
fetch.
