> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getmcpulse.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Ingest

> POST /v1/ingest — batches, the 202, and why one bad payload never costs you the other 499.

`POST /v1/ingest` is the only route the SDK talks to.

```json theme={null}
{ "batch": [ { "...payload" }, { "...payload" } ] }
```

Authenticated with an [ingest key](/api/concepts/authentication). The key resolves to exactly one MCP, and that is where every payload in the batch lands.

## The rules

| | |
| - | - |
| Max batch | **500** payloads. Larger returns `413` |
| Rate limit | **10,000 payloads per minute per key**. Over returns `429` |
| Response | **`202`**, empty body, before the write happens |
| Invalid items | Dropped silently. The batch still succeeds |

## Why 202 before the write

Your server is mid-tool-call. Making it wait on our database to hand a result back to the model would put MCPulse on the latency path of every call it measures.

So the write is scheduled and the response returns immediately. A failed write costs a data point; it must never cost a tool call.

## Why invalid items are dropped silently

The envelope is checked for shape, then each item is validated **individually**.

A whole batch is never failed for one bad item. Your server cannot fix a payload we rejected — the SDK does not retry, by design — so all a `400` would achieve is losing the 499 good payloads alongside the bad one.

Drops are logged server-side with a count, so a systematically malformed client is visible to us even though it is invisible to the caller.

<Note>
  This means a `202` is **not** confirmation that every item was accepted. If you are writing your own producer rather than using the SDK, validate against the payload shapes in [What is sent](/sdk/what-is-sent) before you trust the numbers.
</Note>

## What each payload does

**Startup** upserts the `tools` table: tool name, `schema_bytes`, `last_seen`. This is the only way MCPulse learns a tool exists, and what makes [dead-tool detection](/tools/dead) and [schema size](/metrics/schema-size) possible.

**Call** does three writes:

1. inserts into `calls`, the per-call log the [live feed](/metrics/live-calls) and the [nightly pass](/api/concepts/nightly-pass) read
2. upserts `tool_hours`, keyed `(mcp_id, hour, tool_name, client_name)` — every counter on the product lives here
3. upserts `sessions`, keyed `(mcp_id, session_id)`

Every upsert is `on conflict do update set x = table.x + excluded.x`. Never read-then-write, which races under load.

<Note>
  **A payload with no `client_name` is bucketed as `unknown`** before anything is grouped, so every client's calls sum to the call total — the one property that makes the split checkable.

  One consequence, because it was a real bug: `sessions.client_name` prefers a *real* name over `unknown`, and only then does recency decide. Picking the newest name alone meant a later anonymous call renamed a session that had already identified itself.
</Note>

## Field limits

Free-text fields are bounded because ingest is a public endpoint. Without caps a hostile client writes whatever it likes into the metrics tables and into every dashboard reading them.

| Field | Limit |
| - | - |
| `session_id` | 128 characters |
| `tool_name` | 200 |
| `client_name` | 128 |
| `args_hash` | 4–64 lowercase hex characters |
| `duration_ms` | 0 – 86,400,000 |
| byte counts | 0 – 2,147,483,647 |
| `tools` per startup payload | 500 |

`v` must be exactly `1`. See [Versioning](/api/concepts/versioning).

## Writing your own producer

Supported, and the wire format is documented — but the SDK does several things that are easy to get wrong and hard to notice:

* one shared session per process, without which [retries](/metrics/first-call-success) can never be detected
* telling `crashed` from `tool_error`, which `McpServer` erases
* [empty detection](/metrics/empty-results), including the JSON-in-a-text-item case
* RFC 8785 canonical argument hashing, which no language's JSON encoder produces by default

If you build your own, read [What is sent](/sdk/what-is-sent) for the payloads and [Wire format](/sdk/wire-format) for the hash — the second one ships a 23-case fixture file you can run against your implementation to prove it agrees with the ten official SDKs.

## Related

* [Rate limits](/api/concepts/rate-limits)
* [Install the SDK](/sdk/install)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.