erpnext-api-client: Generic API client library for ERPNext

[ api, library, mit ] [ Propose Tags ] [ Report a vulnerability ]

This is a Haskell API client for ERPNext. It aims to be a light-weight library based on http-client and user-provided record types.


[Skip to Readme]

Downloads

Maintainer's Corner

Package maintainers

For package maintainers and hackage trustees

Candidates

  • No Candidates
Versions [RSS] 0.0.0.1, 0.1.0.0, 0.1.0.1, 0.2.0.0, 0.3.0.0
Change log CHANGELOG.md
Dependencies aeson, aeson-pretty, base (>=4.7 && <5), bytestring, http-client, http-types, network-uri, scientific, text, time [details]
License MIT
Copyright 2025 Intensovet GmbH
Author Jakob Schöttl, Omar Elbeltagui
Maintainer jakob.schoettl@intensovet.de
Uploaded by schoettl at 2026-06-25T21:24:19Z
Category API
Home page https://github.com/schoettl/erpnext-api-client
Distributions
Downloads 130 total (13 in the last 30 days)
Rating (no votes yet) [estimated by Bayesian average]
Your Rating
  • λ
  • λ
  • λ
Status Docs available [build log]
Last success reported on 2026-06-25 [all 1 reports]

Readme for erpnext-api-client-0.3.0.0

[back to package description]

erpnext-api-client

This is a Haskell API client for ERPNext. It aims to be a light-weight library based on http-client and user-provided record types.

ERPNext has the concept of DocTypes which model entities like Customer, Sales Order, etc.

The ERPNext REST API 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 of a DocType (getDocList)
  • POST to create a new document (postDoc)
  • GET a document by name (getDoc)
  • PUT to update a document by name (putDoc)
  • DELETE a document by name (deleteDoc)

Note: Remote method calls and file uploads are not yet supported.

In ERPNext, DocTypes 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

This sample code makes a GET request for a named document of the given DocType.

