openapi: 3.1.0

# SkyScribe Integration API
#
# This document describes the INTERFACE ONLY: the endpoints a hospital system
# calls, what they accept, and what they return. It deliberately says nothing
# about how SkyScribe is built — no internal services, no data model, no model
# vendors. An integrator does not need any of that, and publishing it would give
# away work rather than remove friction.
#
# Everything here is implemented and callable today. Anything not listed is not
# part of the contract; if you need something that is missing, ask, and it will
# either be added here or refused clearly.
#
# Getting a credential takes no sales call and no NDA: a hospital administrator
# issues one from Settings → Integrations inside SkyScribe.

info:
  title: SkyScribe Integration API
  version: '1.0.0'
  summary: HL7 v2 ingest, FHIR R4 read, and a DICOM modality worklist for hospital systems.
  description: |
    ## What this is for

    Three things a hospital's existing systems need from an EMR:

    1. **Send us data.** Your ADT feed keeps patient identity in step; your
       LIS/RIS sends results back as ORU. HL7 v2 over HTTPS, or MLLP if you
       have an interface engine that prefers a socket.
    2. **Read a record.** A FHIR R4 Bundle for one patient, by the MRN your own
       registration desk issued — you do not have to learn our identifiers.
    3. **Feed a modality.** A worklist in DICOM tag shape, so a radiographer
       picks the patient off the scanner instead of typing a name.

    ## Authentication

    One long-lived credential per interface:

    ```
    Authorization: Bearer skc_live_<keyId>.<secret>   # a real hospital
    Authorization: Bearer skc_test_<keyId>.<secret>   # an integration sandbox
    ```

    A **live** credential is issued by the hospital's own administrator. A
    **test** credential you mint yourself, with no account and no conversation:
    `POST /sandbox` returns one against a throwaway tenant seeded with synthetic
    data, so you can build the whole integration before your customer signs up.
    The two prefixes look different on purpose — nobody should be able to say
    "I thought that was the test key" about a clinical write.

    The `keyId` half is public and safe to quote in a support ticket — it names
    the interface. The secret is shown **once**, at issue, and is never
    retrievable afterwards; if it is lost, rotate it.

    Two properties worth knowing when you design around this:

    * **The credential decides the hospital.** Never a field in your message. A
      facility code in MSH-4 that disagrees with the credential does not move
      data to another hospital — the credential wins, always.
    * **Revocation is immediate.** A disabled interface is refused on its very
      next message, not at the end of some cache window.

    ## Errors

    | Status | Meaning | What to do |
    | --- | --- | --- |
    | `401` | Credential missing, malformed, or not recognised | Check the header; rotate if the secret was lost |
    | `403` | Credential is genuine but the interface was revoked, or is registered in the other direction | Ask the hospital administrator to re-enable it |
    | `400` | The request was understood but unusable (e.g. empty body, missing `mrn`) | Fix and resend |
    | `404` | No such patient in this hospital | Confirm the MRN; a patient with no data is not a 404 |
    | `429` | Too many requests | Back off and retry; the response is not a rejection of the content |

    Note that `POST /connectors/hl7` is the exception: it answers **200 with an
    ACK** even for a message it refuses. See that endpoint for why.

    ## Retries and idempotency

    Resending a message is safe. ADT is applied by patient identity, so the same
    A08 twice leaves one patient, updated once. ORU results are recorded against
    the order and the observation, so a repeated result does not duplicate a
    row in the chart.

    There is no separate idempotency key: your HL7 control id (MSH-10) is what
    we log against, and it is what a support conversation will refer to.

servers:
  - url: https://api.skyscribeai.com/api/v1
    description: Production
  - url: http://localhost:4000/api/v1
    description: Local development

tags:
  - name: HL7 v2
    description: Inbound ADT and results.
  - name: FHIR R4
    description: Read a patient's record.
  - name: Worklist
    description: Feed a modality.

security:
  - connectorCredential: []

