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.appRegister the URL:
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.
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.
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 want | How to trigger |
|---|---|
patient_flow.completed | Run a complete PatientFlow against an Epic sandbox (or another supported sandbox source). |
connection.token_refresh_failed | Hard to trigger on demand. Usually surfaces hours after a real refresh failure. Capture one in a tunnel session and replay it. |
records.sync.completed | Triggers automatically after a successful patient_flow.completed once the background pull settles. |
records.sync.failed | Run 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.
Related
- Quickstart. Start here.
- Signatures. What
constructEventis verifying. - Events Catalog. What payloads to expect.
