{"openapi":"3.1.0","info":{"title":"NitroPing API","description":"Audit logging as a service. Two credentials, two jobs:\n\n- an **API key** (`Authorization: Bearer npk_…`) writes events and can trade itself for a read token — this is what an SDK, the MCP server or a script holds;\n- a **session cookie**, obtained by signing in with GitHub, manages projects and keys — this is what the panel holds, and it can never read an API key back.\n\nEvery event is written against the project the credential belongs to. A `tenant_id` sent by a client is ignored: the server assigns it, along with the event id, arrival timestamp and observed IP.","license":{"name":""},"version":"0.1.0"},"paths":{"/health":{"get":{"tags":["ingest"],"operationId":"health","responses":{"200":{"description":"The service is up and the database answers"}}}},"/v1/admin/projects":{"get":{"tags":["admin"],"operationId":"list_projects","responses":{"200":{"description":"Every project on the platform","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AdminProjectRow"}}}}},"401":{"description":"Not signed in"},"403":{"description":"Signed in, but not an admin"}}}},"/v1/admin/users":{"get":{"tags":["admin"],"operationId":"list_users","responses":{"200":{"description":"Every user on the platform","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AdminUserRow"}}}}},"401":{"description":"Not signed in"},"403":{"description":"Signed in, but not an admin"}}}},"/v1/auth/github/callback":{"get":{"tags":["auth"],"summary":"Where GitHub sends the browser back. Creates the user and their first\nproject on a first sign-in, sets the session cookie, and redirects to the\npanel.","operationId":"callback","parameters":[{"name":"code","in":"query","description":"GitHub's one-time authorization code.","required":false,"schema":{"type":["string","null"]}},{"name":"state","in":"query","description":"Echoed CSRF value, compared against the state cookie in constant time.","required":false,"schema":{"type":["string","null"]}}],"responses":{"302":{"description":"Redirect to the panel, signed in or with an error in the query"}}}},"/v1/auth/github/start":{"get":{"tags":["auth"],"summary":"Begins GitHub sign-in. Open this in a browser rather than calling it — it\nanswers with a redirect to GitHub and sets a short-lived state cookie.","operationId":"start","parameters":[{"name":"redirect_to","in":"query","description":"Where to land after signing in. Restricted to the panel origin, so this\ncan't be used to bounce a fresh session somewhere else.","required":false,"schema":{"type":["string","null"]}}],"responses":{"302":{"description":"Redirect to GitHub's consent screen"}}}},"/v1/auth/logout":{"post":{"tags":["auth"],"operationId":"logout","responses":{"200":{"description":"Session destroyed"}}}},"/v1/auth/me":{"get":{"tags":["auth"],"operationId":"me","responses":{"200":{"description":"The signed-in user and their projects"},"401":{"description":"Not signed in"}}}},"/v1/erasure":{"post":{"tags":["erasure"],"summary":"A subject's right to be forgotten reaches further than retention does, so\nthis is a real delete rather than a filter — and it takes the same ownership\npath as every other project route, so a guessed tenant_id gets a 404 rather\nthan deleting someone else's audit log.","operationId":"erase","parameters":[{"name":"project","in":"query","description":"The project to act on, by tenant_id.","required":false,"schema":{"type":["string","null"]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErasureBody"}}},"required":true},"responses":{"202":{"description":"Erasure accepted. The rows are scheduled to go, not gone — poll the job."},"400":{"description":"Malformed selector"},"404":{"description":"No such project for this user"}}}},"/v1/erasure/{job_id}":{"get":{"tags":["erasure"],"summary":"Not ownership-gated to a project the way the other routes are: the project\na job was requested for is typically gone by the time anyone checks on\nit — that is the whole point of erasure. job_id is an unguessable Tinybird\nUUID, and the status it answers with (waiting/working/done/error) carries\nnothing about the tenant's own events, so it is authorised by knowing the\njob_id itself rather than by owning a project that may no longer exist.","operationId":"erasure_status","parameters":[{"name":"job_id","in":"path","description":"Erasure job to check","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Job status: waiting, working, done or error"},"400":{"description":"Malformed job id"},"401":{"description":"Not signed in"}}}},"/v1/errors/state":{"put":{"tags":["errors"],"summary":"Sets or clears the triage state of one error group — identified the same\nway `error_groups` identifies it, by (error_type, error_message), since\nthe underlying audit events are immutable and carry no id this could\nattach to. Authenticates like ingest: an API key, not the Tinybird read\ntoken — this state lives in Postgres, so no Tinybird write scope is\nneeded at all.","operationId":"set_state","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetErrorStateBody"}}},"required":true},"responses":{"200":{"description":"State recorded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorState"}}}},"400":{"description":"Invalid status, or a field too long"},"401":{"description":"Missing or invalid API key"},"429":{"description":"Rate limit exceeded"}},"security":[{"api_key":[]}]}},"/v1/errors/state/list":{"get":{"tags":["errors"],"summary":"Every triaged error for the caller's project — small table, no paging.\nCorrelate with `audit_events_error_groups` client-side by (error_type,\nerror_message); the two live in different stores and are not joined here.","operationId":"list_state","responses":{"200":{"description":"Every error_states row for this project","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ErrorState"}}}}},"401":{"description":"Missing or invalid API key"},"429":{"description":"Rate limit exceeded"}},"security":[{"api_key":[]}]}},"/v1/events":{"post":{"tags":["ingest"],"operationId":"ingest","requestBody":{"content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AuditEventInput"}}}},"required":true},"responses":{"200":{"description":"Batch processed","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer","format":"int64"},"description":"Requests allowed per 60s window"},"X-RateLimit-Remaining":{"schema":{"type":"integer","format":"int64"},"description":"Requests left in the current window"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngestResponse"}}}},"400":{"description":"Batch empty or larger than 500 events"},"401":{"description":"Missing or invalid API key"},"429":{"description":"Rate limit exceeded"},"502":{"description":"Upstream storage rejected the write"}},"security":[{"api_key":[]}]}},"/v1/keys":{"get":{"tags":["keys"],"operationId":"list_keys","parameters":[{"name":"project","in":"query","description":"The project to act on, by tenant_id.","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"Keys for one project, without their hashes","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiKeyInfo"}}}}},"404":{"description":"No such project for this user"}}},"post":{"tags":["keys"],"summary":"Issues a new key for one project. The raw value is returned exactly once —\nonly its SHA-256 is stored, so a lost key is replaced rather than recovered.","operationId":"create_key","parameters":[{"name":"project","in":"query","description":"The project to act on, by tenant_id.","required":false,"schema":{"type":["string","null"]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NameBody"}}},"required":true},"responses":{"200":{"description":"The new key. `api_key` is shown only here, never again."},"400":{"description":"Name missing, or the key limit is reached"},"409":{"description":"This project already has a key with that name"}}}},"/v1/keys/{key_id}":{"delete":{"tags":["keys"],"operationId":"revoke_key","parameters":[{"name":"key_id","in":"path","description":"Key to revoke","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"project","in":"query","description":"The project to act on, by tenant_id.","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"Key revoked"},"404":{"description":"No such key in this project"}}}},"/v1/mcp/login/poll":{"get":{"tags":["mcp-login"],"operationId":"poll","parameters":[{"name":"device_code","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"pending, approved (carrying the key, once) or expired","content":{"application/json":{"schema":{"$ref":"#/components/schemas/McpLoginPollResponse"}}}}}}},"/v1/mcp/login/start":{"post":{"tags":["mcp-login"],"summary":"Starts a device-code login for the MCP server, so there is no API key to\ncopy by hand: the MCP process shows `user_code`, a signed-in panel user\nenters it at `verification_uri` and picks a project, and the waiting\nprocess polls `/v1/mcp/login/poll` for the key that approval mints.\nModelled on RFC 8628 using NitroPing's own GitHub session and API-key\nissuance — no separate OAuth server involved.","operationId":"start","responses":{"200":{"description":"A device/user code pair to poll and approve","content":{"application/json":{"schema":{"$ref":"#/components/schemas/McpLoginStartResponse"}}}}}}},"/v1/mcp/login/{user_code}/approve":{"post":{"tags":["mcp-login"],"summary":"Approves a pending device-code login: mints a new API key for the chosen\nproject exactly the way `POST /v1/keys` does, and hands it to the code's\nrow for the waiting MCP process to collect via poll.","operationId":"approve","parameters":[{"name":"user_code","in":"path","description":"Code shown by the MCP process","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApproveBody"}}},"required":true},"responses":{"200":{"description":"Approved — a new API key was issued to the waiting MCP process"},"404":{"description":"No such project for this user, or the code is unknown, already used, or expired"}}}},"/v1/otlp/v1/logs":{"post":{"tags":["ingest"],"summary":"Accepted so an exporter configured with `OTEL_LOGS_EXPORTER=otlp` doesn't\nerror, but log records carry no spend or trace shape worth a row.","operationId":"otlp_logs","responses":{"200":{"description":"Accepted and discarded"},"401":{"description":"Missing or invalid API key"}},"security":[{"api_key":[]}]}},"/v1/otlp/v1/metrics":{"post":{"tags":["ingest"],"summary":"OTLP metrics, JSON or protobuf. Claude Code reports its token and cost\nfigures here rather than on spans, so this is the route its integration\ndepends on.","operationId":"otlp_metrics","requestBody":{"description":"An `ExportMetricsServiceRequest`, exactly as an OpenTelemetry exporter sends it — `application/json` or `application/x-protobuf`.","content":{"application/json":{"schema":{}}},"required":true},"responses":{"200":{"description":"Data points converted and written","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OtlpResponse"}}}},"401":{"description":"Missing or invalid API key"},"415":{"description":"Content-Type is neither OTLP encoding"},"429":{"description":"Rate limit exceeded"}},"security":[{"api_key":[]}]}},"/v1/otlp/v1/traces":{"post":{"tags":["ingest"],"summary":"OTLP traces, JSON or protobuf. Point any OpenTelemetry exporter here; the\nencoding is read from Content-Type, so neither protocol needs configuring.","operationId":"otlp_traces","requestBody":{"description":"An `ExportTraceServiceRequest`, exactly as an OpenTelemetry exporter sends it — `application/json` or `application/x-protobuf`.","content":{"application/json":{"schema":{}}},"required":true},"responses":{"200":{"description":"Spans converted and written","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OtlpResponse"}}}},"401":{"description":"Missing or invalid API key"},"415":{"description":"Content-Type is neither OTLP encoding"},"429":{"description":"Rate limit exceeded"}},"security":[{"api_key":[]}]}},"/v1/projects":{"get":{"tags":["projects"],"operationId":"list_projects","responses":{"200":{"description":"Every project the signed-in user owns","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Project"}}}}},"401":{"description":"Not signed in"}}},"post":{"tags":["projects"],"operationId":"create_project","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NameBody"}}},"required":true},"responses":{"200":{"description":"Project created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Project"}}}},"400":{"description":"Name missing, or the project limit is reached"},"409":{"description":"The user already has a project with that name"}}}},"/v1/projects/{tenant_id}":{"delete":{"tags":["projects"],"operationId":"delete_project","parameters":[{"name":"tenant_id","in":"path","description":"Project to delete","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Erasure scheduled and the project deleted"},"400":{"description":"This is the user's last project"},"404":{"description":"No such project for this user"},"502":{"description":"Erasure could not be scheduled; the project was not deleted"}}},"patch":{"tags":["projects"],"operationId":"rename_project","parameters":[{"name":"tenant_id","in":"path","description":"Project to rename","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NameBody"}}},"required":true},"responses":{"200":{"description":"Project renamed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Project"}}}},"404":{"description":"No such project for this user"},"409":{"description":"The user already has a project with that name"}}}},"/v1/session":{"post":{"tags":["auth"],"summary":"Trades a panel session for a short-lived Tinybird read JWT scoped to one\nproject. The signed-in GitHub identity is the credential — an API key is\nnever involved in reading the panel, so a browser session can't leak an\ningestion key. An API key in the Authorization header is still accepted for\nnon-browser callers (the MCP server, scripts) and resolves to the project it\nwas issued for.","operationId":"session","parameters":[{"name":"project","in":"query","description":"The project to act on, by tenant_id.","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"A read token for one project","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionResponse"}}}},"400":{"description":"No project named"},"401":{"description":"Neither a session cookie nor a valid API key"},"404":{"description":"No such project for this user"}}}}},"components":{"schemas":{"AdminProjectRow":{"type":"object","description":"One row of the platform-wide project list — every tenant, not just the\ncaller's own, with the owner's login and how many keys it has issued.","required":["tenant_id","name","owner_user_id","owner_login","created_at","key_count"],"properties":{"created_at":{"type":"string","format":"date-time"},"key_count":{"type":"integer","format":"int64"},"name":{"type":"string"},"owner_login":{"type":"string"},"owner_user_id":{"type":"string"},"tenant_id":{"type":"string"}}},"AdminUserRow":{"type":"object","description":"One row of the platform-wide user list — admin-only, so it carries a bit\nmore than `User` does: how many projects the account owns.","required":["user_id","login","name","avatar_url","is_admin","created_at","project_count"],"properties":{"avatar_url":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"is_admin":{"type":"boolean"},"login":{"type":"string"},"name":{"type":"string"},"project_count":{"type":"integer","format":"int64"},"user_id":{"type":"string"}}},"ApiKeyInfo":{"type":"object","description":"A key as the panel sees it. The hash stays in the database — it exists so\nrevocation is a direct delete rather than a scan, not for any caller.","required":["key_id","name","prefix","created_at","created_by"],"properties":{"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string"},"key_id":{"type":"string","format":"uuid"},"last_used_at":{"type":["string","null"],"format":"date-time"},"name":{"type":"string"},"prefix":{"type":"string"}}},"ApproveBody":{"type":"object","required":["tenant_id"],"properties":{"tenant_id":{"type":"string"}}},"AuditEventInput":{"type":"object","description":"What a client is allowed to send. Mirrors `auditEventInputSchema` in\n`packages/schema` — that file and `tinybird/datasources/audit_events.datasource`\nare the other two copies of this shape, and all three are kept in step by hand.\n\n`id`, `timestamp` and `tenant_id` are absent on purpose: the server assigns\nthem. A client-supplied tenant_id is the whole ballgame, so it is not a field\na caller can set even by accident.","required":["actor_id","action"],"properties":{"action":{"type":"string","description":"Dot-namespaced by convention, e.g. `invoice.updated`."},"actor_display_name":{"type":["string","null"]},"actor_id":{"type":"string"},"actor_type":{"type":"string"},"cached_input_tokens":{"type":["integer","null"],"format":"int64"},"category":{"type":["string","null"]},"cost_usd":{"type":["number","null"],"format":"double"},"duration_ms":{"type":["number","null"],"format":"double"},"gen_ai_model":{"type":["string","null"]},"gen_ai_operation":{"type":["string","null"]},"gen_ai_provider":{"type":["string","null"]},"id":{"type":["string","null"],"description":"Optional: the server assigns a UUIDv7 when omitted."},"input_tokens":{"type":["integer","null"],"format":"int64"},"ip":{"type":["string","null"]},"metadata":{"type":["object","null"],"additionalProperties":{},"propertyNames":{"type":"string"}},"output_tokens":{"type":["integer","null"],"format":"int64"},"parent_span_id":{"type":["string","null"],"description":"Another event's id within the same request_id. Client-supplied on\npurpose: only the SDK knows the call tree."},"project_id":{"type":["string","null"]},"reasoning_tokens":{"type":["integer","null"],"format":"int64"},"request_id":{"type":["string","null"]},"severity":{"type":"string"},"target_id":{"type":["string","null"]},"target_type":{"type":["string","null"]},"timestamp":{"type":["string","null"],"description":"Optional ISO-8601 instant; the server stamps arrival time when omitted."},"user_agent":{"type":["string","null"]}}},"ErasureBody":{"type":"object","properties":{"actor_id":{"type":["string","null"],"description":"Erase this actor's rows. Mutually exclusive with `ip`."},"before":{"type":["string","null"],"description":"Erase only rows at or before this ISO-8601 instant."},"ip":{"type":["string","null"],"description":"Erase rows from this address. Mutually exclusive with `actor_id`."}}},"ErrorState":{"type":"object","required":["error_type","error_message","status","resolved_by","created_at","updated_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"error_message":{"type":"string"},"error_type":{"type":"string"},"note":{"type":["string","null"]},"resolved_by":{"type":"string"},"status":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"IngestResponse":{"type":"object","required":["accepted","rejected"],"properties":{"accepted":{"type":"integer","description":"Rows written to the audit log.","minimum":0},"rejected":{"type":"integer","description":"Events that failed validation. The rest of the batch is still written.","minimum":0},"rejected_details":{"type":"array","items":{"$ref":"#/components/schemas/Rejection"}}}},"McpLoginPollResponse":{"type":"object","required":["status"],"properties":{"api_key":{"type":["string","null"],"description":"Present exactly once — the first poll to see `status: \"approved\"`."},"status":{"type":"string","description":"\"pending\", \"approved\", \"expired\" or \"not_found\"."},"tenant_id":{"type":["string","null"]}}},"McpLoginStartResponse":{"type":"object","required":["device_code","user_code","verification_uri","expires_in","interval"],"properties":{"device_code":{"type":"string"},"expires_in":{"type":"integer","format":"int64"},"interval":{"type":"integer","format":"int32","minimum":0},"user_code":{"type":"string"},"verification_uri":{"type":"string"}}},"NameBody":{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}},"OtlpResponse":{"type":"object","required":["accepted","received"],"properties":{"accepted":{"type":"integer","description":"Rows written to the audit log.","minimum":0},"partial_success":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/PartialSuccess"}]},"received":{"type":"integer","description":"Spans or data points read out of the payload.","minimum":0}}},"PartialSuccess":{"type":"object","required":["rejected_data_points"],"properties":{"rejected_data_points":{"type":"integer","minimum":0}}},"Project":{"type":"object","required":["tenant_id","name","owner_user_id","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"name":{"type":"string"},"owner_user_id":{"type":"string"},"tenant_id":{"type":"string"}}},"Rejection":{"type":"object","description":"One rejected event, reported back so a caller can fix the payload rather than\nguess which of a 500-event batch was wrong.","required":["index","issues"],"properties":{"index":{"type":"integer","minimum":0},"issues":{"type":"array","items":{"type":"string"}}}},"SessionResponse":{"type":"object","required":["tenant_id","token","expires_at"],"properties":{"expires_at":{"type":"integer","format":"int64","description":"Unix seconds."},"tenant_id":{"type":"string"},"token":{"type":"string","description":"Short-lived Tinybird read JWT, scoped to this project alone."}}},"SetErrorStateBody":{"type":"object","required":["error_type","error_message","status"],"properties":{"error_message":{"type":"string"},"error_type":{"type":"string"},"note":{"type":["string","null"]},"status":{"type":"string","description":"\"open\", \"resolved\" or \"ignored\"."}}}},"securitySchemes":{"api_key":{"type":"http","scheme":"bearer"},"session":{"type":"apiKey","in":"cookie","name":"nitroping_session"}}},"tags":[{"name":"ingest","description":"Writing events: the SDK's own path, plus OTLP/JSON for OpenTelemetry exporters."},{"name":"auth","description":"Signing in with GitHub, and trading a session for a scoped read token."},{"name":"projects","description":"A project is a tenant: its own events, its own keys, its own read token."},{"name":"keys","description":"Ingest keys. The raw value is shown once at creation and never again."},{"name":"erasure","description":"Deleting a subject's events outright, for a right-to-be-forgotten request."},{"name":"admin","description":"Platform-wide views across every tenant. Restricted to accounts with is_admin set."},{"name":"mcp-login","description":"Device-code login for the MCP server: no API key to copy by hand."},{"name":"errors","description":"Triage state (open/resolved/ignored) over error groups. The events themselves stay immutable; this is Postgres-backed workflow state alongside them."}]}