{-# LANGUAGE OverloadedStrings #-}

import ERPNext.Client
import System.Environment
import Data.Text (pack)
import Network.HTTP.Client.TLS
import Data.Aeson
import GHC.Generics

data Customer = Customer
  { name :: String
  } deriving Generic

instance FromJSON Customer
instance IsDocType Customer where
  docTypeName = "Customer"
  docName = error "not implemented"

main :: IO ()
main = do
  url <- pack <$> getEnv "ERPNEXT_BASE_URL" -- e.g. "https://my-erpnext.frappe.cloud/api"
  apiKey <- pack <$> getEnv "ERPNEXT_API_KEY"
  apiSecret <- mkSecret . pack <$> getEnv "ERPNEXT_API_SECRET"

  let config = mkConfig url apiKey apiSecret
  manager <- newTlsManager
  response <- ERPNext.Client.getDoc manager config "Company A" :: IO (ApiResponse Customer)
  case response of
    Ok _ _ c -> putStrLn $ name c
    Err r _  -> putStrLn $ show r

For running the example, define these variables in a .env file:

export ERPNEXT_BASE_URL=https://my-test.frappe.cloud/api
export ERPNEXT_API_KEY=xxxxxx
export ERPNEXT_API_SECRET=yyyyyy

Then, inside this repository directory you can run:

. .env
stack runhaskell --package http-client-tls example1.hs

Interactive testing in GHCi

. .env
stack ghci --package http-client-tls
{-# LANGUAGE OverloadedStrings #-}
import ERPNext.Client qualified as Client
import ERPNext.Client.Simple qualified as Simple
import ERPNext.Client.QueryStringParam
import ERPNext.Client.Filter
import Network.HTTP.Client.TLS
import System.Environment
import Data.Aeson
import GHC.Generics

url <- pack <$> getEnv "ERPNEXT_BASE_URL"
apiKey <- pack <$> getEnv "ERPNEXT_API_KEY"
apiSecret <- mkSecret . pack <$> getEnv "ERPNEXT_API_SECRET"

let config = mkConfig url apiKey apiSecret
manager <- newTlsManager

-- Explicitly name the doctype to fetch, "DocType" in this example:
Ok _ jsonResponse _ <- Simple.getDoc manager config "DocType" "Task"
putStrLn $ showJsonPretty jsonResponse

-- Define a DocType. Use :{ and :} in GHCi to paste multi-line Haskell code.
data Customer = Customer
  { name :: Text
  , customer_name :: Text
  } deriving (Show, Generic)
instance FromJSON Customer
instance IsDocType Customer where
  docTypeName = "Customer"
  docName = name

-- Get a customer, infer doctype via type system:
Ok _ jsonResponse parsedCustomer <- Client.getDoc @Customer manager config "Company A"
print parsedCustomer
print $ docName parsedCustomer

-- Get a filtered list of customers, infer doctype via type system:
Ok _ jsonResponse parsedCustomerList <- Client.getDocList @Customer manager config [Fields ["name", "customer_name"], AndFilter [Like "name" "%Test%"]]
print parsedCustomerList

Advanced example use

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE DuplicateRecordFields #-}

import ERPNext.Client
import ERPNext.Client.Filter as Filter
import ERPNext.Client.QueryStringParam
import System.Environment
import Data.Text (Text, pack, unpack, intercalate)
import Network.HTTP.Client.TLS
import Data.Aeson
import GHC.Generics

data Customer = Customer
  { name :: Text
  , customer_name :: Text
  } deriving Generic

instance FromJSON Customer
instance IsDocType Customer where
  docTypeName = "Customer"
  docName = error "not implemented"

data SalesOrder = SalesOrder
  { name :: Text
  , customer :: Text
  } deriving Generic

instance FromJSON SalesOrder
instance IsDocType SalesOrder where
  docTypeName = "Sales Order"
  docName = error "not implemented"

main :: IO ()
main = do
  url <- pack <$> getEnv "ERPNEXT_BASE_URL" -- e.g. "https://my-erpnext.frappe.cloud/api"
  apiKey <- pack <$> getEnv "ERPNEXT_API_KEY"
  apiSecret <- mkSecret . pack <$> getEnv "ERPNEXT_API_SECRET"

  let cfg = mkConfig url apiKey apiSecret
  mgr <- newTlsManager

  -- Get all customers with given customer name:
  customersRes <- getDocList @Customer mgr cfg [Fields ["name", "customer_name"], AndFilter [Filter.Eq "customer_name" "Test Customer"]]
  case customersRes of
    Ok _ _ customers -> putStrLn $ show (length customers) ++ " customer(s)"
    err  -> putStrLn $ "error: \n" ++ showJsonResponsePretty err

  -- Get customer linked to sales order:
  let andThenWithCustomer = andThenWith (\o -> o.customer)
  (salesOrderRes, mCustomerRes) <- getDoc @SalesOrder mgr cfg "SAL-ORD-2026-00002"
                              `andThenWithCustomer` getDoc @Customer mgr cfg
  case (salesOrderRes, mCustomerRes) of
    (Ok _ _ salesOrder, Just (Ok _ _ customer)) -> putStrLn $ unpack salesOrder.name ++ ": " ++ unpack customer.customer_name
    (x, my) -> putStrLn $ "error: \n" ++ showJsonResponsePretty x ++ "\n" ++ maybe "" showJsonResponsePretty my

  res <- getAllFieldnames @Customer mgr cfg
  case res of
    Ok _ _ fieldnames -> putStrLn $ unpack $ intercalate ", " (take 5 fieldnames) <> ", ..."
    err -> putStrLn $ "error: \n" ++ showJsonResponsePretty res
. .env
stack runhaskell --package http-client-tls example2.hs

Scope and Limits

Data records for DocTypes

Haskell record types for the DocTypes can be coded by hand which requires some boilerplate 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.

  1. A script generates an OpenAPI Specification file in yaml format from your models file.
  2. The 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 a dependency. The resulting record types can be used together with this API client but the IsDocType instance must still be defined by hand.

Note: The API client part generated from the OpenAPI spec can not be used.

Example models file:

SalesOrder
  name Text
  total Double
  transaction_date Text
  items [SalesOrderItem]
  Required name

SalesOrderItem
  name Text
  Required name

Item
  item_code Text
  item_name Text
  item_group Text
  default_warehouse Text
  country_of_origin Text
  disabled Int
  is_purchase_item Int
  is_sales_item Int
  is_stock_item Int
  stock_uom Text
  Required item_code item_name is_purchase_item is_sales_item is_stock_item
$ ./scripts/gen-openapi-yaml.sh 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
│   │   │   ├── SalesOrder.hs
│   │   │   ├── SalesOrderItem.hs
│   │   │   ├── Item.hs
│   │   └── Types.hs
│   └── ERPNextAPI.hs
└── stack.yaml

To include the generated model types in your stack project:

Add to your stack.yaml:

extra-deps:
- …
- ./api-client/

In your package.yaml:

dependencies:
- …
- erpnext-api-client-models

In your Haskell code:

import ERPNext.Client -- the erpnext-api-client
import ERPNextAPI.Types -- the generated types

…

-- And here some orphan instances:

instance IsDocType SalesOrder where
  docTypeName = "Sales Order"

instance IsDocType Customer where
  docTypeName = "Customer"

Notes on Maybe and Nullable

In the DocType definition model,

  • Maybe becomes Nullable meaning that the field value can be null (or Null in Haskell).

  • Fields not listed after Required become Maybe meaning that the fields are optional and Nothing will be left out in the JSON representation.

Type level:

Project
  name Text
  project_name Text
  status Text
  project_type Text Maybe
  Required name project_name

data Project = Project
  { projectName :: Text
  , projectProject_name :: Text
  , projectStatus :: Maybe Text
  , projectProject_type :: Maybe (Nullable Text)
  } deriving (Show, Eq)

data ProjectPartial = ProjectPartial
  { projectPartialName :: Maybe Text
  , projectPartialProject_name :: Maybe Text
  , projectPartialStatus :: Maybe Text
  , projectPartialProject_type :: Maybe (Nullable Text)
  } deriving (Show, Eq)

For each DocType, two types are generated, e.g. Project and ProjectPartial. The *Partial version has no required fields i.e. all fields are Maybe and are left out when assigned to Nothing.

Value level:

Project                                             {
  { projectName = "PROJ-001"                          "name": "PROJ-001",
  , projectProject_name = "My Project"      =>        "project_name": "My Project",
  , projectStatus = Nothing
  , projectProject_type = Just Null                   "project_type": null
  }                                                 }
mkProjectPartial                                    {
  { projectProject_name = Just "My Project 2"   =>    "project_name": "My Project"
  }                                                 }

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:

import Network.HTTP.Client.TLS (mkManagerSettings)
import Network.Connection (TLSSettings (..))

…

let tlsSettings =
      mkManagerSettings
        ( TLSSettingsSimple
            { settingDisableCertificateValidation = True
            }
        )
        Nothing
manager <- Network.HTTP.Client.newManager tlsSettings
…

Edit the example code from the first section accordingly and run it with:

stack runhaskell --package crypton-connection --package http-client-tls example1.hs