diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,3 @@
+## 0.1.0.0
+
+- Initial release.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 Edward Yang
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,10 @@
+# captcha-haskell
+
+[![Hackage](http://img.shields.io/hackage/v/captcha-core.svg)](https://hackage.haskell.org/package/captcha-core)
+
+This repository is split into multiple packages:
+- [captcha-core](https://github.com/qwbarch/captcha-haskell/tree/main/core)
+- [captcha-2captcha](https://github.com/qwbarch/captcha-haskell/tree/main/2captcha)
+- [captcha-capmonster](https://github.com/qwbarch/captcha-haskell/tree/main/capmonster)
+
+Visit the [microsite](https://qwbarch.github.io/captcha-haskell/) for more information.
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/captcha-2captcha.cabal b/captcha-2captcha.cabal
new file mode 100644
--- /dev/null
+++ b/captcha-2captcha.cabal
@@ -0,0 +1,107 @@
+cabal-version: 1.12
+
+-- This file has been generated from package.yaml by hpack version 0.34.4.
+--
+-- see: https://github.com/sol/hpack
+
+name:           captcha-2captcha
+version:        0.1.0.0
+synopsis:       A package for integrating a variety of captcha solving services.
+description:    A package for integrating a variety of captcha solving services.
+                .
+                Feature list:
+                .
+                * Makes use of the [lens](https://hackage.haskell.org/package/lens) package
+                * Mtl-style typeclass, allowing you to use your own monad transformer stack
+                * No exceptions are thrown. Errors are shown explicitly through its type signature
+                * Minimal test suite provided for each captcha type
+                * Captcha services are separated into different packages. Use what you need!
+category:       Network
+homepage:       https://github.com/qwbarch/captcha-haskell#readme
+bug-reports:    https://github.com/qwbarch/captcha-haskell/issues
+author:         Edward Yang
+maintainer:     qwbarch@gmail.com
+copyright:      2021 Edward Yang
+license:        MIT
+license-file:   LICENSE
+build-type:     Simple
+extra-source-files:
+    README.md
+    CHANGELOG.md
+
+source-repository head
+  type: git
+  location: https://github.com/qwbarch/captcha-haskell
+
+library
+  exposed-modules:
+      Captcha.TwoCaptcha
+      Captcha.TwoCaptcha.Internal
+      Captcha.TwoCaptcha.Internal.Error
+      Captcha.TwoCaptcha.Internal.Types.FunCaptcha
+      Captcha.TwoCaptcha.Internal.Types.HCaptcha
+      Captcha.TwoCaptcha.Internal.Types.Image
+      Captcha.TwoCaptcha.Internal.Types.ReCaptchaV2
+      Captcha.TwoCaptcha.Internal.Types.ReCaptchaV3
+      Captcha.TwoCaptcha.Internal.Types.Text
+  other-modules:
+      Paths_captcha_2captcha
+  hs-source-dirs:
+      src
+  ghc-options: -Wall -Wno-name-shadowing
+  build-depends:
+      aeson >=1.5.6 && <1.6
+    , base >=4.7 && <5
+    , bytestring >=0.10.12.0 && <0.11
+    , captcha-core ==0.1.*
+    , errors >=2.3.0 && <2.4
+    , extra >=1.7.9 && <1.8
+    , http-client >=0.6.4.1 && <0.8
+    , lens >=4.19.2 && <5.1
+    , lens-aeson >=1.1.3 && <1.2
+    , mtl >=2.2.2 && <2.3
+    , o-clock >=1.2.1 && <1.3
+    , string-conversions >=0.4.0.1 && <0.5
+    , string-interpolate >=0.3.1.1 && <0.4
+    , text >=1.2.4.1 && <1.3
+    , unliftio >=0.2.20 && <0.3
+    , wreq >=0.5.3.3 && <0.6
+  default-language: Haskell2010
+
+test-suite 2captcha-test
+  type: exitcode-stdio-1.0
+  main-is: Main.hs
+  other-modules:
+      Captcha.TwoCaptcha
+      Captcha.TwoCaptcha.Test.FunCaptcha
+      Captcha.TwoCaptcha.Test.HCaptcha
+      Captcha.TwoCaptcha.Test.Image
+      Captcha.TwoCaptcha.Test.ReCaptchaV2
+      Captcha.TwoCaptcha.Test.ReCaptchaV3
+      Captcha.TwoCaptcha.Test.Text
+      Paths_captcha_2captcha
+  hs-source-dirs:
+      test
+  ghc-options: -threaded -rtsopts -with-rtsopts=-N -Wall -Wno-name-shadowing
+  build-depends:
+      aeson >=1.5.6 && <1.6
+    , base >=4.7 && <5
+    , bytestring >=0.10.12.0 && <0.11
+    , captcha-2captcha
+    , captcha-core ==0.1.*
+    , data-default
+    , errors >=2.3.0 && <2.4
+    , extra >=1.7.9 && <1.8
+    , http-client >=0.6.4.1 && <0.8
+    , lens >=4.19.2 && <5.1
+    , lens-aeson >=1.1.3 && <1.2
+    , mtl >=2.2.2 && <2.3
+    , o-clock >=1.2.1 && <1.3
+    , string-conversions >=0.4.0.1 && <0.5
+    , string-interpolate >=0.3.1.1 && <0.4
+    , tasty
+    , tasty-hunit
+    , text >=1.2.4.1 && <1.3
+    , unliftio >=0.2.20 && <0.3
+    , wreq >=0.5.3.3 && <0.6
+  default-language: Haskell2010
diff --git a/src/Captcha/TwoCaptcha.hs b/src/Captcha/TwoCaptcha.hs
new file mode 100644
--- /dev/null
+++ b/src/Captcha/TwoCaptcha.hs
@@ -0,0 +1,20 @@
+-- |
+-- Module: Captcha.TwoCaptcha
+-- Copyright: (c) 2022 Edward Yang
+-- License: MIT
+--
+-- This module exports functions that follow the pvp versioning policies.
+module Captcha.TwoCaptcha
+  ( module Captcha.TwoCaptcha.Internal.Error,
+    module Captcha.TwoCaptcha.Internal,
+  )
+where
+
+import Captcha.TwoCaptcha.Internal (TwoCaptcha)
+import Captcha.TwoCaptcha.Internal.Error (TwoCaptchaError (..), TwoCaptchaErrorCode (..))
+import Captcha.TwoCaptcha.Internal.Types.FunCaptcha ()
+import Captcha.TwoCaptcha.Internal.Types.HCaptcha ()
+import Captcha.TwoCaptcha.Internal.Types.Image ()
+import Captcha.TwoCaptcha.Internal.Types.ReCaptchaV2 ()
+import Captcha.TwoCaptcha.Internal.Types.ReCaptchaV3 ()
+import Captcha.TwoCaptcha.Internal.Types.Text ()
diff --git a/src/Captcha/TwoCaptcha/Internal.hs b/src/Captcha/TwoCaptcha/Internal.hs
new file mode 100644
--- /dev/null
+++ b/src/Captcha/TwoCaptcha/Internal.hs
@@ -0,0 +1,144 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE InstanceSigs #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE NumericUnderscores #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE TypeFamilies #-}
+
+-- |
+-- Module: Captcha.TwoCaptcha.Internal
+-- Copyright: (c) 2022 Edward Yang
+-- License: MIT
+--
+-- This module is for internal-use and does not follow pvp versioning policies.
+module Captcha.TwoCaptcha.Internal where
+
+import Captcha.Internal (getProxyAddress, getProxyPassword, getProxyPort, getProxyUsername)
+import Captcha.Internal.Monad (HasCaptchaEnv)
+import Captcha.Internal.Monad.Class (CaptchaId (CaptchaId, unCaptchaId), CaptchaRequest (request), CaptchaResponse (parseResult), MonadCaptcha (CaptchaError, createTask, getTask, solve))
+import Captcha.Internal.Request (get)
+import Captcha.Internal.Types (HasApiKey (apiKey), HasPollingInterval (pollingInterval), HasProtocol (protocol), HasProxy (proxy), HasTimeoutDuration (timeoutDuration), Proxy)
+import Captcha.TwoCaptcha.Internal.Error (TwoCaptchaError (NetworkError, TimeoutError, TwoCaptchaResponseError, UnknownError, UnknownResponseError), TwoCaptchaErrorCode (BadDuplicates, CaptchaNotReady, CaptchaUnsolvable), parseError)
+import Control.Error (ExceptT (ExceptT), note, runExceptT)
+import Control.Lens (preview, view, (&), (.~), (^.), (^?), _Just)
+import Control.Monad (liftM2, (<=<))
+import Control.Monad.Reader (MonadReader)
+import Data.Aeson (Value)
+import Data.Aeson.Lens (key, _Integer, _String, _Value)
+import Data.Bifunctor (Bifunctor (bimap))
+import Data.ByteString.Lazy (ByteString)
+import Data.Maybe (fromMaybe, maybeToList)
+import Data.String.Conversions (cs)
+import Data.String.Interpolate (i)
+import Data.Text (Text, toUpper)
+import Data.Text.Read (decimal)
+import Network.HTTP.Client (HttpException)
+import Network.Wreq (Options, Response, defaults, param, responseBody)
+import Time (Microsecond, Millisecond, Time (Time), threadDelay, toNum)
+import UnliftIO (MonadUnliftIO, timeout, try)
+
+-- | Used for picking 'MonadCaptcha' instances for 2Captcha.
+data TwoCaptcha
+
+-- | Parse the http response into the captcha answer, handling any errors found.
+parseResponse :: (Value -> Maybe Value) -> Either HttpException (Response ByteString) -> Either TwoCaptchaError Value
+parseResponse f response =
+  liftM2 (=<<) adaptError parseStatus =<< parseBody
+  where
+    errorFooter = "This is likely due to a change in 2Captcha's API and will need to be fixed." :: Text
+    missingResponse = [i|The response body is missing. #{errorFooter}|]
+    invalidResponse = [i|Unable to parse 2Captcha response. #{errorFooter}|]
+    parseBody = note (UnknownError missingResponse) =<< bimap NetworkError (preview $ responseBody . _Value) response
+    parseStatus body = note (UnknownError "2Captcha response body is missing a status/request field.") $ do
+      status <- body ^? key "status" . _Integer
+      code <- body ^? key "request" . _String
+      return (status, code, fromMaybe mempty (body ^? key "error_text" . _String))
+    adaptError body (status, code, errorText)
+      | status == 0 =
+        case parseError code of
+          Just exception -> Left $ TwoCaptchaResponseError exception
+          Nothing -> Left $ UnknownResponseError code errorText
+      | otherwise = note (UnknownError invalidResponse) (f body)
+
+instance (HasCaptchaEnv r, MonadReader r m, MonadUnliftIO m) => MonadCaptcha TwoCaptcha r m where
+  type CaptchaError TwoCaptcha r m = TwoCaptchaError
+
+  createTask :: forall ctx. CaptchaRequest TwoCaptcha ctx r m => ctx -> m (Either TwoCaptchaError (CaptchaId ctx))
+  createTask captcha =
+    (parseCaptchaId <=< parseResponse (preview $ key "request")) <$> try (request @TwoCaptcha @ctx @r @m captcha url)
+    where
+      url = "https://2captcha.com/in.php"
+      captchaIdMissing = [i|2Captcha did not send a captcha id after creating a task.|]
+      captchaIdInvalid captchaId = [i|CaptchaId is not an Integer: #{captchaId}|]
+      parseCaptchaId captchaId =
+        bimap (const . UnknownError $ captchaIdInvalid captchaId) (CaptchaId . fst) . decimal
+          =<< note (UnknownError captchaIdMissing) (captchaId ^? _String)
+
+  getTask :: forall ctx. CaptchaResponse TwoCaptcha ctx => Text -> CaptchaId ctx -> m (Either TwoCaptchaError Text)
+  getTask apiKey captchaId =
+    fmap (view _String) . parseResponse (parseResult @TwoCaptcha @ctx) <$> try (get options url)
+    where
+      url = "https://2captcha.com/res.php"
+      options =
+        defaultOptions
+          & param "key" .~ [apiKey]
+          & param "id" .~ [cs . show $ unCaptchaId captchaId]
+          & param "action" .~ ["get"]
+
+  solve ::
+    forall ctx.
+    ( CaptchaRequest TwoCaptcha ctx r m,
+      CaptchaResponse TwoCaptcha ctx,
+      HasApiKey ctx Text,
+      HasPollingInterval ctx (Maybe (Time Millisecond)),
+      HasTimeoutDuration ctx (Maybe (Time Millisecond))
+    ) =>
+    ctx ->
+    m (Either TwoCaptchaError Text)
+  solve captcha =
+    handleTimeout (captcha ^. timeoutDuration) . runExceptT $
+      ExceptT . pollResult =<< ExceptT (createTask @TwoCaptcha @r @m captcha)
+    where
+      handleTimeout (Just duration) f = fromMaybe (Left TimeoutError) <$> timeout (toNum @Microsecond duration) f
+      handleTimeout Nothing f = f
+      pollResult captchaId =
+        threadDelay (fromMaybe (Time @Millisecond 10_000) (captcha ^. pollingInterval))
+          *> getTask @TwoCaptcha @r @m @ctx (captcha ^. apiKey) captchaId
+          >>= \case
+            Left (TwoCaptchaResponseError CaptchaNotReady) -> pollResult captchaId
+            -- 2Captcha sends this error when users fail enough times.
+            Left (TwoCaptchaResponseError BadDuplicates) -> solve @TwoCaptcha captcha
+            -- 2Captcha sends this error when the captcha isn't solved quick enough.
+            Left (TwoCaptchaResponseError CaptchaUnsolvable) -> solve @TwoCaptcha captcha
+            x -> pure x
+
+instance CaptchaResponse TwoCaptcha ctx where
+  parseResult = preview $ key "request"
+
+-- | Parse the proxy type to its textual representation.
+parseProxyType :: HasProxy a (Maybe Proxy) => a -> [Text]
+parseProxyType captcha = maybeToList $ toUpper . cs . show <$> captcha ^? proxy . _Just . protocol
+
+-- | Parse the proxy into the format: username:password@address:port
+parseProxy :: HasProxy a (Maybe Proxy) => a -> [Text]
+parseProxy captcha = maybeToList $ do
+  let auth = do
+        username <- getProxyUsername captcha
+        password <- getProxyPassword captcha
+        pure [i|#{username}:#{password}@|]
+  address <- getProxyAddress captcha
+  port <- getProxyPort captcha
+  pure $ fromMaybe mempty auth <> [i|#{address}:#{port}|]
+
+-- | Default option parameters when making 2Captcha requests.
+defaultOptions :: Options
+defaultOptions =
+  defaults
+    & param "json" .~ ["1"]
+    & param "soft_id" .~ ["3283"]
diff --git a/src/Captcha/TwoCaptcha/Internal/Error.hs b/src/Captcha/TwoCaptcha/Internal/Error.hs
new file mode 100644
--- /dev/null
+++ b/src/Captcha/TwoCaptcha/Internal/Error.hs
@@ -0,0 +1,158 @@
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE StrictData #-}
+
+-- |
+-- Module: Captcha.TwoCaptcha.Internal.Error
+-- Copyright: (c) 2022 Edward Yang
+-- License: MIT
+--
+-- This module is for internal-use and does not follow pvp versioning policies.
+module Captcha.TwoCaptcha.Internal.Error where
+
+import Control.Exception (Exception)
+import Data.Foldable (find)
+import Data.Text (Text)
+import Network.HTTP.Client (HttpException)
+
+-- | All possible errors when solving a captcha using 2Captcha.
+data TwoCaptchaError
+  = TwoCaptchaResponseError TwoCaptchaErrorCode
+  | UnknownResponseError Text Text
+  | UnknownError Text
+  | NetworkError HttpException
+  | TimeoutError
+  deriving (Show, Exception)
+
+-- | An error code returned by the TwoCaptcha API.
+data TwoCaptchaErrorCode
+  = -- | The api key you provided is invalid. Please ensure it is 32 characters long.
+    WrongUserKey
+  | -- | The key you've provided does not exist.
+    KeyDoesNotExist
+  | -- | You don't have funds in your account.
+    ZeroBalance
+  | -- | The __pageurl__ parameter is missing in your request.
+    PageUrlMissing
+  | -- |
+    -- You can receive this error in two cases:
+    --
+    -- 1. __If you solve token-based captchas (reCAPTCHA, hCaptcha, ArkoseLabs FunCaptcha, GeeTest, etc):__
+    -- the queue of your captchas that are not distributed to workers is too long.
+    -- Queue limit changes dynamically and depends on total amount of captchas awaiting solution and usually it’s between 50 and 100 captchas.
+    --
+    -- 2. __If you solve Normal Captcha:__ your maximum rate for normal captchas is lower than current rate on the server.
+    -- You can change your maximum rate in <https://2captcha.com/setting your account's settings.>
+    NoSlotAvailable
+  | -- | Image size is less than 100 bytes.
+    ZeroCaptchaFileSize
+  | -- | Image size is more than 100 kB.
+    TooBigCaptchaFileSize
+  | -- | Image file has unsupported extension. Accepted extensions: jpg, jpeg, gif, png.
+    WrongFileExtension
+  | -- | Server can't recognize image file type.
+    ImageTypeNotSupported
+  | -- |
+    -- Server can't get file data from your POST-request.
+    -- That happens if your POST-request is malformed or base64 data is not a valid base64 image.
+    UploadFailure
+  | -- | The request is sent from the IP that is not on the list of your allowed IPs.
+    IpNotAllowed
+  | -- | Your IP address is banned due to many frequent attempts to access the server using wrong authorization keys.
+    IpBanned
+  | -- |
+    -- You can get this error code when sending reCAPTCHA V2. This happens if your request contains invalid pair of googlekey and pageurl.
+    -- The common reason for that is that reCAPTCHA is loaded inside an iframe hosted on another domain/subdomain.
+    BadTokenOrPageUrl
+  | -- | You can get this error code when sending reCAPTCHA V2. That means that sitekey value provided in your request is incorrect: it's blank or malformed.
+    GoogleKeyInvalid
+  | -- | The __googlekey__ parameter is missing in your request.
+    GoogleKeyMissing
+  | -- |
+    -- You've sent an image that is marked in 2captcha's database as unrecognizable.
+    -- Usually that happens if the website where you found the captcha stopped sending you captchas and started to send a "deny access" image.
+    CaptchaImageBlocked
+  | -- | You are sending too many unrecognizable images.
+    TooManyBadImages
+  | -- |
+    -- You made more than 60 requests to in.php within 3 seconds.
+    -- Your account is banned for 10 seconds. Ban will be lifted automatically.
+    RateLimited
+  | -- |
+    -- The error code is returned if some required parameters are missing in your request or the values have incorrect format.
+    -- For example if you submit <https://2captcha.com/2captcha-api#grid Grid images> but your request is missing an instruction for workers.
+    --
+    -- Blocking time: 5 minutes.
+    BadParameters
+  | -- | You can get this error code when sending a captcha via proxy server which is marked as BAD by the 2captcha API.
+    BadProxy
+  | -- | Your captcha is not solved yet.
+    CaptchaNotReady
+  | -- |
+    -- 2captcha was unable to solve your captcha - three of their workers were unable solve it or they didn't get an answer within 90 seconds (300 seconds for reCAPTCHA V2).
+    --
+    -- You will not be charged for that request.
+    CaptchaUnsolvable
+  | -- | You've provided captcha ID in wrong format. The ID can contain numbers only.
+    WrongIdFormat
+  | -- | You provided an invalid captcha id.
+    WrongCaptchaId
+  | -- | Error is returned when 100% accuracy feature is enabled. The error means that max numbers of tries is reached but min number of matches not found.
+    BadDuplicates
+  | -- |
+    -- Error is returned to your <https://2captcha.com/2captcha-api#complain report> request if you already complained lots of correctly solved captchas (more than 40%).
+    -- Or if more than 15 minutes passed after you submitted the captcha.
+    ReportNotRecorded
+  | -- | Error is returned to your <https://2captcha.com/2captcha-api#complain report request> if you are trying to report the same captcha more than once.
+    DuplicateReport
+  | -- |
+    -- You can receive this error code when sending <https://2captcha.com/2captcha-api#solving_geetest GeeTest>.
+    -- This error means the __challenge__ value you provided is expired.
+    TokenExpired
+  | -- | Action parameter is missing or no value is provided for __action__ parameter.
+    EmptyAction
+  | -- |
+    -- You can get this error code if we were unable to load a captcha through your proxy server.
+    -- The proxy will be marked as BAD by our API and we will not accept requests with the proxy during 10 minutes.
+    -- You will recieve ERROR_BAD_PROXY code from in.php API endpoint in such case.
+    ProxyConnectionFailed
+  deriving (Show, Eq, Ord, Enum, Bounded)
+
+-- | Textual representation of a 'TwoCaptchaErrorCode'
+errorCode :: TwoCaptchaErrorCode -> Text
+errorCode = \case
+  WrongUserKey -> "ERROR_WRONG_USER_KEY"
+  KeyDoesNotExist -> "ERROR_KEY_DOES_NOT_EXIST"
+  ZeroBalance -> "ERROR_ZERO_BALANCE"
+  PageUrlMissing -> "ERROR_PAGEURL"
+  NoSlotAvailable -> "ERROR_NO_SLOT_AVAILABLE"
+  ZeroCaptchaFileSize -> "ERROR_ZERO_CAPTCHA_FILESIZE"
+  TooBigCaptchaFileSize -> "ERROR_TOO_BIG_CAPTCHA_FILESIZE"
+  WrongFileExtension -> "ERROR_WRONG_FILE_EXTENSION"
+  ImageTypeNotSupported -> "ERROR_IMAGE_TYPE_NOT_SUPPORTED"
+  UploadFailure -> "ERROR_UPLOAD"
+  IpNotAllowed -> "ERROR_IP_NOT_ALLOWED"
+  IpBanned -> "IP_BANNED"
+  BadTokenOrPageUrl -> "ERROR_BAD_TOKEN_OR_PAGEURL"
+  GoogleKeyInvalid -> "ERROR_GOOGLEKEY"
+  GoogleKeyMissing -> "ERROR_WRONG_GOOGLEKEY"
+  CaptchaImageBlocked -> "ERROR_CAPTCHAIMAGE_BLOCKED"
+  TooManyBadImages -> "TOO_MANY_BAD_IMAGES"
+  RateLimited -> "MAX_USER_TURN"
+  BadParameters -> "ERROR_BAD_PARAMETERS"
+  BadProxy -> "ERROR_BAD_PROXY"
+  CaptchaNotReady -> "CAPCHA_NOT_READY"
+  CaptchaUnsolvable -> "ERROR_CAPTCHA_UNSOLVABLE"
+  WrongIdFormat -> "ERROR_WRONG_ID_FORMAT"
+  WrongCaptchaId -> "ERROR_WRONG_CAPTCHA_ID"
+  BadDuplicates -> "ERROR_BAD_DUPLICATES"
+  ReportNotRecorded -> "ERROR_REPORT_NOT_RECORDED"
+  DuplicateReport -> "ERROR_DUPLICATE_REPORT"
+  TokenExpired -> "ERROR_TOKEN_EXPIRED"
+  EmptyAction -> "ERROR_EMPTY_ACTION"
+  ProxyConnectionFailed -> "ERROR_PROXY_CONNECTION_FAILED"
+
+-- | Parse an error code into its equivalent 'TwoCaptchaErrorCode'.
+parseError :: Text -> Maybe TwoCaptchaErrorCode
+parseError code = find ((== code) . errorCode) [minBound .. maxBound]
diff --git a/src/Captcha/TwoCaptcha/Internal/Types/FunCaptcha.hs b/src/Captcha/TwoCaptcha/Internal/Types/FunCaptcha.hs
new file mode 100644
--- /dev/null
+++ b/src/Captcha/TwoCaptcha/Internal/Types/FunCaptcha.hs
@@ -0,0 +1,38 @@
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# OPTIONS_GHC -Wno-orphans #-}
+
+-- |
+-- Module: Captcha.TwoCaptcha.Internal.Types.FunCaptcha
+-- Copyright: (c) 2022 Edward Yang
+-- License: MIT
+--
+-- This module is for internal-use and does not follow pvp versioning policies.
+module Captcha.TwoCaptcha.Internal.Types.FunCaptcha where
+
+import Captcha.Internal.Monad (HasCaptchaEnv)
+import Captcha.Internal.Monad.Class (CaptchaRequest (request))
+import Captcha.Internal.Request (post)
+import Captcha.Internal.Types (FunCaptcha, HasApiKey (apiKey), HasCaptchaKey (captchaKey), HasCaptchaUrl (captchaUrl), HasServiceUrl (serviceUrl), HasUserAgent (userAgent))
+import Captcha.TwoCaptcha.Internal (TwoCaptcha, defaultOptions, parseProxy, parseProxyType)
+import Control.Lens ((&), (.~), (^.))
+import Control.Monad.Cont (MonadIO)
+import Control.Monad.Reader (MonadReader)
+import Data.Aeson (object)
+import Data.Maybe (maybeToList)
+import Network.Wreq (param)
+
+instance (HasCaptchaEnv r, MonadReader r m, MonadIO m) => CaptchaRequest TwoCaptcha FunCaptcha r m where
+  request captcha = flip (post options) (object [])
+    where
+      options =
+        defaultOptions
+          & param "key" .~ [captcha ^. apiKey]
+          & param "method" .~ ["funcaptcha"]
+          & param "publickey" .~ [captcha ^. captchaKey]
+          & param "pageurl" .~ [captcha ^. captchaUrl]
+          & param "surl" .~ maybeToList (captcha ^. serviceUrl)
+          & param "userAgent" .~ maybeToList (captcha ^. userAgent)
+          & param "proxy" .~ parseProxy captcha
+          & param "proxytype" .~ parseProxyType captcha
diff --git a/src/Captcha/TwoCaptcha/Internal/Types/HCaptcha.hs b/src/Captcha/TwoCaptcha/Internal/Types/HCaptcha.hs
new file mode 100644
--- /dev/null
+++ b/src/Captcha/TwoCaptcha/Internal/Types/HCaptcha.hs
@@ -0,0 +1,40 @@
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# OPTIONS_GHC -Wno-orphans #-}
+
+-- |
+-- Module: Captcha.TwoCaptcha.Internal.Types.HCaptcha
+-- Copyright: (c) 2022 Edward Yang
+-- License: MIT
+--
+-- This module is for internal-use and does not follow pvp versioning policies.
+module Captcha.TwoCaptcha.Internal.Types.HCaptcha where
+
+import Captcha.Internal.Monad (HasCaptchaEnv)
+import Captcha.Internal.Monad.Class (CaptchaRequest (request))
+import Captcha.Internal.Request (post)
+import Captcha.Internal.Types (HCaptcha, HasApiKey (apiKey), HasCaptchaKey (captchaKey), HasCaptchaUrl (captchaUrl), HasInvisible (invisible), HasRqData (rqData), HasUserAgent (userAgent))
+import Captcha.TwoCaptcha.Internal (TwoCaptcha, defaultOptions, parseProxy, parseProxyType)
+import Control.Lens ((&), (.~), (^.))
+import Control.Monad.Cont (MonadIO)
+import Control.Monad.Reader (MonadReader)
+import Data.Aeson (object)
+import Data.Maybe (maybeToList)
+import Data.String.Conversions (cs)
+import Network.Wreq (param)
+
+instance (HasCaptchaEnv r, MonadReader r m, MonadIO m) => CaptchaRequest TwoCaptcha HCaptcha r m where
+  request captcha = flip (post options) (object [])
+    where
+      options =
+        defaultOptions
+          & param "key" .~ [captcha ^. apiKey]
+          & param "method" .~ ["hcaptcha"]
+          & param "sitekey" .~ [captcha ^. captchaKey]
+          & param "pageurl" .~ [captcha ^. captchaUrl]
+          & param "invisible" .~ [cs . show $ captcha ^. invisible]
+          & param "data" .~ maybeToList (captcha ^. rqData)
+          & param "userAgent" .~ maybeToList (captcha ^. userAgent)
+          & param "proxy" .~ parseProxy captcha
+          & param "proxytype" .~ parseProxyType captcha
diff --git a/src/Captcha/TwoCaptcha/Internal/Types/Image.hs b/src/Captcha/TwoCaptcha/Internal/Types/Image.hs
new file mode 100644
--- /dev/null
+++ b/src/Captcha/TwoCaptcha/Internal/Types/Image.hs
@@ -0,0 +1,32 @@
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# OPTIONS_GHC -Wno-orphans #-}
+
+-- |
+-- Module: Captcha.TwoCaptcha.Internal.Types.Image
+-- Copyright: (c) 2022 Edward Yang
+-- License: MIT
+--
+-- This module is for internal-use and does not follow pvp versioning policies.
+module Captcha.TwoCaptcha.Internal.Types.Image where
+
+import Captcha.Internal.Monad (HasCaptchaEnv)
+import Captcha.Internal.Monad.Class (CaptchaRequest (request))
+import Captcha.Internal.Request (post)
+import Captcha.Internal.Types (HasApiKey (apiKey), HasBody (body), ImageCaptcha)
+import Captcha.TwoCaptcha.Internal (TwoCaptcha, defaultOptions)
+import Control.Lens ((^.))
+import Control.Monad.Cont (MonadIO)
+import Control.Monad.Reader (MonadReader)
+import Data.Text (Text)
+import Network.Wreq (FormParam ((:=)))
+
+instance (HasCaptchaEnv r, MonadReader r m, MonadIO m) => CaptchaRequest TwoCaptcha ImageCaptcha r m where
+  request captcha = flip (post defaultOptions) payload
+    where
+      payload =
+        [ "key" := (captcha ^. apiKey),
+          "method" := ("base64" :: Text),
+          "body" := (captcha ^. body)
+        ]
diff --git a/src/Captcha/TwoCaptcha/Internal/Types/ReCaptchaV2.hs b/src/Captcha/TwoCaptcha/Internal/Types/ReCaptchaV2.hs
new file mode 100644
--- /dev/null
+++ b/src/Captcha/TwoCaptcha/Internal/Types/ReCaptchaV2.hs
@@ -0,0 +1,42 @@
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# OPTIONS_GHC -Wno-orphans #-}
+
+-- |
+-- Module: Captcha.TwoCaptcha.Internal.Types.ReCaptchaV2
+-- Copyright: (c) 2022 Edward Yang
+-- License: MIT
+--
+-- This module is for internal-use and does not follow pvp versioning policies.
+module Captcha.TwoCaptcha.Internal.Types.ReCaptchaV2 where
+
+import Captcha.Internal (renderCookies)
+import Captcha.Internal.Monad (HasCaptchaEnv)
+import Captcha.Internal.Monad.Class (CaptchaRequest (request))
+import Captcha.Internal.Request (post)
+import Captcha.Internal.Types (HasApiKey (apiKey), HasCaptchaKey (captchaKey), HasCaptchaUrl (captchaUrl), HasDataS (dataS), HasInvisible (invisible), HasUserAgent (userAgent), ReCaptchaV2)
+import Captcha.TwoCaptcha.Internal (TwoCaptcha, defaultOptions, parseProxy, parseProxyType)
+import Control.Lens ((&), (.~), (^.))
+import Control.Monad.Cont (MonadIO)
+import Control.Monad.Reader (MonadReader)
+import Data.Aeson (object)
+import Data.Maybe (maybeToList)
+import Data.String.Conversions (cs)
+import Network.Wreq (param)
+
+instance (HasCaptchaEnv r, MonadReader r m, MonadIO m) => CaptchaRequest TwoCaptcha ReCaptchaV2 r m where
+  request captcha = flip (post options) (object [])
+    where
+      options =
+        defaultOptions
+          & param "key" .~ [captcha ^. apiKey]
+          & param "method" .~ ["userrecaptcha"]
+          & param "googlekey" .~ [captcha ^. captchaKey]
+          & param "pageurl" .~ [captcha ^. captchaUrl]
+          & param "data-s" .~ maybeToList (captcha ^. dataS)
+          & param "invisible" .~ [cs . show $ captcha ^. invisible]
+          & param "userAgent" .~ maybeToList (captcha ^. userAgent)
+          & param "cookies" .~ [renderCookies captcha]
+          & param "proxy" .~ parseProxy captcha
+          & param "proxytype" .~ parseProxyType captcha
diff --git a/src/Captcha/TwoCaptcha/Internal/Types/ReCaptchaV3.hs b/src/Captcha/TwoCaptcha/Internal/Types/ReCaptchaV3.hs
new file mode 100644
--- /dev/null
+++ b/src/Captcha/TwoCaptcha/Internal/Types/ReCaptchaV3.hs
@@ -0,0 +1,36 @@
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# OPTIONS_GHC -Wno-orphans #-}
+
+-- |
+-- Module: Captcha.TwoCaptcha.Internal.Types.ReCaptchaV3
+-- Copyright: (c) 2022 Edward Yang
+-- License: MIT
+--
+-- This module is for internal-use and does not follow pvp versioning policies.
+module Captcha.TwoCaptcha.Internal.Types.ReCaptchaV3 where
+
+import Captcha.Internal.Monad (HasCaptchaEnv)
+import Captcha.Internal.Monad.Class (CaptchaRequest (request))
+import Captcha.Internal.Request (post)
+import Captcha.Internal.Types (HasApiKey (apiKey), HasCaptchaKey (captchaKey), HasCaptchaUrl (captchaUrl), HasMinScore (minScore), ReCaptchaV3)
+import Captcha.TwoCaptcha.Internal (TwoCaptcha, defaultOptions)
+import Control.Lens ((&), (.~), (^.))
+import Control.Monad.Cont (MonadIO)
+import Control.Monad.Reader (MonadReader)
+import Data.Aeson (object)
+import Data.String.Conversions (cs)
+import Network.Wreq (param)
+
+instance (HasCaptchaEnv r, MonadReader r m, MonadIO m) => CaptchaRequest TwoCaptcha ReCaptchaV3 r m where
+  request captcha = flip (post options) (object [])
+    where
+      options =
+        defaultOptions
+          & param "key" .~ [captcha ^. apiKey]
+          & param "method" .~ ["userrecaptcha"]
+          & param "version" .~ ["v3"]
+          & param "googlekey" .~ [captcha ^. captchaKey]
+          & param "pageurl" .~ [captcha ^. captchaUrl]
+          & param "min_score" .~ [cs . show $ captcha ^. minScore]
diff --git a/src/Captcha/TwoCaptcha/Internal/Types/Text.hs b/src/Captcha/TwoCaptcha/Internal/Types/Text.hs
new file mode 100644
--- /dev/null
+++ b/src/Captcha/TwoCaptcha/Internal/Types/Text.hs
@@ -0,0 +1,31 @@
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# OPTIONS_GHC -Wno-orphans #-}
+
+-- |
+-- Module: Captcha.TwoCaptcha.Internal.Types.Text
+-- Copyright: (c) 2022 Edward Yang
+-- License: MIT
+--
+-- This module is for internal-use and does not follow pvp versioning policies.
+module Captcha.TwoCaptcha.Internal.Types.Text where
+
+import Captcha.Internal.Monad (HasCaptchaEnv)
+import Captcha.Internal.Monad.Class (CaptchaRequest (request))
+import Captcha.Internal.Request (post)
+import Captcha.Internal.Types (HasApiKey (apiKey), HasBody (body), TextCaptcha)
+import Captcha.TwoCaptcha.Internal (TwoCaptcha, defaultOptions)
+import Control.Lens ((&), (.~), (^.))
+import Control.Monad.Cont (MonadIO)
+import Control.Monad.Reader (MonadReader)
+import Data.Aeson (object)
+import Network.Wreq (param)
+
+instance (HasCaptchaEnv r, MonadReader r m, MonadIO m) => CaptchaRequest TwoCaptcha TextCaptcha r m where
+  request captcha = flip (post options) (object [])
+    where
+      options =
+        defaultOptions
+          & param "key" .~ [captcha ^. apiKey]
+          & param "textcaptcha" .~ [captcha ^. body]
diff --git a/test/Captcha/TwoCaptcha.hs b/test/Captcha/TwoCaptcha.hs
new file mode 100644
--- /dev/null
+++ b/test/Captcha/TwoCaptcha.hs
@@ -0,0 +1,45 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE MonoLocalBinds #-}
+{-# LANGUAGE TypeApplications #-}
+
+module Captcha.TwoCaptcha where
+
+import Captcha.Internal.Monad (Captcha (runCaptcha), CaptchaEnv, mkCaptchaEnv)
+import Captcha.Internal.Monad.Class (CaptchaRequest, CaptchaResponse, MonadCaptcha (solve))
+import Captcha.Internal.Types
+import Captcha.TwoCaptcha.Internal (TwoCaptcha)
+import Captcha.TwoCaptcha.Internal.Error (TwoCaptchaError (TimeoutError, TwoCaptchaResponseError), TwoCaptchaErrorCode (NoSlotAvailable))
+import Captcha.TwoCaptcha.Internal.Types.Image ()
+import Control.Lens ((&), (?~))
+import Data.Default (Default (def))
+import Data.Text (Text)
+import Test.Tasty (TestTree)
+import Test.Tasty.HUnit (assertFailure, testCase)
+import Time (Millisecond, Time (Time))
+
+type ApiKey = Text
+
+assertCaptcha ::
+  ( CaptchaRequest TwoCaptcha ctx CaptchaEnv Captcha,
+    CaptchaResponse TwoCaptcha ctx,
+    HasApiKey ctx Text,
+    HasPollingInterval ctx (Maybe (Time Millisecond)),
+    HasTimeoutDuration ctx (Maybe (Time Millisecond))
+  ) =>
+  ctx ->
+  IO ()
+assertCaptcha captcha =
+  mkCaptchaEnv >>= runCaptcha (solve @TwoCaptcha captcha) >>= \case
+    Left (TwoCaptchaResponseError NoSlotAvailable) -> pure ()
+    Right _ -> pure ()
+    Left exception -> assertFailure $ show exception
+
+testTimeout :: TestTree
+testTimeout =
+  testCase "Captcha Timeout" $ do
+    let captcha = def @ImageCaptcha & timeoutDuration ?~ Time @Millisecond 0
+    mkCaptchaEnv >>= runCaptcha (solve @TwoCaptcha captcha) >>= \case
+      Left TimeoutError -> pure ()
+      Left exception -> assertFailure $ show exception
+      Right _ -> assertFailure "Captcha did not timeout."
diff --git a/test/Captcha/TwoCaptcha/Test/FunCaptcha.hs b/test/Captcha/TwoCaptcha/Test/FunCaptcha.hs
new file mode 100644
--- /dev/null
+++ b/test/Captcha/TwoCaptcha/Test/FunCaptcha.hs
@@ -0,0 +1,22 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+
+module Captcha.TwoCaptcha.Test.FunCaptcha where
+
+import Captcha.Internal.Types (FunCaptcha, HasApiKey (apiKey), HasCaptchaKey (captchaKey), HasCaptchaUrl (captchaUrl))
+import Captcha.TwoCaptcha (ApiKey, assertCaptcha)
+import Captcha.TwoCaptcha.Internal.Types.FunCaptcha ()
+import Control.Lens ((&), (.~))
+import Data.Default (Default (def))
+import Test.Tasty (TestTree)
+import Test.Tasty.HUnit (testCase)
+
+test :: ApiKey -> TestTree
+test key = do
+  testCase "FunCaptcha" $
+    let captcha =
+          def @FunCaptcha
+            & apiKey .~ key
+            & captchaUrl .~ "https://api.funcaptcha.com/fc/api/nojs"
+            & captchaKey .~ "69A21A01-CC7B-B9C6-0F9A-E7FA06677FFC"
+     in assertCaptcha captcha
diff --git a/test/Captcha/TwoCaptcha/Test/HCaptcha.hs b/test/Captcha/TwoCaptcha/Test/HCaptcha.hs
new file mode 100644
--- /dev/null
+++ b/test/Captcha/TwoCaptcha/Test/HCaptcha.hs
@@ -0,0 +1,22 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+
+module Captcha.TwoCaptcha.Test.HCaptcha where
+
+import Captcha.Internal.Types (HCaptcha, HasApiKey (apiKey), HasCaptchaKey (captchaKey), HasCaptchaUrl (captchaUrl))
+import Captcha.TwoCaptcha (ApiKey, assertCaptcha)
+import Captcha.TwoCaptcha.Internal.Types.HCaptcha ()
+import Control.Lens ((&), (.~))
+import Data.Default (Default (def))
+import Test.Tasty (TestTree)
+import Test.Tasty.HUnit (testCase)
+
+test :: ApiKey -> TestTree
+test key =
+  testCase "HCaptcha" $
+    let captcha =
+          def @HCaptcha
+            & apiKey .~ key
+            & captchaUrl .~ "https://2captcha.com/demo/hcaptcha"
+            & captchaKey .~ "3ceb8624-1970-4e6b-91d5-70317b70b651"
+     in assertCaptcha captcha
diff --git a/test/Captcha/TwoCaptcha/Test/Image.hs b/test/Captcha/TwoCaptcha/Test/Image.hs
new file mode 100644
--- /dev/null
+++ b/test/Captcha/TwoCaptcha/Test/Image.hs
@@ -0,0 +1,22 @@
+{-# LANGUAGE TypeApplications #-}
+
+module Captcha.TwoCaptcha.Test.Image where
+
+import Captcha.Internal.Types (HasApiKey (apiKey), HasBody (body), ImageCaptcha)
+import Captcha.TwoCaptcha (ApiKey, assertCaptcha)
+import Control.Lens ((&), (.~))
+import Data.Default (Default (def))
+import Data.String.Conversions (cs)
+import Test.Tasty (TestTree)
+import Test.Tasty.HUnit (testCase)
+
+test :: ApiKey -> TestTree
+test key =
+  testCase "Image Captcha" $ do
+    -- Captcha image: Td4eva
+    captchaBody <- cs <$> readFile "../assets/image-captcha.txt"
+    let captcha =
+          def @ImageCaptcha
+            & apiKey .~ key
+            & body .~ captchaBody
+    assertCaptcha captcha
diff --git a/test/Captcha/TwoCaptcha/Test/ReCaptchaV2.hs b/test/Captcha/TwoCaptcha/Test/ReCaptchaV2.hs
new file mode 100644
--- /dev/null
+++ b/test/Captcha/TwoCaptcha/Test/ReCaptchaV2.hs
@@ -0,0 +1,22 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+
+module Captcha.TwoCaptcha.Test.ReCaptchaV2 where
+
+import Captcha.Internal.Types (HasApiKey (apiKey), HasCaptchaKey (captchaKey), HasCaptchaUrl (captchaUrl), ReCaptchaV2)
+import Captcha.TwoCaptcha (ApiKey, assertCaptcha)
+import Captcha.TwoCaptcha.Internal.Types.ReCaptchaV2 ()
+import Control.Lens ((&), (.~))
+import Data.Default (Default (def))
+import Test.Tasty (TestTree)
+import Test.Tasty.HUnit (testCase)
+
+test :: ApiKey -> TestTree
+test key =
+  testCase "ReCaptchaV2" $
+    let captcha =
+          def @ReCaptchaV2
+            & apiKey .~ key
+            & captchaUrl .~ "https://2captcha.com/demo/recaptcha-v2"
+            & captchaKey .~ "6LfD3PIbAAAAAJs_eEHvoOl75_83eXSqpPSRFJ_u"
+     in assertCaptcha captcha
diff --git a/test/Captcha/TwoCaptcha/Test/ReCaptchaV3.hs b/test/Captcha/TwoCaptcha/Test/ReCaptchaV3.hs
new file mode 100644
--- /dev/null
+++ b/test/Captcha/TwoCaptcha/Test/ReCaptchaV3.hs
@@ -0,0 +1,22 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+
+module Captcha.TwoCaptcha.Test.ReCaptchaV3 where
+
+import Captcha.Internal.Types (HasApiKey (apiKey), HasCaptchaKey (captchaKey), HasCaptchaUrl (captchaUrl), ReCaptchaV3)
+import Captcha.TwoCaptcha (ApiKey, assertCaptcha)
+import Captcha.TwoCaptcha.Internal.Types.ReCaptchaV3 ()
+import Control.Lens ((&), (.~))
+import Data.Default (Default (def))
+import Test.Tasty (TestTree)
+import Test.Tasty.HUnit (testCase)
+
+test :: ApiKey -> TestTree
+test key =
+  testCase "ReCaptchaV3" $
+    let captcha =
+          def @ReCaptchaV3
+            & apiKey .~ key
+            & captchaUrl .~ "https://2captcha.com/demo/recaptcha-v3"
+            & captchaKey .~ "6LfB5_IbAAAAAMCtsjEHEHKqcB9iQocwwxTiihJu"
+     in assertCaptcha captcha
diff --git a/test/Captcha/TwoCaptcha/Test/Text.hs b/test/Captcha/TwoCaptcha/Test/Text.hs
new file mode 100644
--- /dev/null
+++ b/test/Captcha/TwoCaptcha/Test/Text.hs
@@ -0,0 +1,21 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+
+module Captcha.TwoCaptcha.Test.Text where
+
+import Captcha.Internal.Types (HasApiKey (apiKey), HasBody (body), TextCaptcha)
+import Captcha.TwoCaptcha (ApiKey, assertCaptcha)
+import Captcha.TwoCaptcha.Internal.Types.Text ()
+import Control.Lens ((&), (.~))
+import Data.Default (Default (def))
+import Test.Tasty (TestTree)
+import Test.Tasty.HUnit (testCase)
+
+test :: ApiKey -> TestTree
+test key =
+  testCase "Text Captcha" $
+    let captcha =
+          def @TextCaptcha
+            & apiKey .~ key
+            & body .~ "Hello world!"
+     in assertCaptcha captcha
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,25 @@
+import qualified Captcha.TwoCaptcha as TwoCaptcha
+import qualified Captcha.TwoCaptcha.Test.FunCaptcha as FunCaptcha
+import qualified Captcha.TwoCaptcha.Test.HCaptcha as HCaptcha
+import qualified Captcha.TwoCaptcha.Test.Image as ImageCaptcha
+import qualified Captcha.TwoCaptcha.Test.ReCaptchaV2 as ReCaptchaV2
+import qualified Captcha.TwoCaptcha.Test.ReCaptchaV3 as ReCaptchaV3
+import qualified Captcha.TwoCaptcha.Test.Text as TextCaptcha
+import Data.String.Conversions (cs)
+import System.Environment (getEnv)
+import Test.Tasty (defaultMain, testGroup)
+
+main :: IO ()
+main = defaultMain . tests . cs =<< getEnv "TWOCAPTCHA_API_KEY"
+  where
+    tests apiKey =
+      testGroup
+        "2Captcha"
+        [ TwoCaptcha.testTimeout,
+          TextCaptcha.test apiKey,
+          ImageCaptcha.test apiKey,
+          ReCaptchaV2.test apiKey,
+          ReCaptchaV3.test apiKey,
+          HCaptcha.test apiKey,
+          FunCaptcha.test apiKey
+        ]
