Skip to content

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.

POST https://vroxy.ai/ingest/errors
Content-Type: application/json
Authorization: Bearer <your workspace API token>
Terminal window
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.

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.

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.
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.

  • 120 reports per minute, per workspace. Past that you get a 429 and a Retry-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.

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.

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.

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.