Skip to content
telemetry.dev
Esc
↑↓navigate↵open⌘Jpreview
On this page

OTLP ingest API

Send OTLP/HTTP traces, logs, and metrics to telemetry.dev.

The ingest API accepts OTLP/HTTP at https://ingest.telemetry.dev. Send traces to /v1/traces, logs to /v1/logs, and metrics to /v1/metrics, as protobuf or OTLP JSON, with your API key as a bearer token.

Send a span with curl

This request sends one model span. The script writes the body to traces.json with the current time and new random IDs:

START="$(date +%s)000000000"
END=$((START + 1200000000))
TRACE_ID=$(openssl rand -hex 16)
SPAN_ID=$(openssl rand -hex 8)

cat > traces.json <<EOF
{
  "resourceSpans": [
    {
      "resource": {
        "attributes": [
          { "key": "service.name", "value": { "stringValue": "support-bot" } },
          { "key": "deployment.environment.name", "value": { "stringValue": "production" } }
        ]
      },
      "scopeSpans": [
        {
          "scope": { "name": "manual-curl" },
          "spans": [
            {
              "traceId": "${TRACE_ID}",
              "spanId": "${SPAN_ID}",
              "name": "chat claude-sonnet-4-5",
              "kind": 3,
              "startTimeUnixNano": "${START}",
              "endTimeUnixNano": "${END}",
              "attributes": [
                { "key": "gen_ai.operation.name", "value": { "stringValue": "chat" } },
                { "key": "gen_ai.provider.name", "value": { "stringValue": "anthropic" } },
                { "key": "gen_ai.request.model", "value": { "stringValue": "claude-sonnet-4-5" } },
                { "key": "gen_ai.usage.input_tokens", "value": { "intValue": "12000" } },
                { "key": "gen_ai.usage.cache_read.input_tokens", "value": { "intValue": "8000" } },
                { "key": "gen_ai.usage.cache_creation.input_tokens", "value": { "intValue": "1000" } },
                { "key": "gen_ai.usage.output_tokens", "value": { "intValue": "1500" } }
              ]
            }
          ]
        }
      ]
    }
  ]
}
EOF

Then post it:

curl "https://ingest.telemetry.dev/v1/traces" \
  --request POST \
  --header "Authorization: Bearer ${TELEMETRY_DEV_API_KEY}" \
  --header "Content-Type: application/json" \
  --data-binary @traces.json

A successful request returns HTTP 200 with the body {}. The span is stored with operation chat, its token counts, and a cost the server calculates from the pricing snapshot. The cost worked example shows that calculation for these exact counts.

In OTLP JSON, trace and span IDs are hex strings, kind is an integer (3 is CLIENT), and timestamps are nanoseconds since the Unix epoch. The script uses the current time because the trace explorer hides spans older than the project’s retention period. It generates new IDs so that each run sends a different body; see Duplicate requests.

For logs, send an ExportLogsServiceRequest to /v1/logs. For metrics, send an ExportMetricsServiceRequest to /v1/metrics.

Endpoints

Method Path Purpose
POST /v1/traces Ingest an OTLP ExportTraceServiceRequest
POST /v1/logs Ingest an OTLP ExportLogsServiceRequest
POST /v1/metrics Ingest an OTLP ExportMetricsServiceRequest
GET /health Liveness. Always returns {"ok":true}.
GET /ready Readiness. Returns 200 when the service can accept data and 503 when it cannot.

Any other path or method returns HTTP 404 with the JSON body {"error":{"code":"not_found","message":"Route not found"}}. A GET to /v1/traces is a 404, not a 405.

Authentication

Send the API key in the Authorization header:

Authorization: Bearer td_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  • The header value must be Bearer, one space, and the key. Bearer is case-sensitive.
  • The key must start with td_live_ and contain only letters, digits, _, and -.
  • The ingest API does not read an x-api-key header or a key in the URL.

A missing, malformed, unknown, or revoked key returns HTTP 401.

Environment-scoped keys

A key can be limited to one environment. See Environments for how to create one.

The ingest API reads the environment of each resource block from these resource attributes, in order:

  1. deployment.environment.name
  2. deployment.environment
  3. If neither is set: the key’s environment for a scoped key, or default for an unscoped key.

