openapi: 3.1.0

info:
  title: Medicine Catalogue API
  version: "1.0.0"
  summary: What a medicine contains, what the regulator says about it, and what else has the same composition.
  description: |
    A read-only HTTP API over a medicine catalogue: composition, the regulator's own
    safety wording, the legal ceiling price where one is fixed, and what else in the
    same market carries the same composition.

    It is public and needs no key. Every response is JSON, every call is a `GET`, and
    the console beside each endpoint on this page is calling the live service.

    ### What is in the published catalogue

    124,524 products, from two sources that are unambiguously publishable:

    - **openFDA** — the United States drug directory and its structured product labels,
      which are US public domain. This is where the safety text comes from.
    - **NPPA** — India's National List of Essential Medicines with the ceiling prices
      the regulator fixes for them, published for public reference.

    Indian *brand* names are not here. They come from a dataset whose publisher does not
    say where it came from, so they stay on the shop's own machine rather than being
    republished. A self-hosted instance carries all 378,500 products including those, and
    serves this same reference at `http://127.0.0.1:8910/docs`.

    ### Two ways to ask for alternatives

    `GET /v1/medicines/{id}/alternatives` starts from a catalogue entry and defaults to
    `mode=exact` — the same salt and the same strength, which is the conservative answer.

    `GET /v1/alternatives?composition=` starts from text you type and defaults to
    `mode=base` — the salt form is ignored, so amlodipine besylate and amlodipine maleate
    count as one molecule. That is what a pharmacist reaching for a substitute usually
    means, and it is the call PharmaDesk makes because it already knows the salt from its
    own product master.

    Neither mode ever ignores strength. Swapping 650 mg for 500 mg is a dispensing error,
    not a substitution.

    ### This is reference data, not medical advice

    Every clinical response carries a `disclaimer` field and it is not decoration. Safety
    text is the manufacturer's own label wording, reproduced verbatim and never
    summarised, because paraphrasing a drug warning would be both useless and dangerous.

    ### Fair use

    There is no key and no quota, but responses are cached for five minutes at the edge
    and the service is one small function over a file. Please do not use it to mirror the
    catalogue; if you need it in bulk, run your own instance.
  license:
    name: Proprietary - all rights reserved
  contact:
    name: PharmaDesk
    url: https://pharmadesk.accron.in

servers:
  - url: https://pharmadesk.accron.in/api
    description: The public catalogue - openFDA and India's essential medicines
  - url: http://127.0.0.1:8910
    description: Your own instance - the full catalogue, including Indian brands

# No authentication. The catalogue is read-only public reference data served on the
# shop's own machine, and MEDICINE_HOST binds it to 127.0.0.1 by default - the boundary
# is the network interface, not a key. Declared explicitly so it reads as a decision.
security: []

tags:
  - name: Catalogue
    description: Finding a medicine and reading what is in it.
  - name: Alternatives
    description: What else carries the same composition, in a given market.
  - name: Molecules
    description: Ingredients, and the safety text that belongs to them rather than to a brand.
  - name: Service
    description: Whether it is up, and how fresh the data is.

