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-IDwhere applicable). - Bundle schema: the vendored draft at
backend/internal/modules/interchange/schema/v0.1.0.json(specovahol/interchange-schemav0.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_ADMINmay target any facility;ORG_ADMINonly facilities in its own organization;FACILITY_ADMINonly its own facility. Out-of-scope requests return403before the payload is validated, and an unknown facility returns404.
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
| Endpoint | Minimum roles |
|---|---|
export, catalog/names, validate, convert | facility admin, engineering manager, engineering tech, clinical user, org admin |
import | facility 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.
- 200json
{ "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.
- 200json
{ "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.
- RequestjsonProvide either
{ "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 }records(already-parsed row objects) orcontent(raw CSV/JSON text). Unmapped columns are preserved per row, never dropped. - 200json
{ "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: truemeans 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 —
422when 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:
{ "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.