diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Revision history for spotify
+
+## 0.1.0.0 -- 2023-06-22
+
+* First release. Unstable.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2020, George Thomas
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * 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.
+
+    * Neither the name of George Thomas nor the names of other
+      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
+OWNER 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,11 @@
+# Spotify Web API bindings for Haskell
+
+Built around Servant + Aeson.
+
+Work in progress. Will be put on Hackage once remotely stable.
+
+Designed to be very high-level (eg. handling token refresh etc. automatically). I can think about exposing the lower level functionality if anyone requests it.
+
+Designed for use with lenses and/or modern record extensions, eg. to work around duplicated identifiers. This allows us to mirror the Spotify API in a methodical way without being too verbose. `RecordDotSyntax` will suit us nicely whenever it lands.
+
+All request take place inside a monad implementing `MonadSpotify`. In particular, this abstracts away authentication - we don't need to explicitly pass tokens around. A concrete monad `Spotify` is provided, though you'll often want to roll your own. There is also an instance `MonadSpotify IO` - this caches authentication data to disk, so one should be careful about security. It also creates a new connection `Manager` for every request, which can be expensive. Thus a `State`-like monad is preferred for serious code. Nonetheless, being able to run the functions directly in `IO` can be very convenient, especially from `GHCI`.
diff --git a/examples/Main.hs b/examples/Main.hs
new file mode 100644
--- /dev/null
+++ b/examples/Main.hs
@@ -0,0 +1,52 @@
+module Main (main) where
+
+import Spotify
+import Spotify.CheckPlaylistOverlap qualified
+import Spotify.CreateArtistLikedSongsPlaylist qualified
+import Spotify.CreatePlaylist qualified
+import Spotify.DeleteRecentPlaylists qualified
+import Spotify.Servant.Playlists (CreatePlaylistOpts (..))
+import Spotify.TransferPlayback qualified
+
+import Data.String (fromString)
+import Data.Text qualified as T
+import System.Environment (getArgs)
+import System.Exit (exitFailure)
+import Text.Pretty.Simple (pPrintForceColor)
+import Text.Read (readMaybe)
+
+main :: IO ()
+main = do
+    example <-
+        getArgs >>= \case
+            "CheckPlaylistOverlap" : args -> case args of
+                [fromString -> p1, fromString -> p2] -> pure $ Spotify.CheckPlaylistOverlap.main p1 p2
+                _ -> badArgs
+            "CreateArtistLikedSongsPlaylist" : artists ->
+                pure $ Spotify.CreateArtistLikedSongsPlaylist.main $ map fromString artists
+            "CreatePlaylist" : args -> case args of
+                [readMaybe . (<> "Search") -> Just searchType, T.pack -> name, readMaybe -> Just public, readMaybe -> Just collaborative, T.pack -> description] ->
+                    pure $
+                        Spotify.CreatePlaylist.main
+                            searchType
+                            CreatePlaylistOpts
+                                { name
+                                , public
+                                , collaborative
+                                , description
+                                }
+                _ -> badArgs
+            "DeleteRecentPlaylists" : args ->
+                case args of
+                    [readMaybe -> Just n] -> pure $ Spotify.DeleteRecentPlaylists.main n
+                    _ -> badArgs
+            "TransferPlayback" : args ->
+                case args of
+                    [T.pack -> t, readMaybe -> Just b] -> pure $ Spotify.TransferPlayback.main t b
+                    _ -> badArgs
+            x : _ -> putStrLn ("unknown example: " <> x) >> exitFailure
+            [] -> putStrLn "no args" >> exitFailure
+    auth <- getAuth -- we use the `MonadSpotify IO` instance, as an easy way to get credentials
+    either pPrintForceColor pure =<< runSpotify auth example
+  where
+    badArgs = putStrLn "bad args" >> exitFailure
diff --git a/examples/Spotify/CheckPlaylistOverlap.hs b/examples/Spotify/CheckPlaylistOverlap.hs
new file mode 100644
--- /dev/null
+++ b/examples/Spotify/CheckPlaylistOverlap.hs
@@ -0,0 +1,31 @@
+module Spotify.CheckPlaylistOverlap (main) where
+
+import Spotify
+import Spotify.Types.Misc
+import Spotify.Types.Playlists
+import Spotify.Types.Tracks
+
+import Control.Monad (join, (<=<))
+import Control.Monad.IO.Class (liftIO)
+import Data.Bitraversable (Bitraversable (bitraverse))
+import Data.Foldable (traverse_)
+import Data.List.NonEmpty (nonEmpty)
+import Data.Map qualified as Map
+import Data.Text.IO qualified as T
+import Data.Tuple.Extra ((&&&))
+
+main :: (MonadSpotify m) => PlaylistID -> PlaylistID -> m ()
+main =
+    curry $
+        maybe (putT "No overlap found.") ((putT "Found songs in both playlists:" >>) . traverse_ putT)
+            . nonEmpty
+            . Map.elems
+            . uncurry Map.intersection
+            <=< join
+                bitraverse
+                ( pure . Map.fromList . map (((.id) &&& (.name)) . (.track))
+                    <=< (allPages Nothing . const . pure . (.tracks))
+                    <=< getPlaylist
+                )
+  where
+    putT = liftIO . T.putStrLn
diff --git a/examples/Spotify/CreateArtistLikedSongsPlaylist.hs b/examples/Spotify/CreateArtistLikedSongsPlaylist.hs
new file mode 100644
--- /dev/null
+++ b/examples/Spotify/CreateArtistLikedSongsPlaylist.hs
@@ -0,0 +1,59 @@
+module Spotify.CreateArtistLikedSongsPlaylist (main) where
+
+import Spotify
+import Spotify.Servant.Playlists
+import Spotify.Types.Artists
+import Spotify.Types.Misc
+import Spotify.Types.Simple
+import Spotify.Types.Tracks
+import Spotify.Types.Users
+
+import Control.Monad (void)
+import Control.Monad.IO.Class (liftIO)
+import Data.Foldable (for_, toList)
+import Data.List.NonEmpty (NonEmpty)
+import Data.Map qualified as Map
+import Data.Text qualified as T
+import Data.Text.IO qualified as T
+
+main :: (MonadSpotify m) => [ArtistID] -> m ()
+main artists = do
+    allSaved <- allPages (Just logger) getSavedTracks
+    let savedTracksByArtist =
+            Map.fromListWith (<>) $
+                allSaved >>= \t ->
+                    map (\a -> (a.id, pure @NonEmpty t)) t.track.artists
+    putT $ "Found tracks by " <> showT (length savedTracksByArtist) <> " artists."
+    me <- getMe
+    for_ artists \a' -> do
+        a <- getArtist a'
+        putT $ "Creating playlist for artist: " <> a.name
+        p <-
+            createPlaylist
+                me.id
+                CreatePlaylistOpts
+                    { name = a.name
+                    , public = False
+                    , collaborative = False
+                    , description = "All my liked songs by this artist. Auto-generated with Haskell."
+                    }
+        void
+            . addToPlaylist p.id Nothing
+            . map (\t -> toURI t.track.id)
+            . maybe [] toList
+            $ Map.lookup a' savedTracksByArtist
+  where
+    showT = T.pack . show
+    putT = liftIO . T.putStrLn
+    logger p = do
+        putT $
+            mconcat
+                [ "Getting saved tracks "
+                , showT (p.offset + 1)
+                , " to "
+                , showT (min p.total (p.offset + p.limit))
+                , " of "
+                , showT p.total
+                , "..."
+                ]
+        pure True
diff --git a/examples/Spotify/CreatePlaylist.hs b/examples/Spotify/CreatePlaylist.hs
new file mode 100644
--- /dev/null
+++ b/examples/Spotify/CreatePlaylist.hs
@@ -0,0 +1,75 @@
+{- HLINT ignore "Redundant <$>" -}
+module Spotify.CreatePlaylist (main) where
+
+import Spotify
+import Spotify.Servant.Playlists
+import Spotify.Types.Albums
+import Spotify.Types.Misc
+import Spotify.Types.Search
+import Spotify.Types.Simple
+import Spotify.Types.Tracks
+import Spotify.Types.Users
+
+import Control.Monad ((<=<))
+import Control.Monad.Except (MonadError (throwError), runExceptT)
+import Control.Monad.State (MonadIO (liftIO), MonadTrans (lift))
+import Data.Foldable (traverse_)
+import Data.Function (on)
+import Data.List (find)
+import Data.List.Extra (chunksOf)
+import Data.Maybe (isJust)
+import Data.Text (Text)
+import Data.Text qualified as T
+import Data.Text.IO qualified as T
+import Data.Traversable (for)
+import System.Exit (exitFailure)
+
+main :: (MonadSpotify m) => SearchType -> CreatePlaylistOpts -> m ()
+main searchType opts = do
+    SomeSearchTypeInfo @a (itemName, extractItems, getResult, getUris) <- case searchType of
+        TrackSearch -> pure $ SomeSearchTypeInfo @Track ("track", (.tracks), (.artists), pure . pure . (.uri))
+        AlbumSearch -> pure $ SomeSearchTypeInfo @AlbumSimple ("album", (.albums), (.artists), fmap (map (.uri) . (.tracks.items)) . getAlbum . (.id))
+        _ -> exit "unsupported search type"
+    liftIO $ T.putStrLn $ "Enter lines of artist;" <> itemName
+    parsedLines <-
+        (map (T.splitOn ";") . T.lines <$> liftIO T.getContents) >>= traverse \case
+            [a, b] -> pure (a, b)
+            _ -> exit "parse failure"
+    items <- for parsedLines \(artist, item) ->
+        runExceptT @a -- we actually use this monad to indicate success - it's just a way to return early
+            ( allPages
+                ( Just \p -> do
+                    if p.offset > searchLimit
+                        then pure False
+                        else case find (isJust . find (((==) `on` T.toCaseFold) artist) . map (.name) . getResult) p.items of
+                            Just t -> throwError t
+                            Nothing -> pure True
+                )
+                ( maybe (exit $ "no " <> itemName <> "s") pure . extractItems
+                    <=< lift . search (T.unwords [item, artist]) [searchType] Nothing Nothing
+                )
+            )
+            >>= either
+                pure
+                ( exit
+                    . T.unlines
+                    . (("No " <> itemName <> " \"" <> item <> "\" with artist: \"" <> artist <> "\". Found:") :)
+                    . map (T.intercalate "; " . map (.name) . getResult)
+                )
+    playlist <- flip createPlaylist opts . (.id) =<< getMe
+    traverse_ (addToPlaylist playlist.id Nothing) =<< chunksOf playlistMaxBatchLimit . concat <$> traverse getUris items
+  where
+    playlistMaxBatchLimit = 100 -- API won't accept more than this
+    exit s = liftIO $ T.putStrLn s >> exitFailure
+
+data SomeSearchTypeInfo where
+    SomeSearchTypeInfo ::
+        ( Text
+        , SearchResult -> Maybe (Paging a)
+        , a -> [ArtistSimple]
+        , forall m. (MonadSpotify m) => a -> m [URI]
+        ) ->
+        SomeSearchTypeInfo
+
+searchLimit :: Int
+searchLimit = 250
diff --git a/examples/Spotify/DeleteRecentPlaylists.hs b/examples/Spotify/DeleteRecentPlaylists.hs
new file mode 100644
--- /dev/null
+++ b/examples/Spotify/DeleteRecentPlaylists.hs
@@ -0,0 +1,28 @@
+module Spotify.DeleteRecentPlaylists (main) where
+
+import Spotify
+import Spotify.Types.Misc
+import Spotify.Types.Simple
+
+import Control.Monad.IO.Class (MonadIO, liftIO)
+import Data.Foldable (for_)
+import Data.Text (Text)
+import Data.Text.IO qualified as T
+import System.IO (hFlush, stdout)
+
+main :: (MonadSpotify m) => Int -> m ()
+main n = do
+    playlists <- allPages earlyCutoff getMyPlaylists
+    for_ (take n playlists) \p -> promptForConfirmation p.name $ unfollowPlaylist p.id
+  where
+    -- NB. this is just used as an optimisation, to ensure we don't get pages beyond what we need
+    earlyCutoff = Just \p -> pure $ p.offset + p.limit < n
+
+promptForConfirmation :: (MonadIO m) => Text -> m () -> m ()
+promptForConfirmation t x = do
+    liftIO $ T.putStr (t <> "? [Y/n] ") >> hFlush stdout
+    liftIO T.getLine >>= \case
+        "y" -> x
+        "Y" -> x
+        "" -> x
+        _ -> pure ()
diff --git a/examples/Spotify/TransferPlayback.hs b/examples/Spotify/TransferPlayback.hs
new file mode 100644
--- /dev/null
+++ b/examples/Spotify/TransferPlayback.hs
@@ -0,0 +1,21 @@
+module Spotify.TransferPlayback (main) where
+
+import Spotify
+import Spotify.Types.Player
+
+import Control.Monad.IO.Class (liftIO)
+import Data.Foldable (for_)
+import Data.List (find)
+import Data.Text (Text)
+import Data.Text.IO qualified as T
+import System.Exit (exitFailure)
+
+main :: (MonadSpotify m) => Text -> Bool -> m ()
+main name play = do
+    devs <- getAvailableDevices
+    case find ((== name) . (.name)) devs of
+        Nothing -> liftIO do
+            putStrLn "No device found with requested name. Only:"
+            for_ devs \d -> T.putStrLn d.name
+            exitFailure
+        Just d -> transferPlayback [d.id] play
diff --git a/lib/Spotify.hs b/lib/Spotify.hs
new file mode 100644
--- /dev/null
+++ b/lib/Spotify.hs
@@ -0,0 +1,298 @@
+module Spotify where
+
+import Spotify.Servant.Albums
+import Spotify.Servant.Artists
+import Spotify.Servant.Categories
+import Spotify.Servant.Core
+import Spotify.Servant.Player
+import Spotify.Servant.Playlists
+import Spotify.Servant.Search
+import Spotify.Servant.Tracks
+import Spotify.Servant.Users
+import Spotify.Types.Albums
+import Spotify.Types.Artists
+import Spotify.Types.Auth
+import Spotify.Types.Categories
+import Spotify.Types.Misc
+import Spotify.Types.Player
+import Spotify.Types.Playlists
+import Spotify.Types.Search
+import Spotify.Types.Simple
+import Spotify.Types.Tracks
+import Spotify.Types.Users
+
+import Control.Applicative ((<|>))
+import Control.Exception (throwIO)
+import Control.Monad.Except (ExceptT, MonadError, liftEither, runExceptT, throwError)
+import Control.Monad.IO.Class (MonadIO, liftIO)
+import Control.Monad.Loops (unfoldrM)
+import Control.Monad.Reader (MonadReader, ReaderT, asks, runReaderT)
+import Control.Monad.State (MonadState, StateT, get, put, runStateT)
+import Data.Aeson (FromJSON, eitherDecode)
+import Data.Bifunctor (bimap)
+import Data.Coerce (coerce)
+import Data.Composition ((.:), (.:.))
+import Data.Proxy (Proxy (Proxy))
+import Data.Set (Set)
+import Data.Text (Text)
+import Data.Text qualified as T
+import Data.Text.IO qualified as T
+import GHC.Generics (Generic)
+import Network.HTTP.Client (Manager)
+import Network.HTTP.Client.TLS (newTlsManager)
+import Network.HTTP.Types (Status (statusCode))
+import Servant.API (NoContent (NoContent))
+import Servant.Client (BaseUrl (BaseUrl, baseUrlHost), ClientError (DecodeFailure, FailureResponse), ClientM, HasClient (Client), Scheme (Http), client, mkClientEnv, responseBody, responseStatusCode, runClientM)
+import Servant.Links (allLinks, linkURI)
+import System.Directory (XdgDirectory (XdgConfig), createDirectoryIfMissing, getTemporaryDirectory, getXdgDirectory)
+import System.FilePath ((</>))
+import System.IO (hFlush, stdout)
+
+class (MonadIO m) => MonadSpotify m where
+    getAuth :: m Auth
+    getManager :: m Manager
+    getToken :: m AccessToken
+    putToken :: AccessToken -> m ()
+    throwClientError :: ClientError -> m a
+
+instance MonadSpotify IO where
+    throwClientError = liftIO . throwIO
+    getAuth = do
+        dir <- getXdgDirectory XdgConfig "spotify-haskell"
+        let getData file prompt =
+                -- look for file - otherwise get from stdin
+                T.readFile path <|> do
+                    res <- T.putStr (prompt <> ": ") >> T.getLine
+                    createDirectoryIfMissing False dir
+                    T.writeFile path res
+                    pure res
+              where
+                path = dir </> file
+        Auth
+            <$> (RefreshToken <$> getData "refresh" "Refresh token")
+            <*> (ClientId <$> getData "id" "Client id")
+            <*> (ClientSecret <$> getData "secret" "Client secret")
+    getManager = newTlsManager
+    getToken = do
+        path <- monadSpotifyIOTokenPath
+        AccessToken <$> T.readFile path <|> do
+            TokenResponse{accessToken} <- newToken
+            putToken accessToken
+            pure accessToken
+    putToken (AccessToken t) = do
+        path <- monadSpotifyIOTokenPath
+        T.writeFile path t
+monadSpotifyIOTokenPath :: IO FilePath
+monadSpotifyIOTokenPath = (</> "spotify-haskell-token") <$> getTemporaryDirectory
+
+newtype Spotify a = Spotify
+    { unwrap :: StateT AccessToken (ReaderT (Auth, Manager) (ExceptT ClientError IO)) a
+    }
+    deriving newtype
+        ( Functor
+        , Applicative
+        , Monad
+        , MonadIO
+        , MonadState AccessToken
+        , MonadReader (Auth, Manager)
+        , MonadError ClientError
+        )
+
+instance MonadSpotify Spotify where
+    getAuth = asks fst
+    getManager = asks snd
+    getToken = get
+    putToken = put
+    throwClientError = throwError
+
+runSpotify :: Auth -> Spotify a -> IO (Either ClientError a)
+runSpotify = fmap (fmap fst) .: runSpotify' Nothing Nothing
+runSpotify' :: Maybe Manager -> Maybe AccessToken -> Auth -> Spotify a -> IO (Either ClientError (a, AccessToken))
+runSpotify' mm mt a x = do
+    man <- maybe newTlsManager pure mm
+    let tok = maybe (fmap (.accessToken) . liftEither =<< liftIO (newTokenIO a man)) pure mt
+    runExceptT $ runReaderT (runStateT x.unwrap =<< tok) (a, man)
+
+liftEitherSpot :: (MonadSpotify m) => Either ClientError a -> m a
+liftEitherSpot = either throwClientError pure
+
+inSpot :: forall m a. (MonadSpotify m) => (AccessToken -> ClientM a) -> m a
+inSpot x = do
+    tok <- getToken
+    man <- getManager
+    liftIO (runClientM (x tok) $ mkClientEnv man mainBase) >>= \case
+        Left e ->
+            expiry e >>= \case
+                True -> retry
+                False -> throwClientError e
+        Right r -> pure r
+  where
+    -- get a new token and try again
+    retry = do
+        putToken . (.accessToken) =<< newToken
+        inSpot x
+    -- does the error indicate that the access token has expired?
+    expiry = \case
+        FailureResponse _ resp -> do
+            if statusCode (responseStatusCode resp) == 401
+                then do
+                    Error{message} <- liftEitherSpot $ bimap mkError (.error) $ eitherDecode @Error' $ responseBody resp
+                    if message == "The access token expired"
+                        then pure True
+                        else no
+                else no
+          where
+            mkError s = DecodeFailure ("Failed to decode a spotify error: " <> T.pack s) resp
+        _ -> no
+      where
+        no = pure False
+newtype Error' = Error' {error :: Error} -- internal - used for decoding the errors we get from Spotify responses
+    deriving stock (Eq, Ord, Show, Generic)
+    deriving anyclass (FromJSON)
+
+data Auth = Auth
+    { refreshToken :: RefreshToken
+    , clientId :: ClientId
+    , clientSecret :: ClientSecret
+    }
+    deriving (Show)
+
+mainBase, accountsBase :: BaseUrl
+mainBase = BaseUrl Http "api.spotify.com" 80 "v1"
+accountsBase = BaseUrl Http "accounts.spotify.com" 80 "api"
+
+-- helpers for wrapping Servant API
+cli :: forall api. (HasClient ClientM api) => Client ClientM api
+cli = client $ Proxy @api
+noContent :: (Functor f) => f NoContent -> f ()
+noContent = fmap \NoContent -> ()
+marketFromToken :: Maybe Market
+marketFromToken = Just "from_token"
+withPagingParams :: PagingParams -> (Maybe Int -> Maybe Int -> t) -> t
+withPagingParams PagingParams{limit, offset} f = f limit offset
+
+data PagingParams = PagingParams
+    { limit :: Maybe Int
+    , offset :: Maybe Int
+    }
+noPagingParams :: PagingParams
+noPagingParams = PagingParams Nothing Nothing
+
+newToken :: (MonadSpotify m) => m TokenResponse
+newToken = liftEitherSpot =<< liftIO =<< (newTokenIO <$> getAuth <*> getManager)
+newTokenIO :: Auth -> Manager -> IO (Either ClientError TokenResponse)
+newTokenIO a m = runClientM (requestToken a) (mkClientEnv m accountsBase)
+  where
+    requestToken (Auth t i s) =
+        cli @RefreshAccessToken
+            (RefreshAccessTokenForm t)
+            (IdAndSecret i s)
+newTokenIO' :: (MonadIO m) => Manager -> ClientId -> ClientSecret -> URL -> AuthCode -> m (Either ClientError TokenResponse')
+newTokenIO' man clientId clientSecret redirectURI authCode =
+    liftIO $
+        runClientM
+            ( cli @RequestAccessToken
+                (RequestAccessTokenForm authCode redirectURI)
+                (IdAndSecret clientId clientSecret)
+            )
+            (mkClientEnv man accountsBase)
+
+-- spotipy-esque
+getAuthCodeInteractive :: ClientId -> URL -> Maybe (Set Scope) -> IO (Maybe AuthCode)
+getAuthCodeInteractive clientId redirectURI scopes = do
+    T.putStrLn $ "Go to this URL: " <> (authorizeUrl clientId redirectURI scopes).unwrap
+    T.putStr "Copy the URL you are redirected to: " >> hFlush stdout
+    fmap AuthCode . T.stripPrefix (redirectURI.unwrap <> "/?code=") <$> T.getLine
+authorizeUrl :: ClientId -> URL -> Maybe (Set Scope) -> URL
+authorizeUrl clientId redirectURI scopes =
+    URL $
+        "https://"
+            <> T.pack
+                ( baseUrlHost accountsBase
+                    <> "/"
+                    <> show (linkURI link)
+                )
+  where
+    link =
+        allLinks
+            (Proxy @Authorize)
+            clientId
+            "code"
+            redirectURI
+            Nothing
+            (ScopeSet <$> scopes)
+            Nothing
+
+getAlbum :: (MonadSpotify m) => AlbumID -> m Album
+getAlbum a = inSpot $ cli @GetAlbum a marketFromToken
+getAlbumTracks :: (MonadSpotify m) => AlbumID -> PagingParams -> m (Paging TrackSimple)
+getAlbumTracks a pps = inSpot $ withPagingParams pps $ cli @GetAlbumTracks a marketFromToken
+removeAlbums :: (MonadSpotify m) => [AlbumID] -> m ()
+removeAlbums = noContent . inSpot . cli @RemoveAlbums
+
+getArtist :: (MonadSpotify m) => ArtistID -> m Artist
+getArtist = inSpot . cli @GetArtist
+
+getTrack :: (MonadSpotify m) => TrackID -> m Track
+getTrack t = inSpot $ cli @GetTrack t marketFromToken
+getSavedTracks :: (MonadSpotify m) => PagingParams -> m (Paging SavedTrack)
+getSavedTracks pps = inSpot $ withPagingParams pps $ cli @GetSavedTracks marketFromToken
+saveTracks :: (MonadSpotify m) => [TrackID] -> m ()
+saveTracks = noContent . inSpot . cli @SaveTracks
+removeTracks :: (MonadSpotify m) => [TrackID] -> m ()
+removeTracks = noContent . inSpot . cli @RemoveTracks
+
+search :: (MonadSpotify m) => Text -> [SearchType] -> Maybe Text -> Maybe Market -> PagingParams -> m SearchResult
+search q t e m = inSpot . flip withPagingParams \limit offset -> cli @GetSearch q t e limit m offset
+
+getMe :: (MonadSpotify m) => m User
+getMe = inSpot $ cli @GetMe
+getUser :: (MonadSpotify m) => UserID -> m User
+getUser u = inSpot $ cli @GetUser u
+unfollowPlaylist :: (MonadSpotify m) => PlaylistID -> m ()
+unfollowPlaylist = noContent . inSpot . cli @UnfollowPlaylist
+
+getPlaylist :: (MonadSpotify m) => PlaylistID -> m Playlist
+getPlaylist = inSpot . cli @GetPlaylist
+addToPlaylist :: (MonadSpotify m) => PlaylistID -> Maybe Int -> [URI] -> m Text
+addToPlaylist p position uris = fmap coerce $ inSpot $ cli @AddToPlaylist p AddToPlaylistBody{..}
+getMyPlaylists :: (MonadSpotify m) => PagingParams -> m (Paging PlaylistSimple)
+getMyPlaylists pps = inSpot $ withPagingParams pps $ cli @GetMyPlaylists
+createPlaylist :: (MonadSpotify m) => UserID -> CreatePlaylistOpts -> m PlaylistSimple
+createPlaylist u opts = inSpot $ cli @CreatePlaylist u opts
+
+getCategories :: (MonadSpotify m) => CategoryID -> Maybe Country -> Maybe Locale -> m Category
+getCategories = inSpot .:. cli @GetCategories
+
+getPlaybackState :: (MonadSpotify m) => Maybe Market -> m PlaybackState
+getPlaybackState = inSpot . cli @GetPlaybackState
+transferPlayback :: (MonadSpotify m) => [DeviceID] -> Bool -> m ()
+transferPlayback device_ids play = noContent . inSpot $ cli @TransferPlayback TransferPlaybackBody{..}
+getAvailableDevices :: (MonadSpotify m) => m [Device]
+getAvailableDevices = fmap (.devices) . inSpot $ cli @GetAvailableDevices
+getCurrentlyPlayingTrack :: (MonadSpotify m) => Maybe Market -> m CurrentlyPlayingTrack
+getCurrentlyPlayingTrack = inSpot . cli @GetCurrentlyPlayingTrack
+startPlayback :: (MonadSpotify m) => Maybe DeviceID -> StartPlaybackOpts -> m ()
+startPlayback = noContent . inSpot .: cli @StartPlayback
+pausePlayback :: (MonadSpotify m) => Maybe DeviceID -> m ()
+pausePlayback = noContent . inSpot . cli @PausePlayback
+skipToNext :: (MonadSpotify m) => Maybe DeviceID -> m ()
+skipToNext = noContent . inSpot . cli @SkipToNext
+skipToPrevious :: (MonadSpotify m) => Maybe DeviceID -> m ()
+skipToPrevious = noContent . inSpot . cli @SkipToPrevious
+seekToPosition :: (MonadSpotify m) => Int -> Maybe DeviceID -> m ()
+seekToPosition = noContent . inSpot .: cli @SeekToPosition
+
+-- higher-level wrappers around main API
+-- takes a callback which can be used for side effects, or to return False for early exit
+allPages :: (Monad m) => Maybe (Paging a -> m Bool) -> (PagingParams -> m (Paging a)) -> m [a]
+allPages callback x =
+    concat <$> flip unfoldrM (0, Nothing, True) \(i, total, keepGoing) -> do
+        if keepGoing && maybe True (i <) total
+            then do
+                p <- x $ PagingParams{limit = Just limit, offset = Just i}
+                keepGoing' <- maybe (pure True) ($ p) callback
+                pure $ Just (p.items, (i + limit, Just p.total, keepGoing'))
+            else pure Nothing
+  where
+    limit = 50 -- API docs say this is the max
diff --git a/servant/Orphans/Servant/Lucid.hs b/servant/Orphans/Servant/Lucid.hs
new file mode 100644
--- /dev/null
+++ b/servant/Orphans/Servant/Lucid.hs
@@ -0,0 +1,13 @@
+{-# OPTIONS_GHC -Wno-orphans #-}
+
+module Orphans.Servant.Lucid where
+
+import Data.ByteString.Lazy qualified as BL
+import Data.Text (Text)
+import Data.Text.Encoding (decodeUtf8)
+import Servant.API (MimeUnrender (mimeUnrender))
+import Servant.HTML.Lucid (HTML)
+
+-- https://github.com/haskell-servant/servant-lucid/issues/14#issuecomment-1250060470
+instance MimeUnrender HTML Text where
+    mimeUnrender _ = pure . decodeUtf8 . BL.toStrict
diff --git a/servant/Spotify/Servant/Albums.hs b/servant/Spotify/Servant/Albums.hs
new file mode 100644
--- /dev/null
+++ b/servant/Spotify/Servant/Albums.hs
@@ -0,0 +1,31 @@
+module Spotify.Servant.Albums where
+
+import Spotify.Servant.Core
+import Spotify.Types.Albums
+import Spotify.Types.Misc
+import Spotify.Types.Simple
+
+import Servant.API (
+    Capture,
+    QueryParam,
+    type (:>),
+ )
+
+type GetAlbum =
+    "albums"
+        :> Capture "id" AlbumID
+        :> QueryParam "market" Market
+        :> SpotGet Album
+
+type GetAlbumTracks =
+    "albums"
+        :> Capture "id" AlbumID
+        :> "tracks"
+        :> QueryParam "market" Market
+        :> SpotPaging TrackSimple
+
+type RemoveAlbums =
+    "me"
+        :> "albums"
+        :> SpotBody [AlbumID]
+        :> SpotDeleteNoContent
diff --git a/servant/Spotify/Servant/Artists.hs b/servant/Spotify/Servant/Artists.hs
new file mode 100644
--- /dev/null
+++ b/servant/Spotify/Servant/Artists.hs
@@ -0,0 +1,15 @@
+module Spotify.Servant.Artists where
+
+import Spotify.Servant.Core
+import Spotify.Types.Artists
+import Spotify.Types.Misc
+
+import Servant.API (
+    Capture,
+    type (:>),
+ )
+
+type GetArtist =
+    "artists"
+        :> Capture "id" ArtistID
+        :> SpotGet Artist
diff --git a/servant/Spotify/Servant/Categories.hs b/servant/Spotify/Servant/Categories.hs
new file mode 100644
--- /dev/null
+++ b/servant/Spotify/Servant/Categories.hs
@@ -0,0 +1,19 @@
+module Spotify.Servant.Categories where
+
+import Spotify.Servant.Core
+import Spotify.Types.Categories
+import Spotify.Types.Misc
+
+import Servant.API (
+    Capture,
+    QueryParam,
+    type (:>),
+ )
+
+type GetCategories =
+    "browse"
+        :> "categories"
+        :> Capture "category_id" CategoryID
+        :> QueryParam "country" Country
+        :> QueryParam "locale" Locale
+        :> SpotGet Category
diff --git a/servant/Spotify/Servant/Core.hs b/servant/Spotify/Servant/Core.hs
new file mode 100644
--- /dev/null
+++ b/servant/Spotify/Servant/Core.hs
@@ -0,0 +1,108 @@
+module Spotify.Servant.Core where
+
+import Orphans.Servant.Lucid ()
+import Spotify.Types.Auth
+import Spotify.Types.Internal.CustomJSON
+import Spotify.Types.Misc
+
+import Data.Aeson (FromJSON)
+import Data.HashMap.Strict qualified as HM
+import Data.Set (Set)
+import Data.Set qualified as Set
+import Data.Text (Text)
+import Data.Text qualified as T
+import GHC.Generics (Generic)
+import Servant.API (
+    Delete,
+    DeleteNoContent,
+    FormUrlEncoded,
+    Get,
+    Header',
+    JSON,
+    Post,
+    PostCreated,
+    PostNoContent,
+    Put,
+    PutAccepted,
+    PutNoContent,
+    QueryParam,
+    QueryParam',
+    ReqBody,
+    Required,
+    Strict,
+    ToHttpApiData (toUrlPiece),
+    type (:>),
+ )
+import Servant.HTML.Lucid (HTML)
+import Web.FormUrlEncoded (Form (Form), ToForm (toForm))
+
+type Authorize =
+    "authorize"
+        :> QueryParam' '[Strict, Required] "client_id" ClientId
+        :> QueryParam' '[Strict, Required] "response_type" Text
+        :> QueryParam' '[Strict, Required] "redirect_uri" URL
+        :> QueryParam "state" Text
+        :> QueryParam "scope" ScopeSet
+        :> QueryParam "show_dialog" Bool
+        :> Get '[HTML] Text
+
+type RequestAccessToken =
+    "token"
+        :> ReqBody '[FormUrlEncoded] RequestAccessTokenForm
+        :> Header' '[Strict, Required] "Authorization" IdAndSecret
+        :> Post '[JSON] TokenResponse'
+data RequestAccessTokenForm = RequestAccessTokenForm AuthCode URL
+instance ToForm RequestAccessTokenForm where
+    toForm (RequestAccessTokenForm (AuthCode t) r) =
+        Form $
+            HM.fromList
+                [ ("grant_type", ["authorization_code"])
+                , ("code", [t])
+                , ("redirect_uri", [r.unwrap])
+                ]
+data TokenResponse' = TokenResponse'
+    { accessToken :: AccessToken
+    , tokenType :: TokenType
+    , expiresIn :: Int
+    , scope :: Text
+    , refreshToken :: RefreshToken
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON TokenResponse'
+
+type RefreshAccessToken =
+    "token"
+        :> ReqBody '[FormUrlEncoded] RefreshAccessTokenForm
+        :> Header' '[Strict, Required] "Authorization" IdAndSecret
+        :> Post '[JSON] TokenResponse
+newtype RefreshAccessTokenForm = RefreshAccessTokenForm RefreshToken
+instance ToForm RefreshAccessTokenForm where
+    toForm (RefreshAccessTokenForm (RefreshToken t)) =
+        Form $
+            HM.fromList
+                [ ("grant_type", ["refresh_token"])
+                , ("refresh_token", [t])
+                ]
+
+type AuthHeader = Header' '[Strict, Required] "Authorization" AccessToken
+
+-- various patterns which appear throughout the API
+type SpotGet a = AuthHeader :> Get '[JSON] a
+type SpotPut a = AuthHeader :> Put '[JSON] a
+type SpotPutAccepted a = AuthHeader :> PutAccepted '[JSON] a
+type SpotPutNoContent = AuthHeader :> PutNoContent
+type SpotPost a = AuthHeader :> Post '[JSON] a
+type SpotPostCreated a = AuthHeader :> PostCreated '[JSON] a
+type SpotPostNoContent = AuthHeader :> PostNoContent
+type SpotDelete a = AuthHeader :> Delete '[JSON] a
+type SpotDeleteNoContent = AuthHeader :> DeleteNoContent
+type SpotBody = ReqBody '[JSON]
+type SpotPaging a =
+    QueryParam "limit" Int
+        :> QueryParam "offset" Int
+        :> SpotGet (Paging a)
+
+-- types that only exist for the instances
+newtype ScopeSet = ScopeSet {unwrap :: Set Scope}
+instance ToHttpApiData ScopeSet where
+    toUrlPiece = toUrlPiece . T.intercalate " " . map showScope . Set.toList . (.unwrap)
diff --git a/servant/Spotify/Servant/Player.hs b/servant/Spotify/Servant/Player.hs
new file mode 100644
--- /dev/null
+++ b/servant/Spotify/Servant/Player.hs
@@ -0,0 +1,100 @@
+module Spotify.Servant.Player where
+
+import Spotify.Servant.Core
+import Spotify.Types.Internal.CustomJSON
+import Spotify.Types.Misc
+import Spotify.Types.Player
+
+import Data.Aeson (FromJSON, ToJSON)
+import GHC.Generics (Generic)
+import Servant.API (
+    JSON,
+    QueryParam,
+    QueryParam',
+    ReqBody,
+    Required,
+    Strict,
+    type (:>),
+ )
+
+type GetPlaybackState =
+    "me"
+        :> "player"
+        :> QueryParam "market" Market
+        :> SpotGet PlaybackState
+
+type TransferPlayback =
+    "me"
+        :> "player"
+        :> ReqBody '[JSON] TransferPlaybackBody
+        :> SpotPutNoContent
+data TransferPlaybackBody = TransferPlaybackBody
+    { device_ids :: [DeviceID]
+    , play :: Bool
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (ToJSON)
+
+type GetAvailableDevices =
+    "me"
+        :> "player"
+        :> "devices"
+        :> SpotGet GetAvailableDevicesResponse
+newtype GetAvailableDevicesResponse = GetAvailableDevicesResponse
+    { devices :: [Device]
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON GetAvailableDevicesResponse
+
+type GetCurrentlyPlayingTrack =
+    "me"
+        :> "player"
+        :> "currently-playing"
+        :> QueryParam "market" Market
+        :> SpotGet CurrentlyPlayingTrack
+
+type StartPlayback =
+    "me"
+        :> "player"
+        :> "play"
+        :> QueryParam "device_id" DeviceID
+        :> ReqBody '[JSON] StartPlaybackOpts
+        :> SpotPutNoContent
+data StartPlaybackOpts = StartPlaybackOpts
+    { context_uri :: Maybe URI
+    , uris :: Maybe [URI]
+    , offset :: Maybe Offset
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (ToJSON)
+emptyStartPlaybackOpts :: StartPlaybackOpts
+emptyStartPlaybackOpts = StartPlaybackOpts Nothing Nothing Nothing
+
+type PausePlayback =
+    "me"
+        :> "player"
+        :> "pause"
+        :> QueryParam "device_id" DeviceID
+        :> SpotPutNoContent
+
+type SkipToNext =
+    "me"
+        :> "player"
+        :> "next"
+        :> QueryParam "device_id" DeviceID
+        :> SpotPostNoContent
+
+type SkipToPrevious =
+    "me"
+        :> "player"
+        :> "previous"
+        :> QueryParam "device_id" DeviceID
+        :> SpotPostNoContent
+
+type SeekToPosition =
+    "me"
+        :> "player"
+        :> "seek"
+        :> QueryParam' '[Strict, Required] "position_ms" Int
+        :> QueryParam "device_id" DeviceID
+        :> SpotPutNoContent
diff --git a/servant/Spotify/Servant/Playlists.hs b/servant/Spotify/Servant/Playlists.hs
new file mode 100644
--- /dev/null
+++ b/servant/Spotify/Servant/Playlists.hs
@@ -0,0 +1,58 @@
+module Spotify.Servant.Playlists where
+
+import Spotify.Servant.Core
+import Spotify.Types.Internal.CustomJSON
+import Spotify.Types.Misc
+import Spotify.Types.Playlists
+import Spotify.Types.Simple
+
+import Data.Aeson (FromJSON, ToJSON)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import Servant.API (
+    Capture,
+    type (:>),
+ )
+
+type GetPlaylist =
+    "playlists"
+        :> Capture "playlist_id" PlaylistID
+        :> SpotGet Playlist
+
+type AddToPlaylist =
+    "playlists"
+        :> Capture "playlist_id" PlaylistID
+        :> "tracks"
+        :> SpotBody AddToPlaylistBody
+        :> SpotPostCreated AddToPlaylistResponse
+data AddToPlaylistBody = AddToPlaylistBody
+    { position :: Maybe Int
+    , uris :: [URI]
+    }
+    deriving (Generic)
+    deriving (ToJSON)
+newtype AddToPlaylistResponse = AddToPlaylistResponse
+    { snapshotId :: SnapshotID
+    }
+    deriving (Generic)
+    deriving (FromJSON) via CustomJSON AddToPlaylistResponse
+
+type GetMyPlaylists =
+    "me"
+        :> "playlists"
+        :> SpotPaging PlaylistSimple
+
+type CreatePlaylist =
+    "users"
+        :> Capture "user_id" UserID
+        :> "playlists"
+        :> SpotBody CreatePlaylistOpts
+        :> SpotPostCreated PlaylistSimple
+data CreatePlaylistOpts = CreatePlaylistOpts
+    { name :: Text
+    , public :: Bool
+    , collaborative :: Bool
+    , description :: Text
+    }
+    deriving (Generic)
+    deriving (ToJSON)
diff --git a/servant/Spotify/Servant/Search.hs b/servant/Spotify/Servant/Search.hs
new file mode 100644
--- /dev/null
+++ b/servant/Spotify/Servant/Search.hs
@@ -0,0 +1,24 @@
+module Spotify.Servant.Search where
+
+import Spotify.Servant.Core
+import Spotify.Types.Misc
+import Spotify.Types.Search
+
+import Data.Text (Text)
+import Servant.API (
+    QueryParam,
+    QueryParam',
+    Required,
+    Strict,
+    type (:>),
+ )
+
+type GetSearch =
+    "search"
+        :> QueryParam' '[Strict, Required] "q" Text
+        :> QueryParam' '[Strict, Required] "type" [SearchType]
+        :> QueryParam "include_external" Text
+        :> QueryParam "limit" Int
+        :> QueryParam "market" Market
+        :> QueryParam "offset" Int
+        :> SpotGet SearchResult
diff --git a/servant/Spotify/Servant/Tracks.hs b/servant/Spotify/Servant/Tracks.hs
new file mode 100644
--- /dev/null
+++ b/servant/Spotify/Servant/Tracks.hs
@@ -0,0 +1,35 @@
+module Spotify.Servant.Tracks where
+
+import Spotify.Servant.Core
+import Spotify.Types.Misc
+import Spotify.Types.Tracks
+
+import Servant.API (
+    Capture,
+    QueryParam,
+    type (:>),
+ )
+
+type GetTrack =
+    "tracks"
+        :> Capture "id" TrackID
+        :> QueryParam "market" Market
+        :> SpotGet Track
+
+type GetSavedTracks =
+    "me"
+        :> "tracks"
+        :> QueryParam "market" Market
+        :> SpotPaging SavedTrack
+
+type SaveTracks =
+    "me"
+        :> "tracks"
+        :> SpotBody [TrackID]
+        :> SpotDeleteNoContent
+
+type RemoveTracks =
+    "me"
+        :> "tracks"
+        :> SpotBody [TrackID]
+        :> SpotDeleteNoContent
diff --git a/servant/Spotify/Servant/Users.hs b/servant/Spotify/Servant/Users.hs
new file mode 100644
--- /dev/null
+++ b/servant/Spotify/Servant/Users.hs
@@ -0,0 +1,25 @@
+module Spotify.Servant.Users where
+
+import Spotify.Servant.Core
+import Spotify.Types.Misc
+import Spotify.Types.Users
+
+import Servant.API (
+    Capture,
+    type (:>),
+ )
+
+type GetMe =
+    "me"
+        :> SpotGet User
+
+type GetUser =
+    "users"
+        :> Capture "user_id" UserID
+        :> SpotGet User
+
+type UnfollowPlaylist =
+    "playlists"
+        :> Capture "playlist_id" PlaylistID
+        :> "followers"
+        :> SpotDeleteNoContent
diff --git a/spotify.cabal b/spotify.cabal
new file mode 100644
--- /dev/null
+++ b/spotify.cabal
@@ -0,0 +1,144 @@
+cabal-version:      3.0
+name:               spotify
+version:            0.1.0.0
+author:             George Thomas
+maintainer:         George Thomas
+description:        Bindings for interacting with the Spotify Web API
+synopsis:           Spotify Web API
+homepage:           https://github.com/georgefst/spotify
+license:            BSD-3-Clause
+license-file:       LICENSE
+category:           Music, Web
+build-type:         Simple
+extra-source-files:
+    CHANGELOG.md
+    README.md
+
+source-repository head
+    type: git
+    location: git://github.com/georgefst/spotify.git
+
+common common
+    build-depends:
+        base >= 4.16 && < 5,
+        aeson ^>= 2.1.0,
+        bytestring ^>= 0.11.3,
+        composition ^>= 1.0.2,
+        containers ^>= 0.6.5,
+        directory ^>= 1.3.6,
+        exceptions ^>= 0.10,
+        extra ^>= 1.7.12,
+        filepath ^>= 1.4.2,
+        monad-loops ^>= 0.4.3,
+        mtl ^>= {2.2.2, 2.3},
+        pretty-simple ^>= 4.1,
+        servant ^>= 0.19,
+        text ^>= {1.2.5, 2.0},
+        transformers ^>= {0.5.6, 0.6},
+        time ^>= {1.11.1, 1.12},
+        unordered-containers ^>= 0.2.19,
+    default-language: GHC2021
+    default-extensions:
+        AllowAmbiguousTypes
+        BlockArguments
+        DataKinds
+        DeriveAnyClass
+        DerivingStrategies
+        DerivingVia
+        DuplicateRecordFields
+        ExplicitNamespaces
+        ImpredicativeTypes
+        LambdaCase
+        NoFieldSelectors
+        NoMonomorphismRestriction
+        OverloadedLabels
+        OverloadedRecordDot
+        OverloadedStrings
+        PartialTypeSignatures
+        RecordWildCards
+        ViewPatterns
+    ghc-options:
+        -Wall
+        -threaded
+
+library spotify-types
+    import: common
+    hs-source-dirs: types
+    exposed-modules:
+        Spotify.Types.Albums
+        Spotify.Types.Artists
+        Spotify.Types.Tracks
+        Spotify.Types.Search
+        Spotify.Types.Users
+        Spotify.Types.Playlists
+        Spotify.Types.Categories
+        Spotify.Types.Player
+        Spotify.Types.Simple
+        Spotify.Types.Misc
+        Spotify.Types.Auth
+        Spotify.Types.Internal.CustomJSON
+        Spotify.Types.Internal.EnumJSON
+    build-depends:
+        base64-bytestring ^>= 1.2.1,
+
+library spotify-servant
+    import: common
+    hs-source-dirs: servant
+    exposed-modules:
+        Spotify.Servant.Core
+        Spotify.Servant.Albums
+        Spotify.Servant.Artists
+        Spotify.Servant.Tracks
+        Spotify.Servant.Search
+        Spotify.Servant.Users
+        Spotify.Servant.Playlists
+        Spotify.Servant.Categories
+        Spotify.Servant.Player
+    other-modules:
+        Orphans.Servant.Lucid
+    build-depends:
+        spotify-types,
+        http-api-data ^>= {0.4.3, 0.5},
+        servant-client ^>= 0.19,
+        servant-lucid ^>= 0.9,
+
+library
+    import: common
+    hs-source-dirs: lib
+    exposed-modules:
+        Spotify
+    build-depends:
+        spotify-types,
+        spotify-servant,
+        http-client ^>= 0.7.13,
+        http-client-tls ^>= 0.3.6,
+        http-types ^>= 0.12.3,
+        lucid ^>= 2.11,
+        servant-client ^>= 0.19,
+        servant-lucid ^>= 0.9,
+
+executable examples
+    import: common
+    hs-source-dirs: examples
+    main-is: Main.hs
+    other-modules:
+        Spotify.CheckPlaylistOverlap
+        Spotify.CreateArtistLikedSongsPlaylist
+        Spotify.CreatePlaylist
+        Spotify.DeleteRecentPlaylists
+        Spotify.TransferPlayback
+    build-depends:
+        spotify-types,
+        spotify-servant,
+        spotify,
+
+test-suite tests
+    import: common
+    type: detailed-0.9
+    test-module: Test
+    hs-source-dirs: test
+    build-depends:
+        spotify,
+        spotify-types,
+        spotify-servant,
+        Cabal ^>= {3.6, 3.8, 3.10},
diff --git a/test/Test.hs b/test/Test.hs
new file mode 100644
--- /dev/null
+++ b/test/Test.hs
@@ -0,0 +1,72 @@
+{-# OPTIONS_GHC -Wno-unused-do-bind #-}
+
+module Test (tests) where
+
+import Spotify
+import Spotify.Servant.Playlists
+import Spotify.Types.Misc
+import Spotify.Types.Simple
+import Spotify.Types.Users
+
+import Control.Monad
+import Data.Functor
+import Distribution.TestSuite
+
+tests :: IO [Test]
+tests = do
+    auth <- getAuth
+    pure . pure . Test $
+        TestInstance
+            { run =
+                runSpotify auth runAll
+                    <&> Finished . \case
+                        Left s -> Fail $ show s
+                        Right _ -> Pass
+            , name = "Spotify tests"
+            , tags = []
+            , options = []
+            , setOption = \_ _ -> Left "no options"
+            }
+
+runAll :: Spotify ()
+runAll = do
+    User{id = me} <- getMe
+    getUser me
+
+    getAlbum album1
+    getAlbumTracks album1 noPagingParams
+
+    getSavedTracks noPagingParams
+    removeAlbums [album2, album3]
+    removeTracks [track1, track2]
+    saveTracks [track1, track2]
+
+    Paging{items} <- getMyPlaylists $ PagingParams{limit = Just 50, offset = Nothing}
+    forM_ (filter ((== playlistName) . (.name)) items) $ unfollowPlaylist . (.id)
+    PlaylistSimple{id = playlistId} <-
+        createPlaylist
+            me
+            CreatePlaylistOpts
+                { name = playlistName
+                , public = False
+                , collaborative = False
+                , description = "a description"
+                }
+    addToPlaylist playlistId Nothing $
+        map toURI [track3 :: TrackID, track4, track5]
+            <> map toURI [podEpisode1 :: EpisodeID]
+
+    getTrack track1
+
+    pure ()
+  where
+    album1 = "6l8tlcL7JOuG03fAAmcohv" -- push barman
+    album2 = "4sIViEgySoHMaqsDLXN45p" -- aerosmith 1
+    album3 = "5NHCg9rnAwBPDKOu2LSSUq" -- aerosmith 2
+    track1 = "4rTurOCj7TorrnvmacjAFc" -- aerosmith 1.1
+    track2 = "6z2uJuztb6zgLpUJJHupv7" -- aerosmith 1.2
+    track3 = "7h8bnu16P1gLNCwtiQ1Nk9" -- OTN/MGF
+    track4 = "1Qr6phLmzW5Z4k3ggATKh5" -- DSN
+    track5 = "2twka2Q4vhCaBMJgtcqLR1" -- CHC
+    podEpisode1 = "2T8XOBBkTrqtw8ikXFB2eM" -- Parched
+    playlistName = "Haskell test playlist"
diff --git a/types/Spotify/Types/Albums.hs b/types/Spotify/Types/Albums.hs
new file mode 100644
--- /dev/null
+++ b/types/Spotify/Types/Albums.hs
@@ -0,0 +1,32 @@
+module Spotify.Types.Albums where
+
+import Spotify.Types.Internal.CustomJSON
+import Spotify.Types.Misc
+import Spotify.Types.Simple
+
+import Data.Aeson (FromJSON)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+
+data Album = Album
+    { albumType :: AlbumType
+    , artists :: [ArtistSimple]
+    , availableMarkets :: Maybe [Text]
+    , copyrights :: [Copyright]
+    , externalIds :: ExternalIDs
+    , externalUrls :: ExternalURLs
+    , genres :: [Text]
+    , href :: Href
+    , id :: AlbumID
+    , images :: [Image]
+    , label :: Text
+    , name :: Text
+    , popularity :: Int
+    , releaseDate :: Text
+    , releaseDatePrecision :: DatePrecision
+    , restrictions :: Maybe Restrictions
+    , tracks :: Paging TrackSimple
+    , uri :: URI
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON Album
diff --git a/types/Spotify/Types/Artists.hs b/types/Spotify/Types/Artists.hs
new file mode 100644
--- /dev/null
+++ b/types/Spotify/Types/Artists.hs
@@ -0,0 +1,22 @@
+module Spotify.Types.Artists where
+
+import Spotify.Types.Internal.CustomJSON
+import Spotify.Types.Misc
+
+import Data.Aeson (FromJSON)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+
+data Artist = Artist
+    { externalUrls :: ExternalURLs
+    , followers :: Followers
+    , genres :: [Genre]
+    , href :: Href
+    , id :: ArtistID
+    , images :: [Image]
+    , name :: Text
+    , popularity :: Int
+    , uri :: URI
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON Artist
diff --git a/types/Spotify/Types/Auth.hs b/types/Spotify/Types/Auth.hs
new file mode 100644
--- /dev/null
+++ b/types/Spotify/Types/Auth.hs
@@ -0,0 +1,54 @@
+module Spotify.Types.Auth where
+
+import Spotify.Types.Internal.CustomJSON
+
+import Control.Monad ((>=>))
+import Data.Aeson (FromJSON, parseJSON)
+import Data.ByteString.Base64 qualified as B64
+import Data.String (IsString)
+import Data.Text (Text)
+import Data.Text.Encoding (decodeUtf8, encodeUtf8)
+import GHC.Generics (Generic)
+import Servant.API (FromHttpApiData, ToHttpApiData (toUrlPiece))
+
+newtype ClientId = ClientId {unwrap :: Text}
+    deriving newtype (Eq, Ord, Show, IsString, ToHttpApiData)
+
+newtype ClientSecret = ClientSecret {unwrap :: Text}
+    deriving newtype (Eq, Ord, Show, IsString)
+
+newtype AccessToken = AccessToken {unwrap :: Text}
+    deriving newtype (Eq, Ord, Show, IsString)
+    deriving newtype (FromJSON)
+instance ToHttpApiData AccessToken where
+    toUrlPiece (AccessToken t) = toUrlPiece $ "Bearer " <> t
+
+newtype RefreshToken = RefreshToken {unwrap :: Text}
+    deriving newtype (Eq, Ord, Show, IsString)
+    deriving newtype (FromJSON)
+newtype AuthCode = AuthCode {unwrap :: Text}
+    deriving newtype (Eq, Ord, Show, IsString, FromHttpApiData)
+    deriving newtype (FromJSON)
+
+data IdAndSecret = IdAndSecret ClientId ClientSecret
+instance ToHttpApiData IdAndSecret where
+    toUrlPiece (IdAndSecret (ClientId i) (ClientSecret s)) =
+        toUrlPiece . ("Basic " <>) . decodeUtf8 . B64.encode $ encodeUtf8 $ i <> ":" <> s
+
+data TokenType
+    = TokenTypeBearer
+    deriving (Eq, Ord, Show, Generic)
+instance FromJSON TokenType where
+    parseJSON =
+        parseJSON >=> \case
+            "Bearer" -> pure TokenTypeBearer
+            s -> fail $ "unknown type: " <> s
+
+data TokenResponse = TokenResponse
+    { accessToken :: AccessToken
+    , tokenType :: TokenType
+    , expiresIn :: Int
+    , scope :: Text
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON TokenResponse
diff --git a/types/Spotify/Types/Categories.hs b/types/Spotify/Types/Categories.hs
new file mode 100644
--- /dev/null
+++ b/types/Spotify/Types/Categories.hs
@@ -0,0 +1,17 @@
+module Spotify.Types.Categories where
+
+import Spotify.Types.Internal.CustomJSON
+import Spotify.Types.Misc
+
+import Data.Aeson (FromJSON)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+
+data Category = Category
+    { href :: Href
+    , icons :: [Image]
+    , id :: CategoryID
+    , name :: Text
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON Category
diff --git a/types/Spotify/Types/Internal/CustomJSON.hs b/types/Spotify/Types/Internal/CustomJSON.hs
new file mode 100644
--- /dev/null
+++ b/types/Spotify/Types/Internal/CustomJSON.hs
@@ -0,0 +1,34 @@
+{-# LANGUAGE UndecidableInstances #-}
+
+module Spotify.Types.Internal.CustomJSON where
+
+import Data.Aeson (
+    FromJSON (parseJSON),
+    GFromJSON,
+    Options (constructorTagModifier, fieldLabelModifier),
+    Zero,
+    defaultOptions,
+    genericParseJSON,
+ )
+import Data.Char (isUpper, toLower)
+import Data.List (dropWhileEnd, stripPrefix)
+import Data.Maybe (fromMaybe)
+import GHC.Generics (Generic (Rep))
+import Type.Reflection (Typeable, typeRep)
+
+newtype CustomJSON a = CustomJSON a deriving (Generic)
+instance (Generic a, GFromJSON Zero (Rep a), Typeable a) => FromJSON (CustomJSON a) where
+    parseJSON = fmap CustomJSON . genericParseJSON defaultOptions{constructorTagModifier, fieldLabelModifier}
+      where
+        fieldLabelModifier = camelToSnake . dropWhileEnd (== '_')
+        constructorTagModifier = camelToSnake . (fromMaybe <*> stripPrefix (show $ typeRep @a))
+        camelToSnake = \case
+            [] -> []
+            x : xs -> toLower x : go xs
+          where
+            go = \case
+                [] -> []
+                x : xs ->
+                    if isUpper x
+                        then '_' : toLower x : go xs
+                        else x : go xs
diff --git a/types/Spotify/Types/Internal/EnumJSON.hs b/types/Spotify/Types/Internal/EnumJSON.hs
new file mode 100644
--- /dev/null
+++ b/types/Spotify/Types/Internal/EnumJSON.hs
@@ -0,0 +1,8 @@
+module Spotify.Types.Internal.EnumJSON where
+
+import Data.Aeson (FromJSON (parseJSON))
+
+newtype EnumJSON a = EnumJSON a
+
+instance (Enum a) => FromJSON (EnumJSON a) where
+    parseJSON = fmap (EnumJSON . toEnum @a) . parseJSON
diff --git a/types/Spotify/Types/Misc.hs b/types/Spotify/Types/Misc.hs
new file mode 100644
--- /dev/null
+++ b/types/Spotify/Types/Misc.hs
@@ -0,0 +1,253 @@
+{- HLINT ignore "Use newtype instead of data" -}
+{-# LANGUAGE UndecidableInstances #-}
+
+module Spotify.Types.Misc where
+
+import Spotify.Types.Internal.CustomJSON
+import Spotify.Types.Internal.EnumJSON
+
+import Data.Aeson (FromJSON, ToJSON)
+import Data.List.Extra (enumerate)
+import Data.Map (Map)
+import Data.Proxy (Proxy (Proxy))
+import Data.Set qualified as Set
+import Data.String (IsString)
+import Data.Text (Text)
+import Data.Text qualified as T
+import GHC.Generics (Generic)
+import GHC.Records (HasField)
+import GHC.TypeLits (KnownSymbol, Symbol, symbolVal)
+import Servant.API (ToHttpApiData)
+
+data Copyright = Copyright
+    { text :: Text
+    , type_ :: CopyrightType
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON Copyright
+
+data CopyrightType
+    = C
+    | P
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON)
+
+data Error = Error
+    { status :: HTTPError
+    , message :: Text
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON Error
+
+data Followers = Followers
+    { href :: Maybe Text
+    , total :: Int
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON Followers
+
+data Image = Image
+    { height :: Maybe Int
+    , url :: Text
+    , width :: Maybe Int
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON Image
+
+data Paging a = Paging
+    { href :: Href
+    , items :: [a]
+    , limit :: Int
+    , next :: Maybe Text
+    , offset :: Int
+    , previous :: Maybe Text
+    , total :: Int
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON (Paging a)
+
+data Tracks = Tracks
+    { href :: Maybe Text
+    , total :: Int
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON Tracks
+
+data DatePrecision
+    = DatePrecisionYear
+    | DatePrecisionMonth
+    | DatePrecisionDay
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON DatePrecision
+
+data Key
+    = KeyC
+    | KeyCSharp
+    | KeyD
+    | KeyDSharp
+    | KeyE
+    | KeyF
+    | KeyFSharp
+    | KeyG
+    | KeyGSharp
+    | KeyA
+    | KeyASharp
+    | KeyB
+    deriving (Eq, Ord, Show, Generic, Enum)
+    deriving (FromJSON) via EnumJSON Key
+
+data TrackLink = TrackLink
+    { externalUrls :: ExternalURLs
+    , href :: Href
+    , id :: TrackID
+    , url :: Maybe Text
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON TrackLink
+
+data AlbumGroup
+    = GroupAlbum
+    | GroupSingle
+    | GroupCompilation
+    | AppearsOn
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON AlbumGroup
+
+data AlbumType
+    = AlbumTypeAlbum
+    | AlbumTypeSingle
+    | AlbumTypeCompilation
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON AlbumType
+
+data ExplicitContent = ExplicitContent
+    { filterEnabled :: Bool
+    , filterLocked :: Bool
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON ExplicitContent
+
+data Product
+    = Premium
+    | Free
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON Product
+
+data Offset = Offset
+    { position :: Int
+    , uri :: Maybe URI
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (ToJSON)
+
+newtype Market = Market {unwrap :: Text}
+    deriving newtype (Eq, Ord, Show, ToHttpApiData, IsString)
+
+newtype Genre = Genre {unwrap :: Text}
+    deriving newtype (Eq, Ord, Show, FromJSON, IsString)
+
+newtype Href = Href {unwrap :: Text}
+    deriving newtype (Eq, Ord, Show, FromJSON, IsString)
+
+class ToURI a where
+    toURI :: a -> URI
+newtype URIPrefix (s :: Symbol) a = URIPrefix a
+instance (KnownSymbol s, HasField "unwrap" a Text) => ToURI (URIPrefix s a) where
+    toURI (URIPrefix x) = URI $ "spotify:" <> T.pack (symbolVal (Proxy @s)) <> ":" <> x.unwrap
+newtype DeviceID = DeviceID {unwrap :: Text}
+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)
+newtype AlbumID = AlbumID {unwrap :: Text}
+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)
+    deriving (ToURI) via URIPrefix "album" AlbumID
+newtype ArtistID = ArtistID {unwrap :: Text}
+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)
+    deriving (ToURI) via URIPrefix "artist" ArtistID
+newtype EpisodeID = EpisodeID {unwrap :: Text}
+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)
+    deriving (ToURI) via URIPrefix "episode" EpisodeID
+newtype TrackID = TrackID {unwrap :: Text}
+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)
+    deriving (ToURI) via URIPrefix "track" TrackID
+newtype UserID = UserID {unwrap :: Text}
+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)
+    deriving (ToURI) via URIPrefix "user" UserID
+newtype PlaylistID = PlaylistID {unwrap :: Text}
+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)
+    deriving (ToURI) via URIPrefix "playlist" PlaylistID
+newtype CategoryID = CategoryID {unwrap :: Text}
+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)
+newtype SnapshotID = SnapshotID {unwrap :: Text}
+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)
+
+newtype URL = URL {unwrap :: Text}
+    deriving newtype (Eq, Ord, Show, FromJSON, IsString, ToHttpApiData)
+
+newtype URI = URI {unwrap :: Text}
+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, IsString)
+
+newtype Country = Country {unwrap :: Text}
+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)
+
+newtype Locale = Locale {unwrap :: Text}
+    deriving newtype (Eq, Ord, Show, FromJSON, ToJSON, ToHttpApiData, IsString)
+
+newtype HTTPError = HTTPError {unwrap :: Int}
+    deriving (Show)
+    deriving newtype (Eq, Ord, FromJSON)
+
+newtype Restrictions = Restrictions {unwrap :: Map Text Text}
+    deriving (Show)
+    deriving newtype (Eq, Ord, FromJSON)
+
+newtype ExternalIDs = ExternalIDs {unwrap :: Map Text Text}
+    deriving (Show)
+    deriving newtype (Eq, Ord, FromJSON)
+
+newtype ExternalURLs = ExternalURLs {unwrap :: Map Text Text}
+    deriving (Show)
+    deriving newtype (Eq, Ord, FromJSON)
+
+data Scope
+    = UgcImageUpload
+    | UserModifyPlaybackState
+    | UserReadPlaybackState
+    | UserReadCurrentlyPlaying
+    | UserFollowModify
+    | UserFollowRead
+    | UserReadRecentlyPlayed
+    | UserReadPlaybackPosition
+    | UserTopRead
+    | PlaylistReadCollaborative
+    | PlaylistModifyPublic
+    | PlaylistReadPrivate
+    | PlaylistModifyPrivate
+    | AppRemoteControl
+    | Streaming
+    | UserReadEmail
+    | UserReadPrivate
+    | UserLibraryModify
+    | UserLibraryRead
+    deriving (Eq, Ord, Show, Enum, Bounded)
+allScopes :: Set.Set Scope
+allScopes = Set.fromList enumerate
+showScope :: Scope -> Text
+showScope = \case
+    UgcImageUpload -> "ugc-image-upload"
+    UserModifyPlaybackState -> "user-modify-playback-state"
+    UserReadPlaybackState -> "user-read-playback-state"
+    UserReadCurrentlyPlaying -> "user-read-currently-playing"
+    UserFollowModify -> "user-follow-modify"
+    UserFollowRead -> "user-follow-read"
+    UserReadRecentlyPlayed -> "user-read-recently-played"
+    UserReadPlaybackPosition -> "user-read-playback-position"
+    UserTopRead -> "user-top-read"
+    PlaylistReadCollaborative -> "playlist-read-collaborative"
+    PlaylistModifyPublic -> "playlist-modify-public"
+    PlaylistReadPrivate -> "playlist-read-private"
+    PlaylistModifyPrivate -> "playlist-modify-private"
+    AppRemoteControl -> "app-remote-control"
+    Streaming -> "streaming"
+    UserReadEmail -> "user-read-email"
+    UserReadPrivate -> "user-read-private"
+    UserLibraryModify -> "user-library-modify"
+    UserLibraryRead -> "user-library-read"
diff --git a/types/Spotify/Types/Player.hs b/types/Spotify/Types/Player.hs
new file mode 100644
--- /dev/null
+++ b/types/Spotify/Types/Player.hs
@@ -0,0 +1,78 @@
+module Spotify.Types.Player where
+
+import Spotify.Types.Internal.CustomJSON
+import Spotify.Types.Misc
+import Spotify.Types.Tracks
+
+import Data.Aeson (FromJSON)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+
+data PlaybackState = PlaybackState
+    { device :: Device
+    , repeatState :: Repeat
+    , shuffleState :: Bool
+    , context :: Context
+    , timestamp :: Int
+    , progressMs :: Int
+    , isPlaying :: Bool
+    , item :: Track
+    , currentlyPlayingType :: Text
+    , actions :: Actions
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON PlaybackState
+data Repeat
+    = RepeatOff
+    | RepeatContext
+    | RepeatTrack
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON Repeat
+
+data CurrentlyPlayingTrack = CurrentlyPlayingTrack
+    { context :: Context
+    , timestamp :: Int
+    , progressMs :: Int
+    , isPlaying :: Bool
+    , item :: Track
+    , currentlyPlayingType :: Text
+    , actions :: Actions
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON CurrentlyPlayingTrack
+
+data Device = Device
+    { id :: DeviceID
+    , isActive :: Bool
+    , isPrivateSession :: Bool
+    , isRestricted :: Bool
+    , name :: Text
+    , type_ :: Text
+    , volumePercent :: Int
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON Device
+
+data Context = Context
+    { type_ :: Text
+    , href :: Href
+    , externalUrls :: ExternalURLs
+    , uri :: URI
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON Context
+
+data Actions = Actions
+    { interruptingPlayback :: Maybe Bool
+    , pausing :: Maybe Bool
+    , resuming :: Maybe Bool
+    , seeking :: Maybe Bool
+    , skippingNext :: Maybe Bool
+    , skippingPrev :: Maybe Bool
+    , togglingRepeatContext :: Maybe Bool
+    , togglingShuffle :: Maybe Bool
+    , togglingRepeatTrack :: Maybe Bool
+    , transferringPlayback :: Maybe Bool
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON Actions
diff --git a/types/Spotify/Types/Playlists.hs b/types/Spotify/Types/Playlists.hs
new file mode 100644
--- /dev/null
+++ b/types/Spotify/Types/Playlists.hs
@@ -0,0 +1,28 @@
+module Spotify.Types.Playlists where
+
+import Spotify.Types.Internal.CustomJSON
+import Spotify.Types.Misc
+import Spotify.Types.Simple
+import Spotify.Types.Tracks
+
+import Data.Aeson (FromJSON)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+
+data Playlist = Playlist
+    { collaborative :: Bool
+    , description :: Maybe Text
+    , externalUrls :: ExternalURLs
+    , followers :: Followers
+    , href :: Href
+    , id :: PlaylistID
+    , images :: [Image]
+    , name :: Text
+    , owner :: UserSimple
+    , public :: Maybe Bool
+    , snapshotId :: SnapshotID
+    , tracks :: Paging SavedTrack
+    , uri :: URI
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON Playlist
diff --git a/types/Spotify/Types/Search.hs b/types/Spotify/Types/Search.hs
new file mode 100644
--- /dev/null
+++ b/types/Spotify/Types/Search.hs
@@ -0,0 +1,44 @@
+module Spotify.Types.Search where
+
+import Spotify.Types.Artists
+import Spotify.Types.Internal.CustomJSON
+import Spotify.Types.Misc
+import Spotify.Types.Simple
+import Spotify.Types.Tracks
+
+import Data.Aeson (FromJSON, Value)
+import Data.Text qualified as T
+import GHC.Generics (Generic)
+import Servant.API (ToHttpApiData (toUrlPiece))
+
+data SearchType
+    = AlbumSearch
+    | ArtistSearch
+    | PlaylistSearch
+    | TrackSearch
+    | ShowSearch
+    | EpisodeSearch
+    | AudiobookSearch
+    deriving (Eq, Ord, Show, Read, Generic)
+instance ToHttpApiData [SearchType] where
+    toUrlPiece =
+        T.intercalate "," . map \case
+            AlbumSearch -> "album"
+            ArtistSearch -> "artist"
+            PlaylistSearch -> "playlist"
+            TrackSearch -> "track"
+            ShowSearch -> "show"
+            EpisodeSearch -> "episode"
+            AudiobookSearch -> "audiobook"
+
+data SearchResult = SearchResult
+    { tracks :: Maybe (Paging Track)
+    , artists :: Maybe (Paging Artist)
+    , albums :: Maybe (Paging AlbumSimple)
+    , playlists :: Maybe (Paging PlaylistSimple)
+    , shows :: Maybe (Paging Value)
+    , episodes :: Maybe (Paging Value)
+    , audiobooks :: Maybe (Paging Value)
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON SearchResult
diff --git a/types/Spotify/Types/Simple.hs b/types/Spotify/Types/Simple.hs
new file mode 100644
--- /dev/null
+++ b/types/Spotify/Types/Simple.hs
@@ -0,0 +1,83 @@
+module Spotify.Types.Simple where
+
+import Spotify.Types.Internal.CustomJSON
+import Spotify.Types.Misc
+
+import Data.Aeson (FromJSON)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+
+data UserSimple = UserSimple
+    { displayName :: Maybe Text
+    , externalUrls :: ExternalURLs
+    , followers :: Maybe Followers
+    , href :: Href
+    , id :: UserID
+    , images :: Maybe [Image]
+    , uri :: URI
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON UserSimple
+
+data TrackSimple = TrackSimple
+    { artists :: [ArtistSimple]
+    , availableMarkets :: Maybe [Text]
+    , discNumber :: Int
+    , durationMs :: Int
+    , explicit :: Bool
+    , externalUrls :: ExternalURLs
+    , href :: Href
+    , id :: TrackID
+    , isPlayable :: Maybe Bool
+    , linkedFrom :: Maybe TrackLink
+    , name :: Text
+    , previewUrl :: Maybe Text
+    , trackNumber :: Int
+    , uri :: URI
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON TrackSimple
+
+data AlbumSimple = AlbumSimple
+    { albumType :: AlbumType
+    , artists :: [ArtistSimple]
+    , availableMarkets :: Maybe [Text]
+    , externalUrls :: ExternalURLs
+    , albumGroup :: Maybe AlbumGroup
+    , href :: Href
+    , id :: AlbumID
+    , images :: [Image]
+    , name :: Text
+    , releaseDate :: Text
+    , releaseDatePrecision :: DatePrecision
+    , restrictions :: Maybe Restrictions
+    , uri :: URI
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON AlbumSimple
+
+data ArtistSimple = ArtistSimple
+    { externalUrls :: ExternalURLs
+    , href :: Href
+    , id :: ArtistID
+    , name :: Text
+    , uri :: URI
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON ArtistSimple
+
+data PlaylistSimple = PlaylistSimple
+    { collaborative :: Bool
+    , externalUrls :: ExternalURLs
+    , href :: Href
+    , id :: PlaylistID
+    , images :: [Image]
+    , name :: Text
+    , owner :: UserSimple
+    , public :: Maybe Bool
+    , snapshotId :: SnapshotID
+    , tracks :: Tracks
+    , uri :: URI
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON PlaylistSimple
diff --git a/types/Spotify/Types/Tracks.hs b/types/Spotify/Types/Tracks.hs
new file mode 100644
--- /dev/null
+++ b/types/Spotify/Types/Tracks.hs
@@ -0,0 +1,68 @@
+module Spotify.Types.Tracks where
+
+import Spotify.Types.Internal.CustomJSON
+import Spotify.Types.Internal.EnumJSON
+import Spotify.Types.Misc
+import Spotify.Types.Simple
+
+import Data.Aeson (FromJSON)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+
+data Track = Track
+    { album :: AlbumSimple
+    , artists :: [ArtistSimple]
+    , availableMarkets :: Maybe [Text]
+    , discNumber :: Int
+    , durationMs :: Int
+    , explicit :: Bool
+    , externalIds :: ExternalIDs
+    , externalUrls :: ExternalURLs
+    , href :: Href
+    , id :: TrackID
+    , isPlayable :: Maybe Bool
+    , linkedFrom :: Maybe TrackLink
+    , restrictions :: Maybe Restrictions
+    , name :: Text
+    , popularity :: Int
+    , previewUrl :: Maybe Text
+    , trackNumber :: Int
+    , uri :: URI
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON Track
+
+data AudioFeatures = AudioFeatures
+    { acousticness :: Float
+    , analysisUrl :: URL
+    , danceability :: Float
+    , durationMs :: Int
+    , energy :: Float
+    , id :: TrackID
+    , instrumentalness :: Float
+    , key :: Key
+    , liveness :: Float
+    , loudness :: Float
+    , mode :: Modality
+    , speechiness :: Float
+    , tempo :: Float
+    , timeSignature :: Int
+    , trackHref :: Href
+    , uri :: URI
+    , valence :: Float
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON AudioFeatures
+
+data Modality
+    = Minor
+    | Major
+    deriving (Eq, Ord, Show, Generic, Enum)
+    deriving (FromJSON) via EnumJSON Modality
+
+data SavedTrack = SavedTrack
+    { addedAt :: Text
+    , track :: Track
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON SavedTrack
diff --git a/types/Spotify/Types/Users.hs b/types/Spotify/Types/Users.hs
new file mode 100644
--- /dev/null
+++ b/types/Spotify/Types/Users.hs
@@ -0,0 +1,24 @@
+module Spotify.Types.Users where
+
+import Spotify.Types.Internal.CustomJSON
+import Spotify.Types.Misc
+
+import Data.Aeson (FromJSON)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+
+data User = User
+    { country :: Maybe Text
+    , displayName :: Maybe Text
+    , email :: Maybe Text
+    , explicitContent :: Maybe ExplicitContent
+    , externalUrls :: ExternalURLs
+    , followers :: Maybe Followers
+    , href :: Href
+    , id :: UserID
+    , images :: Maybe [Image]
+    , product :: Maybe Product
+    , uri :: URI
+    }
+    deriving (Eq, Ord, Show, Generic)
+    deriving (FromJSON) via CustomJSON User
