Local Development

Tunneling, replaying real events, and unit-testing your receiver without leaving your laptop.

Webhooks need a public HTTPS URL. Medblocks can’t reach localhost:3000. There are three workable patterns for developing receivers locally.

Tunnel A Public URL To Your Laptop

cloudflared and ngrok both work. Pick one, get a HTTPS URL, register it as a webhook endpoint pointing at your local port.

# install once: brew install cloudflared
cloudflared tunnel --url http://localhost:3000
# → https://<random>.trycloudflare.com
# install once: brew install ngrok
ngrok http 3000
# → https://<random>.ngrok.app

Register the URL:

server/scripts/register-dev-endpoint.ts
import { Medblocks } from "@medblocks/connect";

const mb = new Medblocks(process.env.MEDBLOCKS_API_KEY!);

const endpoint = await mb.webhooks.create({
  url: "https://<your-tunnel>.trycloudflare.com/api/webhook",
  events: ["*"],
  description: "Dev tunnel",
});

console.log("MEDBLOCKS_WEBHOOK_SECRET=" + endpoint.secret);

Drop the secret in your .env.local, start your receiver, and trigger a real PatientFlow against test data.

Delete the endpoint when you’re done. Leaving stale tunnels registered counts against your endpoint limit and noisily 502s.

await mb.webhooks.delete("wh_...");

Replay A Captured Delivery

If you have a real delivery you captured during a prior run, you can replay it against your local receiver without going through Medblocks. The signature is reproducible from the raw body and the timestamp.

scripts/replay-locally.ts
const captured = {
  rawBody: '{"id":"evt_...","object":"event","type":"patient_flow.completed","..."}',
  signature: "t=1714060800,v1=8d1b3c...",
};

const response = await fetch("http://localhost:3000/api/webhook", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Medblocks-Signature": captured.signature,
  },
  body: captured.rawBody,
});

console.log(response.status, await response.text());

The signature only verifies if your receiver’s MEDBLOCKS_WEBHOOK_SECRET matches the secret that signed the captured delivery. Typically your dev tunnel’s secret. If you rotated since capture, the signature won’t match.

You can also fetch a recent delivery directly via the SDK to capture one:

const page = await mb.webhooks.listEvents("wh_...", { limit: 5 });
for (const evt of page.data) {
  console.log(evt.id, evt.last_status_code, evt.last_response_body);
}

The body itself isn’t returned by the API. Capture it at delivery time from your tunnel’s logs (cloudflared and ngrok both have inspector UIs that show the raw request).

Unit-Test Without A Tunnel

For unit tests, sign a payload yourself with the same algorithm constructEvent verifies. This is the same approach the SDK’s own integration tests use.

test/webhook.test.ts
import { describe, expect, test } from "bun:test";
import { handle } from "../src/webhook";

const SECRET = "whsec_test_only";

async function sign(body: string, t: number): Promise<string> {
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(SECRET),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const sig = await crypto.subtle.sign(
    "HMAC",
    key,
    new TextEncoder().encode(`${t}.${body}`),
  );
  return Array.from(new Uint8Array(sig))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

describe("webhook handler", () => {
  test("accepts a valid patient_flow.completed delivery", async () => {
    const t = Math.floor(Date.now() / 1000);
    const body = JSON.stringify({
      id: "evt_test",
      object: "event",
      type: "patient_flow.completed",
      api_version: "2026-04-25",
      created_at: new Date(t * 1000).toISOString(),
      data: {
        object: {
          id: "pf_test",
          resource_type: "patient_flow",
          status: "complete",
          patient_id: "user_42",
          /* ...rest of PatientFlow shape */
        },
      },
    });
    const v1 = await sign(body, t);

    const req = new Request("http://test/api/webhook", {
      method: "POST",
      headers: { "Medblocks-Signature": `t=${t},v1=${v1}` },
      body,
    });

    const res = await handle(req);
    expect(res.status).toBe(200);
  });
});

Set MEDBLOCKS_WEBHOOK_SECRET=whsec_test_only in your test env. The constructEvent call inside handle will verify against that fake secret.

For a complete fixture covering all four event types, see sdk/connect/test/server/constructEvent.test.ts in the repo.

Triggering Real Events On Dev

To exercise the full delivery loop end-to-end against your tunnel:

Event you wantHow to trigger
patient_flow.completedRun a complete PatientFlow against an Epic sandbox (or another supported sandbox source).
connection.token_refresh_failedHard to trigger on demand. Usually surfaces hours after a real refresh failure. Capture one in a tunnel session and replay it.
records.sync.completedTriggers automatically after a successful patient_flow.completed once the background pull settles.
records.sync.failedRun a flow against a source you’ve mis-configured (wrong scope, expired client secret) so the pull fails.

For routine local work, the realistic loop is: tunnel + a sandbox patient_flow → real patient_flow.completed event hits your receiver in seconds.