diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Revision history for pa-json
+
+## 0.1.0.0 -- 2023-05-24
+
+- First version
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,11 @@
+Copyright 2023 Possehl Analytics GmbH
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,1 @@
+# pa-json
diff --git a/pa-json.cabal b/pa-json.cabal
new file mode 100644
--- /dev/null
+++ b/pa-json.cabal
@@ -0,0 +1,88 @@
+cabal-version:      3.0
+-- !!! ATTN: file autogenerated from pa-template.cabal.mustache on toplevel !!!
+name:               pa-json
+version:            0.1.0.0
+synopsis:           Our JSON parsers/encoders
+description:        The interface of `aeson` is unfortunately extremely … suboptimal. Here’s some wrappers trying to improve the situation, which we use internally.
+license:            BSD-3-Clause
+license-file:       LICENSE
+-- author:
+maintainer:         Philip Patsch <philip.patsch@possehl-analytics.com>
+copyright:          2023 Possehl Analytics GmbH
+homepage:           https://github.com/possehl-analytics/pa-hackage
+category:           Data, Possehl-Analytics
+build-type:         Simple
+extra-doc-files:    CHANGELOG.md
+                    README.md
+-- extra-source-files:
+
+
+common common-options
+  ghc-options:
+      -Wall
+      -Wno-type-defaults
+      -Wunused-packages
+      -Wredundant-constraints
+      -fwarn-missing-deriving-strategies
+
+  -- See https://downloads.haskell.org/ghc/latest/docs/users_guide/exts.html
+  -- for a description of all these extensions
+  default-extensions:
+      -- Infer Applicative instead of Monad where possible
+    ApplicativeDo
+
+    -- Allow literal strings to be Text
+    OverloadedStrings
+
+    -- Syntactic sugar improvements
+    LambdaCase
+    MultiWayIf
+
+    -- Makes the (deprecated) usage of * instead of Data.Kind.Type an error
+    NoStarIsType
+
+    -- Convenient and crucial to deal with ambiguous field names, commonly
+    -- known as RecordDotSyntax
+    OverloadedRecordDot
+
+    -- does not export record fields as functions, use OverloadedRecordDot to access instead
+    NoFieldSelectors
+
+    -- Record punning
+    RecordWildCards
+
+    -- Improved Deriving
+    DerivingStrategies
+    DerivingVia
+
+    -- Type-level strings
+    DataKinds
+
+    -- to enable the `type` keyword in import lists (ormolu uses this automatically)
+    ExplicitNamespaces
+
+  default-language: GHC2021
+
+library
+    import:           common-options
+    exposed-modules:
+      Json,
+      Json.Enc,
+    -- other-modules:
+    -- other-extensions:
+    hs-source-dirs:   src
+    build-depends:
+      base <5,
+      pa-prelude,
+      pa-label,
+      pa-error-tree,
+      aeson,
+      aeson-better-errors,
+      containers,
+      scientific,
+      time,
+      text,
+      vector,
+      hspec-core,
+      hspec-expectations,
+
diff --git a/src/Json.hs b/src/Json.hs
new file mode 100644
--- /dev/null
+++ b/src/Json.hs
@@ -0,0 +1,194 @@
+{-# LANGUAGE KindSignatures #-}
+{-# LANGUAGE UndecidableInstances #-}
+
+module Json where
+
+import Data.Aeson (FromJSON (parseJSON), ToJSON (toEncoding, toJSON), Value (..), withObject)
+import Data.Aeson.BetterErrors qualified as Json
+import Data.Aeson.KeyMap qualified as KeyMap
+import Data.Error.Tree
+import Data.Maybe (catMaybes)
+import Data.Text qualified as Text
+import Data.Vector qualified as Vector
+import Label
+import PossehlAnalyticsPrelude
+import Test.Hspec.Core.Spec (describe, it)
+import Test.Hspec.Core.Spec qualified as Hspec
+import Test.Hspec.Expectations (shouldBe)
+
+-- | Convert a 'Json.ParseError' to a corresponding 'ErrorTree'
+--
+-- TODO: build a different version of 'Json.displayError' so that we can nest 'ErrorTree' as well
+parseErrorTree :: Error -> Json.ParseError ErrorTree -> ErrorTree
+parseErrorTree contextMsg errs =
+  errs
+    & Json.displayError prettyErrorTree
+    & Text.intercalate "\n"
+    & newError
+    -- We nest this here because the json errors is multiline, so the result looks like
+    --
+    -- @
+    -- contextMsg
+    -- \|
+    -- `- At the path: ["foo"]["bar"]
+    --   Type mismatch:
+    --   Expected a value of type object
+    --   Got: true
+    -- @
+    & singleError
+    & nestedError contextMsg
+
+-- | Parse a key from the object, à la 'Json.key', return a labelled value.
+--
+-- We don’t provide a version that infers the json object key,
+-- since that conflates internal naming with the external API, which is dangerous.
+--
+-- @@
+-- do
+--   txt <- keyLabel @"myLabel" "jsonKeyName" Json.asText
+--   pure (txt :: Label "myLabel" Text)
+-- @@
+keyLabel ::
+  forall label err m a.
+  Monad m =>
+  Text ->
+  Json.ParseT err m a ->
+  Json.ParseT err m (Label label a)
+keyLabel = do
+  keyLabel' (Proxy @label)
+
+-- | Parse a key from the object, à la 'Json.key', return a labelled value.
+-- Version of 'keyLabel' that requires a proxy.
+--
+-- @@
+-- do
+--   txt <- keyLabel' (Proxy @"myLabel") "jsonKeyName" Json.asText
+--   pure (txt :: Label "myLabel" Text)
+-- @@
+keyLabel' ::
+  forall label err m a.
+  Monad m =>
+  Proxy label ->
+  Text ->
+  Json.ParseT err m a ->
+  Json.ParseT err m (Label label a)
+keyLabel' Proxy key parser = label @label <$> Json.key key parser
+
+-- | Parse an optional key from the object, à la 'Json.keyMay', return a labelled value.
+--
+-- We don’t provide a version that infers the json object key,
+-- since that conflates internal naming with the external API, which is dangerous.
+--
+-- @@
+-- do
+--   txt <- keyLabelMay @"myLabel" "jsonKeyName" Json.asText
+--   pure (txt :: Label "myLabel" (Maybe Text))
+-- @@
+keyLabelMay ::
+  forall label err m a.
+  Monad m =>
+  Text ->
+  Json.ParseT err m a ->
+  Json.ParseT err m (Label label (Maybe a))
+keyLabelMay = do
+  keyLabelMay' (Proxy @label)
+
+-- | Parse an optional key from the object, à la 'Json.keyMay', return a labelled value.
+-- Version of 'keyLabelMay' that requires a proxy.
+--
+-- @@
+-- do
+--   txt <- keyLabelMay' (Proxy @"myLabel") "jsonKeyName" Json.asText
+--   pure (txt :: Label "myLabel" (Maybe Text))
+-- @@
+keyLabelMay' ::
+  forall label err m a.
+  Monad m =>
+  Proxy label ->
+  Text ->
+  Json.ParseT err m a ->
+  Json.ParseT err m (Label label (Maybe a))
+keyLabelMay' Proxy key parser = label @label <$> Json.keyMay key parser
+
+-- | Like 'Json.key', but allows a list of keys that are tried in order.
+--
+-- This is intended for renaming keys in an object.
+-- The first key is the most up-to-date version of a key, the others are for backward-compatibility.
+--
+-- If a key (new or old) exists, the inner parser will always be executed for that key.
+keyRenamed :: Monad m => NonEmpty Text -> Json.ParseT err m a -> Json.ParseT err m a
+keyRenamed (newKey :| oldKeys) inner =
+  keyRenamedTryOldKeys oldKeys inner >>= \case
+    Nothing -> Json.key newKey inner
+    Just parse -> parse
+
+-- | Like 'Json.keyMay', but allows a list of keys that are tried in order.
+--
+-- This is intended for renaming keys in an object.
+-- The first key is the most up-to-date version of a key, the others are for backward-compatibility.
+--
+-- If a key (new or old) exists, the inner parser will always be executed for that key.
+keyRenamedMay :: Monad m => NonEmpty Text -> Json.ParseT err m a -> Json.ParseT err m (Maybe a)
+keyRenamedMay (newKey :| oldKeys) inner =
+  keyRenamedTryOldKeys oldKeys inner >>= \case
+    Nothing -> Json.keyMay newKey inner
+    Just parse -> Just <$> parse
+
+-- | Helper function for 'keyRenamed' and 'keyRenamedMay' that returns the parser for the first old key that exists, if any.
+keyRenamedTryOldKeys :: Monad m => [Text] -> Json.ParseT err m a -> Json.ParseT err m (Maybe (Json.ParseT err m a))
+keyRenamedTryOldKeys oldKeys inner = do
+  oldKeys & traverse tryOld <&> catMaybes <&> nonEmpty <&> \case
+    Nothing -> Nothing
+    Just (old :| _moreOld) -> Just old
+  where
+    tryOld key =
+      Json.keyMay key (pure ()) <&> \case
+        Just () -> Just $ Json.key key inner
+        Nothing -> Nothing
+
+test_keyRenamed :: Hspec.Spec
+test_keyRenamed = do
+  describe "keyRenamed" $ do
+    let parser = keyRenamed ("new" :| ["old"]) Json.asText
+    let p = Json.parseValue @() parser
+    it "accepts the new key and the old key" $ do
+      p (Object (KeyMap.singleton "new" (String "text")))
+        `shouldBe` (Right "text")
+      p (Object (KeyMap.singleton "old" (String "text")))
+        `shouldBe` (Right "text")
+    it "fails with the old key in the error if the inner parser is wrong" $ do
+      p (Object (KeyMap.singleton "old" Null))
+        `shouldBe` (Left (Json.BadSchema [Json.ObjectKey "old"] (Json.WrongType Json.TyString Null)))
+    it "fails with the new key in the error if the inner parser is wrong" $ do
+      p (Object (KeyMap.singleton "new" Null))
+        `shouldBe` (Left (Json.BadSchema [Json.ObjectKey "new"] (Json.WrongType Json.TyString Null)))
+    it "fails if the key is missing" $ do
+      p (Object KeyMap.empty)
+        `shouldBe` (Left (Json.BadSchema [] (Json.KeyMissing "new")))
+  describe "keyRenamedMay" $ do
+    let parser = keyRenamedMay ("new" :| ["old"]) Json.asText
+    let p = Json.parseValue @() parser
+    it "accepts the new key and the old key" $ do
+      p (Object (KeyMap.singleton "new" (String "text")))
+        `shouldBe` (Right (Just "text"))
+      p (Object (KeyMap.singleton "old" (String "text")))
+        `shouldBe` (Right (Just "text"))
+    it "allows the old and new key to be missing" $ do
+      p (Object KeyMap.empty)
+        `shouldBe` (Right Nothing)
+
+-- | A simple type isomorphic to `()` that that transforms to an empty json object and parses
+data EmptyObject = EmptyObject
+  deriving stock (Show, Eq)
+
+instance FromJSON EmptyObject where
+  -- allow any fields, as long as its an object
+  parseJSON = withObject "EmptyObject" (\_ -> pure EmptyObject)
+
+instance ToJSON EmptyObject where
+  toJSON EmptyObject = Object mempty
+  toEncoding EmptyObject = toEncoding $ Object mempty
+
+-- | Create a json array from a list of json values.
+jsonArray :: [Value] -> Value
+jsonArray xs = xs & Vector.fromList & Array
diff --git a/src/Json/Enc.hs b/src/Json/Enc.hs
new file mode 100644
--- /dev/null
+++ b/src/Json/Enc.hs
@@ -0,0 +1,222 @@
+{-# LANGUAGE AllowAmbiguousTypes #-}
+{-# LANGUAGE QuasiQuotes #-}
+
+module Json.Enc where
+
+import Data.Aeson (Encoding, Value (..))
+import Data.Aeson.Encoding qualified as AesonEnc
+import Data.Aeson.Key qualified as Key
+import Data.Aeson.KeyMap (KeyMap)
+import Data.Aeson.KeyMap qualified as KeyMap
+import Data.Int (Int64)
+import Data.Map.Strict qualified as Map
+import Data.Scientific
+import Data.String (IsString (fromString))
+import Data.Text.Lazy qualified as Lazy
+import Data.Time qualified as Time
+import Data.Time.Format.ISO8601 qualified as ISO8601
+import GHC.TypeLits
+import PossehlAnalyticsPrelude
+
+-- | A JSON encoder.
+--
+-- It is faster than going through 'Value', because 'Encoding' is just a wrapper around a @Bytes.Builder@.
+-- But the @aeson@ interface for 'Encoding' is extremely bad, so let’s build a better one.
+newtype Enc = Enc {unEnc :: Encoding}
+  deriving (Num, Fractional) via (NumLiteralOnly "Enc" Enc)
+
+-- | You can create an @Enc any@ that renders an 'Aeson.String' value with @OverloadedStrings@.
+instance IsString Enc where
+  fromString = Enc . AesonEnc.string
+
+-- | You can create an @Enc any@ that renders an 'Aeson.Number' value with an integer literal.
+instance IntegerLiteral Enc where
+  integerLiteral = Enc . AesonEnc.integer
+
+-- | You can create an @Enc any@ that renders an 'Aeson.Number' value with an floating point literal.
+--
+-- ATTN: Bear in mind that this will crash on repeating rationals, so only use for literals in code!
+instance RationalLiteral Enc where
+  rationalLiteral r = Enc $ AesonEnc.scientific (r & fromRational @Scientific)
+
+-- | Embed an 'Encoding' verbatim (it’s a valid JSON value)
+encoding :: Encoding -> Enc
+encoding = Enc
+
+-- | Encode a 'Value' verbatim (it’s a valid JSON value)
+value :: Value -> Enc
+value = Enc . AesonEnc.value
+
+-- | Encode an empty 'Array'
+emptyArray :: Enc
+emptyArray = Enc AesonEnc.emptyArray_
+
+-- | Encode an empty 'Object'
+emptyObject :: Enc
+emptyObject = Enc AesonEnc.emptyObject_
+
+-- | Encode a 'Text'
+text :: Text -> Enc
+text = Enc . AesonEnc.text
+
+-- | Encode a lazy @Text@
+lazyText :: Lazy.Text -> Enc
+lazyText = Enc . AesonEnc.lazyText
+
+-- | Encode a 'String'
+string :: String -> Enc
+string = Enc . AesonEnc.string
+
+-- | Encode as 'Null' if 'Nothing', else use the given encoder for @Just a@
+nullOr :: (a -> Enc) -> Maybe a -> Enc
+nullOr inner = \case
+  Nothing -> Enc AesonEnc.null_
+  Just a -> inner a
+
+-- | Encode a list as 'Array'
+list :: (a -> Enc) -> [a] -> Enc
+list f = Enc . AesonEnc.list (\a -> (f a).unEnc)
+
+-- | Encode a 'NonEmpty' as an 'Array'.
+nonEmpty :: (a -> Enc) -> NonEmpty a -> Enc
+nonEmpty f = list f . toList
+
+-- | Encode the given list of keys and their encoders as 'Object'.
+--
+-- Like with 'Map.fromList', if the list contains the same key multiple times, the last value in the list is retained:
+--
+-- @
+-- (object [ ("foo", 42), ("foo", 23) ])
+-- ~= "{\"foo\":23}"
+-- @
+object :: Foldable t => t (Text, Enc) -> Enc
+object m =
+  Enc $
+    AesonEnc.dict
+      AesonEnc.text
+      (\recEnc -> recEnc.unEnc)
+      Map.foldrWithKey
+      (Map.fromList $ toList m)
+
+-- | A tag/value encoder; See 'choice'
+data Choice = Choice Text Enc
+
+-- | Encode a sum type as a @Choice@, an object with a @tag@/@value@ pair,
+-- which is the conventional json sum type representation in our codebase.
+--
+-- @
+-- foo :: Maybe Text -> Enc
+-- foo = choice $ \case
+--   Nothing -> Choice "no" emptyObject ()
+--   Just t -> Choice "yes" text t
+--
+-- ex = foo Nothing == "{\"tag\": \"no\", \"value\": {}}"
+-- ex2 = foo (Just "hi") == "{\"tag\": \"yes\", \"value\": \"hi\"}"
+-- @
+choice :: (from -> Choice) -> from -> Enc
+choice f from = case f from of
+  Choice key encA -> singleChoice key encA
+
+-- | Like 'choice', but simply encode a single possibility into a @tag/value@ object.
+-- This can be useful, but if you want to match on an enum, use 'choice' instead.
+singleChoice :: Text -> Enc -> Enc
+singleChoice key encA =
+  Enc $
+    AesonEnc.pairs $
+      mconcat
+        [ AesonEnc.pair "tag" (AesonEnc.text key),
+          AesonEnc.pair "value" encA.unEnc
+        ]
+
+-- | Encode a 'Map'.
+--
+-- We can’t really set the key to anything but text (We don’t keep the tag of 'Encoding')
+-- so instead we allow anything that’s coercible from text as map key (i.e. newtypes).
+map :: forall k v. (Coercible k Text) => (v -> Enc) -> Map k v -> Enc
+map valEnc m =
+  Enc $
+    AesonEnc.dict
+      (AesonEnc.text . coerce @k @Text)
+      (\v -> (valEnc v).unEnc)
+      Map.foldrWithKey
+      m
+
+-- | Encode a 'KeyMap'
+keyMap :: (v -> Enc) -> KeyMap v -> Enc
+keyMap valEnc m =
+  Enc $
+    AesonEnc.dict
+      (AesonEnc.text . Key.toText)
+      (\v -> (valEnc v).unEnc)
+      KeyMap.foldrWithKey
+      m
+
+-- | Encode 'Null'
+null :: Enc
+null = Enc AesonEnc.null_
+
+-- | Encode 'Bool'
+bool :: Bool -> Enc
+bool = Enc . AesonEnc.bool
+
+-- | Encode an 'Integer' as 'Number'.
+-- TODO: is it okay to just encode an arbitrarily-sized integer into json?
+integer :: Integer -> Enc
+integer = Enc . AesonEnc.integer
+
+-- | Encode a 'Scientific' as 'Number'.
+scientific :: Scientific -> Enc
+scientific = Enc . AesonEnc.scientific
+
+-- | Encode a 'Natural' as 'Number'.
+natural :: Natural -> Enc
+natural = integer . toInteger @Natural
+
+-- | Encode an 'Int' as 'Aeson.Number'.
+int :: Int -> Enc
+int = Enc . AesonEnc.int
+
+-- | Encode an 'Int64' as 'Number'.
+int64 :: Int64 -> Enc
+int64 = Enc . AesonEnc.int64
+
+-- | Encode UTCTime as 'String', as an ISO8601 timestamp with timezone (@yyyy-mm-ddThh:mm:ss[.sss]Z@)
+utcTime :: Time.UTCTime -> Enc
+utcTime =
+  text . stringToText . ISO8601.iso8601Show @Time.UTCTime
+
+-- | Implement this class if you want your type to only implement the part of 'Num'
+-- that allows creating them from Integer-literals, then derive Num via 'NumLiteralOnly':
+--
+-- @
+-- data Foo = Foo Integer
+--   deriving (Num) via (NumLiteralOnly "Foo" Foo)
+--
+-- instance IntegerLiteral Foo where
+--  integerLiteral i = Foo i
+-- @
+class IntegerLiteral a where
+  integerLiteral :: Integer -> a
+
+-- | The same as 'IntegerLiteral' but for floating point literals.
+class RationalLiteral a where
+  rationalLiteral :: Rational -> a
+
+-- | Helper class for @deriving (Num) via …@, implements only literal syntax for integer and floating point numbers,
+-- and throws descriptive runtime errors for any other methods in 'Num'.
+--
+-- See 'IntegerLiteral' and 'RationalLiteral' for examples.
+newtype NumLiteralOnly (sym :: Symbol) num = NumLiteralOnly num
+
+instance (IntegerLiteral num, KnownSymbol sym) => Num (NumLiteralOnly sym num) where
+  fromInteger = NumLiteralOnly . integerLiteral
+  (+) = error [fmt|Only use as numeric literal allowed for {symbolVal (Proxy @sym)}, you tried to add (+) (NumLiteralOnly)|]
+  (*) = error [fmt|Only use as numeric literal allowed for {symbolVal (Proxy @sym)}, you tried to multiply (*) (NumLiteralOnly)|]
+  (-) = error [fmt|Only use as numeric literal allowed for {symbolVal (Proxy @sym)}, you tried to subtract (-) (NumLiteralOnly)|]
+  abs = error [fmt|Only use as numeric literal allowed for {symbolVal (Proxy @sym)}, you tried to use `abs` (NumLiteralOnly)|]
+  signum = error [fmt|Only use as numeric literal allowed for {symbolVal (Proxy @sym)}, you tried to use `signum` (NumLiteralOnly)|]
+
+instance (IntegerLiteral num, RationalLiteral num, KnownSymbol sym) => Fractional (NumLiteralOnly sym num) where
+  fromRational = NumLiteralOnly . rationalLiteral
+  recip = error [fmt|Only use as rational literal allowed for {symbolVal (Proxy @sym)}, you tried to use `recip` (NumLiteralOnly)|]
+  (/) = error [fmt|Only use as numeric literal allowed for {symbolVal (Proxy @sym)}, you tried to divide (/) (NumLiteralOnly)|]
