How to Build a Mock API and Webhook Tester Without Running a Local Tunnel
This app has three launch paths:
- build a mock API from routes in the UI
- import an API file and turn it into routes
- run a webhook receiver or raw server file directly
The outer app handles editing and imports. BrowserPod runs the server inside /project and exposes it through the portal URL.
1. Normalize uploaded files into one route format
Start by converting the formats people already have into one internal routes[] shape. In this demo, parseUploadedFile() accepts OpenAPI 3, Swagger 2, Postman, HAR, Insomnia, routes.json, and raw .js server files:
export function parseUploadedFile(filename, content) {
const lower = filename.toLowerCase();
if (lower.endsWith(".js")) {
return { type: "raw", formatLabel: "Server file" };
}
const parsed = yaml.load(content);
const format = detectFormat(parsed);
let routes;
switch (format) {
case "openapi3":
routes = openapiToRoutes(parsed);
break;
case "swagger2":
routes = swagger2ToRoutes(parsed);
break;
case "postman":
routes = postmanToRoutes(parsed);
break;
case "har":
routes = harToRoutes(parsed);
break;
case "insomnia":
routes = insomniaToRoutes(parsed);
break;
case "routes":
routes = parsed;
break;
}
return {
type: format === "routes" ? "routes" : "openapi",
routes,
formatLabel,
};
}
Once everything becomes routes[], the rest of the app only has to deal with one runtime shape.
2. Boot BrowserPod and wait for the portal URL
When the user clicks RUN, boot a pod, create a terminal, and register pod.onPortal(...) before starting the server:
const pod = await BrowserPod.boot({ apiKey: import.meta.env.VITE_BP_APIKEY });
await new Promise((r) => setTimeout(r, 500));
const terminal = await pod.createDefaultTerminal(consoleEl);
pod.onPortal(({ url }) => {
const routeCount = getRoutes().length;
serverState = { url, routeCount, isWebhook };
setStatus(isWebhook ? "Webhook Live" : "API Running", "running");
portalUrlEl.textContent = url;
openBtn.href = url;
});
await pod.createDirectory("/project");
The portal callback is the handoff point between the server inside the pod and the UI outside it.
3. Write the runtime into /project
The launch step branches by mode:
- webhook mode copies the webhook runtime
- raw mode writes the uploaded server file
- API mode copies the mock API runtime and writes
routes.json
if (isWebhook) {
await copyFileTo(pod, "webhook/main.js", "/project/main.js");
await copyFileTo(pod, "webhook/package.json", "/project/package.json");
await pod.run("npm", ["install"], { echo: true, terminal, cwd: "/project" });
pod.run("node", ["main.js"], { echo: true, terminal, cwd: "/project" });
} else if (isRaw) {
await writeTextFile(pod, "/project/raw-server.js", uploadedRawContent);
await copyFile(pod, "project/package.json");
pod.run("node", ["raw-server.js"], { echo: true, terminal, cwd: "/project" });
} else {
await copyFile(pod, "project/main.js");
await copyFile(pod, "project/package.json");
await writeTextFile(pod, "/project/routes.json", JSON.stringify(getRoutes()));
await pod.run("npm", ["install"], { echo: true, terminal, cwd: "/project" });
pod.run("node", ["main.js"], { echo: true, terminal, cwd: "/project" });
}
Two details matter here:
npm installis awaited because the server depends on itnode main.jsis not awaited because it needs to stay running
4. Serve the mock API from routes.json
The mock API runtime is a small Express server. It reads /project/routes.json, registers one Express handler per route, and returns either JSON, text, or binary content:
var routes = [];
try {
routes = JSON.parse(fs.readFileSync("/project/routes.json", "utf-8"));
} catch (e) {
console.error("Could not read routes.json: " + e.message);
}
routes.forEach(function (r) {
var method = r.method.toLowerCase();
if (typeof app[method] !== "function") return;
app[method](r.path, function (req, res) {
res.status(r.status);
var ct = r.contentType || "application/json";
res.set("Content-Type", ct);
if (typeof r.body === "string" && r.body.startsWith("data:")) {
var comma = r.body.indexOf(",");
var b64 = comma !== -1 ? r.body.slice(comma + 1) : r.body;
res.send(Buffer.from(b64, "base64"));
} else if (ct === "application/json") {
res.json(r.body);
} else {
res.send(typeof r.body === "string" ? r.body : JSON.stringify(r.body));
}
});
});
This demo also adds permissive CORS headers so browser frontends can call the mock API without extra setup.
5. Use a catch-all Express app for webhooks
Webhook mode uses a different runtime. It parses the request body once, stores a short in-memory history, and treats every request except / and /__webhooks as an incoming webhook:
app.use(function (req, res, next) {
var data = "";
req.on("data", function (chunk) {
data += chunk;
});
req.on("end", function () {
req.rawBody = data;
if (data.trim()) {
try {
req.parsedBody = JSON.parse(data);
} catch (_) {
req.parsedBody = data;
}
} else {
req.parsedBody = null;
}
next();
});
});
app.use(function (req, res) {
var entry = {
id: webhooks.length + 1,
method: req.method,
path: req.path,
headers: headers,
body: req.parsedBody,
receivedAt: new Date().toISOString(),
};
webhooks.unshift(entry);
res.end(JSON.stringify({ received: true, id: entry.id }));
});
The embedded dashboard polls GET /__webhooks to show the captured requests. The public URL can receive requests on any path.
6. Allow raw server uploads
If the uploaded file is a .js server, skip route extraction and run it directly:
await writeTextFile(pod, "/project/raw-server.js", uploadedRawContent);
await copyFile(pod, "project/package.json");
pod.run("node", ["raw-server.js"], { echo: true, terminal, cwd: "/project" });
For this path to work cleanly, the uploaded file needs to behave like a server and listen on port 3000.
7. Keep the runtime constraints explicit
This demo is session-bound:
- closing or refreshing the tab stops the server
- one tab hosts one running server
- the mock API and webhook runtimes expect port
3000
BrowserPod setup still applies:
BrowserPod.boot(...)needs an API key- cross-origin isolation is required
- production needs HTTPS
- pure JavaScript dependencies are the safest default
- native binaries need a WASM-compatible alternative
- each boot and runtime activity consumes tokens