# Error tracking without an SDK: one HTTP request per error

By the ErrorHub team. Published 2026-10-11. Updated 2026-10-12.
Web version: https://errorhub.dev/guides/error-tracking-without-an-sdk

To track errors without an SDK, send one HTTPS POST from your framework's global error hook to `https://errorhub.dev/api/v1/events`, with the project key in the `X-ErrorHub-Key` header and the error as JSON. Send it fire and forget with a timeout of about 3 seconds, and never let the reporter throw. That is the whole integration, in any language that can make an HTTPS request.

Try it with curl first. A `202` reply means the event is stored:

```bash
curl -sS -X POST https://errorhub.dev/api/v1/events \
  -H "Content-Type: application/json" \
  -H "X-ErrorHub-Key: $ERRORHUB_KEY" \
  -d '{"source":"backend","level":"error","exceptionType":"InvalidOperationException","exceptionMessage":"Order 42 has no address"}'
```

```text reply
202 Accepted
{"id":"0199a3c2-6f1e-7b2a-9c41-5d8e2f0a7b13","status":"accepted"}
```

[ErrorHub](https://errorhub.dev/) is error tracking built on this one request. Coding agents can follow [install.md](https://errorhub.dev/install.md) instead, and the machine-readable contract is the [OpenAPI document](https://errorhub.dev/openapi/v1.json).

## The error tracking API contract: endpoint, headers and JSON fields

Every error goes to one endpoint: `POST https://errorhub.dev/api/v1/events`. The request carries two headers: `Content-Type: application/json`, and `X-ErrorHub-Key` with the project key. A client that can only set `Authorization` may send `Authorization: Bearer <key>` instead.

Each project has two kinds of key:

- **Server key** (prefix `eh_sk_`): a secret for code that runs on a server. Read it from the `ERRORHUB_KEY` environment variable, and never write it into source, a committed file, a log or a test.
- **Browser key** (prefix `eh_pk_`): for code that runs in a browser. It is public by design and may sit in client code.

Use the server key for `"source": "backend"` and the browser key for `"source": "frontend"`. A complete event looks like this:

```json
{
  "source": "backend",
  "level": "error",
  "environment": "production",
  "release": "1.4.2",
  "exceptionType": "InvalidOperationException",
  "exceptionMessage": "Order 42 has no address",
  "stackTrace": "...",
  "request": { "method": "POST", "path": "/orders/42", "requestId": "..." },
  "user": { "identifier": "123" },
  "tags": { "region": "eu" },
  "context": { "orderId": 42 }
}
```

| Field | What goes in it |
|---|---|
| `exceptionType` | The error's class or name, such as `InvalidOperationException` or `TypeError` |
| `exceptionMessage` | The error's message |
| `message` | A one-line summary, for a report that is not an exception |
| `stackTrace` | The stack trace as one string |
| `source` | `backend` or `frontend`. Defaults to the kind of key |
| `level` | `debug`, `info`, `warning`, `error` or `fatal`. Defaults to `error` |
| `environment` | The app's own setting, such as `production` |
| `release` | The version or commit that was running |
| `timestamp` | When the error happened, in ISO 8601. Defaults to the time the server received it |
| `request` | `method`, `path` (the path only) and `requestId` |
| `user` | `identifier`: an opaque id, never an email |
| `tags` | Short string pairs for filtering, up to 32 |
| `context` | Small structured diagnostics |

Required: `exceptionType` and `exceptionMessage`, or `message`. Everything else is optional. The server cuts long fields instead of refusing the event: the stack trace at 65,536 characters and each message at 8,192. The whole request body may be up to 256 KB.

## Reply codes and what to do with each

A stored event gets `202 Accepted` with `{"id": "...", "status": "accepted"}`. Refusals are problem documents (`application/problem+json`), and the ones below carry a `code`:

| Reply | Code | Meaning | What the reporter does |
|---|---|---|---|
| `400` | `invalid_event` | The event carries no error. Malformed JSON or an unknown `level` or `source` is a `400` too | Drop it |
| `401` | `missing_key`, `invalid_key`, `revoked_key` | The key is missing, wrong or revoked | Drop it |
| `402` | `subscription_required` | The account has no trial or subscription, so the event is not stored | Drop it |
| `403` | `project_archived` | The project is archived | Drop it |
| `413` | `payload_too_large` | The body is over 256 KB | Drop it |
| `429` | `rate_limited` | The key sent more than 600 requests in a minute. `Retry-After` says when there is room again | Drop it |
| `500` | `storage_failure` | ErrorHub could not store the event | Drop it |

So a reporter needs no branches: anything other than `202` is dropped. Read the code only where a person sees the result, such as a deploy check that stops on `401`.

A new account has no trial yet, so its events get `402` until the owner starts the 7-day trial in the dashboard. The same reply comes back after a trial or subscription ends.

## Timeouts, retries and never breaking the app

A reporter runs when the app is already in trouble. These rules keep it from making things worse:

- **Never throw from the reporter.** Wrap everything in a catch that swallows. A failed report is dropped.
- **Never block or slow a request.** Send fire and forget, with a timeout of about 3 seconds.
- **No retry loop, no queue** and no background thread that keeps the process alive. An error that matters happens again, and that report gets through.
- **Do nothing when the key is not set.** Local development and tests then need no key.
- **Never report the reporter's own failures.** Reporting an error while reporting an error loops.
- **Report, then let the error continue** exactly as before: same response, same exit code, same logging.

One exception: when the process or function is about to stop, await the POST, still with the timeout, before it does. That covers serverless handlers, a crashing process and a command-line script, because the runtime freezes or exits as soon as they end.

## Send errors from .NET (ASP.NET Core) without an SDK

In ASP.NET Core, an `IExceptionHandler` sees every unhandled exception of a request. This one reports and returns `false`, so the app's existing handling still writes the response.

```csharp
// ErrorHubReporter.cs: one small reporter, no new package.
using Microsoft.AspNetCore.Diagnostics;

public static class ErrorHubReporter
{
    private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(3) };
    private static readonly string? Key = Environment.GetEnvironmentVariable("ERRORHUB_KEY");

    public static void Report(Exception error, HttpContext? context = null)
    {
        if (string.IsNullOrEmpty(Key))
        {
            return;
        }

        try
        {
            var payload = JsonContent.Create(new
            {
                source = "backend",
                level = "error",
                environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"),
                exceptionType = error.GetType().FullName,
                exceptionMessage = error.Message,
                stackTrace = error.ToString(),
                request = context is null ? null : new
                {
                    method = context.Request.Method,
                    path = context.Request.Path.Value,
                    requestId = context.TraceIdentifier,
                },
            });

            _ = SendAsync(payload); // Fire and forget.
        }
        catch
        {
            // Reporting must never break the app.
        }
    }

    private static async Task SendAsync(HttpContent payload)
    {
        try
        {
            using var request = new HttpRequestMessage(HttpMethod.Post, "https://errorhub.dev/api/v1/events");
            request.Headers.Add("X-ErrorHub-Key", Key);
            request.Content = payload;
            using var response = await Http.SendAsync(request);
        }
        catch
        {
            // A failed report is dropped.
        }
    }
}

public sealed class ErrorHubExceptionHandler : IExceptionHandler
{
    public ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken cancellationToken)
    {
        ErrorHubReporter.Report(exception, context);
        return ValueTask.FromResult(false); // Not handled here: the existing handling runs as before.
    }
}
```

```csharp
// Program.cs
builder.Services.AddExceptionHandler<ErrorHubExceptionHandler>();

// Background work: exceptions of tasks that nobody awaited.
TaskScheduler.UnobservedTaskException += (_, e) => ErrorHubReporter.Report(e.Exception);
```

`IExceptionHandler` runs inside the exception handler middleware: keep the app's `app.UseExceptionHandler(...)`, or add `builder.Services.AddProblemDetails()` and `app.UseExceptionHandler()`. `error.ToString()` sends the type, the message and the full stack trace, inner exceptions included.

## Send errors from Node.js without an SDK

Node.js 18 and later have `fetch`, so the reporter needs no package. It returns a promise that never rejects, which the crash hooks below wait for.

```js
// errorhub.js: one small reporter, no new package.
const KEY = process.env.ERRORHUB_KEY;

export function report(error, req) {
  if (!KEY) return Promise.resolve();
  try {
    const err = error instanceof Error ? error : new Error(String(error));
    return fetch("https://errorhub.dev/api/v1/events", {
      method: "POST",
      headers: { "Content-Type": "application/json", "X-ErrorHub-Key": KEY },
      body: JSON.stringify({
        source: "backend",
        level: "error",
        environment: process.env.NODE_ENV,
        exceptionType: err.name,
        exceptionMessage: err.message,
        stackTrace: err.stack,
        request: req ? { method: req.method, path: req.path } : undefined,
      }),
      signal: AbortSignal.timeout(3000),
    }).then(
      () => undefined,
      () => undefined, // A failed report is dropped.
    );
  } catch {
    return Promise.resolve(); // Reporting must never break the app.
  }
}
```

```js
// Express: an error middleware after your routes and before your own error handler.
app.use((err, req, res, next) => {
  report(err, req);
  next(err);
});

// Errors outside a request. Node exits on both by default: report first, then exit as before.
process.on("uncaughtException", (error) => {
  report(error).finally(() => process.exit(1));
});
process.on("unhandledRejection", (reason) => {
  report(reason).finally(() => process.exit(1));
});
```

In Fastify, call `report` from an `onError` hook, and in Koa from `app.on("error", ...)`. Pass the path without the query string.

## Send errors from the browser: window.onerror and unhandledrejection

The browser has two global hooks: the `error` event (the errors `window.onerror` receives) for uncaught exceptions, and `unhandledrejection` for promises that nobody caught. Load the reporter before the rest of your code. It uses the browser key and `"source": "frontend"`.

```js
// errorhub-browser.js: load it before the rest of your code.
// The browser key (eh_pk_...) is public by design. Here it comes from the build (Vite shown).
const KEY = import.meta.env.VITE_ERRORHUB_BROWSER_KEY;

function report(error) {
  if (!KEY) return;
  try {
    const err = error instanceof Error ? error : new Error(String(error));
    fetch("https://errorhub.dev/api/v1/events", {
      method: "POST",
      headers: { "Content-Type": "application/json", "X-ErrorHub-Key": KEY },
      body: JSON.stringify({
        source: "frontend",
        level: "error",
        exceptionType: err.name,
        exceptionMessage: err.message,
        stackTrace: err.stack,
        request: { path: location.pathname },
      }),
      signal: AbortSignal.timeout(3000),
    }).catch(() => {});
  } catch {
    // Reporting must never break the page.
  }
}

window.addEventListener("error", (event) => report(event.error ?? event.message));
window.addEventListener("unhandledrejection", (event) => report(event.reason));
```

Anyone can read the browser key from your page, and that is expected: it can only add events to its project, and each key is limited to 600 requests a minute. If a key is abused, rotate it in the dashboard. A React error boundary can call `report` from `componentDidCatch` too.

Errors thrown by a script from another origin arrive as `Script error.` without a stack, unless the script tag has `crossorigin` and its server sends CORS headers. ErrorHub stores browser stacks as they are sent and does not apply source maps.

## Send errors from Python without an SDK

The standard library is enough. The reporter sends from a daemon thread, which never keeps the process alive, and sends at once when it is told to wait.

```python
# errorhub.py: one small reporter, standard library only.
import json
import os
import sys
import threading
import traceback
import urllib.request

KEY = os.environ.get("ERRORHUB_KEY")


def report(error, method=None, path=None, wait=False):
    if not KEY:
        return
    try:
        event = {
            "source": "backend",
            "level": "error",
            "environment": os.environ.get("APP_ENV"),
            "exceptionType": type(error).__name__,
            "exceptionMessage": str(error),
            "stackTrace": "".join(traceback.format_exception(type(error), error, error.__traceback__)),
        }
        if path:
            event["request"] = {"method": method, "path": path}
        body = json.dumps(event).encode()
        if wait:
            _send(body)  # The process is about to end: send now.
        else:
            threading.Thread(target=_send, args=(body,), daemon=True).start()
    except Exception:
        pass  # Reporting must never break the app.


def _send(body):
    request = urllib.request.Request(
        "https://errorhub.dev/api/v1/events",
        data=body,
        method="POST",
        headers={"Content-Type": "application/json", "X-ErrorHub-Key": KEY},
    )
    try:
        urllib.request.urlopen(request, timeout=3).close()
    except Exception:
        pass  # A failed report is dropped.
```

```python
# errorhub.py, continued. Django: add "yourapp.errorhub.ErrorHubMiddleware" to MIDDLEWARE.
class ErrorHubMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        return self.get_response(request)

    def process_exception(self, request, exception):
        report(exception, request.method, request.path)
        return None  # Django handles the error exactly as before.


# Scripts and workers: report, then let Python print the traceback and exit as usual.
_previous_excepthook = sys.excepthook


def _excepthook(kind, error, tb):
    report(error, wait=True)
    _previous_excepthook(kind, error, tb)


sys.excepthook = _excepthook
```

In Flask, connect a function that calls `report` to the `got_request_exception` signal. In FastAPI, add an HTTP middleware that wraps `call_next`, reports and raises the error again.

## Send errors from PHP and Laravel without an SDK

PHP's curl extension is enough. PHP sends synchronously, so the timeout is what bounds the cost: at most 3 seconds, when ErrorHub is slow to answer.

```php
<?php
// app/Support/ErrorHub.php: one small reporter, curl only.
namespace App\Support;

use Throwable;

final class ErrorHub
{
    public static function report(Throwable $error, ?string $method = null, ?string $path = null): void
    {
        $key = config('services.errorhub.key');
        if (!$key) {
            return;
        }

        try {
            $event = [
                'source' => 'backend',
                'level' => 'error',
                'environment' => config('app.env'),
                'exceptionType' => get_class($error),
                'exceptionMessage' => $error->getMessage(),
                'stackTrace' => (string) $error,
            ];
            if ($path !== null) {
                $event['request'] = ['method' => $method, 'path' => $path];
            }

            $request = curl_init('https://errorhub.dev/api/v1/events');
            curl_setopt_array($request, [
                CURLOPT_POST => true,
                CURLOPT_CONNECTTIMEOUT => 2,
                CURLOPT_TIMEOUT => 3,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-ErrorHub-Key: ' . $key],
                CURLOPT_POSTFIELDS => json_encode($event, JSON_INVALID_UTF8_SUBSTITUTE),
            ]);
            curl_exec($request);
        } catch (Throwable) {
            // Reporting must never break the app.
        }
    }
}
```

```php
// bootstrap/app.php (Laravel 11 and later)
->withExceptions(function (Exceptions $exceptions) {
    $exceptions->report(function (Throwable $e) {
        $request = app()->runningInConsole() ? null : request();
        \App\Support\ErrorHub::report($e, $request?->method(), $request?->getPathInfo());
    });
})
```

Add `'errorhub' => ['key' => env('ERRORHUB_KEY')]` to `config/services.php`. In Laravel 10 and earlier, make the same call from `$this->reportable(...)` in `app/Exceptions/Handler.php`. Without a framework, read the key with `getenv('ERRORHUB_KEY')` and call the reporter from your `set_exception_handler` callback, before the handling you already have.

## Send errors from Go without an SDK

A recover middleware sees a panic in any handler. This one reports and panics again, so `net/http` handles it as before.

```go
// errorhub.go: one small reporter, standard library only.
package errorhub

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"runtime/debug"
	"time"
)

var client = &http.Client{Timeout: 3 * time.Second}

func Report(err error, r *http.Request) {
	key := os.Getenv("ERRORHUB_KEY")
	if key == "" {
		return
	}
	event := map[string]any{
		"source":           "backend",
		"level":            "error",
		"environment":      os.Getenv("APP_ENV"),
		"exceptionType":    fmt.Sprintf("%T", err),
		"exceptionMessage": err.Error(),
		"stackTrace":       string(debug.Stack()),
	}
	if r != nil {
		event["request"] = map[string]string{"method": r.Method, "path": r.URL.Path}
	}
	body, jsonErr := json.Marshal(event)
	if jsonErr != nil {
		return
	}
	go func() { // Fire and forget.
		request, reqErr := http.NewRequest(http.MethodPost, "https://errorhub.dev/api/v1/events", bytes.NewReader(body))
		if reqErr != nil {
			return
		}
		request.Header.Set("Content-Type", "application/json")
		request.Header.Set("X-ErrorHub-Key", key)
		if response, doErr := client.Do(request); doErr == nil {
			response.Body.Close()
		}
	}()
}

// Recover reports a panic in a handler, then panics again so net/http handles it as before.
func Recover(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if v := recover(); v != nil {
				if v != http.ErrAbortHandler {
					err, ok := v.(error)
					if !ok {
						err = fmt.Errorf("%v", v)
					}
					Report(err, r)
				}
				panic(v)
			}
		}()
		next.ServeHTTP(w, r)
	})
}
```

Wrap the router once: `http.ListenAndServe(":8080", errorhub.Recover(mux))`. Errors that a handler returns instead of panicking can go to `errorhub.Report(err, r)` from the place that already logs them.

## Where to hook in for other stacks

| Stack | Hook |
|---|---|
| Next.js | `onRequestError` in `instrumentation.ts` |
| Ruby on Rails | `Rails.error.subscribe`, or `rescue_from` in `ApplicationController` |
| Java and Spring | A `@ControllerAdvice` with an `@ExceptionHandler(Exception.class)` |
| Serverless functions | Wrap the handler, and await the POST with the timeout before it returns |

Where a hook replaces the default handling, report first, then raise the error again or return what the app returned before. A coding agent such as Claude Code, Cursor or Codex can do all of this from [install.md](https://errorhub.dev/install.md), and each project in the dashboard has an install prompt with its endpoint and keys.

## What not to send in an error report

An error report should help you fix a bug, not copy your users' data. Never send:

- passwords, cookies, `Authorization` headers or tokens;
- request or response bodies;
- query strings: send the path only;
- personal data in `tags` or `context`. `user.identifier` is an opaque id, never an email.

Keep the event small: a trimmed stack trace and no large objects.

ErrorHub adds a safety net on the server. The event format has no field for bodies, headers or cookies, paths are cut at the first `?` or `#`, and passwords, keys, tokens and connection strings become `[Redacted]`. Events are stored in Germany, in the European Union, and deleted after 30 days. The net is there for mistakes, not as a reason to send secrets.

## Grouping: how events become issues

Events with the same fingerprint in one project are one issue. The fingerprint follows fixed rules, with no AI model:

- **Source**: backend and frontend errors never merge.
- **Exception type**: the class or name you send in `exceptionType`.
- **Normalized message**: numbers, ids, GUIDs, timestamps, URLs, email addresses, file paths and quoted text are replaced first, so "Order 42 has no address" and "Order 43 has no address" are one issue.
- **Application stack frames**: up to five, without line numbers, absolute paths, bundle hashes or framework frames, so a deploy does not split an issue.

Stack-frame grouping is tuned for .NET and JavaScript traces today. For other languages, events group on the source, the type and the normalized message. Send a stable class name as `exceptionType` and keep changing values out of it.

When a resolved issue receives a new event, it reopens and alerts as a regression. Push alerts go out for new errors and regressions; warnings and repeats of a known error stay quiet.

## When an SDK is the better choice

ErrorHub tracks errors and nothing else. A monitoring tool with its own SDK is the better choice when you need:

- **Session replay** of what a user did before the error.
- **Tracing and performance monitoring**: distributed traces, slow requests, profiling.
- **Breadcrumbs**: an automatic trail of logs, clicks and requests before the error. With one HTTP request, you send only what you put in `context`.
- **Source maps** applied to minified browser stacks.
- **Logs, uptime checks or status pages** from the same vendor.

If you want to know what broke, where, and when it comes back, in every app you run, one HTTP request is enough.

Comparing tools? [ErrorHub vs Sentry](https://errorhub.dev/vs/sentry) and [Sentry alternatives for small teams](https://errorhub.dev/alternatives) set them side by side on price, hosting, integration and scope.

## FAQ: error tracking without an SDK

### Can I track errors without installing an SDK?

Yes. Error tracking needs one HTTPS POST per error, not a library. Send the error as JSON to `https://errorhub.dev/api/v1/events` with the project key in the `X-ErrorHub-Key` header, from the framework's global error hook. Any language that can make an HTTPS request can do it, with the HTTP client it already has.

### Is it safe to put an error tracking key in browser code?

Yes, if it is a browser key. ErrorHub browser keys (prefix `eh_pk_`) are public by design: they can only add events to their project and cannot read anything. The server checks and trims every event and limits each key to 600 requests a minute. Keep the server key (prefix `eh_sk_`) out of client code.

### Should an error reporter retry failed requests?

No. Send each report once, fire and forget, with a timeout of about 3 seconds, and drop it on any reply other than `202`. A retry loop or a queue can slow the app or fill its memory exactly when something is going wrong. An error that matters happens again, and that report gets through.

### How does ErrorHub group errors into issues?

ErrorHub fingerprints each event from its source, exception type, normalized message and up to five application stack frames. Numbers, ids, timestamps and URLs in the message are replaced first, so "Order 42 has no address" and "Order 43 has no address" become one issue. Stack-frame grouping is tuned for .NET and JavaScript traces today.

### How much does ErrorHub cost?

One plan: €9 a month excluding VAT, with unlimited error events, projects and users and 30 days of event history. It starts with a 7-day free trial. A card is needed to start it, and nothing is charged if you cancel before the trial ends. There is no free plan and no overage charge.

## Start sending errors

Create an account at [errorhub.dev/signup](https://errorhub.dev/signup), start the 7-day free trial, create a project and send the curl request above with its key. [Pricing](https://errorhub.dev/#pricing): one plan, €9 a month excl. VAT. Questions go to [office@errorhub.dev](mailto:office@errorhub.dev).
