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
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:
{
"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
500here 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
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:
{"accepted":["f4b1a0c2-0f4a-4a1e-9d7a-2b6c5d8e1f30"],"rejected":[]}