# BugTape from a server, worker, or cron job

No SDK. One `POST /v1/ingest` per failure, with `platform: "server"`.

Works from anything that can send JSON: a Node service, a Cloudflare Worker, a Python
job, a Go binary, a bash `curl` in a CI step.

## curl

```bash
curl -X POST https://app.bugtape.ai/v1/ingest \
  -H 'Content-Type: application/json' \
  -H "X-BugTape-Key: $BUGTAPE_KEY" \
  -d '{
    "title": "TimeoutError: payment provider did not answer in 8s",
    "description": "POST /charge to acme-pay timed out after 3 attempts.",
    "severity": "high",
    "platform": "server",
    "release": "2026.09.03",
    "environment": "production",
    "userId": "cus_123",
    "url": "worker://billing/charge",
    "userAgent": "billing-worker/2026.09.03",
    "sessionId": "job_8821",
    "metadata": { "queue": "charges", "attempt": 3, "region": "apac" },
    "events": [
      {
        "type": "error",
        "data": {
          "message": "TimeoutError: payment provider did not answer in 8s",
          "stack": "TimeoutError: payment provider did not answer in 8s\n    at charge (billing/charge.ts:82:11)"
        }
      }
    ]
  }'
```

Response, `201 Created`:

```json
{ "id": "9d1f…", "fingerprint": "4a7c02e11b93d0f8", "deduplicated": false, "regression": false }
```

### The four fields that decide grouping

| Field | Rule |
|-------|------|
| `title` | Must start `<Token>Error: `. The fingerprint reads the error kind off a `^(\w+Error):` prefix. `TimeoutError: …`, `NSURLError: …`, `ValidationError: …`. A title with no such prefix is filed under a generic `Error` kind, and unrelated failures merge. |
| `url` | A stable pseudo-URL for the code path: `worker://billing/charge`, `cron://nightly-invoices`, `job://import/csv`. It is the third fingerprint input. Send the same value for the same code path; do **not** interpolate ids into it. |
| `release` | Your deploy version or commit sha. Drives regression detection and "which release is this on". |
| `environment` | `production`, `staging`, … Keeps staging noise out of production triage. |

`userId` is what makes **Affected users** work — pass the customer/account id when the
failure belongs to one.

Always include one `type: "error"` event with `message` and `stack`. Without it the
classifier can read the report as a diagnostic probe instead of a runtime issue.

## Node (fetch)

```js
const body = { title: `${err.name}: ${err.message}`, severity: 'high', platform: 'server',
  release: process.env.RELEASE ?? 'unknown', environment: process.env.NODE_ENV ?? 'production',
  userId: customerId, url: 'worker://billing/charge',
  events: [{ type: 'error', data: { message: `${err.name}: ${err.message}`, stack: err.stack } }] };
await fetch('https://app.bugtape.ai/v1/ingest', { method: 'POST', body: JSON.stringify(body),
  headers: { 'Content-Type': 'application/json', 'X-BugTape-Key': process.env.BUGTAPE_KEY } });
```

Node's built-in `Error` has `name === 'Error'`, which has no `\w+` before `Error` and so
does not match the prefix regex. Use a subclass (`class TimeoutError extends Error`) or
build the token yourself.

## Python (requests)

```python
import os, requests, traceback
title = f"{type(exc).__name__}: {exc}"
requests.post("https://app.bugtape.ai/v1/ingest", timeout=5,
    headers={"X-BugTape-Key": os.environ["BUGTAPE_KEY"]},
    json={"title": title, "severity": "high", "platform": "server", "userId": customer_id,
          "release": os.environ.get("RELEASE", "unknown"), "environment": "production", "url": "worker://billing/charge",
          "events": [{"type": "error", "data": {"message": title, "stack": traceback.format_exc()}}]})
```

Most Python exception classes already end in `Error` (`ValueError`, `KeyError`,
`TimeoutError`). Ones that do not — `Exception`, `StopIteration`, custom classes — need
the suffix added: `f"{type(exc).__name__}Error: {exc}"`.

## Semantics

**One POST per failure.** Do not batch, do not aggregate counts yourself. BugTape counts
occurrences; sending "this happened 40 times" as one report loses the timeline, the
affected users and the per-occurrence context.

**Dedup is server-side, by fingerprint.** `SHA256(errorType + ":" + normalizedMessage + ":" + urlPattern)`,
truncated to 16 hex chars. Ids, UUIDs, timestamps, hex addresses, IPs, ports and long
numbers are normalised out of the message first, so `charge 91821 failed` and
`charge 91822 failed` are the same issue.

The response tells you what happened:

| Response | Meaning |
|----------|---------|
| `deduplicated: false, regression: false` | New issue created. |
| `deduplicated: true` | Grouped onto an existing open issue; its occurrence count went up. |
| `regression: true` | The matching issue was already resolved and has been reopened. |

**On the v1 web/server path, platform is not part of the fingerprint.** The same
failure reported from your web app and your worker stays one issue with two
platforms. Native v2 is a different hash and includes `platform`.

**Do not send heartbeats or health checks as bugs.** A report whose title carries probe
language (`probe`, `heartbeat`, `healthcheck`, `health check`, `diagnostic`,
`validation`) and whose events are only `console:info` is classified **diagnostic**:
excluded from the agent inbox and scored low. A report with a real `error` event is
classified as an issue whatever the title says — so the failure mode is not a silenced
heartbeat, it is a heartbeat that files itself as a bug and buries the real ones. Send
failures only; use your uptime monitor for liveness.

### Handling the error responses

| Status | Body | What to do |
|--------|------|------------|
| `400` | `{ "error": "Validation failed", "details": [...] }` | Your payload is wrong. Do not retry — it will fail identically. Log and fix. |
| `401` | `{ "error": "Missing or invalid API key" }` | Check `X-BugTape-Key`. Do not retry. |
| `402` | `{ "error": "Plan limit reached", "limit": 100, "current": 100, "plan": "free", "upgradeUrl": "..." }` | Monthly bug limit hit. **Stop sending for the rest of the period** — every further POST is wasted. Set a local circuit breaker until the next month. |
| `429` | `{ "error": "Too many requests", "retryAfter": 45 }` | Sleep `retryAfter` seconds, then retry once. Do not tight-loop. |
| `5xx` | — | Retry once with backoff, then drop or queue locally. |

Never let the reporter take down the caller: wrap the POST in a timeout (5s is plenty)
and swallow its errors. A monitoring client that throws is worse than no monitoring.

## Optional fields and null

Every optional scalar field (`release`, `environment`, `userId`, `reporterEmail`, …)
accepts `null` and treats it as absent, so a templated payload with missing values is
safe. The one exception is `metadata`: it must be an object or be omitted — `"metadata": null`
is rejected with `400`.

## Prove the path first

The console **Setup** page has **Send a test event** with a **Server** tab. It mints a
real occurrence through the normal pipeline — grouping, webhooks, the agent queue — so
you can watch it arrive before wiring anything into your code.

See [../../platforms.md](../../platforms.md) for the full field mapping,
[evidence-floor.md](./evidence-floor.md) for the agent-facing server evidence floor + Streamlit guidance,
[bigquery-timeout.md](./bigquery-timeout.md) for the redacting Python helper,
and [../../ingest-schema.md](../../ingest-schema.md) for every field and limit.
