{"openapi":"3.1.0","info":{"title":"Pradicta Ingestion API","description":"Ingestion endpoint for telemetry data from mining equipment. v0.2 — multi-tenant, Postgres-backed (SQLite for local dev). See discovery/integration/manual-api-{es,en}.md.","version":"0.2.0"},"paths":{"/v1/health":{"get":{"summary":"Health","description":"Lightweight liveness check. No auth required.","operationId":"health_v1_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}}}}}},"/v1/ingest":{"post":{"summary":"Ingest","description":"Ingest a batch of telemetry events under a single transaction.\n\n- 401 if the API key is missing, invalid, or revoked.\n- 413 if the request body exceeds 5 MB (enforced by BodySizeLimitMiddleware).\n- 422 if the batch fails Pydantic validation.\n- 429 if this tenant exceeds the per-tenant rate cap.\n- 503 if the storage layer returns a transient error (caller should retry,\n      response carries an `incident_id` correlatable with server logs).\n- 200 with `{accepted, rejected, errors}` for everything else.\n\nAll events that pass Pydantic validation are accepted under one DB transaction,\nusing `INSERT ... ON CONFLICT (tenant_id, event_id) DO NOTHING`. Idempotent\nretries (same event_id seen twice) silently dedupe; the response still counts\nthem as accepted, matching the v0.1 contract. Per the v0.2 manual update,\npayload mismatches on a repeated event_id are treated as first-write-wins\n(the client's later send is dropped silently — same `accepted` reply, no new row).","operationId":"ingest_v1_ingest_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Batch"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngestResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/v1/contact":{"post":{"summary":"Contact","description":"Receive a message from the public contact form. No auth — prospects\ndon't have API keys.\n\n- 422 if the submission fails validation (blank name/message, bad email).\n- 429 if this IP exceeds the contact rate cap (3/min, burst 5).\n- 503 on storage failure (sanitized, carries an `incident_id`).\n- 200 `{ok: true}` otherwise — including for honeypot hits, which are\n  dropped silently so bots can't detect the trap.\n\nSubmissions are stored in the `contact_messages` table. They are read\nmanually (Railway Postgres data browser) — there is no notification.","operationId":"contact_v1_contact_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactSubmission"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"Batch":{"properties":{"events":{"items":{"$ref":"#/components/schemas/Event"},"type":"array","maxItems":1000,"minItems":1,"title":"Events"},"batch_id":{"anyOf":[{"type":"string","maxLength":64},{"type":"null"}],"title":"Batch Id"}},"additionalProperties":false,"type":"object","required":["events"],"title":"Batch","description":"A batch of events sent in a single request."},"ContactResponse":{"properties":{"ok":{"type":"boolean","title":"Ok","default":true}},"type":"object","title":"ContactResponse","description":"Response body for `POST /v1/contact`."},"ContactSubmission":{"properties":{"name":{"type":"string","maxLength":120,"minLength":1,"title":"Name"},"email":{"type":"string","maxLength":254,"minLength":3,"title":"Email"},"message":{"type":"string","maxLength":2000,"minLength":1,"title":"Message"},"company":{"anyOf":[{"type":"string","maxLength":120},{"type":"null"}],"title":"Company"},"phone":{"anyOf":[{"type":"string","maxLength":40},{"type":"null"}],"title":"Phone"},"lang":{"type":"string","enum":["en","es"],"title":"Lang","default":"en"},"website":{"type":"string","maxLength":256,"title":"Website","default":""}},"additionalProperties":false,"type":"object","required":["name","email","message"],"title":"ContactSubmission","description":"A message from the public contact form on pradicta.com / the API landing.\n\nUnauthenticated by design — prospects don't have API keys. Abuse is\nbounded by the per-IP rate limit in the route, the field caps here,\nand the `website` honeypot (hidden in the form's markup; humans never\nsee it, naive bots fill every field — a non-empty value drops the\nsubmission silently)."},"ErrorDetail":{"properties":{"index":{"type":"integer","title":"Index"},"reason":{"type":"string","title":"Reason"}},"type":"object","required":["index","reason"],"title":"ErrorDetail","description":"A per-event error reported back in a partially-successful batch."},"Event":{"properties":{"timestamp":{"type":"string","format":"date-time","title":"Timestamp","description":"ISO 8601 timestamp, must be timezone-aware (UTC strongly recommended)."},"asset_id":{"type":"string","maxLength":64,"minLength":1,"title":"Asset Id"},"signal":{"type":"string","maxLength":64,"minLength":1,"title":"Signal"},"value":{"anyOf":[{"type":"number"},{"type":"string","maxLength":256}],"title":"Value"},"unit":{"anyOf":[{"type":"string","maxLength":32},{"type":"null"}],"title":"Unit"},"metadata":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"number"},{"type":"boolean"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Metadata"},"event_id":{"anyOf":[{"type":"string","maxLength":64},{"type":"null"}],"title":"Event Id"}},"additionalProperties":false,"type":"object","required":["timestamp","asset_id","signal","value"],"title":"Event","description":"A single telemetry event from a piece of mining equipment."},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HealthResponse":{"properties":{"status":{"type":"string","title":"Status"},"version":{"type":"string","title":"Version"},"timestamp":{"type":"string","format":"date-time","title":"Timestamp"}},"type":"object","required":["status","version","timestamp"],"title":"HealthResponse","description":"Response body for `GET /v1/health`."},"IngestResponse":{"properties":{"accepted":{"type":"integer","title":"Accepted"},"rejected":{"type":"integer","title":"Rejected"},"errors":{"items":{"$ref":"#/components/schemas/ErrorDetail"},"type":"array","title":"Errors"}},"type":"object","required":["accepted","rejected","errors"],"title":"IngestResponse","description":"Response body for `POST /v1/ingest`."},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}},"securitySchemes":{"HTTPBearer":{"type":"http","description":"Paste the raw API key here (NOT the SHA-256 hash). The server only stores the hash; this token is what your client sends.","scheme":"bearer","bearerFormat":"api-key"}}}}