The comparison ignores surrounding whitespace and letter case. If any resource block names a different environment from a scoped key, the server rejects the whole request with HTTP 403. A scoped key also gets 403 when the batch adds to a trace that is already stored under a different environment.

Request headers

Header Accepted values Notes
Content-Type application/x-protobuf or application/json Parameters such as ; charset=utf-8 are allowed. Any other value, or no header, returns 415.
Content-Encoding gzip or identity Optional. No header means identity. Any other value returns 415.
Authorization Bearer td_live_... Required. See Authentication.

The response uses the same encoding as the request: protobuf for a protobuf request and JSON for a JSON request.

OTLP JSON rules

  • Field names must be lowerCamelCase, such as traceId and startTimeUnixNano. A snake_case field name such as trace_id returns 400.
  • Trace and span IDs are hex strings: 32 hex characters for a trace ID and 16 for a span ID.
  • Enum values, such as span kind and status code, must be integers.
  • A duplicate key in a JSON object returns 400.
  • Unknown fields are ignored.

IDs

A span must have a 16-byte trace ID and an 8-byte span ID, and neither can be all zeros. A wrong length or an all-zero span or trace ID returns 400. On logs and metric exemplars, IDs are optional; an all-zero ID there is treated as absent.

Body limits

Limit Maximum
Request body as sent 4 MiB
Body after gzip decompression 16 MiB

The server returns 413 as soon as a declared Content-Length is over 4 MiB, or while it streams a body that grows past 4 MiB. For gzip bodies it counts decompressed bytes as it goes and stops at 16 MiB. If you hit either limit, send smaller batches.

Rate limits and quotas

Limit Scope Default Response
Failed authentication attempts Per sender, per minute 20 429 with Retry-After
Requests Per project, per minute 6,000 429 with Retry-After
Monthly ingestion units Per organization without a paid subscription Plan dependent 429 until the next UTC month

The per-minute limits use fixed one-minute windows. Retry-After gives the seconds until the current window ends. A key that authenticated recently has its own failure budget, so failed attempts from other clients behind the same address do not normally lock it out.

Organizations without a paid subscription have a hard monthly cap on ingestion units. For the cap and how units are counted, see Plans and billing. Paid subscriptions are metered and are not rejected for volume.

When a cap applies, every accepted response also carries:

Response header Value
ratelimit-limit The organization’s hard cap in ingestion units
ratelimit-reset Seconds until the next UTC month

A request that would go over the monthly cap returns 429 with retry-after set to the seconds until the next UTC month. An organization that redeemed a gift code also has a lifetime gift budget, which does not reset monthly. A batch that would go over it returns 429 with ratelimit-limit set to the budget and ratelimit-remaining set to the units left, and no retry-after.

Success response

A successful request returns HTTP 200. The body is an empty OTLP response message (ExportTraceServiceResponse, ExportLogsServiceResponse, or ExportMetricsServiceResponse): {} in JSON, or zero bytes in protobuf.

The server never returns OTLP partial-success counts. A batch is either accepted or rejected as a whole; see Imperfect and out-of-order data.

Every response from the ingest API, including errors, carries the header x-telemetry-dev: telemetry.dev.

Error responses

An OTLP error body is a google.rpc.Status in protobuf or JSON, with the same encoding as the request. It carries only a message:

{ "message": "API key was not accepted" }

Status.code is always 0. Use the HTTP status to decide what to do. A 415 for an unsupported Content-Type uses protobuf, because the server cannot tell which encoding you wanted.

telemetry.dev also records each rejection of an authenticated request on the project, with the diagnostic code below. Ingestion error alerts name this code; the response body does not include it. Rejections that happen before authentication, such as 415 and 401, are not recorded on any project.

