# TraceOS API — Integration Guide

TraceOS is a lot-traceability and recall transparency hub built on an append-only
SHA-256 hash chain. This document is for **external systems** (your ERP, WMS, or
any backend) that want to push trace events into TraceOS and/or read public
verification data.

- **Base URL (production):** `https://traceos.cosmicegg.tech`
- **Interactive OpenAPI / Swagger UI:** [`/docs`](https://traceos.cosmicegg.tech/docs)
- **Raw OpenAPI JSON:** [`/openapi.json`](https://traceos.cosmicegg.tech/openapi.json)
- **This document, rendered:** [`/api-docs`](https://traceos.cosmicegg.tech/api-docs)

---

## 1. Authentication

Every endpoint under `/v1/*` requires two headers:

| Header | Required | Meaning |
|---|---|---|
| `X-Api-Key` | Yes | Secret issued to your integration. Contact Cosmic Egg Technologies to get one. |
| `X-Tenant-Id` | Yes | Your tenant/brand ID (e.g. `demo-factory`). Scopes all reads/writes to your data only. |

**Two separate keys exist, and they are not interchangeable:**

- The **factory/tenant key** unlocks `/v1/events*`, `/v1/corrections`, `/v1/lots*`,
  `/v1/recall/*`, and `/v1/dashboard/*` — everything scoped to *your* tenant.
- A separate **admin key** unlocks `/v1/admin/*` — the cross-tenant hub overview.
  This is only ever issued to Cosmic Egg Technologies' own ops team. A
  factory/tenant key will get `401` on `/v1/admin/*`, and the admin key will
  get `401` on the tenant endpoints — each key does exactly one job.

Endpoints under `/public/*` (and `/health`) require **no authentication** — they're
designed for end customers and search engines to verify a lot without a login.

```bash
curl https://traceos.cosmicegg.tech/v1/lots \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "X-Tenant-Id: your-tenant-id"
```

A missing or wrong key returns:

```json
HTTP 401
{"detail": "Invalid API key"}
```

**Security note:** the key is never exposed by the API itself — there is no
"get my key back" endpoint. If you lose it, ask Cosmic Egg Technologies to
rotate it. Treat it like a password: don't commit it to a public repo or
embed it in client-side/mobile code (call TraceOS from your backend only).

---

## 2. The hash chain, in one paragraph

Every accepted event (`RECEIVE`, `PRODUCE`, `PACK`, `SHIP`, `SELL`, `CORRECTION`, …)
becomes one **block**. Each block's hash is `SHA-256(height + prev_block_hash + payload)`,
so blocks form a chain — editing history breaks the chain. Corrections are never
applied in place; instead a new `CORRECTION` block is appended that records the
original block height/hash and exactly which fields changed (`from` → `to`).
The exact JSON string that was hashed (`canonical`) is returned with every block
so **any client can recompute `SHA-256(canonical)` itself** and check it against
`block_hash` — you don't have to trust TraceOS's own `chain_valid: true` claim.

---

## 3. Quick start — push your first event

```bash
curl -X POST https://traceos.cosmicegg.tech/v1/events \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "X-Tenant-Id: your-tenant-id" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "RECEIVE",
    "occurred_at": "2026-07-13T09:00:00Z",
    "location": "WH-A",
    "reference": "PO-1001",
    "lines": [
      {"lot_code": "RAW-2026-001", "product_sku": "MILK-RAW", "direction": "in", "quantity": 500, "unit": "kg"}
    ]
  }'
```

Response — `chain` tells you exactly where this landed on the ledger:

```json
{
  "id": 10,
  "external_id": null,
  "status": "accepted",
  "event_type": "RECEIVE",
  "chain": {
    "height": 10,
    "tip_hash": "…",
    "chain_valid": true,
    "block_height": 10,
    "block_hash": "…",
    "tx_count": 1,
    "summary": "RECEIVE · RAW-2026-001"
  }
}
```

Then confirm it publicly (no auth) at:
`GET https://traceos.cosmicegg.tech/public/explorer/lot/RAW-2026-001`

Python example:

```python
import requests

BASE = "https://traceos.cosmicegg.tech"
HEADERS = {"X-Api-Key": "YOUR_API_KEY", "X-Tenant-Id": "your-tenant-id"}

resp = requests.post(f"{BASE}/v1/events", headers=HEADERS, json={
    "event_type": "PRODUCE",
    "occurred_at": "2026-07-13T10:30:00Z",
    "location": "LINE-2",
    "reference": "WO-5521",
    "lines": [
        {"lot_code": "RAW-2026-001", "product_sku": "MILK-RAW", "direction": "in", "quantity": 500, "unit": "kg"},
        {"lot_code": "YOG-2026-001", "product_sku": "YOG-PLAIN-1L", "direction": "out", "quantity": 480, "unit": "ea"},
    ],
})
resp.raise_for_status()
print(resp.json()["chain"])
```

---

## 4. Public endpoints (no auth)

| Method | Path | Purpose |
|---|---|---|
| GET | `/health` | Liveness check |
| GET | `/public/verify/{lot_code}` | Full public verification of a lot: journey, brand, recall notices, ledger, trust statement |
| GET | `/public/notices` | List of currently open recall notices across all brands |
| GET | `/public/explorer` | BlockExplorer-style home: chain stats + latest blocks + latest transactions |
| GET | `/public/explorer/blocks` | Paged list of all blocks (newest first) |
| GET | `/public/explorer/transactions` | Paged list of all transactions (newest first) |
| GET | `/public/explorer/block/{height}` | Full detail of one block, incl. `canonical` preimage |
| GET | `/public/explorer/search?q=` | Search blocks/transactions/lots by height, hash prefix, lot code, or reference |
| GET | `/public/explorer/lot/{lot_code}` | Chain filtered to one lot and everything genealogically related to it |

All explorer endpoints accept an optional `?brand=<tenant_id>` filter; omit it to see the whole hub.

**Example — verify a lot (what your customers/QR codes should call):**

```bash
curl https://traceos.cosmicegg.tech/public/verify/CASE-YOG-240702A
```

```json
{
  "verified": true,
  "brand": {"id": "demo-factory", "name": "Demo Factory"},
  "lot": {"lot_code": "CASE-YOG-240702A", "product_sku": "YOG-PLAIN-1L", "status": "active", "hold_status": "released"},
  "journey_title": "YOG PLAIN 1L · 01 Jul 2026 → 02 Jul 2026",
  "steps": [
    {"event_type": "RECEIVE", "occurred_at": "2026-07-01T08:00:00+00:00", "location": "WH-A", "product_sku": "MILK-RAW", "lot_code": "RAW-001", "note": null, "channel": null}
  ],
  "channels_reached": ["Shopee", "Lazada"],
  "notices": [],
  "ledger": { "chain_id": "TRACEOS", "height": 9, "tip_hash": "…", "blocks": [ ... ], "chain_valid": true },
  "trust": {
    "statement": "Append-only SHA-256 hash chain — each record links to the previous block. Tampering breaks verification.",
    "data_owner": "factory",
    "hub": "Cosmic Egg TraceOS",
    "chain_valid": true,
    "tip_hash": "…"
  }
}
```

**Example — client-side verification (any language, shown here in JS):**

```javascript
async function sha256Hex(text) {
  const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
  return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
}

const { blocks } = await fetch('/public/explorer/blocks').then(r => r.json());
for (const b of blocks) {
  const recomputed = await sha256Hex(b.canonical);
  if (recomputed !== b.block_hash) throw new Error(`Block ${b.height} hash mismatch — chain tampered`);
}
```

---

## 5. Traceability endpoints (`X-Api-Key` + `X-Tenant-Id` required)

### Events

| Method | Path | Purpose |
|---|---|---|
| POST | `/v1/events` | Ingest one trace event (see `EventType` below) |
| GET | `/v1/events?limit=` | List recent events for your tenant (max 200) |
| POST | `/v1/events/batch` | Ingest multiple events in one call |
| POST | `/v1/import/csv` | Bulk import via CSV upload (multipart form, field `file`) |

`EventType`: `RECEIVE`, `PRODUCE`, `CONSUME`, `PACK`, `TRANSFER`, `SHIP`, `SELL`, `DISPOSE`, `CORRECTION`.

**Request body for `POST /v1/events`:**

```json
{
  "external_id": "your-own-idempotency-key",
  "event_type": "SHIP",
  "occurred_at": "2026-07-13T12:00:00Z",
  "location": "WH-A",
  "reference": "DO-8842",
  "notes": "optional free text",
  "lines": [
    {
      "lot_code": "YOG-2026-001",
      "product_sku": "YOG-PLAIN-1L",
      "direction": "out",
      "quantity": 200,
      "unit": "ea",
      "customer_ref": "CUST-4471",
      "channel": "Shopee"
    }
  ]
}
```

- `direction` is `"in"` (consumed/received) or `"out"` (produced/shipped/sold).
- `external_id` is optional but recommended — it's unique per tenant, so retrying
  the same request with the same `external_id` won't create a duplicate event.
- `customer_ref` / `channel` are only kept for `SELL` events; public verify never
  exposes them, so they're safe to include.
- For `RECEIVE` (or any incoming, `direction: "in"`) line, you can also send
  **upstream provenance** so the public journey shows real origin instead of a
  free-text note:
  - `supplier_name` — the brand/co-op/vendor you received it from
  - `origin_facility` — which of their facilities produced it
  - `supplier_lot_code` — their own lot/batch number for that material

```json
{
  "event_type": "RECEIVE",
  "occurred_at": "2026-07-13T09:00:00Z",
  "location": "WH-A",
  "reference": "PO-1001",
  "lines": [
    {
      "lot_code": "RAW-2026-001",
      "product_sku": "MILK-RAW",
      "direction": "in",
      "quantity": 500,
      "unit": "kg",
      "supplier_name": "Northern Dairy Co-op",
      "origin_facility": "Northern Dairy Co-op — Chiang Mai Plant #2",
      "supplier_lot_code": "ND-BATCH-88341"
    }
  ]
}
```

These three fields are also part of the hashed block payload — so once
recorded, "who supplied this and under which of their own lot numbers" is
just as tamper-evident as the quantity is; changing it later requires a
visible `CORRECTION` block, not a silent edit.

**CSV import columns** (header row required):
`external_id,event_type,occurred_at,location,reference,lot_code,product_sku,direction,quantity,unit,customer_ref,channel,supplier_name,origin_facility,supplier_lot_code,notes`
— rows sharing the same `external_id` (or same `event_type`+`occurred_at`+`reference` if no id) are grouped into one event with multiple lines.

### Corrections — fixing a mistake without rewriting history

| Method | Path | Purpose |
|---|---|---|
| POST | `/v1/corrections` | Append a `CORRECTION` block referencing an earlier event |

```json
{
  "corrects_event_id": 42,
  "reason": "Recorded weight was a data-entry typo, corrected from source scale log",
  "requested_by": "qa-lead",
  "changes": [
    {"field": "quantity", "old_value": 480, "new_value": 460}
  ]
}
```

The original event/block is **never modified**. The response includes
`corrects_block_height`, `corrects_block_hash`, and the `chain` info for the new
correction block — and it shows up on `/public/explorer` as a distinct
`CORRECTION` block, publicly showing exactly what changed.

### Lots & genealogy

| Method | Path | Purpose |
|---|---|---|
| GET | `/v1/lots?q=` | Search/list lots for your tenant |
| GET | `/v1/lots/{lot_code}/genealogy?depth=` | Upstream/downstream lot graph (which lots fed into / came out of this one) |
| GET | `/v1/lots/{lot_code}/chain` | Full chronological step-by-step trace for one lot + related lots |

### Recall impact simulation

| Method | Path | Purpose |
|---|---|---|
| POST | `/v1/recall/simulate` | Dry-run: "if I recall lot X, what else is affected?" — no data is changed |

```json
{ "lot_code": "RAW-2026-001", "direction": "both", "include_disposed": false }
```

Returns `affected_lots`, `affected_products`, `shipments`, `customers`, `channels`,
`event_count` — use this to decide scope *before* opening a real recall case.

---

## 6. Recall case management (`X-Api-Key` + `X-Tenant-Id` required)

Recall **cases** are the formal, data-owner-initiated workflow (as opposed to the
simulate-only endpoint above, which never persists anything).

| Method | Path | Purpose |
|---|---|---|
| POST | `/v1/recall/cases` | Open a new recall case (optionally auto-holds affected lots) |
| GET | `/v1/recall/cases` | List all recall cases for your tenant |
| GET | `/v1/recall/cases/{case_no}` | Case detail incl. computed impact + notifications |
| PATCH | `/v1/recall/cases/{case_no}/status` | Move a case through `investigation → notification → containment → closed` (or `cancelled`) |
| GET | `/v1/recall/cases/{case_no}/report` | Rendered HTML recall report (printable) |

```json
POST /v1/recall/cases
{
  "seed_lot_code": "RAW-2026-001",
  "title": "Possible allergen cross-contact",
  "reason": "Line audit found unlabeled milk residue on shared equipment",
  "severity": "class_ii",
  "direction": "both",
  "opened_by": "qa-lead",
  "auto_hold": true
}
```

An open recall case for a lot automatically appears in the public `/public/notices`
and `/public/verify/{lot_code}` feeds so downstream customers are informed —
this is the "who validates?" answer for recalls: the **data owner** (you) opens
it, and the public record is immutable from that point on.

---

## 7. Dashboard & admin (`X-Api-Key` + `X-Tenant-Id` required)

| Method | Path | Purpose |
|---|---|---|
| GET | `/v1/dashboard/summary` | Per-tenant KPI summary (lots on hold, open recalls, compliance snapshot) |
| GET | `/v1/dashboard/audit` | Your tenant's audit log |
| GET | `/v1/admin/overview` | Cross-tenant hub overview (all factories, totals) — system-admin use |
| GET | `/v1/admin/audit` | Cross-tenant audit log — system-admin use |

---

## 8. Errors

| Status | Meaning |
|---|---|
| `400` | Bad request — invalid business rule (e.g. correcting a non-existent event) |
| `401` | Missing/invalid `X-Api-Key` |
| `404` | Resource not found (unknown lot, block height, case number) |
| `422` | Schema validation failed — response body lists the exact field(s) |
| `429` | Rate limit exceeded — see `Retry-After` header for seconds to wait |

Validation errors follow FastAPI/Pydantic's standard shape:

```json
{
  "detail": [
    {"loc": ["body", "lines", 0, "quantity"], "msg": "Input should be greater than or equal to 0", "type": "greater_than_equal"}
  ]
}
```

---

## 9. Operational notes

- **Rate limits:** `/v1/*` is capped at 120 requests/minute per API key;
  `/public/*` is capped at 60 requests/minute per IP. Exceeding either returns
  `429` with a `Retry-After` header (seconds until the window resets). Batch
  writes via `/v1/events/batch` or CSV import instead of one request per line
  item if you're pushing a lot of data at once.
- **Idempotency:** use `external_id` on events to make retries safe.
- **Timestamps:** send `occurred_at` in ISO-8601; TraceOS always normalizes and
  hashes on UTC internally, so mixed timezones from different senders are safe.
- **Multi-tenant:** `X-Tenant-Id` is created automatically on first use — there's
  no separate "register a tenant" call, just start sending events with the ID
  you want.
- **Versioning:** the current surface is unversioned beyond the `/v1` prefix.
  Breaking changes will move to `/v2`; `/v1` will keep working.

---

## 10. Support

Questions or want a key issued/rotated: **contact@cosmicegg.tech**
