Should the browser post JS errors directly, or through your backend?
Frontend key exposure is the famous reason to proxy client-side errors. The measured reason is simpler: Infrai's API returns no CORS headers, so the direct call fails.
Through your own backend. Two independent reasons, and the second one settles it on its own: an Infrai API key is an account-wide bearer credential that also spends money on inference, email and SMS, so it has no business in a JavaScript bundle — and https://api.infrai.cc returns no Access-Control-Allow-Origin header, so a browser fetch() from your page origin is blocked by the browser before the request is even useful.
The warnings you keep reading are about products where the direct call is the supported design. Sentry’s DSN is public on purpose: it’s ingest-only, scoped to one project, and can be rotated if someone abuses it. Infrai has no equivalent write-only browser credential, which is the honest boundary here — the pattern that works is a thin same-origin endpoint of your own that forwards to POST /v1/errors/capture.
Two questions people merge into one
“Is it safe?” and “does it work?” have different answers, and only one of them is a judgement call.
Safety first. A leaked ingest token on a client-side error product buys an attacker the ability to write junk into your issue stream. Annoying, cheap to fix, rate-limited by the vendor. A leaked Infrai key is a different asset class: the same string reads your error history, lists your storage buckets, sends email, and bills AI inference against your balance. The blast radius isn’t your error tracker, it’s your account.
Then the practical part. We checked the preflight from a page origin and there’s nothing to argue about:
OPTIONS /v1/errors/capture HTTP/2
Host: api.infrai.cc
Origin: https://shop.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization,content-type
HTTP/2 401
content-type: application/json
x-request-id: req_2bf9c02c66744c5fbd2dffa4
{"ok":false,"error":{"code":"UNAUTHORIZED","http_status":401,"message":"Invalid Project Key."}}
No access-control-allow-origin comes back on a successful authenticated response either. This is a server-to-server API, and browsers enforce that whatever you think about key hygiene.
Three architectures, honestly rated
| Approach | Key exposure | Works from a browser | Where it fits |
|---|---|---|---|
| Browser → Infrai directly | Account key in the bundle | No — CORS blocks it | Nowhere |
| Browser → your endpoint → Infrai | Key stays on the server | Yes | The pattern to build |
| Browser → a client-side product (Sentry, Datadog RUM) | Public DSN or client token by design | Yes | You want browser-native capture, session context, symbolicated frames |
Row three is a real choice, not a courtesy. If frame-level browser telemetry is your requirement, a product built around a public ingest key will beat a proxy you maintain — the minified stack trace guide covers what Infrai’s store does and doesn’t keep from a trace.
The browser half
Two global handlers and navigator.sendBeacon, which survives the page unloading — the moment most white-screen errors actually happen:
const ENDPOINT = "/api/client-error";
function report(kind, message, source) {
const payload = {
kind,
message: String(message ?? "unknown").slice(0, 500),
source: String(source ?? location.pathname).slice(0, 200),
at: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(payload)], { type: "application/json" });
if (!navigator.sendBeacon || !navigator.sendBeacon(ENDPOINT, blob)) {
fetch(ENDPOINT, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
keepalive: true,
}).catch(() => {});
}
}
window.addEventListener("error", (e) => report("error", e.message, `${e.filename}:${e.lineno}:${e.colno}`));
window.addEventListener("unhandledrejection", (e) => report("rejection", e.reason?.message ?? e.reason, location.pathname));
Note what isn’t in that payload: no key, no environment, no release. A value the client controls is a value an attacker controls, so the trustworthy fields get attached on the server side.
The server half
A Next.js route handler (App Router, Node 22 runtime). It rate-limits per IP, caps the body, builds the fingerprint itself, and only then forwards:
const apiKey = process.env.INFRAI_API_KEY;
const RELEASE = process.env.RELEASE ?? "web-2026.07.12";
const MAX_BYTES = 4096;
const buckets = new Map();
function allowed(ip, limit = 10, windowMs = 60_000) {
const now = Date.now();
const hits = (buckets.get(ip) ?? []).filter((t) => now - t < windowMs);
hits.push(now);
buckets.set(ip, hits);
return hits.length <= limit;
}
function fingerprintOf(message, source) {
const component = /at ([A-Za-z0-9_$]+)/.exec(message)?.[1] ?? "unknown";
const route = source.split("?")[0].replace(/\/\d+/g, "/:id");
return `web:${route}:${component}`;
}
export async function POST(request) {
if (!apiKey) return new Response("not configured", { status: 500 });
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
if (!allowed(ip)) return new Response(null, { status: 429 });
const raw = await request.text();
if (raw.length > MAX_BYTES) return new Response(null, { status: 413 });
let body;
try {
body = JSON.parse(raw);
} catch {
return new Response(null, { status: 400 });
}
const message = String(body.message ?? "").slice(0, 500);
const source = String(body.source ?? "/").slice(0, 200);
if (!message) return new Response(null, { status: 400 });
const res = await fetch("https://api.infrai.cc/v1/errors/capture", {
method: "POST",
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
body: JSON.stringify({
message: `browser: ${message} (${source})`,
exception: body.kind === "rejection" ? "UnhandledRejection" : "Error",
fingerprint: fingerprintOf(message, source),
environment: process.env.NODE_ENV === "production" ? "production" : "staging",
release: RELEASE,
}),
});
if (!res.ok) {
console.error(`capture failed: HTTP ${res.status} ${await res.text()}`);
return new Response(null, { status: 502 });
}
return new Response(null, { status: 204 });
}
The equivalent call, if you want to check the forward leg from a terminal before wiring any of it up:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/errors/capture" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"message": "browser: TypeError: undefined is not an object (evaluating \"user.profile.name\") at ProfileCard (src/components/ProfileCard.tsx:22:9)",
"exception": "TypeError",
"fingerprint": "web:ProfileCard:TypeError:profile-name",
"environment": "production",
"release": "web-2026.07.12"
}'
{
"ok": true,
"data": {
"event_id": "evt_err_qBAVe5jOlQPnFlmyOF0mn10O",
"fingerprint": "e0745bd0815b54970973b51b8ab19c5da58df02a8259b44d17b13d7f9e09f9dc",
"error_group_id": "errgrp_AV9EjHT2UAj5zIw46q1ZPF5R",
"is_new_group": true,
"dashboard_url": "https://infrai.cc/projects/proj_local/errors/evt_err_qBAVe5jOlQPnFlmyOF0mn10O"
}
}
And the read-back, which is how you confirm the proxy attached the fields the client wasn’t allowed to set:
curl -sS "https://api.infrai.cc/v1/errors/get/evt_err_qBAVe5jOlQPnFlmyOF0mn10O" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Why the rate limit isn’t optional
Your endpoint is public, unauthenticated and forwards to a metered route. Capture bills $0.00005 per event, verified 2026-07-26, so a bot hammering it at 200 requests per second would run about $0.01 a second — small, but nobody wants to discover it from an invoice. Cap it, sample the noisy groups, and check what you’re actually spending:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; caps=json.load(sys.stdin)['capabilities']; print([c['billing'] for c in caps if c['id']=='errors.capture'])"
Rates drift downward over time, so treat that figure as a reading rather than a fact. The structure is the durable part: capture is metered per event, every read route in the namespace is free, and the trial credit covers roughly 39,999 captures before you pay anything.
One more caveat before you ship it: a proxy adds a hop you now own. If your Node process is down, client errors vanish — buffering them in localStorage and retrying on next load is a twenty-line fix that most teams skip.