Error reporting
Your workspace has an Errors page: exceptions reported from your own application and from any hand-rolled server client you point at it. It’s the same place your support conversations live, so “three people complained about checkout” and “here’s the exception that broke checkout” sit next to each other.
Most people never touch the endpoint directly — the server libraries report for you. This page is what’s underneath.
Reporting is server-side only. It needs a workspace API token, and a browser cannot hold one.
The endpoint
Section titled “The endpoint”POST https://vroxy.ai/ingest/errorsContent-Type: application/jsonAuthorization: Bearer <your workspace API token>curl -X POST https://vroxy.ai/ingest/errors \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer YOUR_API_TOKEN' \ -d '{ "source": "api", "error_class": "Stripe::CardError", "message": "Your card was declined.", "backtrace": ["app/services/charge.rb:41:in `call'"], "request_path": "/checkout", "request_method": "POST", "environment": "production", "app_version": "2026.08.30", "context": { "order_id": "1234" } }'A successful call answers 202 Accepted:
{ "accepted": 1, "ids": ["a1b2c3d4"] }Authentication is a secret token, not the public key
Section titled “Authentication is a secret token, not the public key”Reports are authenticated with a tenant-owned API token carrying
the tenant:write scope — the same kind of credential the
REST API uses. Mint one under API tokens → New token. It is
shown once.
It used to be the public key from your embed snippet, so that browsers could report too. That was a mistake, and worth explaining rather than quietly changing.
The public key is visible in the page source of every site running the
widget, and source is supplied by the caller. Anyone who had ever
visited your site could therefore post a fabricated
source: "ruby" exception that sat in your Errors list looking exactly
like a real failure in your backend — same shape, same fields, nothing
to distinguish it. Rate limits and field caps bound the volume of
that, but they can’t make a forged report identifiable. And an error
list is precisely the kind of surface a person or a
code agent reads to decide what to go and fix.
A credential a browser can read is not a credential. So the endpoint now requires one a browser cannot hold, which means reporting belongs to your server.
The other protections still apply, because a real token can still send a bad payload:
- Per-workspace rate limiting — see below.
- Hard caps on every field, so no single report can be large.
- Retention pruning, so a flood can’t grow without bound.
- Nothing in the payload is trusted. Every value is coerced and truncated at write time; an invalid report is dropped rather than raising. A report is attributed to the token’s own workspace, never to one named in the body.
Batching
Section titled “Batching”Send one report as a plain object, or several under an errors key:
{ "source": "node", "errors": [ { "error_class": "TypeError", "message": "x is not a function" }, { "error_class": "RangeError", "message": "Invalid array length" } ]}At most 10 reports per request are accepted; anything beyond that
is ignored. A top-level source applies to every report in the batch,
and any report can override it with its own source.
Fields
Section titled “Fields”| Field | Notes |
|---|---|
error_class |
Truncated to 200 characters. Blank becomes UnknownError. |
message |
Truncated to 1,000 characters. |
backtrace |
Array of strings, or a newline-separated string. First 30 lines, each capped at 400 characters, 8,000 characters total. |
url |
600 characters. |
request_method |
10 characters. |
request_path |
500 characters. |
environment |
40 characters — e.g. production, staging, browser. |
app_version |
40 characters. Useful for “did the deploy cause this”. |
context |
Object, at most 30 keys. Keys capped at 60 characters; scalar values at 500. Nested objects and arrays are kept as given. |
occurred_at |
Any parseable timestamp. Unparseable, or more than an hour in the future, falls back to now. |
source |
One of ruby, js, api, mobile, node, python, php. Anything else is recorded as api. |
Responses
Section titled “Responses”| Status | Meaning |
|---|---|
202 Accepted |
Stored. Body has accepted (how many were saved) and ids. |
401 Unauthorized |
Missing, invalid, or revoked token; a user-owned token; or one without tenant:write. |
429 Too Many Requests |
Over the rate limit. A Retry-After: 60 header is set. |
503 Service Unavailable |
Error ingestion is turned off on the deployment. |
Note that accepted can be lower than the number you sent: a report
that fails validation is dropped silently rather than failing the whole
batch.
Limits and retention
Section titled “Limits and retention”- 120 reports per minute, per workspace. Past that you get a
429and aRetry-After: 60. This is a workspace-wide budget shared by your server, your browsers and every other source. - 20,000 stored occurrences per workspace. Once you’re over, oldest occurrences are pruned. Pruning is opportunistic — it runs on a fraction of incoming reports, not on every one — so the count hovers around the limit rather than sitting exactly on it.
- The SDKs throttle on their side too, at 60 reports per minute per process, so a crash loop in one app server doesn’t spend your whole workspace budget.
If a noisy exception is eating your quota, the fix is the SDK’s ignore
list — add the class name to error_ignore / errorIgnore and it’s
never sent.
Grouping by fingerprint
Section titled “Grouping by fingerprint”Occurrences are grouped so a thousand hits of the same bug are one row, not a thousand. The fingerprint is computed once, when the report is written, from:
- the error class, plus
- the top frame of the backtrace — same error, same place, same group.
When there’s no backtrace at all, which is common for JavaScript errors, the first 120 characters of the message are used instead.
Message text is deliberately not part of the fingerprint when a backtrace exists. Messages carry variable data — “order 4471 not found”, “order 4472 not found” — and including them would shatter one bug into thousands of groups.
Two consequences worth knowing:
- Two genuinely different bugs raising the same class from the same line will share a group.
- Changing the code moves the line numbers, so the same bug can start a new group after a deploy.
Reading them in your workspace
Section titled “Reading them in your workspace”The Errors page is at /w/<workspace-id>/<slug>/errors and needs the
errors.read capability — member and above have it by default.
The list shows one row per group: the error class, the latest message, the source, how many occurrences there are, and when it was first and last seen. It’s ordered by most recently seen, 25 groups per page, and you can filter to a single source. The header shows the total number of occurrences currently retained.
Opening a group shows its most recent occurrences (up to 100) with their full detail — backtrace, URL, request path, environment, app version, your context, and the visitor when one is known.
The widget does not report errors
Section titled “The widget does not report errors”It used to, and it no longer does — see
the authentication note
for why. vroxy("reportError", …) is still accepted so that bundles
cached on your pages don’t break, but it does nothing except warn in
the console.
If you want browser exceptions in your workspace, catch them on the page and forward them from your own server, where a token can live safely. That also lets you decide what’s worth reporting rather than shipping everything a browser throws.