For a tenant's own developer: reporting what every endpoint actually returned
Event API reference
Internal analytics capability
What this API reports
Send request outcomes from your servers to the private Internal page. This measures failures in real
traffic, independently of scheduled uptime checks. For example, /health can return
200 while /v1/payments/charge fails one request in five.
Authentication
Generate a key: Dashboard → Settings → API keys, choosing Report telemetry only. The full key is shown exactly once, in that response, and never again.
Authorization: Bearer ibk_ingest_<...>Ingest keys are a separate scope from the ibk_live_ keys used by the
incident API. An incident key can publish to your public page; an ingest key can
submit telemetry, including Database Health reports when that separate feature is enabled. Use ingest keys on the application servers that send telemetry.
They are not interchangeable in either direction: an ingest key on /api/v1/incidents is
rejected with 403, and so is an incident key here.
POST/api/v1/events
curl -X POST https://inbrief.example/api/v1/events \
-H "Authorization: Bearer ibk_ingest_<your key>" \
-H "Content-Type: application/json" \
-d '{
"events": [
{
"service": "orders",
"endpoint": "/v1/orders/:id",
"method": "GET",
"status": 200,
"ms": 31
},
{
"service": "orders",
"endpoint": "/v1/orders/:id",
"method": "GET",
"status": 503,
"ms": 2140
},
{
"service": "payments",
"endpoint": "/v1/charge",
"method": "POST",
"status": 500
}
]
}'const response = await fetch('https://inbrief.example/api/v1/events', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.INBRIEF_INGEST_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"events": [
{
"service": "orders",
"endpoint": "/v1/orders/:id",
"method": "GET",
"status": 200,
"ms": 31
},
{
"service": "orders",
"endpoint": "/v1/orders/:id",
"method": "GET",
"status": 503,
"ms": 2140
},
{
"service": "payments",
"endpoint": "/v1/charge",
"method": "POST",
"status": 500
}
]
}),
});
if (!response.ok) throw new Error(`InBrief responded ${response.status}`);A bare array is accepted too, if that is easier to produce.
| Field | Required | Notes |
|---|---|---|
service | no | Which of your back-end services served this (see below). Lower-cased. Omit it and events land in Unassigned. Aliases: app, component. |
endpoint | yes | The route template (see below). Aliases: path, route. |
method | no | Upper-cased. Defaults to GET. |
status | yes | 100–599. Aliases: statusCode, status_code. |
ms | no | Response time in milliseconds, used to calculate average latency. The Internal page also shows p95 where histogram measurements are available. Alias: duration_ms. |
message | no | Short description of what happened. Sampled, not logged (see below). Alias: error. |
at | no | ISO-8601 timestamp. Defaults to arrival time. |
Send every response, not only the failures. The successes are what make a rate possible: “412 errors” means nothing without “out of how many”, and the rate is the number anyone can act on.
4xx is recorded and visible, but never triggers an alert or opens an incident. A caller sending bad requests is not your service being down.
message is sampled, not stored per event. Only the most recent message on a
failing response is kept, per endpoint per minute. Storing one string per event would undo the
point of counting, while a sample answers the question you actually have in front of a red row:
what did it say? It is truncated to 200 characters, control characters are stripped, and it is shown
verbatim in your dashboard, so keep it generic: no customer data, no tokens, no full stack traces.
Send service if you run more than one
Set it to the name of the deployable that served the request: payments,
auth, listings. It is optional.
Without it, every service you run shares one namespace. Three services that each expose
GET /health become one row: their counts are added together, and the sampled
error message can come from a different service than the failures did. Once merged, these records
cannot identify which service failed.
Setting service keeps each service's counts, error rate and endpoints separate in the
fleet map and table. A service failing 12% of its requests remains visible even when the overall
error rate is 0.1%.
- Lower-cased and trimmed.
Paymentsandpaymentsare the same service. This is the opposite ofendpoint, which keeps its case because paths are case-sensitive. - A name, not an instance. Never a hostname, pod id, container id, or region; those are per-instance and would create a separate reporter per container. The per-page reporter allowance is 2,000; new reporters past that limit are refused.
- A malformed
servicefiles the event under Unassigned rather than dropping it. - Max 60 characters.
You can omit service when all traffic belongs to one service.
Send route templates, not paths
/v1/orders/:id correct
/v1/orders/8837 wrongEvery framework knows the template it matched: req.route.path in Express,
req.routeOptions.url in Fastify, Route::current()->uri() in Laravel,
request.route_uri_pattern in Rails, request.resolver_match.route in Django.
Send raw paths and every id becomes its own endpoint. A day of traffic produces hundreds of thousands of one-request “endpoints”, the page becomes unreadable, and nothing can merge them back later: once split, the fact that those rows were one route is gone.
Query strings and fragments are stripped for you, and a trailing slash is normalised away. Case is preserved, because paths are case-sensitive.
Batching
Buffer events in memory and POST the batch every couple of seconds. Do not open a request per event.
Your code does no arithmetic. It appends to an array and flushes on a timer. All counting happens on our side, which is what lets us change what we measure without you upgrading anything.
The flush interval bounds the request count by time rather than by traffic: a server flushing every two seconds makes 30 requests a minute whether it handled 5 requests or 50,000.
Responses
202 Accepted:
{
"accepted": 3,
"rejected": 0,
"rows": 3,
"statements": 3
}accepted: events counted.rejected: events dropped as malformed. Skipped individually, never failing the whole batch. Watch this while integrating; a non-zero value means something is wrong with what you send.rows: database rows the batch collapsed into.statements: writes reported by the selected event store. Useacceptedandrejectedto verify your integration.
If a cardinality guard fires, a warning field is added naming what was dropped. Nothing is
discarded silently.
| Status | Meaning |
|---|---|
400 | Body was not JSON, or carried no event array. |
401 | Missing, unknown, or revoked key. |
403 | Key is not ingest-scoped, or internal analytics is unavailable. |
413 | More than 1,000 events in one batch. Flush more often. |
405 | Method other than POST. |
POST/api/v2/events
The same events, sent by a reporter that names itself. Use this one when you want the Internal page to notice a process going quiet, and the one above when you only want the numbers.
curl -X POST https://inbrief.example/api/v2/events \
-H "Authorization: Bearer ibk_ingest_<your key>" \
-H "Content-Type: application/json" \
-d '{
"reporter": {
"reporterId": "orders-api-eu-1",
"englishName": "Orders API (Frankfurt)",
"region": "eu-central",
"team": "payments",
"expectedEverySeconds": 30
},
"events": [
{
"endpoint": "/v1/orders/:id",
"method": "GET",
"status": 200,
"ms": 31
},
{
"endpoint": "/v1/orders/:id",
"method": "GET",
"status": 503,
"ms": 2140
}
]
}'const response = await fetch('https://inbrief.example/api/v2/events', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.INBRIEF_INGEST_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"reporter": {
"reporterId": "orders-api-eu-1",
"englishName": "Orders API (Frankfurt)",
"region": "eu-central",
"team": "payments",
"expectedEverySeconds": 30
},
"events": [
{
"endpoint": "/v1/orders/:id",
"method": "GET",
"status": 200,
"ms": 31
},
{
"endpoint": "/v1/orders/:id",
"method": "GET",
"status": 503,
"ms": 2140
}
]
}),
});
if (!response.ok) throw new Error(`InBrief responded ${response.status}`);| Field | Required | Notes |
|---|---|---|
reporter.reporterId | yes | Stable per process and unchanged across restarts. It is the identity the silence is measured against, so an id that changes on every deploy reads as one reporter dying and another appearing. |
reporter.englishName | yes | What an operator reads beside it. |
reporter.region | no | Lower-cased label, such as eu-central. |
reporter.team | no | Lower-cased label, such as payments. |
reporter.expectedEverySeconds | no | 15–300. How often you promise to call, heartbeat included. |
events | yes | The same event objects as above, and service is ignored: the reporter names itself. An empty array is a heartbeat, not an error. |
Send this on your flush timer whether or not anything happened. A process with nothing to report still has something to say:
{
"reporter": {
"reporterId": "orders-api-eu-1",
"englishName": "Orders API (Frankfurt)",
"region": "eu-central",
"team": "payments",
"expectedEverySeconds": 30
},
"events": []
}202 Accepted answers with the v1 fields plus the two only a reporter batch has:
{
"accepted": 2,
"rejected": 0,
"rows": 2,
"statements": 2,
"heartbeat": true,
"reporterId": "orders-api-eu-1"
}Events older than ten minutes, or more than a minute in the future, are counted in
rejected rather than failing the batch: a clock that is wrong should not cost you the
rest of the events in the request.
Limits
| Events per batch | 1,000 |
| Distinct endpoints per batch | 200 , counted per service |
| Distinct endpoints per status page | 25,000 |
| Distinct services/reporters per status page | 2,000 |
| Endpoint length | 200 characters |
| Service name length | 60 characters |
| Message length | 200 characters |
| Traffic protection | The deployment operator owns gateway controls; this route does not publish an application-level per-key quota |
| Retention | 14 days at one-minute resolution in InBrief storage; yours to set in your own warehouse |
Two rules for any integration
Never block a response on reporting. Buffer, and flush out of band.
Never throw on a failed flush. Telemetry that can take down the service it measures is worse than no telemetry. Dropping a batch is always better than failing a customer's request.
Where your events are stored
InBrief storage retains event history for 14 days at one-minute resolution. To use your own warehouse, open Dashboard → Settings → Storage.
| Primary event store | Traffic written to | Traffic read from | History retention |
|---|---|---|---|
| InBrief storage (default) | our database | our database | 14 days, fixed |
| Your warehouse | your warehouse | your warehouse | whatever you set |
Supported adapters are BigQuery, ClickHouse (Cloud or self-hosted) and Snowflake. Postgres, MySQL and Redshift are not supported destinations.
The selected store owns event history. InBrief retains reporter identity, region, team and reporting timestamps locally, plus encrypted connection credentials. Reporter metadata has no automatic expiry. Failed external writes are also buffered locally as aggregated batches. This operational data does not constitute a complete local copy of your warehouse history.
What changes when you switch
If the warehouse stops answering, the Internal page reports that it is unavailable. An accepted batch
waiting for delivery returns 202 with a warning. That response confirms
buffered acceptance, not arrival in the warehouse.
The local buffer holds at most 2,000 batches per status page and removes older batches above that cap. Automatic delivery stops after 50 failed attempts. Exhausted batches become eligible for deletion seven days after creation, not seven days after the last attempt. Switching back to InBrief storage discards pending external batches.
These limits can leave gaps in warehouse history. Restore the connection and check recent traffic before changing storage mode. Keep source records if you need to recover missing history; exhausted batches are not replayed automatically.
History does not move. What is already in InBrief storage stays there for its 14 days; your warehouse starts from the moment you switch. The page polls every 60 seconds instead of 15, because warehouse queries may be billed to your account. You create the table: the settings page shows the exact DDL, and we never create tables in your account.
A deduplication key accompanies every batch, but provider deduplication is bounded. A retry after a timeout can deliver a duplicate and inflate request or error counts. Bounded retries do not provide a guarantee that every batch will arrive.
Or keep ours and take a copy
If you like the live page as it is and only want longer retention, leave storage on InBrief and set a daily backup destination instead. Once a day we copy the previous day's rows into your warehouse, on your retention, through the same connectors. It runs out of band and never touches the path your servers report on.
With InBrief as the primary store, a warehouse outage does not interrupt the Internal page's traffic queries.
A note on Snowflake
Frequent writes and reads can keep a Snowflake warehouse running. Check your warehouse size, auto-suspend settings and Snowflake's warehouse guidance before using it as the primary store. A daily backup makes fewer requests, but its cost still depends on your configuration and data volume.
Credentials
Stored encrypted and never shown again once saved. Grant the narrowest thing that works: a service account with write access to that one table, not to the project. We verify the connection before saving anything, so a typo fails at the form rather than at 3am.
Where it shows up
Dashboard → Internal combines a briefing, fleet map, filters and a service table. Select a service to inspect endpoints, requests, errors, error rate and latency. The view supports one-hour, six-hour and 24-hour windows, pinned investigations and copied links.
Any errors in the selected window classify a reporter as failing. Otherwise its reporting age determines late, silent or clean. Without a v2 heartbeat, reporting health is estimated from traffic. A sample below 100 requests is marked thin.
The Internal page guide explains those states, public-status gaps, unmonitored endpoints and alert rules. The page is private to your team. Publishing an incident requires a separate, authorised action.