paths:
  /health:
    get:
      tags: [Service]
      summary: Liveness and row counts
      description: |
        Also served at `/` and `/v1/health`. Returns how much is in the catalogue and when
        it was last updated, which is what a monitoring check should look at — an ingest
        that silently stopped is the failure mode worth catching.
      operationId: getHealth
      responses:
        "200":
          description: The service is up.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, const: true }
                  service: { type: string, const: medicine-api }
                  counts:
                    type: object
                    properties:
                      products: { type: integer, example: 378497 }
                      ingredients: { type: integer, example: 12324 }
                      safety_records: { type: integer, example: 604 }
                  lastIngest:
                    oneOf:
                      - type: object
                        properties:
                          source: { type: string, example: india_brands }
                          finished_at: { type: string, example: "2026-09-17 16:24:51" }
                          status: { $ref: "#/components/schemas/RunStatus" }
                      - type: "null"
        "500":
          $ref: "#/components/responses/ServerError"

  /v1/search:
    get:
      tags: [Catalogue]
      summary: Free-text search
      description: |
        Full-text search over brand name, generic name, manufacturer and composition,
        ranked by relevance. Punctuation that SQLite's FTS5 would read as an operator is
        stripped rather than rejected, so a pharmacist typing `Augmentin 625 (Duo)` gets
        results instead of an error. Each term is matched as a prefix, so `dolo` finds
        `Dolo 650 Tablet`.

        Results omit `ingredients` to keep the response small; fetch the medicine for those.
      operationId: searchMedicines
      parameters:
        - name: q
          in: query
          required: true
          description: At least two characters.
          schema: { type: string, minLength: 2 }
          examples:
            brand: { value: "dolo 650", summary: "An Indian brand" }
            molecule: { value: "amoxicillin", summary: "A molecule" }
            maker: { value: "micro labs", summary: "A manufacturer" }
        - $ref: "#/components/parameters/Market"
        - name: limit
          in: query
          description: 1–100. Values outside the range are clamped, not rejected.
          schema: { type: integer, default: 25, minimum: 1, maximum: 100 }
      responses:
        "200":
          description: Matching medicines, best first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, const: true }
                  query: { type: string }
                  market:
                    oneOf: [{ type: string }, { type: "null" }]
                  count: { type: integer }
                  results:
                    type: array
                    items: { $ref: "#/components/schemas/Medicine" }
                  disclaimer: { $ref: "#/components/schemas/Disclaimer" }
        "400":
          $ref: "#/components/responses/BadRequest"

  /v1/medicines/{id}:
    get:
      tags: [Catalogue]
      summary: One medicine, in full
      description: |
        The product, its parsed ingredients, every price any source has for it, and the
        safety text of the molecules it contains.

        Safety is attached to the molecule rather than the brand, so an Indian brand of
        paracetamol carries the FDA's liver warning without anyone having copied it onto
        3,755 separate rows.
      operationId: getMedicine
      parameters:
        - $ref: "#/components/parameters/MedicineId"
      responses:
        "200":
          description: The medicine.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, const: true }
                  medicine: { $ref: "#/components/schemas/Medicine" }
                  prices:
                    type: array
                    items: { $ref: "#/components/schemas/Price" }
                  safety:
                    type: array
                    items: { $ref: "#/components/schemas/Safety" }
                  alternativeCount:
                    type: integer
                    description: Active products in the same market with the same exact composition.
                  disclaimer: { $ref: "#/components/schemas/Disclaimer" }
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/medicines/{id}/alternatives:
    get:
      tags: [Alternatives]
      summary: What else has this composition
      description: |
        Other active products carrying the same composition, in a market you choose.

        Defaults to `mode=exact`: same molecules, same salts, same strengths. Pass
        `mode=base` to ignore the salt form.

        Pass `market` to cross markets — an Indian paracetamol tablet can be asked what
        the United States has with the same composition, which is how the American label
        safety text became reachable from an Indian brand.

        A medicine whose composition could not be parsed returns an empty list with a
        `note` saying so, rather than an error or a misleading empty result.
      operationId: getAlternatives
      parameters:
        - $ref: "#/components/parameters/MedicineId"
        - name: mode
          in: query
          description: "`exact` keeps the salt form; `base` ignores it. Anything else is read as `exact`."
          schema: { type: string, enum: [exact, base], default: exact }
        - name: market
          in: query
          description: Defaults to the market of the medicine itself.
          schema: { type: string, example: IN }
        - name: limit
          in: query
          schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
      responses:
        "200":
          description: The alternatives.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AlternativesOfMedicine" }
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/alternatives:
    get:
      tags: [Alternatives]
      summary: Alternatives for a composition you type
      description: |
        The same question without a catalogue id. The text is run through the same parser
        the ingest uses, so `PARACETAMOL IP 650 MG`, `Paracetamol 650mg` and
        `Acetaminophen 650 mg` all reduce to the same fingerprint.

        The response includes `parsed` and `key`, so you can see exactly what it understood
        before trusting the list. If it read your text wrongly, that is where it shows.

        Defaults to `mode=base` and `market=IN`.
      operationId: getAlternativesByComposition
      parameters:
        - name: composition
          in: query
          required: true
          schema: { type: string }
          examples:
            single: { value: "Paracetamol 650mg", summary: "One molecule" }
            combination: { value: "Amoxicillin 500mg + Clavulanic Acid 125mg", summary: "A combination" }
            shouted: { value: "PARACETAMOL IP 650 MG", summary: "As a label writes it" }
        - name: market
          in: query
          schema: { type: string, default: IN }
        - name: mode
          in: query
          schema: { type: string, enum: [base, exact], default: base }
        - name: limit
          in: query
          schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
      responses:
        "200":
          description: What the text was understood to mean, and what matches it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AlternativesByComposition" }
        "400":
          description: Missing composition, or no ingredient could be read from it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/ingredients:
    get:
      tags: [Molecules]
      summary: Search molecules
      description: |
        Prefix search over molecule names and their base forms. Returns at most 50.
      operationId: searchIngredients
      parameters:
        - name: q
          in: query
          required: true
          description: At least two characters, normalised the same way the catalogue is.
          schema: { type: string, minLength: 2 }
          examples:
            para: { value: "para" }
            amlo: { value: "amlodipine" }
      responses:
        "200":
          description: Matching molecules.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, const: true }
                  count: { type: integer }
                  results:
                    type: array
                    items: { $ref: "#/components/schemas/IngredientSummary" }
        "400":
          $ref: "#/components/responses/BadRequest"

  /v1/ingredients/{id}:
    get:
      tags: [Molecules]
      summary: One molecule, its safety text and where it is sold
      description: |
        The molecule, every source's safety text for it, and how many brands carry it in
        each market. This is the level safety actually lives at — one row, not one per brand.
      operationId: getIngredient
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: The molecule.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, const: true }
                  ingredient: { $ref: "#/components/schemas/Ingredient" }
                  safety:
                    type: array
                    items: { $ref: "#/components/schemas/Safety" }
                  brandsByMarket:
                    type: array
                    items:
                      type: object
                      properties:
                        market: { type: string, example: IN }
                        brands: { type: integer, example: 3755 }
                  disclaimer: { $ref: "#/components/schemas/Disclaimer" }
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/stats:
    get:
      tags: [Service]
      summary: What is in the catalogue, and what could not be read
      description: |
        Counts by market and by source, the last twenty ingest runs, and the most common
        reasons a record was rejected.

        `topRejects` is the interesting half. Nothing unparseable is dropped silently — it
        is kept with its reason, so a growing pile is the signal that a parser needs work
        rather than a gap nobody notices.
      operationId: getStats
      responses:
        "200":
          description: Catalogue statistics.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, const: true }
                  byMarket:
                    type: array
                    items:
                      type: object
                      properties:
                        market: { type: string, example: IN }
                        products: { type: integer, example: 254881 }
                  bySource:
                    type: array
                    items:
                      type: object
                      properties:
                        source: { type: string, example: india_brands }
                        products: { type: integer }
                  recentRuns:
                    type: array
                    items: { $ref: "#/components/schemas/IngestRun" }
                  topRejects:
                    type: array
                    items:
                      type: object
                      properties:
                        source: { type: string }
                        reason: { type: string, example: no active ingredients listed }
                        n: { type: integer }
        "500":
          $ref: "#/components/responses/ServerError"

