# API reference

> The three HTTP endpoints you can call directly: send spans, send OpenTelemetry traces, and fetch your hashing key.

Most people never call these endpoints directly: the [Python SDK](https://ai-tally.com/docs/connect/python-sdk) and [OpenTelemetry](https://ai-tally.com/docs/connect/opentelemetry) call them for you. Use this page if you are writing your own client.

- **Base URL:** `https://ingest.ai-tally.com`
- **Authentication:** every request sends your API key as `Authorization: Bearer <key>`. The key needs `write` or `admin` scope.
- **Where this comes from:** the details below are generated from the ai-tally gateway's own code.

## POST /v1/batches

`POST https://ingest.ai-tally.com/v1/batches`

The endpoint the Python SDK posts to. Idempotent on `batch_id`: resending a batch after a timeout or a 429/503 never double counts it.

Authentication: `Authorization: Bearer $TALLY_KEY`, a key with `write` or `admin` scope.

Responses:

- `200`: Accepted. `status` is `accepted`, or `partial` when some spans were rejected (see `partial_errors`). A batch whose `batch_id` was already processed returns the original result with `replayed: true`.
- `400`: `INVALID_SCHEMA`: `X-Ingest-Protocol` names a protocol the gateway does not support. Supported: ingest-v1.
- `401`: `UNAUTHENTICATED`: missing bearer token, or an invalid or revoked key.
- `403`: `FORBIDDEN_SCOPE`: the key has `read` scope and cannot write spans. `TENANT_MISMATCH`: the body names a tenant the key is not bound to.
- `422`: Every span in the batch was rejected (a `BatchResponse` with `status: rejected` and per-span `partial_errors`), or the body is malformed (`{"detail": "..."}`).
- `429`: `RATE_LIMITED`, `QUOTA_EXCEEDED`: back off for `Retry-After` seconds and resend the same body with the same `batch_id`.
- `503`: Temporarily unable to accept the batch (`status: retry`). Includes `IDEMPOTENCY_UNAVAILABLE`. Resend the same body with the same `batch_id`; it is never double counted.

Each span is a flat object of `gen_ai.*` attributes. Give each batch its own `batch_id`: sending the same `batch_id` again returns the first result instead of counting the batch twice, which makes retries safe.

**curl**

```bash
curl https://ingest.ai-tally.com/v1/batches \
  -H "Authorization: Bearer $TALLY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tenant_id": "", "batch_id": "my-batch-0001", "resource_spans": [{
        "trace_id": "t1", "span_id": "s1",
        "gen_ai.system": "openai", "gen_ai.operation.name": "chat",
        "gen_ai.request.model": "gpt-4o-mini",
        "gen_ai.usage.input_tokens": 12, "gen_ai.usage.output_tokens": 5}]}'
```

**Python**

```python
import json, os, urllib.request

body = {
    "tenant_id": "",  # your key decides the organization
    "batch_id": "my-batch-0001",
    "resource_spans": [{
        "trace_id": "t1", "span_id": "s1",
        "gen_ai.system": "openai", "gen_ai.operation.name": "chat",
        "gen_ai.request.model": "gpt-4o-mini",
        "gen_ai.usage.input_tokens": 12, "gen_ai.usage.output_tokens": 5,
    }],
}
request = urllib.request.Request(
    "https://ingest.ai-tally.com/v1/batches",
    data=json.dumps(body).encode(),
    headers={"Authorization": f"Bearer {os.environ['TALLY_KEY']}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(request) as response:
    print(json.load(response))
```

## POST /v1/otlp/traces

`POST https://ingest.ai-tally.com/v1/otlp/traces`

Accepts an OTLP `ExportTraceServiceRequest` encoded as JSON (`OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/json`). Protobuf is not supported. Spans are grouped by `gen_ai.operation.name`.

Authentication: `Authorization: Bearer $TALLY_KEY`, a key with `write` or `admin` scope.

Responses:

- `200`: Accepted. `status` is `accepted`, or `partial` when some spans were rejected (see `partial_errors`). A batch whose `batch_id` was already processed returns the original result with `replayed: true`.
- `401`: `UNAUTHENTICATED`: missing bearer token, or an invalid or revoked key.
- `403`: `FORBIDDEN_SCOPE`: the key has `read` scope and cannot write spans. `TENANT_MISMATCH`: the body names a tenant the key is not bound to.
- `422`: Every span in the batch was rejected (a `BatchResponse` with `status: rejected` and per-span `partial_errors`), or the body is malformed (`{"detail": "..."}`).
- `429`: `RATE_LIMITED`, `QUOTA_EXCEEDED`: back off for `Retry-After` seconds and resend the same body with the same `batch_id`.
- `503`: Temporarily unable to accept the batch (`status: retry`). Includes `IDEMPOTENCY_UNAVAILABLE`. Resend the same body with the same `batch_id`; it is never double counted.

**curl**

```bash
curl https://ingest.ai-tally.com/v1/otlp/traces \
  -H "Authorization: Bearer $TALLY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"resourceSpans": [{"scopeSpans": [{"spans": [{
        "traceId": "5b8efff798038103d269b633813fc60c", "spanId": "eee19b7ec3c1b174",
        "name": "chat", "startTimeUnixNano": "'"$(date +%s)000000000"'",
        "attributes": [
          {"key": "gen_ai.system", "value": {"stringValue": "openai"}},
          {"key": "gen_ai.operation.name", "value": {"stringValue": "chat"}},
          {"key": "gen_ai.request.model", "value": {"stringValue": "gpt-4o-mini"}},
          {"key": "gen_ai.usage.input_tokens", "value": {"intValue": "12"}},
          {"key": "gen_ai.usage.output_tokens", "value": {"intValue": "5"}}]}]}]}]}'
```

**Python**

```python
import json, os, time, urllib.request

def attr(key, value):
    kind = "intValue" if isinstance(value, int) else "stringValue"
    return {"key": key, "value": {kind: str(value)}}

body = {"resourceSpans": [{"scopeSpans": [{"spans": [{
    "traceId": "5b8efff798038103d269b633813fc60c",
    "spanId": "eee19b7ec3c1b174",
    "name": "chat",
    "startTimeUnixNano": str(time.time_ns()),
    "attributes": [
        attr("gen_ai.system", "openai"),
        attr("gen_ai.operation.name", "chat"),
        attr("gen_ai.request.model", "gpt-4o-mini"),
        attr("gen_ai.usage.input_tokens", 12),
        attr("gen_ai.usage.output_tokens", 5),
    ],
}]}]}]}
request = urllib.request.Request(
    "https://ingest.ai-tally.com/v1/otlp/traces",
    data=json.dumps(body).encode(),
    headers={"Authorization": f"Bearer {os.environ['TALLY_KEY']}", "Content-Type": "application/json"},
)
with urllib.request.urlopen(request) as response:
    print(json.load(response))
```

## GET /v1/tenant/hmac-key

`GET https://ingest.ai-tally.com/v1/tenant/hmac-key`

Returns the active HMAC-SHA256 key the SDK uses to hash account ids before they leave your process. Always requires a key with `write` or `admin` scope. The response is a secret: do not log it.

Authentication: `Authorization: Bearer $TALLY_KEY`, a key with `write` or `admin` scope.

Responses:

- `200`: The active key and its version.
- `401`: `UNAUTHENTICATED`: missing bearer token, or an invalid or revoked key.
- `403`: `FORBIDDEN_SCOPE`: a `read` key cannot fetch key material. `HMAC_EXPORT_DISABLED`: key export is disabled for this organization.
- `404`: No key material exists for this organization. Hash nothing and send spans unattributed rather than sending raw ids.
- `503`: The key store could not be reached. Retryable; no key material was returned.

The reply contains your organization's secret hashing key. Keep it out of logs. Most apps never call this: `tally.hash_account` calls it for you.

**curl**

```bash
curl https://ingest.ai-tally.com/v1/tenant/hmac-key \
  -H "Authorization: Bearer $TALLY_KEY"
```

**Python**

```python
import json, os, urllib.request

request = urllib.request.Request(
    "https://ingest.ai-tally.com/v1/tenant/hmac-key",
    headers={"Authorization": f"Bearer {os.environ['TALLY_KEY']}"},
)
with urllib.request.urlopen(request) as response:
    key = json.load(response)  # a secret: do not print or log it
```

## Next

- Sending from Python? The [Python SDK](https://ai-tally.com/docs/connect/python-sdk) handles batching, retries and hashing for you.
