Skip to the text
ErrorHub
Why ErrorHub How it works Features Pricing FAQ
Sign in Start free trial
Menu
Why ErrorHub How it works Features Pricing FAQ Sign in
ErrorHub / Error tracking without an SDK

Guide

Error tracking without an SDK: one HTTP request per error

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.

By the ErrorHub team · Published 11 October 2026 · Updated 12 October 2026 · Read as Markdown

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

Shell
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"}'
Reply
202 Accepted
{"id":"0199a3c2-6f1e-7b2a-9c41-5d8e2f0a7b13","status":"accepted"}

ErrorHub is error tracking built on this one request. Coding agents can follow install.md instead, and the machine-readable contract is the OpenAPI document.

On this page

  1. The error tracking API contract: endpoint, headers and JSON fields
  2. Reply codes and what to do with each
  3. Timeouts, retries and never breaking the app
  4. Send errors from .NET (ASP.NET Core) without an SDK
  5. Send errors from Node.js without an SDK
  6. Send errors from the browser: window.onerror and unhandledrejection
  7. Send errors from Python without an SDK
  8. Send errors from PHP and Laravel without an SDK
  9. Send errors from Go without an SDK
  10. Where to hook in for other stacks
  11. What not to send in an error report
  12. Grouping: how events become issues
  13. When an SDK is the better choice
  14. FAQ: error tracking without an SDK
  15. Start sending errors

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 }
}
FieldWhat goes in it
exceptionTypeThe error's class or name, such as InvalidOperationException or TypeError
exceptionMessageThe error's message
messageA one-line summary, for a report that is not an exception
stackTraceThe stack trace as one string
sourcebackend or frontend. Defaults to the kind of key
leveldebug, info, warning, error or fatal. Defaults to error
environmentThe app's own setting, such as production
releaseThe version or commit that was running
timestampWhen the error happened, in ISO 8601. Defaults to the time the server received it
requestmethod, path (the path only) and requestId
useridentifier: an opaque id, never an email
tagsShort string pairs for filtering, up to 32
contextSmall 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:

ReplyCodeMeaningWhat the reporter does
400invalid_eventThe event carries no error. Malformed JSON or an unknown level or source is a 400 tooDrop it
401missing_key, invalid_key, revoked_keyThe key is missing, wrong or revokedDrop it
402subscription_requiredThe account has no trial or subscription, so the event is not storedDrop it
403project_archivedThe project is archivedDrop it
413payload_too_largeThe body is over 256 KBDrop it
429rate_limitedThe key sent more than 600 requests in a minute. Retry-After says when there is room againDrop it
500storage_failureErrorHub could not store the eventDrop 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.

C#
// 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.
    }
}
C#
// 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.

JavaScript
// 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.
  }
}
JavaScript
// 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".

JavaScript
// 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

StackHook
Next.jsonRequestError in instrumentation.ts
Ruby on RailsRails.error.subscribe, or rescue_from in ApplicationController
Java and SpringA @ControllerAdvice with an @ExceptionHandler(Exception.class)
Serverless functionsWrap 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, 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 and Sentry alternatives for small teams 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, start the 7-day free trial, create a project and send the curl request above with its key. Pricing: one plan, €9 a month excl. VAT. Questions go to office@errorhub.dev.

Start free trial See pricing

ErrorHub

Error tracking for every app you run. Nothing else.

Product

Why ErrorHub How it works Features Pricing FAQ ErrorHub vs Sentry Sentry alternatives

Developers

Error tracking without an SDK OpenAPI document Pricing as Markdown llms.txt

Account

Sign in Start free trial office@errorhub.dev

Legal

Privacy Policy Terms of Service Data Processing Agreement

© 2026 Autrady EOOD, Varna, Bulgaria. UIC 206828616. This page sets no cookies and loads no third-party scripts.

Page updated 11 October 2026