paths:
  /sandbox:
    post:
      tags: [Sandbox]
      operationId: createSandbox
      summary: Mint a throwaway integration sandbox
      security: []
      description: |
        **No account, no key, no conversation.** Returns a `skc_test_…`
        credential against a throwaway tenant so you can build the whole
        integration before your customer signs up.

        It arrives seeded with one synthetic patient (`SBX-100001`) and one
        pending imaging order, so your first read returns something
        recognisable rather than an empty array you cannot tell from a rejected
        credential. Every route in this document works against it, because it is
        the same code that serves production — there is no separate sandbox host.

        * Synthetic data only. **Do not send real patient information.**
        * Not a trial of SkyScribe: no clinician login, no AI. It is for wiring.
        * Deleted after 14 days. Mint another.
        * Rate limited to 3 per hour per client.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                organisation: { type: string, maxLength: 80 }
                email: { type: string, format: email }
      responses:
        '201':
          description: A live sandbox credential. The secret is shown once.
          content:
            application/json:
              schema:
                type: object
                properties:
                  credential: { type: string, example: 'skc_test_a1b2c3d4e5f6a7b8.<secret>' }
                  hospitalId: { type: string, format: uuid }
                  expiresAt: { type: string, format: date-time }
                  seeded:
                    type: object
                    properties:
                      mrn: { type: string, example: 'SBX-100001' }
                      patientName: { type: string }
                  tryThis:
                    type: object
                    description: Copy-pasteable curl for the first four calls.
        '429': { description: Rate limited — 3 sandboxes per hour per client. }

  /sandbox/reset:
    post:
      tags: [Sandbox]
      operationId: resetSandbox
      summary: Empty the sandbox and re-seed it
      description: |
        Start again without asking anyone. Refused with `400` on a live
        hospital's credential — a reset on a real tenant would erase records.
      responses:
        '201': { description: Emptied and re-seeded. }
        '400': { description: This credential belongs to a live hospital, not a sandbox. }

  /connectors/hl7/explain:
    post:
      tags: [HL7 v2]
      operationId: explainHl7
      summary: Say what would happen to this message — and do none of it
      description: |
        An ACK is a fourteen-character verdict. `MSA|AE|MSG001|` tells you the
        message was refused and nothing else, which normally means asking
        whoever runs the receiving system what they did not like about it.

        This reads the message exactly as `POST /connectors/hl7` does and
        answers the three questions you actually have: did you parse it, what
        did you understand, and what would you have done. Problems point at a
        **field** — `PID-3`, `OBX-5#2` — because "PID-3 carries no MRN" is
        actionable and "invalid message" is not.

        **Files nothing.** No patient is created, no result is stored, and
        nothing is written to the hospital's message log. Safe against a live
        credential.
      requestBody:
        required: true
        content:
          application/hl7-v2:
            schema: { type: string }
      responses:
        '200':
          description: What we parsed, understood, and would have done.
          content:
            application/json:
              schema:
                type: object
                properties:
                  parsed: { type: boolean }
                  messageType: { type: string, example: 'ADT^A04' }
                  controlId: { type: string }
                  testMode: { type: boolean, description: MSH-11 is T or D — acknowledged, never filed. }
                  wouldDo:
                    type: array
                    items: { type: string }
                  problems:
                    type: array
                    items:
                      type: object
                      properties:
                        at: { type: string, example: 'PID-3' }
                        problem: { type: string }
                        fix: { type: string }
                        severity: { type: string, enum: [error, warning] }
                  understood:
                    type: object
                    description: Everything we read, so you can check we read it your way.
        '401': { $ref: '#/components/responses/Unauthorized' }

  /connectors/hl7:
    post:
      tags: [HL7 v2]
      operationId: ingestHl7
      summary: Send an HL7 v2 message (ADT or ORU)
      description: |
        Post the raw ER7 message as the request body.

        **This endpoint always answers `200` with an ACK**, including for
        messages it refuses. That is deliberate, and it is the behaviour your
        interface engine wants: HL7 senders read `MSA-1`, not the HTTP status.
        A `4xx` with an empty body leaves an engine unable to tell "rejected, do
        not retry" from "the network ate it, try again" — so it retries the same
        bad message for ever.

        **Read `MSA-1`:**

        | Code | Meaning |
        | --- | --- |
        | `AA` | Accepted and filed |
        | `AR` | Rejected — do NOT retry unchanged. The reason is in `MSA-3` |
        | `AE` | Error our side — safe to retry later |

        `AR` is what you get for a message type we do not accept, or for a
        credential registered `outbound` only. We would rather refuse loudly
        than answer `AA` to something we ignored: a sender told `AA` for a
        message we drop will keep sending it for years believing it works.

        **Supported:** `ADT^A01`, `A04`, `A08`, `A28`, `A31`, `A03`, and
        `ORU^R01`.

        **Content type:** send `text/plain`, `application/hl7-v2`, or
        `x-application/hl7-v2+er7`. The list is deliberately wider than the
        spec because interface engines disagree; the credential is what
        authorizes the request, not the content type. Bodies are decoded as
        UTF-8 — patient names in other scripts survive intact.
      requestBody:
        required: true
        content:
          application/hl7-v2:
            schema:
              type: string
            examples:
              admit:
                summary: ADT^A01 — admit
                value: |
                  MSH|^~\&|LIS|CENTRAL LAB|SKYSCRIBE|SKYSCRIBE|20260731120000||ADT^A01|MSG00001|P|2.5
                  PID|1||MRN123456^^^HOSP^MR||KUMAR^RAMESH||19680304|M|||12 MG ROAD^^BENGALURU^KA^560001
                  PV1|1|I|WARD^B-12
              result:
                summary: ORU^R01 — a result coming back
                value: |
                  MSH|^~\&|LIS|CENTRAL LAB|SKYSCRIBE|SKYSCRIBE|20260731124500||ORU^R01|MSG00002|P|2.5
                  PID|1||MRN123456^^^HOSP^MR||KUMAR^RAMESH||19680304|M
                  OBR|1|ORD789||LFT^Liver function tests
                  OBX|1|NM|BILT^Total bilirubin||14.2|mg/dL|0.3-1.2|H|||F
      responses:
        '200':
          description: An HL7 ACK. Read `MSA-1` — this status does not tell you whether the message was accepted.
          content:
            application/hl7-v2:
              schema:
                type: string
              examples:
                accepted:
                  summary: Accepted (MSA-1 = AA)
                  value: |
                    MSH|^~\&|SKYSCRIBE|SKYSCRIBE|LIS|CENTRAL LAB|20260731120001||ACK|ACK00001|P|2.5
                    MSA|AA|MSG00001
                refused:
                  summary: Refused (MSA-1 = AR) — do not retry unchanged
                  value: |
                    MSH|^~\&|SKYSCRIBE|SKYSCRIBE|LIS|CENTRAL LAB|20260731120001||ACK|ACK00003|P|2.5
                    MSA|AR|MSG00003|SkyScribe does not accept SIU^S12. Supported: ADT (A01/A04/A08/A28/A31/A03), ORU^R01.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Revoked'

  /connectors/fhir/Patient:
    get:
      tags: [FHIR R4]
      operationId: readPatientBundle
      summary: Read one patient's record as a FHIR R4 Bundle
      description: |
        Look the patient up by the **medical record number your own system
        issued** — not by a SkyScribe identifier. Demanding you learn our UUIDs
        first is the sort of friction that turns a two-day integration into a
        two-month one.

        **Read only, permanently.** There is no FHIR write and there will not
        be one. Writes arrive as HL7 through `POST /connectors/hl7`, where the
        clinical rules apply: an external system cannot put arbitrary content
        straight into a patient's chart.
      parameters:
        - name: mrn
          in: query
          required: true
          description: The MRN your registration desk issued for this patient.
          schema:
            type: string
          example: MRN123456
      responses:
        '200':
          description: A FHIR R4 Bundle.
          content:
            application/fhir+json:
              schema:
                type: object
                properties:
                  resourceType:
                    type: string
                    const: Bundle
                  type:
                    type: string
                    example: collection
                  entry:
                    type: array
                    items:
                      type: object
        '400':
          description: "`mrn` was not supplied."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Revoked'
        '404':
          description: |
            No patient with that MRN in this hospital.

            A `404` means "no such patient" — distinct from a patient who exists
            but has no data yet, which returns `200` with an empty Bundle. An
            integrator debugging at 2 a.m. needs to be able to tell those apart.

  /connectors/worklist:
    get:
      tags: [Worklist]
      operationId: readWorklist
      summary: Modality worklist, in DICOM tag shape
      description: |
        The imaging orders currently waiting, as JSON using the DICOM tag names
        a modality copies across — so a small on-site broker (dcm4che, Orthanc,
        or the vendor's own) is a thin adapter rather than a translation project.

        We do not expose a C-FIND SCP: a modality speaks DICOM over a raw
        socket, which does not survive a load balancer or TLS termination, and
        no hospital puts a CT scanner on the public internet anyway.

        The payoff is that a radiographer picks the patient off the scanner
        instead of typing the name, so the study comes back with an accession
        number that matches the order.
      parameters:
        - name: modality
          in: query
          required: false
          description: Filter to one modality, e.g. `CT`, `MR`, `US`, `CR`.
          schema:
            type: string
          example: CT
      responses:
        '200':
          description: Scheduled procedure steps.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    PatientName: { type: string, example: 'KUMAR^RAMESH' }
                    PatientID: { type: string, example: 'MRN123456' }
                    PatientBirthDate: { type: string, example: '19680304' }
                    PatientSex: { type: string, example: 'M' }
                    AccessionNumber: { type: string }
                    Modality: { type: string, example: 'CT' }
                    ScheduledProcedureStepDescription: { type: string }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Revoked'

components:
  securitySchemes:
    connectorCredential:
      type: http
      scheme: bearer
      bearerFormat: skc_live_<keyId>.<secret> or skc_test_<keyId>.<secret>
      description: |
        `skc_live_…` is issued by the hospital's administrator under
        Settings → Integrations and writes into a real clinical record.
        `skc_test_…` comes from `POST /sandbox`, needs no account, and reaches
        only a throwaway sandbox tenant.

        Either way the secret is displayed once and cannot be retrieved later —
        store it where your interface engine keeps its other credentials, and
        rotate rather than hunt for it if it is lost.

  responses:
    BadRequest:
      description: The request was understood but unusable.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: |
        Credential missing, malformed, or not recognised.

        Deliberately the same answer for "no such key" and "wrong secret", so
        the endpoint cannot be used to discover which interfaces exist.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Revoked:
      description: |
        The credential is genuine but the interface has been revoked, or is
        registered in the other direction (an outbound-only key cannot send in).

        A distinct answer from `401` on purpose: your engineer should be told it
        was switched off, not sent hunting for a typo in a config file.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      properties:
        statusCode: { type: integer, example: 401 }
        message:
          type: string
          example: That connector credential was not recognised.
        error: { type: string, example: Unauthorized }
