Quickstart

Register an endpoint, verify the signature, switch on event.type, respond 2xx. Five steps.

Build a working webhook receiver in five steps.

Install The SDK

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

Register An Endpoint

Pick the URL where Medblocks should POST deliveries. Register it from a server script. You get the signing secret back once, so persist it immediately.

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

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

const endpoint = await mb.webhooks.create({
  url: "https://api.example.com/api/webhook",
  events: ["patient_flow.completed", "records.sync.completed"],
  description: "Production webhook",
});

console.log("Endpoint id:", endpoint.id);
console.log("Save this secret:", endpoint.secret);

endpoint.secret is returned only here and from mb.webhooks.rotateSecret. There is no endpoint that returns it again. Store it in your secrets manager before doing anything else.

Set it on the server that will receive deliveries:

.env
MEDBLOCKS_WEBHOOK_SECRET="whsec_..."

Write The Receiver

The SDK’s Medblocks.webhooks.constructEvent reads the raw request body and the Medblocks-Signature header, verifies the HMAC, checks the timestamp window, and returns a typed WebhookEvent you can switch on.

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

function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`${name} is not configured`);
  return value;
}

const SECRET = requireEnv("MEDBLOCKS_WEBHOOK_SECRET");

export async function webhook(req: Request): Promise<Response> {
  const rawBody = await req.text();
  const signature = req.headers.get("Medblocks-Signature");

  try {
    const event = await Medblocks.webhooks.constructEvent(rawBody, signature, SECRET);

    switch (event.type) {
      case "patient_flow.completed":
        console.log("flow complete", event.data.object.id);
        break;
      case "connection.token_refresh_failed":
        console.warn("refresh failed", event.data.object.connection_id);
        break;
      case "records.sync.completed":
        console.log("sync done", event.data.object.patient_id, event.data.object.total);
        break;
      case "records.sync.failed":
        console.error("sync failed", event.data.object.error_message);
        break;
    }

    return new Response("ok", { status: 200 });
  } catch (err) {
    if (err instanceof MedblocksSignatureError) {
      console.warn("signature verification failed", err.reason);
      return new Response("bad signature", { status: 400 });
    }
    throw err;
  }
}

The switch narrows event.data.object to the right payload shape for each type. See Events Catalog for the full list of payload shapes.

Wire The Route

Wire the receiver to a route handler. Each framework reads the raw body slightly differently. Pick yours.

src/server.ts
import { webhook } from "./webhook";

Bun.serve({
  port: 3000,
  routes: {
    "/api/webhook": { POST: webhook },
  },
});
app/api/webhook/route.ts
import { Medblocks, MedblocksSignatureError } from "@medblocks/connect";

export async function POST(req: Request) {
  const rawBody = await req.text();
  const signature = req.headers.get("Medblocks-Signature");
  try {
    const event = await Medblocks.webhooks.constructEvent(
      rawBody,
      signature,
      process.env.MEDBLOCKS_WEBHOOK_SECRET!,
    );
    // handle event...
    return new Response("ok", { status: 200 });
  } catch (err) {
    if (err instanceof MedblocksSignatureError) {
      return new Response("bad signature", { status: 400 });
    }
    throw err;
  }
}

Calling req.text() is mandatory. Do not read await req.json(). That re-serializes and the signature breaks.

src/index.ts
import { Hono } from "hono";
import { Medblocks, MedblocksSignatureError } from "@medblocks/connect";

const app = new Hono();

app.post("/api/webhook", async (c) => {
  const rawBody = await c.req.text();
  const signature = c.req.header("Medblocks-Signature") ?? null;
  try {
    const event = await Medblocks.webhooks.constructEvent(
      rawBody,
      signature,
      process.env.MEDBLOCKS_WEBHOOK_SECRET!,
    );
    // handle event...
    return c.text("ok");
  } catch (err) {
    if (err instanceof MedblocksSignatureError) {
      return c.text("bad signature", 400);
    }
    throw err;
  }
});

export default app;

Be Idempotent

The same event.id can be delivered more than once. Medblocks retries on non-2xx responses and supports manual redelivery. Dedupe by event.id before applying side effects.

server/webhook-handler.ts
async function handle(event: WebhookEvent) {
  const seen = await store.get(`evt:${event.id}`);
  if (seen) return;

  await applySideEffect(event);
  await store.set(`evt:${event.id}`, "ok", { ttl: 90 * 24 * 60 * 60 });
}

90 days covers the longest plausible window in which Medblocks would still be retrying or you might manually redeliver.

What Could Go Wrong

You seeCauseFix
MedblocksSignatureError with reason: "missing_header"Endpoint URL is reachable but the request didn’t include the header.The endpoint URL receiving traffic isn’t ours. Check what’s calling.
MedblocksSignatureError with reason: "signature_mismatch"Wrong secret in env, or the framework re-serialized the body.Verify MEDBLOCKS_WEBHOOK_SECRET matches and you’re reading the raw body.
MedblocksSignatureError with reason: "timestamp_expired"Clock drift > 5 minutes.Fix NTP. As a temporary mitigation, raise tolerance (see Signatures).
Endpoint auto-disabledYour receiver returned non-2xx repeatedly.Fix the receiver, re-enable via mb.webhooks.update(id, { status: "active" }), then redeliver.