diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Real Dental Costs Data & Research Team
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,25 @@
+# realdentalcosts (Haskell)
+
+Typed client for the [Real Dental Costs Open Data API](https://realdentalcosts.com/en/api/):
+US dental procedure cash price ranges by state, state cost indexes and Medicaid paid amounts
+per CDT code (HHS/CMS T-MSIS, 2018-2024). Data CC BY 4.0, code MIT.
+
+```haskell
+import RealDentalCosts.Api
+
+main :: IO ()
+main = do
+  Right p <- getProcedure "root-canal"
+  print (avgUsd (procNational p), stateRow "TX" p, provConfidence (procProvenance p))
+  Right m <- getMedicaid "D3330"
+  print (msPaidPerLine (mdNational m))
+```
+
+Human-readable tables: [root canal cost by state](https://realdentalcosts.com/en/root-canal/),
+[denture cost by state](https://realdentalcosts.com/en/dentures/),
+[dental implant cost by state](https://realdentalcosts.com/en/dental-implants/),
+[Medicaid dental coverage by state](https://realdentalcosts.com/en/medicaid-dental-coverage-by-state/).
+Methodology: <https://realdentalcosts.com/en/methodology/>. Dataset DOI 10.7910/DVN/7F2BZI.
+Pricing and market research, not medical advice.
+
+Status: source published; Hackage upload pending uploader-group approval.
diff --git a/realdentalcosts.cabal b/realdentalcosts.cabal
new file mode 100644
--- /dev/null
+++ b/realdentalcosts.cabal
@@ -0,0 +1,33 @@
+cabal-version:      2.4
+name:               realdentalcosts
+version:            0.1.0.0
+synopsis:           Client for the Real Dental Costs Open Data API (US dental prices)
+description:
+  Typed client for the Real Dental Costs Open Data API
+  (<https://realdentalcosts.com/en/api/>): US dental procedure cash price
+  ranges by state, state cost indexes and Medicaid paid amounts per CDT code
+  derived from the HHS/CMS T-MSIS provider spending files. Every payload
+  carries a provenance block (basis, confidence, sample size). Data licence
+  CC BY 4.0; see <https://realdentalcosts.com/en/methodology/>.
+homepage:           https://realdentalcosts.com/en/api/
+bug-reports:        https://codeberg.org/tresor2k/realdentalcosts-hs/issues
+license:            MIT
+license-file:       LICENSE
+author:             Real Dental Costs Data and Research Team
+maintainer:         research@realdentalcosts.com
+category:           Web, Data
+build-type:         Simple
+extra-doc-files:    README.md
+
+source-repository head
+  type:     git
+  location: https://codeberg.org/tresor2k/realdentalcosts-hs.git
+
+library
+  exposed-modules:  RealDentalCosts.Api
+  build-depends:    base >=4.14 && <5,
+                    aeson >=2.0 && <2.3,
+                    http-conduit >=2.3 && <2.4,
+                    text >=1.2 && <2.2
+  hs-source-dirs:   src
+  default-language: Haskell2010
diff --git a/src/RealDentalCosts/Api.hs b/src/RealDentalCosts/Api.hs
new file mode 100644
--- /dev/null
+++ b/src/RealDentalCosts/Api.hs
@@ -0,0 +1,132 @@
+{-# LANGUAGE OverloadedStrings #-}
+-- | Client for the Real Dental Costs Open Data API
+-- (<https://realdentalcosts.com/en/api/>).
+--
+-- The API is static JSON (CC BY 4.0) covering US dental procedure cash price
+-- ranges by state (<https://realdentalcosts.com/en/root-canal/>,
+-- <https://realdentalcosts.com/en/dentures/>,
+-- <https://realdentalcosts.com/en/dental-implants/>), state cost indexes and
+-- Medicaid paid amounts per CDT code from the HHS/CMS T-MSIS files
+-- (<https://realdentalcosts.com/en/medicaid-dental-coverage-by-state/>).
+-- Every payload carries a @provenance@ block; see
+-- <https://realdentalcosts.com/en/methodology/>.
+module RealDentalCosts.Api
+  ( baseUrl
+  , Provenance (..)
+  , PriceRange (..)
+  , StatePrice (..)
+  , Procedure (..)
+  , MedicaidStats (..)
+  , MedicaidState (..)
+  , Medicaid (..)
+  , getProcedure
+  , getState
+  , getMedicaid
+  , getJSON
+  , stateRow
+  ) where
+
+import Data.Aeson (FromJSON (..), Value, withObject, (.:), (.:?), eitherDecode)
+import Data.Char (toLower, toUpper)
+import Data.List (find)
+import Data.Text (Text)
+import Network.HTTP.Simple (getResponseBody, getResponseStatusCode, httpLBS, parseRequest, setRequestHeader)
+
+-- | Production API root.
+baseUrl :: String
+baseUrl = "https://realdentalcosts.com/api/v1"
+
+-- | Where a figure comes from and how much to trust it.
+data Provenance = Provenance
+  { provBasis      :: Text     -- ^ "observed" or "estimated"
+  , provModeled    :: Bool
+  , provConfidence :: Int      -- ^ 0-100
+  , provSampleN    :: Maybe Int
+  , provPeriod     :: Maybe Text
+  } deriving (Show, Eq)
+
+instance FromJSON Provenance where
+  parseJSON = withObject "Provenance" $ \o ->
+    Provenance <$> o .: "basis" <*> o .: "modeled" <*> o .: "confidence"
+               <*> o .:? "sample_n" <*> o .:? "period"
+
+data PriceRange = PriceRange { lowUsd :: Int, avgUsd :: Int, highUsd :: Int }
+  deriving (Show, Eq)
+
+instance FromJSON PriceRange where
+  parseJSON = withObject "PriceRange" $ \o ->
+    PriceRange <$> o .: "low_usd" <*> o .: "avg_usd" <*> o .: "high_usd"
+
+data StatePrice = StatePrice
+  { spState :: Text, spCode :: Text, spLow :: Int, spAvg :: Int, spHigh :: Int, spConfidence :: Int }
+  deriving (Show, Eq)
+
+instance FromJSON StatePrice where
+  parseJSON = withObject "StatePrice" $ \o ->
+    StatePrice <$> o .: "state" <*> o .: "code" <*> o .: "low_usd" <*> o .: "avg_usd"
+               <*> o .: "high_usd" <*> o .: "confidence"
+
+-- | Payload of @/procedure/{slug}.json@.
+data Procedure = Procedure
+  { procSlug :: Text, procLabel :: Text, procBasis :: Text
+  , procProvenance :: Provenance, procNational :: PriceRange, procByState :: [StatePrice] }
+  deriving (Show, Eq)
+
+instance FromJSON Procedure where
+  parseJSON = withObject "Procedure" $ \o ->
+    Procedure <$> o .: "slug" <*> o .: "label" <*> o .: "basis" <*> o .: "provenance"
+              <*> o .: "national" <*> o .: "by_state"
+
+data MedicaidStats = MedicaidStats
+  { msPaidPerLine :: Double, msMedianCell :: Double, msLines :: Int, msPatients :: Int, msProviders :: Int }
+  deriving (Show, Eq)
+
+instance FromJSON MedicaidStats where
+  parseJSON = withObject "MedicaidStats" $ \o ->
+    MedicaidStats <$> o .: "paid_per_line_wavg" <*> o .: "median_cell" <*> o .: "lines"
+                  <*> o .: "patients" <*> o .: "n_providers"
+
+data MedicaidState = MedicaidState
+  { mstState :: Text, mstCode :: Text, mstQuality :: Text, mstConfidence :: Int, mstLatest :: Maybe MedicaidStats }
+  deriving (Show, Eq)
+
+instance FromJSON MedicaidState where
+  parseJSON = withObject "MedicaidState" $ \o ->
+    MedicaidState <$> o .: "state" <*> o .: "code" <*> o .: "quality_flag" <*> o .: "confidence" <*> o .:? "latest"
+
+-- | Payload of @/medicaid/{cdt}.json@ (e.g. @D3330@ molar root canal).
+data Medicaid = Medicaid
+  { mdCode :: Text, mdLabel :: Text, mdProvenance :: Provenance, mdNational :: MedicaidStats
+  , mdStates :: [MedicaidState], mdCaveats :: [Text] }
+  deriving (Show, Eq)
+
+instance FromJSON Medicaid where
+  parseJSON = withObject "Medicaid" $ \o ->
+    Medicaid <$> o .: "code" <*> o .: "label" <*> o .: "provenance" <*> o .: "national"
+             <*> o .: "states" <*> o .: "caveats"
+
+-- | Fetch and decode any endpoint path (e.g. @"/states.json"@).
+getJSON :: FromJSON a => String -> IO (Either String a)
+getJSON path = do
+  req <- setRequestHeader "User-Agent" ["realdentalcosts-hs/0.1"] <$> parseRequest (baseUrl ++ path)
+  resp <- httpLBS req
+  let code = getResponseStatusCode resp
+  pure $ if code == 404 then Left ("not found: " ++ path)
+         else if code < 200 || code > 299 then Left ("HTTP " ++ show code ++ ": " ++ path)
+         else eitherDecode (getResponseBody resp)
+
+-- | One procedure by slug (e.g. @"root-canal"@, @"dentures"@, @"dental-implant"@).
+getProcedure :: String -> IO (Either String Procedure)
+getProcedure slug = getJSON ("/procedure/" ++ map toLower slug ++ ".json")
+
+-- | One state by two-letter code; returned as a raw JSON 'Value'.
+getState :: String -> IO (Either String Value)
+getState code = getJSON ("/state/" ++ map toLower code ++ ".json")
+
+-- | Medicaid paid amounts per claim line for a CDT code.
+getMedicaid :: String -> IO (Either String Medicaid)
+getMedicaid cdt = getJSON ("/medicaid/" ++ map toUpper cdt ++ ".json")
+
+-- | Find a state's row in a procedure table.
+stateRow :: Text -> Procedure -> Maybe StatePrice
+stateRow code = find ((== code) . spCode) . procByState
