---
titel: "Anvil Feed: Server-Vertrag"
adresse: https://docs.anvil-coder.tech/docs/anvil-feed-ingest
beschreibung: "Der Vertrag der Annahme für Betriebsfehler (englisch): Endpunkte, Statuscodes und Ablehnungsgründe, Grenzwerte, die Reihenfolge der Prüfungen und ein curl-Beispiel — für alle, die aus einer anderen Sprache melden."
sprache: de
---

Dokumentation · Anbindung

Der Vertrag der Annahme für Betriebsfehler (englisch): Endpunkte, Statuscodes und Ablehnungsgründe, Grenzwerte, die Reihenfolge der Prüfungen und ein curl-Beispiel — für alle, die aus einer anderen Sprache melden.

## The Anvil Feed ingest — the server-side contract

This is the entrance the `anvil-feed-*` SDKs post to. It is the wire contract of chapter 03 §2/§3 of the Anvil Feed Protocol specification v0.1, as this server implements it (issue #59, release A).

It replaced `POST /api/observability/{tenantId}/{projectKey}`. That endpoint is gone, with its `X-Observability-Secret` header and the single shared secret behind it: every sender now authenticates with the token of its own channel, which can be revoked one customer at a time.

---

### Endpoints

```text
POST /api/feed/events          Content-Type: application/json   Authorization: Bearer anv_srv_…
POST /api/feed/events/batch    same headers
```

`…/events` carries one event object, `…/events/batch` a JSON array of at most 50. Both answer the same body:

```json
{
  "accepted": ["f4b1a0c2-0f4a-4a1e-9d7a-2b6c5d8e1f30"],
  "rejected": [
    { "event_id": "1c2d3e4f-…", "reason_code": "no_binding",
      "message": "no binding for environment 'staging'" }
  ]
}
```

Everything an SDK sends gets exactly one verdict. An event is in `accepted` or in `rejected`, never in neither and never in both — except in the `503` case below, where events that are in neither list are the ones the sender must send again.

The host is the cockpit’s public host, for example `https://app.anvil-coder.tech/api/feed/events`. The ingest is served by the read tier, not by the orchestrator: it only writes rows, and it keeps accepting while the orchestrator restarts.

---

### Authentication

`Authorization: Bearer anv_srv_<32 base62 characters>`.

The token is issued per **connection** in the cockpit, shown once, and stored here as its first sixteen characters (the lookup key) plus a SHA-256 hash. It carries its organisation and its project itself — no tenant and no project id is ever read out of the URL or the body, so there is no identity for a caller to assert.

Unknown prefix, revoked connection and a prefix that exists with the wrong secret all answer the same bare `401`. That is deliberate: a finer answer would turn this endpoint into a service that tells an attacker which token prefixes exist and which of them are still live.

A request that carries an `Origin` header **and a token that resolves** is refused with `403`. A server token is meant to live in a server process; an `Origin` means a browser sent it, which means it is in somebody’s bundle and every visitor of that page now holds it. Rotate it.

**The order is fixed, and an SDK’s test double has to have it the same way round:** body size (`413`), then the token (`401`), then `Origin` (`403`), then the rate limit (`429`). So an `Origin` on a missing, unknown, revoked or mismatched token is a `401`, never a `403` — a `403` says the token exists and is live, and handing that answer to anybody with an `Origin` header and a guessed prefix would be exactly the lookup service the generic `401` above refuses to be. This paragraph used to read “refused with `403`, whatever the token says”; the server never did that.

---

### Status codes

| Status | When | Retry? |
| --- | --- | --- |
| `202` | the batch was processed; read `accepted` and `rejected` | no |
| `400` | the body is not JSON, is neither an object nor an array, or the array holds more than 50 events | no — fix the sender |
| `401` | no `Bearer anv_srv_…` token, or the token is unknown, revoked or wrong | no |
| `403` | an `Origin` header was present (FR-9f: a leaked server token) | no |
| `413` | the request body is above 256 KiB | no — send fewer events per batch |
| `429` | more than 600 **requests** from this connection within a minute; `Retry-After: 60` | yes, after the delay |
| `503` | some events hit a transient store failure; `Retry-After: 30` | **yes, the whole batch** |

`4xx` is never worth retrying; `5xx` is. That is the SDKs’ rule and this table keeps it true.

**About the `503`.** The body still carries the `accepted` and `rejected` lists of the events that did get through. The sender repeats the **whole** batch, which is safe because the delivery key is `(organisation, event_id)`: an event that was already stored is recognised and counted exactly once. A `202` in that situation would be a promise nobody keeps.

The organisation is part of that key on purpose. `event_id` comes from the sender — this contract requires a canonical UUID and cannot require randomness — so a key on the event alone would let the first organisation to use an id own it for everybody: another organisation’s delivery of the same id would be answered `202` with the event in `accepted` and stored nowhere.

---

### Reason codes

Every entry in `rejected` carries one of these. They are a closed set; a sender may switch on them.

| `reason_code` | Meaning |
| --- | --- |
| `schema_violation` | a required field is missing or unusable (`event_id`, `timestamp`, `title`, `level`, `environment`, `service`, `sdk`, `exception.type`), or the value breaks a rule — an `environment` that is not a label, say |
| `unsupported_schema_version` | the event declares a `schema_version` with a different **major**; this ingest speaks major 1 |
| `payload_too_large` | the request body was above the limit (this one arrives with a `413` and a null `event_id`) |
| `no_binding` | the token’s project has no binding for this event’s `environment`, and no wildcard binding either — connect the environment in the cockpit |
| `binding_disabled` | the binding exists and its repair policy is off; the event was seen and deliberately not kept |
| `redaction_failed` | the event could not be scrubbed safely, so it was not stored |
| `internal_error` | a fault on our side; the event is refused rather than left to be retried for ever |

---

### Limits

| Limit | Value | Effect |
| --- | --- | --- |
| request body | 256 KiB (`262144` bytes) | above it: `413` |
| events per batch | `50` | above it: `400`, the whole request refused |
| requests per minute | `600` | **requests** per connection, fixed 60-second window; above it: `429` |

These are the values of the hosted service. An operator of a self-hosted installation can change them, and the endpoint does not exist at all until that operator has armed it — a `404` on every path is the truth about such an installation, not a fault of the sender.

The rate limit is keyed by the **connection**, not by the client address: the identity that has to be bounded is the project. A customer whose service runs on two hundred pods still gets one budget, and a noisy neighbour cannot spend somebody else’s.

It counts **requests, not events**. The limiter is consulted before the body is parsed, so at that point there is no event count to charge against — which means a sender batching 50 at a time effectively gets 600 × 50 = 30 000 events per minute per connection per pod, and more than one pod serves the ingest. Read the 600 as the back-pressure an SDK is expected to respect, not as a ceiling on how many incidents can arrive. If an event budget is what you need, that is a separate change: the check would have to move after the parse, which changes the documented order above.

A body far above the limit may be refused by the proxy in front of the service instead, with an HTML error page and no reason code. The proxy’s bound sits at twice the application’s on purpose, so that an ordinary oversized body meets the `413` with `payload_too_large` and a sender can tell what happened.

---

### What the server does with the event

Unknown fields are ignored (the specification’s Z3: evolution is additive), so an SDK a version ahead keeps working. Long values are bounded rather than refused — a delivery with two hundred stack frames is a useful delivery with fifty — and where something is cut, the cut is visible.

`user.ip_hash` is set **server-side** and whatever the sender put there is discarded (FR-5c). It is `sha256(client address + daily salt)` with `daily salt = sha256(UTC date + connection id)`. This is a **per-day pseudonym, not a secret**: the salt is built from nothing confidential, so anyone who can guess an address can confirm it. What it buys is what the requirement asks for — the raw address is never stored, two events from one machine on one day are recognisable as such, and the link breaks at midnight and never crosses organisations. The client address is the first entry of `X-Forwarded-For` when the proxy in front of the service set one, otherwise the socket’s own.

**`browser` is accepted and deliberately not carried in release A.** It is a schema-1.0 field, so an SDK that posts it — the Astro one will, on every event — is behaving correctly and is answered `202`. The value is simply read past: the browser path of the protocol (the `anv_pub_` token) is stage B, and nothing here stores it yet. This is stated rather than left to be discovered, because “a field we have never heard of” and “a field we know and keep nothing for” are different promises, and only the second has a release attached.

**`event_id` must be a canonical UUID string.** Chapter 03 §3 types it `uuid-v4`. Anything else is refused per event as `schema_violation` with a message naming the format. What comes back in `accepted` and in a `rejected` entry is the id **exactly as you spelled it** — an upper-case UUID returns upper-case — so an SDK may clear its send buffer by matching the answer against what it posted.

**`request.url` is kept only when it is an `http://` or `https://` URL.** Any other scheme — `javascript:`, `data:`, `file:`, a custom app scheme — is dropped silently and the event is still accepted: the value is a piece of a customer’s production request that ends up rendered as a link in somebody’s cockpit, and the two things it may be there are a web address or nothing. Dropping the field rather than refusing the event is deliberate: the error report is the valuable part, and an SDK that cannot fix its own URL scheme would otherwise lose every report it sends.

Events are persisted **before** the answer goes out. There is no executor and no background queue behind the `202`, so there is nothing a rollout can throw away.

#### The portfolio dashboard’s production card

After the batch is written, the project’s **production card** on the portfolio dashboard is replaced from the incident store — every incident of that project whose status is not `CLOSED` or `IGNORED`, under the source name `anvil-feed`. The same replacement happens after every decision in the incident inbox and after every status the repair worker writes, so the card and the inbox can never disagree.

**One card, one producer per project.** A project that already receives incidents through the external dashboard ingest API (`PUT /api/ingest/dashboard/{tenant}/{config}/incidents`) keeps that producer: the incident store does **not** overwrite a card somebody else is writing. Only where no external producer has claimed the card does the incident store feed it. Two writers on one card would mean the last write wins and neither picture is ever complete — and the external producer is the one a customer configured deliberately.

The repair lifecycle is mapped onto the four states a card knows:

| incident status | on the card |
| --- | --- |
| `OBSERVED`, `TRIAGED`, `WAITING_APPROVAL`, `QUEUED`, `NEEDS_HUMAN` | `OPEN` |
| `RUNNING`, `PR_OPEN` | `INVESTIGATING` |
| `CLOSED`, `IGNORED` | not on the card at all |

`NEEDS_HUMAN` counts as `OPEN`, not as `MITIGATED`: nothing about the fault has been mitigated — the machine has stopped and is waiting for a person. A card that showed it as mitigated would be reassuring about the one state that most needs somebody to look.

Three properties of that are worth stating, because each is a decision:

- **The whole project is re-sent every time**, not the one incident that changed. The card is a REPLACE: sending only the changed incident would wipe the project’s other incidents off it.

- **An empty list is sent.** When the last incident of a project closes, something has to say so; skipping the empty snapshot would leave the last non-empty card standing for ever.

- **A failure to update the card never fails the ingest.** The event is already stored. A `500` here would have the SDK retry a delivery the duplicate gate then swallows without counting it — a production error lost to a dashboard.

A project whose configuration has been deleted has no card to feed; that is not an error and is logged at DEBUG.

---

### A curl example

```bash
curl -sS -X POST https://app.anvil-coder.tech/api/feed/events \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $ANVIL_FEED_TOKEN" \
  -d '{
    "schema_version": "1.0",
    "event_id": "f4b1a0c2-0f4a-4a1e-9d7a-2b6c5d8e1f30",
    "timestamp": "2026-09-16T10:11:12Z",
    "title": "NullPointerException in CheckoutService",
    "level": "error",
    "environment": "production",
    "service": "shop-api",
    "language": "java",
    "sdk": { "name": "anvil-feed-java", "version": "0.1.0", "runtime": "jdk-25" },
    "exception": {
      "type": "java.lang.NullPointerException",
      "message": "Cannot invoke \"Cart.total()\" because \"cart\" is null",
      "stacktrace": [
        { "file": "CheckoutService.java", "line": 88, "function": "charge",
          "context_code": "var sum = cart.total();", "in_app": true }
      ]
    },
    "release": "2026.9.3",
    "request": { "method": "POST", "url": "https://shop.example/api/checkout", "status_code": 500 },
    "user": { "id": "u-4711" },
    "breadcrumbs": ["cart.add sku=A1", "checkout.start"],
    "tags": { "tier": "gold" },
    "enrichment": { "affected_file": "src/main/java/shop/CheckoutService.java" },
    "agent_context": { "suggested_ticket_type": "bug", "repo": "shop/api", "branch": "main" }
  }'
```

Answer:

```json
{"accepted":["f4b1a0c2-0f4a-4a1e-9d7a-2b6c5d8e1f30"],"rejected":[]}
```