HTTP Diagnostic code Message sent to the client Cause Retry?
400 invalid_body Failed to read request body The body stream could not be read. No
400 decompression_failed Failed to decompress gzip body The gzip stream is invalid. No
400 invalid_protobuf Failed to decode OTLP protobuf payload The protobuf body does not decode. No
400 invalid_json Failed to decode OTLP JSON payload The JSON does not parse or is not valid UTF-8. No
400 invalid_id Names the field, such as Span span_id must be exactly 8 bytes A trace or span ID has the wrong length, is all zeros on a span, or is not hex in JSON. No
400 invalid_payload Describes the problem A bad or missing timestamp, a duplicate or snake_case JSON key, a non-integer enum, nesting that is too deep, or a trace token total above 253 − 1. No
400 empty_payload OTLP request body is empty, or Request contains no spans, no log records, or no metric data points The body is empty or contains no items. No
401 invalid_api_key Authorization must be Bearer td_live_xxx or API key was not accepted The key is missing, malformed, unknown, or revoked. No
403 environment_mismatch API key is scoped to another environment A resource environment, or the stored trace’s environment, differs from the key’s environment. No
409 telemetry_deleted Telemetry was deleted The project’s telemetry, or a trace in the batch, was deleted. No
413 body_too_large Request body exceeds limit or Decompressed request body exceeds limit The body is over 4 MiB as sent or 16 MiB after decompression. No; split the batch
415 unsupported_media_type Content-Type must be application/x-protobuf or application/json Missing or unsupported Content-Type. No
415 unsupported_encoding Content-Encoding must be gzip or identity Unsupported Content-Encoding. No
429 rate_limited Too many failed authentication attempts, Authentication rate-limit capacity reached; retry later, or Too many requests for this project; export larger batches less often A per-minute limit was reached. After Retry-After
429 quota_exceeded Monthly ingestion quota exceeded; ... or a lifetime gift budget message The batch would exceed the organization’s monthly cap or gift budget. After Retry-After, if present
500 authentication_failed Failed to authenticate request The key lookup failed or took longer than 10 seconds. Later, with backoff
500 privacy_configuration_invalid Project privacy settings are invalid The project’s stored privacy settings cannot be read. No
500 ingestion_failed Failed to decode OTLP payload An unexpected error while decoding. No
503 request_in_progress An identical request is already being processed The same body is still being stored from an earlier attempt. After Retry-After: 1
503 ingestion_failed Failed to claim request key, Failed to reserve ingestion quota, or Failed to persist OTLP batch A storage step failed. After Retry-After: 1

Duplicate requests

The server remembers each accepted request body for one hour, per project and per endpoint. Two requests are the same when they go to the same endpoint and their bodies match byte for byte after gzip decompression.

  • If the same body arrives again within the hour after it was stored, the server returns 200 and does not store it again.
  • If the same body arrives while the first copy is still being stored, the server returns 503 request_in_progress with Retry-After: 1.

This protects retries of the same bytes. It is not a general exactly-once guarantee: a re-serialized batch with different bytes, or a repeat after the hour, is stored again.

Retries

Retry 429, 502, 503, and 504 with backoff, and wait at least as long as Retry-After when the response includes it. A 500 authentication_failed is also temporary and can be retried later. Do not retry other 4xx responses; the same request will fail again.

A 503 from a storage step can arrive after some of the batch was written. Retry with the same body so the server can recognize it, rather than assuming the batch was fully stored or fully rolled back.

The telemetry.dev TypeScript SDK makes at most three attempts for each trace, log, and metric batch. It retries the four statuses above and network errors. Between attempts it waits 100 ms and then 500 ms, or the Retry-After value when the response has one. If Retry-After is longer than 2 seconds, it stops and reports the failure. The Python SDK uses the standard OpenTelemetry OTLP/HTTP exporters with gzip, so their retry behavior applies.

Imperfect and out-of-order data

The same rules apply to the first-party SDKs and to any other OTLP client. There is no separate strict endpoint for SDK traffic.

Situation What the server does
One structurally invalid item in a batch Rejects the whole request before storing anything. Valid items in the same batch are not stored.
Missing optional fields Stores the record with null token, cost, or content fields. Null means unknown, not zero.
Invalid token count, such as negative, fractional, or above 253 − 1 Ignores that attribute and tries the next alias; the rest of the span or log is stored. Metric data points are not affected.
Invalid client cost Ignores that attribute and tries the next cost alias. See Client cost.
Child span before its parent, or a log before its span Stores what arrived and links the records when the rest of the trace arrives, within retention.
End time before start time Keeps both timestamps and stores the duration as 0.

Best-effort extraction never bypasses project ownership, environment scope, capture settings, redaction, or retention. For how the server reads each attribute, see Span attributes.

Last updated on October 18, 2026

Was this page helpful?