diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,8 @@
+## [_Unreleased_](https://github.com/freckle/aws-sns-verify/compare/v0.0.0.0...main)
+
+## [v0.0.0.0](https://github.com/freckle/aws-sns-verify/releases/tag/v0.0.0.0)
+
+- Initial release.
+- Handle JSON parsing.
+- Handle validation of payload signatures via X509.
+- Handle response to subscription messages.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2021 Renaissance Learning Inc
+
+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,31 @@
+# aws-sns-verify
+
+Consumers utilizing SNS need to do 3 tasks:
+
+1. Parse the message JSON
+2. Validate signed signatures
+3. Handle subscriptions
+
+This library encapsulates those actions.
+
+```hs
+myEchoWebhook :: MonadHandler m => m ()
+myEchoWebhook = do
+  message <- verifySNSMessage =<< requireInsecureJsonBody
+  logDebugN message
+```
+
+## Sign For Test
+
+Signatures for testing are produced with the self signed certificate in this
+repository.
+
+```sh
+cat unsigned.txt | openssl dgst -sha1 -sign tests/key.pem | openssl base64
+```
+
+The certificate was produced with
+
+```sh
+openssl req -newkey rsa:2048 -new -nodes -x509 -days 3650 -keyout tests/key.pem -out tests/cert.pem
+```
diff --git a/aws-sns-verify.cabal b/aws-sns-verify.cabal
new file mode 100644
--- /dev/null
+++ b/aws-sns-verify.cabal
@@ -0,0 +1,116 @@
+cabal-version:      1.18
+name:               aws-sns-verify
+version:            0.0.0.0
+license:            MIT
+license-file:       LICENSE
+copyright:          2022 Freckle By Renaissance
+maintainer:         engineering@freckle.com
+author:             Freckle
+homepage:           https://github.com/freckle/aws-sns-verify#readme
+bug-reports:        https://github.com/freckle/aws-sns-verify/issues
+description:
+    Please see the README on GitHub at <https://github.com/freckle/aws-sns-verify#readme>
+
+category:
+    Network, Web, AWS, Amazon, Cloud, Cryptography, Distributed-Computing
+
+build-type:         Simple
+extra-source-files:
+    tests/cert.pem
+    tests/key.pem
+
+extra-doc-files:
+    README.md
+    CHANGELOG.md
+
+source-repository head
+    type:     git
+    location: https://github.com/freckle/aws-sns-verify
+
+library
+    exposed-modules:
+        Amazon.SNS.Verify
+        Amazon.SNS.Verify.Payload
+        Amazon.SNS.Verify.Validate
+
+    hs-source-dirs:     library
+    other-modules:      Amazon.SNS.Verify.Prelude
+    default-language:   Haskell2010
+    default-extensions:
+        BangPatterns DataKinds DeriveAnyClass DeriveFoldable DeriveFunctor
+        DeriveGeneric DeriveLift DeriveTraversable DerivingStrategies
+        FlexibleContexts FlexibleInstances GADTs GeneralizedNewtypeDeriving
+        LambdaCase MultiParamTypeClasses NoImplicitPrelude
+        NoMonomorphismRestriction OverloadedStrings RankNTypes
+        RecordWildCards ScopedTypeVariables StandaloneDeriving
+        TypeApplications TypeFamilies
+
+    ghc-options:
+        -Weverything -Wno-missing-exported-signatures
+        -Wno-missing-import-lists -Wno-missing-local-signatures
+        -Wno-monomorphism-restriction -Wno-unsafe -Wno-safe
+        -Wno-all-missed-specialisations
+
+    build-depends:
+        aeson >=1.3.1.1,
+        base >=4.7 && <5,
+        bytestring >=0.10.8.2,
+        errors >=2.3.0,
+        http-conduit >=2.3.2,
+        memory >=0.14.18,
+        pem >=0.2.4,
+        text >=1.2.3.1,
+        x509 >=1.7.5,
+        x509-validation >=1.6.11
+
+    if impl(ghc >=8.10)
+        ghc-options:
+            -Wno-missing-safe-haskell-mode -Wno-prepositive-qualified-module
+
+    if impl(ghc >=9.2.2)
+        ghc-options: -Wno-missing-kind-signatures
+
+test-suite aws-sns-verify-test
+    type:               exitcode-stdio-1.0
+    main-is:            Main.hs
+    hs-source-dirs:     tests
+    other-modules:
+        Amazon.SNS.Verify.TestPrelude
+        Amazon.SNS.Verify.ValidateSpec
+        Amazon.SNS.VerifySpec
+        Paths_aws_sns_verify
+
+    default-language:   Haskell2010
+    default-extensions:
+        BangPatterns DataKinds DeriveAnyClass DeriveFoldable DeriveFunctor
+        DeriveGeneric DeriveLift DeriveTraversable DerivingStrategies
+        FlexibleContexts FlexibleInstances GADTs GeneralizedNewtypeDeriving
+        LambdaCase MultiParamTypeClasses NoImplicitPrelude
+        NoMonomorphismRestriction OverloadedStrings RankNTypes
+        RecordWildCards ScopedTypeVariables StandaloneDeriving
+        TypeApplications TypeFamilies
+
+    ghc-options:
+        -Weverything -Wno-missing-exported-signatures
+        -Wno-missing-import-lists -Wno-missing-local-signatures
+        -Wno-monomorphism-restriction -Wno-unsafe -Wno-safe
+        -Wno-all-missed-specialisations -threaded -rtsopts -with-rtsopts=-N
+
+    build-depends:
+        aeson-qq >=0.8.2,
+        async >=2.2.1,
+        aws-sns-verify -any,
+        base >=4.7 && <5,
+        hspec >=2.5.5,
+        http-types >=0.12.2,
+        text >=1.2.3.1,
+        wai >=3.2.1.2,
+        warp >=3.2.25,
+        x509-validation >=1.6.11
+
+    if impl(ghc >=8.10)
+        ghc-options:
+            -Wno-missing-safe-haskell-mode -Wno-prepositive-qualified-module
+
+    if impl(ghc >=9.2.2)
+        ghc-options: -Wno-missing-kind-signatures
diff --git a/library/Amazon/SNS/Verify.hs b/library/Amazon/SNS/Verify.hs
new file mode 100644
--- /dev/null
+++ b/library/Amazon/SNS/Verify.hs
@@ -0,0 +1,65 @@
+module Amazon.SNS.Verify
+  ( verifySNSMessage
+  , verifySNSMessageEither
+  , verifySNSMessageJSON
+  , verifySNSMessageJSONEither
+  , SNSNotificationValidationError(..)
+  ) where
+
+import Amazon.SNS.Verify.Prelude
+
+import Amazon.SNS.Verify.Payload
+import Amazon.SNS.Verify.Validate
+import Control.Error (hoistEither, runExceptT)
+import Data.Aeson (FromJSON, Value, eitherDecode)
+import Data.Aeson.Types (Result(Error, Success), fromJSON)
+import Data.Bifunctor (first)
+import Data.ByteString.Lazy (fromStrict)
+import Data.Text.Encoding (encodeUtf8)
+
+-- | Decode and verify an SNS message as JSON
+--
+-- The same as 'verifySNSMessage', but decodes the message as `JSON`.
+--
+verifySNSMessageJSON :: (FromJSON a, MonadIO m) => Value -> m a
+verifySNSMessageJSON = unTry id <=< verifySNSMessageJSONEither
+
+verifySNSMessageJSONEither
+  :: (FromJSON a, MonadIO m)
+  => Value
+  -> m (Either SNSNotificationValidationError a)
+verifySNSMessageJSONEither value =
+  join
+    . traverse (first BadJSONParse . eitherDecode . fromStrict . encodeUtf8)
+    <$> verifySNSMessageEither value
+
+-- | Decode and verify an SNS message
+--
+-- This function follows the process outlined in the following:
+--
+-- <https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html>
+--
+-- A `JSON` payload is:
+--
+-- 1. Parsed as an SNS message type 'Notification', `SubscriptionConfirmation`,
+--    or `UnsubscribeConfirmation`.
+-- 2. Verified against its signature.
+-- 3. And in the case of subscription events responded to.
+--
+verifySNSMessage :: MonadIO m => Value -> m Text
+verifySNSMessage = unTry id <=< verifySNSMessageEither
+
+verifySNSMessageEither
+  :: MonadIO m => Value -> m (Either SNSNotificationValidationError Text)
+verifySNSMessageEither value = runExceptT $ do
+  payload <- hoistEither $ parseSNSPayload value
+  verified <- hoistEither =<< validateSnsMessage payload
+  hoistEither =<< handleSubscription verified
+
+parseSNSPayload :: Value -> Either SNSNotificationValidationError SNSPayload
+parseSNSPayload = first BadJSONParse . fromResult . fromJSON
+
+fromResult :: Result a -> Either String a
+fromResult = \case
+  Success a -> Right a
+  Error str -> Left str
diff --git a/library/Amazon/SNS/Verify/Payload.hs b/library/Amazon/SNS/Verify/Payload.hs
new file mode 100644
--- /dev/null
+++ b/library/Amazon/SNS/Verify/Payload.hs
@@ -0,0 +1,81 @@
+module Amazon.SNS.Verify.Payload
+  ( SNSPayload(..)
+  , SNSType(..)
+  , SNSNotification(..)
+  , SNSSubscription(..)
+  ) where
+
+import Amazon.SNS.Verify.Prelude
+
+import Data.Aeson
+  ( FromJSON
+  , defaultOptions
+  , fieldLabelModifier
+  , genericParseJSON
+  , parseJSON
+  , withObject
+  , (.:)
+  )
+import GHC.Generics (Generic)
+
+data SNSPayload = SNSPayload
+  { snsMessage :: Text
+  , snsMessageId :: Text
+  , snsTimestamp :: Text
+  , snsTopicArn :: Text
+  , snsType :: Text
+  , snsSignatureVersion :: Text
+  , snsSignature :: Text
+  , snsSigningCertURL :: Text
+  , snsTypePayload :: SNSType
+  }
+
+instance FromJSON SNSPayload where
+  parseJSON v = parse v
+   where
+    parse = withObject "SNSPayload" $ \o -> do
+      payloadType <- o .: "Type"
+      SNSPayload
+        <$> o
+        .: "Message"
+        <*> o
+        .: "MessageId"
+        <*> o
+        .: "Timestamp"
+        <*> o
+        .: "TopicArn"
+        <*> pure payloadType
+        <*> o
+        .: "SignatureVersion"
+        <*> o
+        .: "Signature"
+        <*> o
+        .: "SigningCertURL"
+        <*> parseType payloadType
+    parseType = \case
+      "SubscriptionConfirmation" -> SubscriptionConfirmation <$> parseJSON v
+      "UnsubscribeConfirmation" -> UnsubscribeConfirmation <$> parseJSON v
+      "Notification" -> Notification <$> parseJSON v
+      msg -> fail $ "Unknown message type " <> show msg
+
+data SNSType
+  = Notification SNSNotification
+  | SubscriptionConfirmation SNSSubscription
+  | UnsubscribeConfirmation SNSSubscription
+
+newtype SNSNotification = SNSNotification
+  { snsSubject :: Maybe Text
+  }
+  deriving stock (Show, Eq, Generic)
+
+instance FromJSON SNSNotification where
+  parseJSON = genericParseJSON $ defaultOptions { fieldLabelModifier = drop 3 }
+
+data SNSSubscription = SNSSubscription
+  { snsToken :: Text
+  , snsSubscribeURL :: Text
+  }
+  deriving stock (Show, Eq, Generic)
+
+instance FromJSON SNSSubscription where
+  parseJSON = genericParseJSON $ defaultOptions { fieldLabelModifier = drop 3 }
diff --git a/library/Amazon/SNS/Verify/Prelude.hs b/library/Amazon/SNS/Verify/Prelude.hs
new file mode 100644
--- /dev/null
+++ b/library/Amazon/SNS/Verify/Prelude.hs
@@ -0,0 +1,23 @@
+module Amazon.SNS.Verify.Prelude
+  ( module X
+  , module Amazon.SNS.Verify.Prelude
+  ) where
+
+import Prelude as X
+
+import Control.Exception as X (Exception)
+import qualified Control.Exception
+import Control.Monad as X (join, (<=<))
+import Control.Monad.IO.Class as X (MonadIO, liftIO)
+import Data.ByteString as X (ByteString)
+import Data.Text as X (Text)
+import Data.Traversable as X (for)
+
+throwIO :: (MonadIO m, Exception e) => e -> m a
+throwIO = liftIO . Control.Exception.throwIO
+
+unTry :: (MonadIO m, Exception e) => (a -> e) -> Either a b -> m b
+unTry e = either (throwIO . e) pure
+
+fromMaybeM :: Monad m => m a -> Maybe a -> m a
+fromMaybeM f = maybe f pure
diff --git a/library/Amazon/SNS/Verify/Validate.hs b/library/Amazon/SNS/Verify/Validate.hs
new file mode 100644
--- /dev/null
+++ b/library/Amazon/SNS/Verify/Validate.hs
@@ -0,0 +1,128 @@
+module Amazon.SNS.Verify.Validate
+  ( validateSnsMessage
+  , handleSubscription
+  , SNSNotificationValidationError(..)
+  , ValidSNSMessage(..)
+  ) where
+
+import Amazon.SNS.Verify.Prelude
+
+import Amazon.SNS.Verify.Payload
+import Control.Error (ExceptT, catMaybes, headMay, runExceptT, throwE)
+import Control.Monad (when)
+import Data.ByteArray.Encoding (Base(Base64), convertFromBase)
+import Data.PEM (pemContent, pemParseLBS)
+import qualified Data.Text as T
+import Data.Text.Encoding (encodeUtf8)
+import Data.X509
+  ( HashALG(..)
+  , PubKeyALG(..)
+  , SignatureALG(..)
+  , SignedCertificate
+  , certPubKey
+  , decodeSignedCertificate
+  , getCertificate
+  )
+import Data.X509.Validation
+  (SignatureFailure, SignatureVerification(..), verifySignature)
+import Network.HTTP.Simple
+  (getResponseBody, getResponseStatusCode, httpLbs, parseRequest_)
+
+data ValidSNSMessage
+  = SNSMessage Text
+  | SNSSubscribe SNSSubscription
+  | SNSUnsubscribe SNSSubscription
+  deriving stock (Show, Eq)
+
+-- | Validate SNS notification
+--
+-- SNS messages are validated through their signature. The algorithm is detailed
+-- in the documentation below.
+--
+-- <https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html>
+--
+validateSnsMessage
+  :: MonadIO m
+  => SNSPayload
+  -> m (Either SNSNotificationValidationError ValidSNSMessage)
+validateSnsMessage payload@SNSPayload {..} = runExceptT $ do
+  signature <- unTry BadSignature $ convertFromBase Base64 $ encodeUtf8
+    snsSignature
+  signedCert <- retrieveCertificate payload
+  let
+    valid = verifySignature
+      (SignatureALG HashSHA1 PubKeyALG_RSA)
+      (certPubKey $ getCertificate signedCert)
+      (unsignedSignature payload)
+      signature
+  case valid of
+    SignaturePass -> pure $ case snsTypePayload of
+      Notification{} -> SNSMessage snsMessage
+      SubscriptionConfirmation x -> SNSSubscribe x
+      UnsubscribeConfirmation x -> SNSUnsubscribe x
+    SignatureFailed e -> throwE $ InvalidPayload e
+
+retrieveCertificate
+  :: MonadIO m
+  => SNSPayload
+  -> ExceptT SNSNotificationValidationError m SignedCertificate
+retrieveCertificate SNSPayload {..} = do
+  response <- httpLbs $ parseRequest_ $ T.unpack snsSigningCertURL
+  pems <- unTry BadPem $ pemParseLBS $ getResponseBody response
+  cert <-
+    fromMaybeM (throwE $ BadPem "Empty List") $ pemContent <$> headMay pems
+  unTry BadCert $ decodeSignedCertificate cert
+
+unsignedSignature :: SNSPayload -> ByteString
+unsignedSignature SNSPayload {..} =
+  encodeUtf8 $ mconcat $ (<> "\n") <$> catMaybes
+    [ Just "Message"
+    , Just snsMessage
+    , Just "MessageId"
+    , Just snsMessageId
+    , "SubscrieURL" <$ mSubscribeUrl
+    , mSubscribeUrl
+    , "Subject" <$ mSubject
+    , mSubject
+    , Just "Timestamp"
+    , Just snsTimestamp
+    , "Token" <$ mToken
+    , mToken
+    , Just "TopicArn"
+    , Just snsTopicArn
+    , Just "Type"
+    , Just snsType
+    ]
+ where
+  (mSubject, mToken, mSubscribeUrl) = case snsTypePayload of
+    Notification x -> (snsSubject x, Nothing, Nothing)
+    SubscriptionConfirmation x ->
+      (Nothing, Just $ snsToken x, Just $ snsSubscribeURL x)
+    UnsubscribeConfirmation x ->
+      (Nothing, Just $ snsToken x, Just $ snsSubscribeURL x)
+
+handleSubscription
+  :: MonadIO m
+  => ValidSNSMessage
+  -> m (Either SNSNotificationValidationError Text)
+handleSubscription = runExceptT . \case
+  SNSMessage t -> pure t
+  SNSSubscribe SNSSubscription {..} -> do
+    response <- httpLbs $ parseRequest_ $ T.unpack snsSubscribeURL
+    when (getResponseStatusCode response >= 300) $ do
+      throwE $ BadSubscription ()
+    throwE SubscribeMessageResponded
+  SNSUnsubscribe{} -> throwE UnsubscribeMessage
+
+data SNSNotificationValidationError
+  = BadPem String
+  | BadSignature String
+  | BadCert String
+  | BadJSONParse String
+  | BadSubscription ()
+  | InvalidPayload SignatureFailure
+  | MissingMessageTypeHeader
+  | UnsubscribeMessage
+  | SubscribeMessageResponded
+  deriving stock (Show, Eq)
+  deriving anyclass Exception
diff --git a/tests/Amazon/SNS/Verify/TestPrelude.hs b/tests/Amazon/SNS/Verify/TestPrelude.hs
new file mode 100644
--- /dev/null
+++ b/tests/Amazon/SNS/Verify/TestPrelude.hs
@@ -0,0 +1,29 @@
+module Amazon.SNS.Verify.TestPrelude
+  ( module X
+  , module Amazon.SNS.Verify.TestPrelude
+  ) where
+
+import Prelude as X
+
+import Control.Concurrent.Async
+import Control.Concurrent.MVar
+import Data.Text as X (Text)
+import Network.HTTP.Types.Status
+import Network.Wai
+import Network.Wai.Handler.Warp
+import Test.Hspec as X
+
+useCertServer :: IO () -> IO ()
+useCertServer action = do
+  (setReady, whenReady) <- initReadyState
+  race_ (whenReady action)
+    $ runSettings (setBeforeMainLoop setReady . setPort 3000 $ defaultSettings)
+    $ \_req send -> send $ responseFile
+        ok200
+        [("Content-Type", "text/plain")]
+        "./tests/cert.pem"
+        Nothing
+ where
+  initReadyState = do
+    ready <- newEmptyMVar
+    return (putMVar ready (), withMVar ready . const)
diff --git a/tests/Amazon/SNS/Verify/ValidateSpec.hs b/tests/Amazon/SNS/Verify/ValidateSpec.hs
new file mode 100644
--- /dev/null
+++ b/tests/Amazon/SNS/Verify/ValidateSpec.hs
@@ -0,0 +1,123 @@
+module Amazon.SNS.Verify.ValidateSpec
+  ( spec
+  ) where
+
+import Amazon.SNS.Verify.TestPrelude
+
+import Amazon.SNS.Verify.Payload
+import Amazon.SNS.Verify.Validate
+import Data.X509.Validation (SignatureFailure(..))
+
+spec :: Spec
+spec = around_ useCertServer $ do
+  describe "validateSnsMessage" $ do
+    it "successfully validates an SNS notification" $ do
+      let message = "Some message"
+      x <- validateSnsMessage $ SNSPayload
+        { snsMessage = message
+        , snsMessageId = "78d4d7a0-a3eb-5c4d-834f-8d5fa9813ab6"
+        , snsTimestamp = "2022-05-18T14:52:26.952Z"
+        , snsTopicArn = "arn:aws:sns:us-west-2:123456789012:MyTopic"
+        , snsType = "Notification"
+        , snsSignatureVersion = "1"
+        , snsSignature =
+          "Dg24trcOUiLjclt5JwyJS0JEOnEEbi6P30XS6KBxMCwzZ08a04UwjaFTW9Ae8xurhBS5YESz1fY28vTwvEmxh/20WmB3bWIDOMp9v5RI8XSZOvpMm+hdQJ43VqGhEDyAvRU6iCDLihDlZNc/sBCwl9X0H4kh/8vIElRif9gFBbYI94ZHGgqEV+Zc3gVKo9Udrl/MxNvMVadsO/+/oPVUeWibQr3xfGK95oc/ocuNAgi0MOxZmLVnibHu36KOTSvy2qSLonnRRFcbaauYZJ4js7oTq+1ujXNO72oPLaeG3pVJ2grqMc5z8tKQxFnSTE3es7wQarU/CLrbO8j0isbnWw=="
+        , snsSigningCertURL = cert
+        , snsTypePayload = Notification $ SNSNotification
+          { snsSubject =
+            Just
+              "SynthesisTaskNotification { TaskId: 680a1f1b-f3ae-4474-aa8f-3b6dfe52e656, Status: COMPLETED }"
+          }
+        }
+      x `shouldBe` Right (SNSMessage message)
+
+    it "fails to validate a currupt SNS notification" $ do
+      let
+        go = validateSnsMessage $ SNSPayload
+          { snsMessage = "Some message"
+          , snsMessageId = "corrupt"
+          , snsTimestamp = "2022-05-18T14:52:26.952Z"
+          , snsTopicArn = "arn:aws:sns:us-west-2:123456789012:MyTopic"
+          , snsType = "Notification"
+          , snsSignatureVersion = "1"
+          , snsSignature =
+            "Dg24trcOUiLjclt5JwyJS0JEOnEEbi6P30XS6KBxMCwzZ08a04UwjaFTW9Ae8xurhBS5YESz1fY28vTwvEmxh/20WmB3bWIDOMp9v5RI8XSZOvpMm+hdQJ43VqGhEDyAvRU6iCDLihDlZNc/sBCwl9X0H4kh/8vIElRif9gFBbYI94ZHGgqEV+Zc3gVKo9Udrl/MxNvMVadsO/+/oPVUeWibQr3xfGK95oc/ocuNAgi0MOxZmLVnibHu36KOTSvy2qSLonnRRFcbaauYZJ4js7oTq+1ujXNO72oPLaeG3pVJ2grqMc5z8tKQxFnSTE3es7wQarU/CLrbO8j0isbnWw=="
+          , snsSigningCertURL = cert
+          , snsTypePayload = Notification $ SNSNotification
+            { snsSubject =
+              Just
+                "SynthesisTaskNotification { TaskId: 680a1f1b-f3ae-4474-aa8f-3b6dfe52e656, Status: COMPLETED }"
+            }
+          }
+      go `shouldReturn` Left (InvalidPayload SignatureInvalid)
+
+    it "fails to validate a bad PEM" $ do
+      let
+        go = validateSnsMessage $ SNSPayload
+          { snsMessage = "Some message"
+          , snsMessageId = "corrupt"
+          , snsTimestamp = "2022-05-18T14:52:26.952Z"
+          , snsTopicArn = "arn:aws:sns:us-west-2:123456789012:MyTopic"
+          , snsType = "Notification"
+          , snsSignatureVersion = "1"
+          , snsSignature =
+            "Dg24trcOUiLjclt5JwyJS0JEOnEEbi6P30XS6KBxMCwzZ08a04UwjaFTW9Ae8xurhBS5YESz1fY28vTwvEmxh/20WmB3bWIDOMp9v5RI8XSZOvpMm+hdQJ43VqGhEDyAvRU6iCDLihDlZNc/sBCwl9X0H4kh/8vIElRif9gFBbYI94ZHGgqEV+Zc3gVKo9Udrl/MxNvMVadsO/+/oPVUeWibQr3xfGK95oc/ocuNAgi0MOxZmLVnibHu36KOTSvy2qSLonnRRFcbaauYZJ4js7oTq+1ujXNO72oPLaeG3pVJ2grqMc5z8tKQxFnSTE3es7wQarU/CLrbO8j0isbnWw=="
+          , snsSigningCertURL = "https://freckle.com/404.pem"
+          , snsTypePayload = Notification $ SNSNotification
+            { snsSubject =
+              Just
+                "SynthesisTaskNotification { TaskId: 680a1f1b-f3ae-4474-aa8f-3b6dfe52e656, Status: COMPLETED }"
+            }
+          }
+      go `shouldReturn` Left (BadPem "Empty List")
+
+    it "successfully validates an SNS subscription" $ do
+      -- pendingWith "need valid subscription payload"
+      let
+        message
+          = "You have chosen to subscribe to the topic arn:aws:sns:us-west-2:123456789012:MyTopic.\nTo confirm the subscription, visit the SubscribeURL included in this message."
+        subscription = SNSSubscription
+          { snsToken = "2336412f37..."
+          , snsSubscribeURL =
+            "https://sns.us-west-2.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:us-west-2:123456789012:MyTopic&Token=2336412f37..."
+          }
+      x <- validateSnsMessage $ SNSPayload
+        { snsMessage = message
+        , snsMessageId = "165545c9-2a5c-472c-8df2-7ff2be2b3b1b"
+        , snsTimestamp = "2012-04-26T20:45:04.751Z"
+        , snsTopicArn = "arn:aws:sns:us-west-2:123456789012:MyTopic"
+        , snsType = "SubscriptionConfirmation"
+        , snsSignatureVersion = "1"
+        , snsSignature =
+          "ZsPD3cDAHPU0mseSCKSQX8Ka2vT8lmaTHN6GbfCwmtfGFblWveBDUMWbSvhvDL4A6RujW8UfEpCytC2vwbMM2GUreTRpelK06t19nxeTHAnC5+JYBUpmlgauT92g3sLGYDWIUxAbD5u7ZY5QDfWaOpo8OD+xFy0sAB51SSDjTsXbt+gh/S6FbZQ1l/sjyBlDeJwxM/qpLn4xfQKO/Ev6lpV3ioBT8/Q7f1o4rzloYXxu1QnpDUx6L5uZB23lGEI4dcjIixi58WFwkz0SHgR9MWYIKJa2vZO2P8jFG63FGOhNaHbp3e/J6Tl6XnJnc+vriGle2dwhQjuWMDSdi92+kg=="
+        , snsSigningCertURL = cert
+        , snsTypePayload = SubscriptionConfirmation subscription
+        }
+      x `shouldBe` Right (SNSSubscribe subscription)
+
+    it "successfully validates an SNS unsubscribe" $ do
+      -- pendingWith "need valid subscription payload"
+      let
+        message
+          = "You have chosen to subscribe to the topic arn:aws:sns:us-west-2:123456789012:MyTopic.\nTo confirm the subscription, visit the SubscribeURL included in this message."
+        subscription = SNSSubscription
+          { snsToken = "2336412f37..."
+          , snsSubscribeURL =
+            "https://sns.us-west-2.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:us-west-2:123456789012:MyTopic&Token=2336412f37..."
+          }
+      x <- validateSnsMessage $ SNSPayload
+        { snsMessage = message
+        , snsMessageId = "165545c9-2a5c-472c-8df2-7ff2be2b3b1b"
+        , snsTimestamp = "2012-04-26T20:45:04.751Z"
+        , snsTopicArn = "arn:aws:sns:us-west-2:123456789012:MyTopic"
+        , snsType = "UnsubscribeConfirmation"
+        , snsSignatureVersion = "1"
+        , snsSignature =
+          "DqsnMTKKAW65yGEjqGGmHzw660tq9lB0yK3GVznVCINDnzrrq+mvScrHSJQ17+tT+u1aSL8nFvyzC4xdn4j3b1TNSXJdqj1ivvMVpVDbvl8Wvf/+JQ/ac6MPdQMkq0RitX5pzod3bIekY9ZXejLoEP73Q5zcLbTGRnC7G4li5W5ZG9i81zfbM1PgqmkUQnPC49Cqm/KMvBok8PfVBzWfEFLcnSsbm7JJf6y5Y7j+onDLTlbjwdRhej+MUJrrtsXbkARUg6EZAFcbhULj/3EYhvwdDClL4h0OxxhwWRnofTONIZhLB7bAkrbgsikkyhZ49zG8PjD6+TLX3YPaNB5eFw=="
+        , snsSigningCertURL = cert
+        , snsTypePayload = UnsubscribeConfirmation subscription
+        }
+      x `shouldBe` Right (SNSUnsubscribe subscription)
+
+cert :: Text
+cert = "http://localhost:3000"
diff --git a/tests/Amazon/SNS/VerifySpec.hs b/tests/Amazon/SNS/VerifySpec.hs
new file mode 100644
--- /dev/null
+++ b/tests/Amazon/SNS/VerifySpec.hs
@@ -0,0 +1,54 @@
+{-# LANGUAGE QuasiQuotes #-}
+
+module Amazon.SNS.VerifySpec
+  ( spec
+  ) where
+
+import Amazon.SNS.Verify.TestPrelude
+
+import Amazon.SNS.Verify
+import Data.Aeson.QQ
+
+spec :: Spec
+spec = around_ useCertServer $ do
+  describe "verifySNSMessage" $ do
+    it "successfully validates an SNS notification" $ do
+      let
+        payload = [aesonQQ|
+          { Message: "Some message"
+          , MessageId: "78d4d7a0-a3eb-5c4d-834f-8d5fa9813ab6"
+          , Timestamp: "2022-05-18T14:52:26.952Z"
+          , TopicArn: "arn:aws:sns:us-west-2:123456789012:MyTopic"
+          , Type: "Notification"
+          , Subject: "SynthesisTaskNotification { TaskId: 680a1f1b-f3ae-4474-aa8f-3b6dfe52e656, Status: COMPLETED }"
+          , SignatureVersion: "1"
+          , Signature: "Dg24trcOUiLjclt5JwyJS0JEOnEEbi6P30XS6KBxMCwzZ08a04UwjaFTW9Ae8xurhBS5YESz1fY28vTwvEmxh/20WmB3bWIDOMp9v5RI8XSZOvpMm+hdQJ43VqGhEDyAvRU6iCDLihDlZNc/sBCwl9X0H4kh/8vIElRif9gFBbYI94ZHGgqEV+Zc3gVKo9Udrl/MxNvMVadsO/+/oPVUeWibQr3xfGK95oc/ocuNAgi0MOxZmLVnibHu36KOTSvy2qSLonnRRFcbaauYZJ4js7oTq+1ujXNO72oPLaeG3pVJ2grqMc5z8tKQxFnSTE3es7wQarU/CLrbO8j0isbnWw=="
+          , SigningCertURL: "http://localhost:3000"
+          }
+          |]
+      x <- verifySNSMessage payload
+
+      x `shouldBe` "Some message"
+
+    it "successfully confirms a subscription" $ do
+      let
+        payload = [aesonQQ|
+          { Message: "Some message"
+          , MessageId: "78d4d7a0-a3eb-5c4d-834f-8d5fa9813ab6"
+          , Timestamp: "2022-05-18T14:52:26.952Z"
+          , Token: "test"
+          , TopicArn: "arn:aws:sns:us-west-2:123456789012:MyTopic"
+          , Type: "SubscriptionConfirmation"
+          , SignatureVersion: "1"
+          , SubscribeURL: "http://localhost:3000"
+
+          , Signature: "fHExOb2eyGY3mCISjk72nTLRomG9kB+I8c01Jj8iAEUWG2De3QrdRciMXrlP/zEJEbCVDN1lzO91rMu02ng0Akj1s3tN8lRK1QgCRt/35ob9QG9j2xXMowbdEmibj03rILQAriXk1H+pvftBvr9imz+eZID8TZfpjKkVJtn5pvs7LjpOQaTLgZmDIVtsz+04sTQscaETy00kLbqmfknqd6zGmvB7ub/HDhjF3xojBLE+1HCUCgQoS6FAEs/ivbcTdXpqs8L5trStC1So1w/xlTkaGnDmcLpVtj7qk2HBnggJhw4ay8dF4LRpipc5w254dghIeY0YlwflW4l1aZHb3w=="
+          , SigningCertURL: "http://localhost:3000"
+          }
+          |]
+
+      verifySNSMessage payload
+        `shouldThrow` (\case
+                        SubscribeMessageResponded -> True
+                        _ -> False
+                      )
diff --git a/tests/Main.hs b/tests/Main.hs
new file mode 100644
--- /dev/null
+++ b/tests/Main.hs
@@ -0,0 +1,1 @@
+{-# OPTIONS_GHC -F -pgmF hspec-discover -optF --module-name=Main -Wno-missing-export-lists #-}
diff --git a/tests/cert.pem b/tests/cert.pem
new file mode 100644
--- /dev/null
+++ b/tests/cert.pem
@@ -0,0 +1,21 @@
+-----BEGIN CERTIFICATE-----
+MIIDazCCAlOgAwIBAgIUR/7v0Z57wq8BaU90+7lqR+MByIwwDQYJKoZIhvcNAQEL
+BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
+GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMjA1MzEyMjUxMzhaFw0zMjA1
+MjgyMjUxMzhaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
+HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
+AQUAA4IBDwAwggEKAoIBAQCylpwWwMURNfanuv4tIYMJ+1pypf6Nk9dCbYLpwFPt
+SYzeeU+bNPB5y1a5fCaHNT7foIU/WuiJagyarqa0PLotAjWViMdrfGfy3EgUkLQU
+SOEozOLV1WMXGoBh1w9vGI8RaRWiD/3FY970J/JCP4aiXFb22o12JBeUihgANnSB
+qUhjFi2HdmosR40CSUu/X5gQVGyJ9DEdDZue/hoqsZPQJoqWxtEoQ3c/KPmvhKOW
+N1XMUUAxXXlAUVx6T7+IJ9L2+aRGR6HtKhyMXYJL0iGpR84qC0MxZnMg1qwcs0a7
+aru4ung7SRyBHWcfAQaWhszqqh9Aljlq5oZDUgscSpJPAgMBAAGjUzBRMB0GA1Ud
+DgQWBBSUaXOnX7TA8jpLIb3GmrevUHJA7zAfBgNVHSMEGDAWgBSUaXOnX7TA8jpL
+Ib3GmrevUHJA7zAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAD
+IVRm5kOFbmQwM3NcuCVhkxb+Tarx/ySoe6cT1+TtBNBKhg2inA6BxrZH8zxOaQ3i
+6TpXLNZPxngFXNWBV3pQSL4J9TJClSNVcsVwQco+oUUDobzqSgnAi5h65AWmki63
+HHIZK9M7kpMmK4rB4JBpbyKdCdr/4r2zNrla+he+RwROkTSk2T5Cea4fTEfKgre5
+RoODmS5aytJZNJjB1Tn1Zges+mPzrLJ12vczEI/tgJb5q57hZ9OKFTCfhm169flA
+sl56EFRnvjLykfZ8HibqA2dbAwTGR/Yy0JpOCBmAiaGf2Wa8xf/ZXybdtyCHa5eH
+tch7wpVBtacV6tK5GmO4
+-----END CERTIFICATE-----
diff --git a/tests/key.pem b/tests/key.pem
new file mode 100644
--- /dev/null
+++ b/tests/key.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCylpwWwMURNfan
+uv4tIYMJ+1pypf6Nk9dCbYLpwFPtSYzeeU+bNPB5y1a5fCaHNT7foIU/WuiJagya
+rqa0PLotAjWViMdrfGfy3EgUkLQUSOEozOLV1WMXGoBh1w9vGI8RaRWiD/3FY970
+J/JCP4aiXFb22o12JBeUihgANnSBqUhjFi2HdmosR40CSUu/X5gQVGyJ9DEdDZue
+/hoqsZPQJoqWxtEoQ3c/KPmvhKOWN1XMUUAxXXlAUVx6T7+IJ9L2+aRGR6HtKhyM
+XYJL0iGpR84qC0MxZnMg1qwcs0a7aru4ung7SRyBHWcfAQaWhszqqh9Aljlq5oZD
+UgscSpJPAgMBAAECggEBAJCyjw2wavui6ZLFY6RVfsLbsVErRbCYlNTxvhx/BIFC
+Eq0/RUzP+/fy1PW8rcrnTWYLvWqz0a1lC4lY8SEgAwjsvVXHWSyxw9d2u8BN7lpx
+Il3YhAIVvw+9HUAo7AxrrZ/BsRF6O3dvj/nrU9JxfjiW8K9aLTwHykNEEGY7uPJE
+Ri/IvQtwb8zxe2qi2jx4WpBoA2j0W5IhXUYy7IGeeiK1oIQX8yVrbX6i8IzW86gO
+9I5zjbBCy3IPzG8m2JkmFVIqHR7+isDt/NHRdcT5o/jXRMjIJlJMsoXoAv34pt6d
+rBq4+oqU7rqBLGMCV0Gkb9Npa0So0FRL2gnfVRTtlOECgYEA7hZL0jqeTGHh2Ag5
+knFo/9XZBlbJpLhSkpTonjMJKWoW0VcQh8CFUzRXeAYJk4PfQ15wHGQm2p/SfDZB
+J7dvlVf7FyJtVaIvYYW4E+rDGN+NvlUFx2uTvxErtIWNLKl+4LX1x2wm8amW4kax
+LQO5ZmB2zXiuLJBhQEhXwimYcPMCgYEAwAZUnx9YYWAsPN+I01KKWyuj0fTNOpPC
+u46RC6Ii+A5RlV8ksYPT4Sd0HcAzOeKLdj0DKCQ426hn5XVZ8DIS06b7FWXnP6oH
+S6jf50LXzKDKLP1l/J582GtBobAzcUv28z/WMdy7QnfMX0wj1QDNRxtn4C+ZHVEt
+K82dPi+kEDUCgYB48vCvqsGyEeOWSI9euqhZlzx6AOrM+CpSdr05aLnVxSQ0UqN+
+IypX23Y37fE9dDeeVMJlWed3Fsvp9/HfiEtyNdDDruSWJR8mzrR1wcQ8xYyPTUfz
+R841uTZXraTLNIvVAfsKxQF/sOSKrNjjAkpUHNsdhJoO34XYDygt5c2gSwKBgBC6
+PHLkGyHYa+IdouCtxwmsIoguqBYbOxJ1l+MIi71KGgdGE383OaTad/4DeTLorjdt
+lzHO/fj8kZhmgaAv7H3thN/XUa61FA+eNWsC4qObuiDzLgg0kjGUHlKEc+bBtFB6
+ViFf1hwzLWV7z39Fo5w3IfRgkRkBskqnjbQWGFelAoGBAKafavC9fgeDZDKPX1aP
+kSS458nC+uigVbeuxeQPVPVVKXt1jGA/xF3z+ZCx/JBMX+I06pkpRkNMGufx6qWi
+E391Zm2CowQ4uQWELRIyE2tb+aRS/WnfIZoJsvBvz6DfHxURJjr80XmB3AOQWYF+
+KAH5JbFlG4GXUIb3WELjdjdj
+-----END PRIVATE KEY-----