components:
  parameters:
    Market:
      name: market
      in: query
      description: Two-letter market code. Omit to search every market.
      schema:
        type: string
        enum: [IN, US, GB, EU, CA, AU]
      example: IN
    MedicineId:
      name: id
      in: path
      required: true
      description: The catalogue id, as returned by search.
      schema: { type: integer }
      example: 182762

  responses:
    BadRequest:
      description: The request could not be understood.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Nothing with that id.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ServerError:
      description: The catalogue could not be read.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Disclaimer:
      type: string
      description: Present on every clinical response. Reference data for trained staff, not medical advice.
      example: >-
        Information is compiled from official drug regulators and is provided for reference
        by trained staff. It is not medical advice and must not be used to diagnose or
        treat. Always check the physical pack and the prescribing information.

    Error:
      type: object
      properties:
        ok: { type: boolean, const: false }
        error: { type: string, example: q must be at least 2 characters }

    RunStatus:
      type: string
      enum: [RUNNING, OK, PARTIAL, FAILED]
      description: |
        `PARTIAL` is normal, not a failure: a run is bounded by slices, records and
        wall-clock time, and stops cleanly when it hits one. It resumes from its cursor.

    Medicine:
      type: object
      description: One product, in one market.
      properties:
        id: { type: integer, example: 182762 }
        market: { type: string, example: IN }
        name: { type: string, example: Dolo 650 Tablet }
        genericName:
          oneOf: [{ type: string }, { type: "null" }]
        manufacturer:
          oneOf: [{ type: string, example: Micro Labs Ltd }, { type: "null" }]
        dosageForm:
          oneOf: [{ type: string, example: Tablet }, { type: "null" }]
        route:
          oneOf: [{ type: string, example: ORAL }, { type: "null" }]
        strength:
          oneOf: [{ type: string }, { type: "null" }]
        packSize:
          oneOf: [{ type: string, example: strip of 15 tablets }, { type: "null" }]
        prescriptionOnly:
          oneOf: [{ type: boolean }, { type: "null" }]
          description: Null where the source does not say. Not a substitute for the schedule on the pack.
        schedule:
          oneOf: [{ type: string, example: H1 }, { type: "null" }]
        status:
          type: string
          enum: [ACTIVE, DISCONTINUED, WITHDRAWN, UNKNOWN]
        compositionKey:
          oneOf: [{ type: string, example: "paracetamol@650mg" }, { type: "null" }]
          description: |
            The fingerprint alternatives are matched on: molecules and strengths, sorted so
            the source's ordering does not matter. `|`-separated for a combination.
        ingredients:
          type: array
          description: Omitted from list responses to keep them small.
          items: { $ref: "#/components/schemas/ProductIngredient" }
        source:
          type: object
          description: Where this row came from. Nothing in the catalogue is unattributable.
          properties:
            name: { type: string, example: india_brands }
            ref: { type: string }
            url:
              oneOf: [{ type: string }, { type: "null" }]
        firstSeenAt: { type: string, example: "2026-09-17 16:24:51" }
        lastSeenAt: { type: string }

    ProductIngredient:
      type: object
      properties:
        id: { type: integer }
        name: { type: string, example: Paracetamol }
        name_norm: { type: string, example: paracetamol }
        base_norm:
          type: string
          description: The molecule without its salt or ester, e.g. amlodipine besylate becomes amlodipine.
        strength_value:
          oneOf: [{ type: number, example: 650 }, { type: "null" }]
          description: Converted to `strength_unit`.
        strength_unit:
          oneOf: [{ type: string, example: mg }, { type: "null" }]
        strength_text:
          oneOf: [{ type: string, example: "650mg" }, { type: "null" }]
          description: Exactly as the source wrote it.

    Price:
      type: object
      description: |
        Always an integer of minor units. `amount` is the same number as a decimal string,
        so you can print it without a float ever being involved.
      properties:
        kind:
          type: string
          enum: [CEILING, MRP, RETAIL]
          description: |
            `CEILING` is India's legal maximum, fixed by NPPA under the Drugs (Prices
            Control) Order. Selling above it is an offence, not a pricing choice.
        amountMinor: { type: integer, example: 101, description: "Paise, cents." }
        amount: { type: string, example: "1.01" }
        currency: { type: string, example: INR }
        effectiveOn:
          oneOf: [{ type: string, example: "2022-03-30" }, { type: "null" }]
        source: { type: string, example: india_nlem }

    Safety:
      type: object
      description: |
        The regulator's own label text, verbatim and never summarised. Belongs to the
        molecule, so every brand of it carries the same wording.
      properties:
        ingredient: { type: string, example: Paracetamol }
        source: { type: string, example: openfda_label }
        side_effects:
          oneOf: [{ type: string }, { type: "null" }]
        warnings:
          oneOf: [{ type: string }, { type: "null" }]
        contraindications:
          oneOf: [{ type: string }, { type: "null" }]
        interactions:
          oneOf: [{ type: string }, { type: "null" }]
        pregnancy:
          oneOf: [{ type: string }, { type: "null" }]
        overdose:
          oneOf: [{ type: string }, { type: "null" }]
        fetched_at: { type: string }

    IngredientSummary:
      type: object
      properties:
        id: { type: integer }
        name: { type: string, example: Paracetamol }
        name_norm: { type: string }
        base_norm: { type: string }
        rxcui:
          oneOf: [{ type: string }, { type: "null" }]
        atc_code:
          oneOf: [{ type: string }, { type: "null" }]

    Ingredient:
      allOf:
        - $ref: "#/components/schemas/IngredientSummary"
        - type: object
          properties:
            unii:
              oneOf: [{ type: string }, { type: "null" }]
            created_at: { type: string }
            updated_at: { type: string }

    ParsedIngredient:
      type: object
      description: How the API read one part of the composition you typed.
      properties:
        name: { type: string, example: Paracetamol }
        base: { type: string, example: paracetamol }
        strength:
          type: object
          properties:
            value:
              oneOf: [{ type: number, example: 650 }, { type: "null" }]
            unit:
              oneOf: [{ type: string, example: mg }, { type: "null" }]
            text: { type: string, example: "650mg" }

    AlternativesOfMedicine:
      type: object
      properties:
        ok: { type: boolean, const: true }
        of: { $ref: "#/components/schemas/Medicine" }
        mode: { type: string, enum: [exact, base] }
        market: { type: string }
        count: { type: integer }
        results:
          type: array
          items: { $ref: "#/components/schemas/Medicine" }
        note:
          type: string
          description: Present only when the medicine has no parsed composition to match on.
        disclaimer: { $ref: "#/components/schemas/Disclaimer" }

    AlternativesByComposition:
      type: object
      properties:
        ok: { type: boolean, const: true }
        composition: { type: string, description: "The text you sent, unchanged." }
        parsed:
          type: array
          description: What it was understood to mean. Check this before trusting the list.
          items: { $ref: "#/components/schemas/ParsedIngredient" }
        key: { type: string, example: "paracetamol@650mg" }
        mode: { type: string, enum: [base, exact] }
        market: { type: string }
        count: { type: integer }
        results:
          type: array
          items: { $ref: "#/components/schemas/Medicine" }
        disclaimer: { $ref: "#/components/schemas/Disclaimer" }

    IngestRun:
      type: object
      properties:
        source: { type: string }
        started_at: { type: string }
        finished_at:
          oneOf: [{ type: string }, { type: "null" }]
        status: { $ref: "#/components/schemas/RunStatus" }
        fetched: { type: integer }
        inserted: { type: integer }
        updated: { type: integer }
        safety:
          type: integer
          description: Safety records written. Counted apart because an enrichment run writes nothing else.
