diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changelog for `erpnext-api-client`
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to the
+[Haskell Package Versioning Policy](https://pvp.haskell.org/).
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Intensovet GmbH
+
+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,166 @@
+# erpnext-api-client
+
+This is a Haskell API client for
+[ERPNext](https://github.com/frappe/erpnext). It aims to be a
+light-weight library based on
+[http-client](https://hackage.haskell.org/package/http-client) and
+user-provided record types.
+
+ERPNext has the concept of
+[DocTypes](https://docs.frappe.io/erpnext/user/manual/en/doctype) which
+model entities like Customer, Sales Order, etc.
+
+The [ERPNext REST
+API](https://docs.frappe.io/framework/user/en/api/rest) basically has
+seven types of requests covering CRUD operations, remote method calls,
+and file uploads.
+
+CRUD operations on a given DocType are:
+
+-   `GET` list of all documents (`getDocTypeList`)
+-   `POST` to create a new document (`postDocType`)
+-   `GET` a document by name (`getDocType`)
+-   `PUT` to update a document by name (`putDocType`)
+-   `DELETE` a document by name (`deleteDocType`)
+
+Note: Remote method calls and file uploads are not yet supported.
+
+DocTypes in ERPNext can be extended and newly created by users. This is
+why this library does not come with any predefined types for DocTypes.
+Instead, users can provide their own types with only the fields they
+need.
+
+This library also provides tooling to generate Haskell record types for
+DocTypes from a simple DSL (see section below).
+
+## Usage
+
+``` haskell
+{-# LANGUAGE OverloadedStrings #-}
+
+import ERPNext.Client
+import Network.HTTP.Client
+import Data.Aeson
+import GHC.Generics
+
+data Customer = Customer
+  { name :: String
+  } deriving Generic
+
+instance FromJSON Customer
+instance ToJSON Customer
+
+instance IsDocType Customer where
+  docTypeName = "Customer"
+
+main :: IO ()
+main = do
+  let config = mkConfig "https://erpnext.example.com" "api-key" (mkSecret "api-secret")
+  manager <- newManager defaultManagerSettings
+  response <- getDocType manager config "Company A" :: IO (ApiResponse Customer)
+  case response of
+    Ok _ c _ -> putStrLn $ name c
+    Err r _ -> print r
+```
+
+``` bash
+stack runhaskell example1.hs
+```
+
+## Scope and Limits
+
+-   Only [token-based
+    authentication](https://docs.frappe.io/framework/user/en/api/rest#1-token-based-authentication)
+    is supported.
+-   [Remote Method
+    Calls](https://docs.frappe.io/framework/user/en/api/rest#remote-method-calls)
+    are not yet supported.
+-   [File
+    Uploads](https://docs.frappe.io/framework/user/en/api/rest#file-uploads)
+    are not yet supported.
+
+## Data records for DocTypes
+
+Haskell record types for the DocTypes can be coded by hand which
+requires some boiler plate like Aeson instances, handling `null` or
+missing values, and writing helper functions like `mkCustomer`.
+
+This library provides tooling to generate Haskell record types from a
+simple DSL very similar to [persistent's model definition
+syntax](https://hackage.haskell.org/package/persistent/docs/Database-Persist-Quasi.html).
+
+1.  A script generates an [OpenAPI
+    Specification](https://swagger.io/specification/) file in `yaml`
+    format.
+2.  The
+    [Haskell-OpenAPI-Client-Code-Generator](https://github.com/Haskell-OpenAPI-Code-Generator/Haskell-OpenAPI-Client-Code-Generator/)
+    generates an Haskell API client, from which only the type
+    definitions can be used.
+
+The resulting files are a separate Haskell package which can be added as
+dependency. The resulting record types can be used together with this
+API client but the `IsDocType` instance must still be defined by hand.
+
+****The API client part generated from the OpenAPI spec can not be
+used.****
+
+``` example
+$ ./scripts/gen-openapi-yaml.sh erpnext/models > openapi.yaml
+$ openapi3-code-generator-exe \
+    --specification openapi.yaml \
+    --package-name erpnext-api-client-models \
+    --module-name ERPNextAPI \
+    --force --output-dir api-client/
+$ tree api-client/
+api-client/
+├── erpnext-api-client-models.cabal
+├── src
+│   ├── ERPNextAPI
+│   │   ├── Common.hs
+│   │   ├── Configuration.hs
+│   │   ├── Operations
+│   │   │   └── DummyOperation.hs
+│   │   ├── SecuritySchemes.hs
+│   │   ├── TypeAlias.hs
+│   │   ├── Types     <---- here are the generated types
+│   │   │   ├── Customer.hs
+│   │   │   ├── Order.hs
+│   │   │   ├── Supplier.hs
+│   │   └── Types.hs
+│   └── ERPNextAPI.hs
+├── stack.yaml
+└── stack.yaml.lock
+```
+
+## Note on TLS problems
+
+If you're running ERPNext in your test environment, chances are that
+your server does not have a valid TLS certificate signed by a trusted
+CA.
+
+In this case you can configure the HTTP connection manager's TLS
+settings like this:
+
+``` haskell
+import Network.HTTP.Client
+import Network.HTTP.Client.TLS (mkManagerSettings)
+import Network.Connection (TLSSettings (..))
+
+…
+
+let tlsSettings =
+      mkManagerSettings
+        ( TLSSettingsSimple
+            { settingDisableCertificateValidation = True
+            , settingDisableSession = False
+            , settingUseServerName = False
+            }
+        )
+        Nothing
+manager <- Network.HTTP.Client.newManager tlsSettings
+…
+```
+
+``` bash
+stack runhaskell --package crypton-connection --package http-client-tls example1.hs
+```
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/erpnext-api-client.cabal b/erpnext-api-client.cabal
new file mode 100644
--- /dev/null
+++ b/erpnext-api-client.cabal
@@ -0,0 +1,40 @@
+cabal-version:       3.6
+
+name:                erpnext-api-client
+version:             0.0.0.1
+description:         xxx
+homepage:            xxx
+license:             MIT
+license-file:        LICENSE
+author:              Jakob Schöttl
+maintainer:          jakob.schoettl@intensovet.de
+copyright:           2025 Intensovet GmbH
+category:            API
+build-type:          Simple
+extra-source-files:  README.md
+                     CHANGELOG.md
+
+library
+  hs-source-dirs:      src
+  exposed-modules:     ERPNext.Client
+                     , ERPNext.Client.Helper
+                     , ERPNext.Client.Filters
+                     , ERPNext.Client.QueryStringParams
+  build-depends:       base >= 4.7 && < 5
+                     , http-client
+                     , http-types
+                     , text
+                     , aeson
+                     , time
+                     , network-uri
+                     , bytestring
+  default-language:    GHC2021
+  ghc-options:         -Wall
+                       -Wcompat
+                       -Widentities
+                       -Wincomplete-record-updates
+                       -Wincomplete-uni-patterns
+                       -Wmissing-export-lists
+                       -Wmissing-home-modules
+                       -Wpartial-fields
+                       -Wredundant-constraints
diff --git a/src/ERPNext/Client.hs b/src/ERPNext/Client.hs
new file mode 100644
--- /dev/null
+++ b/src/ERPNext/Client.hs
@@ -0,0 +1,169 @@
+{-# LANGUAGE AllowAmbiguousTypes #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module ERPNext.Client
+  ( getDocTypeList
+  , getDocType
+  , postDocType
+  , putDocType
+  , deleteDocType
+  , mkSecret
+  , mkConfig
+  , IsDocType (..)
+  , Config ()
+  , Secret ()
+  , QueryStringParam (..)
+  , ApiResponse (..)
+  , getResponse
+  ) where
+
+import Network.HTTP.Client (Response (..), Request (..), Manager, httpLbs, parseRequest, RequestBody (..))
+import Network.HTTP.Types (hAuthorization, hContentType, Header)
+import Data.Text hiding (map)
+import Data.Text.Encoding (encodeUtf8)
+import Data.Aeson
+import Data.ByteString qualified as BS
+import Data.ByteString.Lazy qualified as LBS
+import ERPNext.Client.QueryStringParams
+import ERPNext.Client.Helper (urlEncode)
+
+-- | Type class for types which represent an ERPNext DocType.
+-- Each DocType has a unique name.
+class IsDocType a where
+  docTypeName :: Text
+  -- TODO: implement auto-derive (using typename and generic)?
+
+getDocTypeList :: forall a. (IsDocType a, FromJSON a)
+               => Manager -> Config  -> [QueryStringParam]-> IO (ApiResponse [a])
+getDocTypeList manager config qsParams = do
+  let path = getResourcePath @a <> "?" <> renderQueryStringParams qsParams
+  request <- createRequest config path "GET"
+  response <- httpLbs request manager
+  return $ parseGetResponse response
+
+getDocType :: forall a. (IsDocType a, FromJSON a)
+           => Manager -> Config -> Text -> IO (ApiResponse a)
+getDocType manager config name = do
+  let path = getResourcePath @a <> "/" <> name
+  request <- createRequest config path "GET"
+  response <- httpLbs request manager
+  return $ parseGetResponse response
+
+{- | Delete a named object.
+
+The phantom type parameter @a@ is used to figure out the DocType.
+A customer can be deleted like this:
+
+@
+res <- deleteDocType @Customer manager config "<customer name>"
+@
+-}
+deleteDocType :: forall a. (IsDocType a)
+              => Manager -> Config -> Text -> IO (ApiResponse ())
+deleteDocType manager config name = do
+  let path = getResourcePath @a <> "/" <> name
+  request <- createRequest config path "DELETE"
+  response <- httpLbs request manager
+  return $ parseDeleteResponse response
+
+postDocType :: forall a. (IsDocType a, FromJSON a, ToJSON a)
+            => Manager -> Config -> a -> IO (ApiResponse a)
+postDocType manager config doc = do
+  let path = getResourcePath @a
+  request <- createRequestWithBody config path "POST" doc
+  response <- httpLbs request manager
+  return $ parseGetResponse response
+
+putDocType :: forall a. (IsDocType a, FromJSON a, ToJSON a)
+           => Manager -> Config -> Text -> a -> IO (ApiResponse a)
+putDocType manager config name doc = do
+  let path = getResourcePath @a <> "/" <> name
+  request <- createRequestWithBody config path "PUT" doc
+  response <- httpLbs request manager
+  return $ parseGetResponse response
+
+
+mkConfig :: Text -> Text -> Secret -> Config
+mkConfig baseUrl apiKey apiSecret = Config
+  { baseUrl = baseUrl
+  , apiKey = apiKey
+  , apiSecret = apiSecret
+  }
+
+-- | Create the API secret used together with the API key for authorization.
+mkSecret :: Text -> Secret
+mkSecret = Secret
+
+
+-- | Create the API Request.
+createRequest :: Config -> Text -> BS.ByteString -> IO Request
+createRequest config path method = do
+  request <- parseRequest $ unpack (baseUrl config <> path)
+  return request
+    { method = method
+    , requestHeaders = [mkAuthHeader config]
+    }
+
+createRequestWithBody :: ToJSON a => Config -> Text -> BS.ByteString -> a -> IO Request
+createRequestWithBody config path method doc = do
+  request <- parseRequest $ unpack (baseUrl config <> path)
+  return request
+    { method = method
+    , requestHeaders = mkAuthHeader config : [(hContentType, encodeUtf8 "application/json")]
+    , requestBody = RequestBodyLBS (encode doc)
+    }
+
+-- | API client configuration.
+data Config = Config
+  { baseUrl :: Text
+  , apiKey :: Text
+  , apiSecret :: Secret
+  }
+
+-- | Opaque type to store the API secret.
+data Secret = Secret
+  { getSecret :: Text
+  }
+
+data DataWrapper a = DataWrapper { getData :: a }
+  deriving Show
+
+instance FromJSON a => FromJSON (DataWrapper a) where
+  parseJSON = withObject "DataWrapper" $ \obj -> do
+    dataValue <- obj .: "data"
+    return (DataWrapper dataValue)
+
+data ApiResponse a
+  = Ok (Response LBS.ByteString) Value a
+  | Err (Response LBS.ByteString) (Maybe (Value, Text))
+  deriving Show
+
+getResponse :: ApiResponse a -> Response LBS.ByteString
+getResponse (Ok r _ _) = r
+getResponse (Err r _) = r
+
+mkAuthHeader :: Config -> Header
+mkAuthHeader config = let authToken = apiKey config <> ":" <> getSecret (apiSecret config)
+                          in (hAuthorization, encodeUtf8 $ "token " <> authToken)
+
+parseGetResponse :: forall a. FromJSON a => Response LBS.ByteString -> ApiResponse a
+parseGetResponse response =
+  case decode @Value (responseBody response) of
+    Just value -> case fromJSON value :: Result (DataWrapper a) of
+      Success result -> Ok response value (getData result)
+      Error err -> Err response (Just (value, pack err))
+    Nothing -> Err response Nothing
+
+parseDeleteResponse :: Response LBS.ByteString -> ApiResponse ()
+parseDeleteResponse response =
+  case decode @Value (responseBody response) of
+    Just value -> case fromJSON value :: Result (DataWrapper Text) of
+      Success (DataWrapper message)
+        | message == "ok" -> Ok response value ()
+        | otherwise -> Err response (Just (value, message))
+      Error err -> Err response (Just (value, pack err))
+    Nothing -> Err response Nothing
+
+getResourcePath :: forall a. IsDocType a => Text
+getResourcePath = "/resource/" <> urlEncode (docTypeName @a)
diff --git a/src/ERPNext/Client/Filters.hs b/src/ERPNext/Client/Filters.hs
new file mode 100644
--- /dev/null
+++ b/src/ERPNext/Client/Filters.hs
@@ -0,0 +1,90 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module ERPNext.Client.Filters
+  ( Filter (..)
+  , FilterOperator (..)
+  , FilterValue (..)
+  , renderFilters
+  ) where
+
+import Data.Text (Text, intercalate)
+import Data.Time.Calendar (Day)
+import ERPNext.Client.Helper (urlEncode, quote, tshow)
+
+-- TODO: refactor this? rename to filter and parameterize each term with fieldname and value?
+data FilterOperator
+  = Eq
+  | NotEq
+  | Greater
+  | GreaterOrEq
+  | Less
+  | LessOrEq
+  | Like
+  | NotLike
+  | In
+  | NotIn
+  | Between
+  | Is
+  deriving (Show, Eq)
+
+data FilterValue
+  = FilterText Text
+  | FilterNumber Double
+  | FilterBool Bool
+  | FilterList [FilterValue]
+  | FilterDay Day
+  | FilterNull -- Used only with Is
+  | FilterNotNull -- Used only with Is
+  deriving (Show, Eq)
+
+data Filter = Filter
+  { filterField :: Text
+  , filterOperator :: FilterOperator
+  , filterValue :: FilterValue
+  }
+  deriving (Show, Eq)
+
+renderFilterOperator :: FilterOperator -> Text
+renderFilterOperator op =
+  case op of
+    Eq -> "="
+    NotEq -> "!="
+    Greater -> ">"
+    GreaterOrEq -> ">="
+    Less -> "<"
+    LessOrEq -> "<="
+    Like -> "like"
+    NotLike -> "not like"
+    In -> "in"
+    NotIn -> "not in"
+    Between -> "between"
+    Is -> "is"
+
+renderFilterValue :: FilterValue -> Text
+renderFilterValue fv =
+  case fv of
+    FilterText t -> quote t
+    FilterNumber n -> tshow n
+    FilterBool b -> if b then "1" else "0"
+    FilterList vs -> "[" <> intercalate ", " (map renderFilterValue vs) <> "]"
+    FilterDay d -> quote (tshow d)
+    FilterNull -> quote "not set"
+    FilterNotNull -> quote "set"
+
+renderFilter :: Filter -> Text
+renderFilter f =
+  "["
+    <> quote (filterField f)
+    <> ","
+    <> quote (renderFilterOperator (filterOperator f))
+    <> ","
+    <> renderFilterValue (filterValue f)
+    <> "]"
+
+renderFilters :: Text -> [Filter] -> Text
+renderFilters prefix filters =
+  let
+    encoded = map renderFilter filters
+    str = "[" <> intercalate ", " encoded <> "]"
+  in
+    prefix <> "=" <> urlEncode str
diff --git a/src/ERPNext/Client/Helper.hs b/src/ERPNext/Client/Helper.hs
new file mode 100644
--- /dev/null
+++ b/src/ERPNext/Client/Helper.hs
@@ -0,0 +1,24 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module ERPNext.Client.Helper
+  ( urlEncode
+  , quote
+  , tshow
+  ) where
+
+import Data.Text
+import Data.Text qualified as T
+import Network.URI (escapeURIString, isUnreserved)
+
+urlEncode :: Text -> Text
+urlEncode = pack . escapeURIString isUnreserved . unpack
+
+sanitizeQuotes :: Text -> Text
+sanitizeQuotes = T.filter (/= '"')
+
+quote :: Text -> Text
+quote t = "\"" <> sanitizeQuotes t <> "\""
+
+tshow :: Show a => a -> Text
+tshow = pack . show
+
diff --git a/src/ERPNext/Client/QueryStringParams.hs b/src/ERPNext/Client/QueryStringParams.hs
new file mode 100644
--- /dev/null
+++ b/src/ERPNext/Client/QueryStringParams.hs
@@ -0,0 +1,59 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module ERPNext.Client.QueryStringParams
+  ( QueryStringParam (..)
+  , renderQueryStringParams
+  ) where
+
+import ERPNext.Client.Filters
+import ERPNext.Client.Helper (urlEncode, quote, tshow)
+import Data.Text hiding (map)
+
+-- https://docs.frappe.io/framework/user/en/api/rest
+
+-- TODO: Maybe change type or make opaque type to prevent invalid combinations?
+-- TODO: add variants for limit, offset, etc.?
+data QueryStringParam
+  = Debug Bool -- ^ if 'True', makes API returning query analysis info instead of data
+  | AsDict Bool -- ^ if 'False', makes API returning the data records as mixed-type arrays which cannot be parsed by this library (default: 'True')
+  | LimitStart Int -- ^ offset
+  | LimitPageLength Int -- ^ page size
+  | Asc Text
+  | Desc Text
+  | Fields [Text]
+  | AndFilter [Filter]
+  | OrFilter [Filter]
+
+renderQueryStringParam :: QueryStringParam -> Text
+renderQueryStringParam qsParam =
+  case qsParam of
+    Debug b -> "debug=" <> tshow b
+    AsDict b -> "as_dict=" <> tshow b
+    LimitStart offset -> "limit_start=" <> tshow offset
+    LimitPageLength n -> "limit=" <> tshow n -- it was limit_page_length up to v13
+
+    Asc field ->
+      renderOrderBy field "asc"
+
+    Desc field ->
+      renderOrderBy field "desc"
+
+    Fields fields ->
+      renderFields fields
+
+    AndFilter filters ->
+      renderFilters "filters" filters
+
+    OrFilter filters ->
+      renderFilters "or_filters" filters
+
+renderFields :: [Text] -> Text
+renderFields fields =
+  "fields=" <> urlEncode ("[" <> intercalate "," (map quote fields) <> "]")
+
+renderOrderBy :: Text -> Text -> Text
+renderOrderBy field order =
+  "order_by=" <> urlEncode (field <> " " <> order)
+
+renderQueryStringParams :: [QueryStringParam] -> Text
+renderQueryStringParams params = intercalate "&" (map renderQueryStringParam params)
