Skip to content

Interchange API ​

Authoritative contract for the facility interchange endpoints. The machine-readable api/openapi.yaml documents these operations from the declarative registry in backend/internal/openapi/apidocs, which openapi-sync applies on every regeneration. They remain x-internal: true, so the public frontend spec strips them; this page documents the same contract for human readers.

  • Tenant-scoped base path: /api/v1/tenant/interchange
  • Auth: Authorization: Bearer <JWT> with facility context (X-Facility-ID where applicable).
  • Bundle schema: the vendored draft at backend/internal/modules/interchange/schema/v0.1.0.json (spec ovahol/interchange-schema v0.1.0).
  • Request bodies are capped at 32 MiB; larger payloads return 413.

Targeted facility routes ​

Importing into a facility chosen from a picker (rather than the caller's ambient tenant context) uses the same operations under:

  • Base path: /api/v1/core/admin/interchange/facilities/{facility_id}
  • Roles: SUPER_ADMIN, ORG_ADMIN, FACILITY_ADMIN.
  • Scope: SUPER_ADMIN may target any facility; ORG_ADMIN only facilities in its own organization; FACILITY_ADMIN only its own facility. Out-of-scope requests return 403 before the payload is validated, and an unknown facility returns 404.

The operations and payloads are identical to the tenant routes below (GET /export, GET /catalog/names, POST /validate, POST /convert, POST /import). Export reads inside a read-only transaction; import stays all-or-nothing.

Adding documentation ​

Endpoint documentation lives in code, not in the generated YAML. Add an Operation (and any component schemas) to backend/internal/openapi/apidocs/, then run make openapi-generate. Entries whose route is not registered are reported as warnings by openapi-sync rather than written into the spec.

Roles ​

EndpointMinimum roles
export, catalog/names, validate, convertfacility admin, engineering manager, engineering tech, clinical user, org admin
importfacility admin, org admin

convert additionally requires a write role when recommend_missing: true is set.

Endpoints ​

GET /export ​

Exports the current facility as one interchange bundle.

  • 200 — bundle JSON (see the v0.1.0 schema).
  • 401/403/500.

GET /catalog/names?kind=device|model|brand|manufacturer ​

Read-only catalog names for match correction. The full dictionary endpoints are super-admin-only; this scoped, names-only view is safe for convert operators.

  • 200
    json
    { "kind": "device", "names": ["Infusion pump", "X-ray machine"] }
  • 400 — unknown kind, or the catalog is too large to list.
  • 401/403/500.

POST /validate ​

Validates a bundle without importing it.

  • Request — bundle JSON.
  • 200
    json
    { "valid": false, "errors": ["assets[0].id: required"] }
  • 400/401/403/413/500.

POST /convert ​

Stages foreign CSV/JSON rows into a candidate bundle. Nothing is imported; feed the returned bundle to validate/import.

  • Request
    json
    {
      "facility_name": "Bongo District Hospital",
      "content_type": "records",
      "records": [{ "Equipment Name": "Infusion Pump", "Serial": "SN-1" }],
      "content": "optional raw CSV instead of records",
      "column_mapping": { "Usr": "department_name" },
      "source_system": "bdh-inventory-2026",
      "recommend_missing": false
    }
    Provide either records (already-parsed row objects) or content (raw CSV/JSON text). Unmapped columns are preserved per row, never dropped.
  • 200
    json
    {
      "bundle": { "format": "medical-device-lifecycle-interchange", "formatVersion": "0.1.0" },
      "valid": true,
      "errors": [],
      "report": {
        "rows_total": 122,
        "rows_converted": 122,
        "rows_rejected": 0,
        "rejections": [{ "row": 7, "reason": "no usable identity (...)" }],
        "unmapped_fields": ["Usr"],
        "catalog_degraded": false
      },
      "catalog": {
        "deviceDefinitions": [{ "id": "d1", "name": "Infusion pump", "matched": true }],
        "models": [], "brands": [], "manufacturers": []
      },
      "recommendations": [{ "id": "…", "item_type": "MODEL", "name": "IP-2000" }],
      "recommendations_skipped": 3,
      "skipped_models": [{ "item_type": "MODEL", "name": "IP-2000", "reason": "unknown device" }]
    }
    catalog_degraded: true means whole-catalog fuzzy matching was disabled for this call (catalog load failed or hit its page cap), so some names may be unmatched that would normally resolve.
  • 400/401/403/413/500.

POST /import?dry_run=true ​

Validates, plans, and applies a bundle in one transaction. dry_run defaults to true; a dry run executes the real apply path inside a transaction that is always rolled back.

  • Request — bundle JSON.
  • 200/422 — 422 when the bundle is invalid; the body is the same report shape.
    json
    {
      "formatVersion": "0.1.0",
      "valid": true,
      "dryRun": true,
      "assetCount": 122,
      "created": 119,
      "linked": 3,
      "rejected": [{ "recordId": "a7", "reason": "no identity …" }],
      "errors": [],
      "catalog_degraded": false
    }
  • 403 when the facility/organization context is missing.
  • 400/401/413/500.

Error envelope ​

All non-report errors use the shared shape:

json
{ "status": "Bad Request", "code": "BAD_REQUEST", "message": "invalid input: …" }

500 responses log the real cause and return a generic message; they do not expose internal error detail.

Ovahol developer documentation