diff --git a/changelog.md b/changelog.md
new file mode 100644
--- /dev/null
+++ b/changelog.md
@@ -0,0 +1,12 @@
+# Changelog
+
+All notable changes to this package will be documented in this file.
+
+The format is based on
+[Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to the
+[Haskell Package Versioning Policy](https://pvp.haskell.org/).
+
+## 1.0.0.0 - 2018-12-20
+
+- Initial release
diff --git a/library/Stripe/Signature.hs b/library/Stripe/Signature.hs
new file mode 100644
--- /dev/null
+++ b/library/Stripe/Signature.hs
@@ -0,0 +1,111 @@
+{-# OPTIONS_GHC -Wall #-}
+
+{- | https://stripe.com/docs/webhooks/signatures#verify-manually -}
+
+module Stripe.Signature
+  ( Sig (..), isSigValid, digest, signedPayload, natBytes, parseSig
+  ) where
+
+-- base
+import qualified Data.List
+import qualified Data.Maybe
+import qualified Data.String
+import           Numeric.Natural (Natural)
+import qualified Text.Read
+
+-- bytestring
+import Data.ByteString (ByteString)
+
+-- cryptonite
+import Crypto.Hash     (SHA256)
+import Crypto.MAC.HMAC as HMAC
+
+-- hex-text
+import qualified Text.Hex
+
+-- memory
+import qualified Data.ByteArray
+
+-- stripe-concepts
+import Stripe.Concepts (WebhookSecretKey (..))
+
+-- text
+import           Data.Text (Text)
+import qualified Data.Text
+
+isSigValid :: Sig -> WebhookSecretKey -> ByteString -> Bool
+isSigValid x secret body =
+    Data.List.any (Data.ByteArray.eq correctDigest) (sigV1 x)
+  where
+    correctDigest = digest secret (sigTime x) body
+
+digest :: WebhookSecretKey -> Natural -> ByteString -> HMAC SHA256
+digest (WebhookSecretKey secret) time body =
+    HMAC.hmac secret (signedPayload time body)
+
+signedPayload :: Natural -> ByteString -> ByteString
+signedPayload time body =
+    mconcat
+        [ natBytes time
+        , encodeAscii "."
+        , body
+        ]
+
+{- | Convert a natural number to the ASCII encoding of its decimal
+representation. -}
+
+natBytes :: Natural -> ByteString
+natBytes = encodeAscii . (show :: Natural -> String)
+
+encodeAscii :: String -> ByteString
+encodeAscii = Data.String.fromString
+
+{- | The relevant bits of data extracted from the Stripe signature header. -}
+
+data Sig =
+  Sig
+    { sigTime :: Natural
+    , sigV1 :: [ByteString]
+    }
+
+{- | Parse the Stripe signature header, returning 'Nothing' if parsing fails. -}
+
+parseSig :: Text -> Maybe Sig
+parseSig txt =
+  let
+    parts :: [(Text, Text)]
+    parts = splitSig txt
+  in
+    do
+      time <- Data.List.lookup (Data.Text.pack "t") parts
+                    >>= (readNatural . Data.Text.unpack)
+
+      let
+          v1 = Data.Maybe.mapMaybe
+              ( \(k, v) ->
+                  if k == Data.Text.pack "v1"
+                  then Text.Hex.decodeHex v
+                  else Nothing
+              )
+              parts
+
+      pure Sig{ sigTime = time, sigV1 = v1 }
+
+splitSig :: Text -> [(Text, Text)]
+splitSig =
+    Data.Maybe.catMaybes
+    . fmap (split2 (Data.Text.pack "="))
+    . Data.Text.splitOn (Data.Text.pack ",")
+
+split2 :: Text -> Text -> Maybe (Text, Text)
+split2 pat src =
+    let
+        (x, y) = Data.Text.breakOn pat src
+        y' = Data.Text.drop (Data.Text.length pat) y
+    in
+        if Data.Text.null y then Nothing else Just (x, y')
+
+{- | Parse a number consisting of one or more digits 0 through 9. -}
+
+readNatural :: String -> Maybe Natural
+readNatural = Text.Read.readMaybe
diff --git a/license.txt b/license.txt
new file mode 100644
--- /dev/null
+++ b/license.txt
@@ -0,0 +1,18 @@
+Copyright 2018 Typeclass Consulting, LLC
+
+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/stripe-signature.cabal b/stripe-signature.cabal
new file mode 100644
--- /dev/null
+++ b/stripe-signature.cabal
@@ -0,0 +1,58 @@
+name: stripe-signature
+version: 1.0.0.0
+
+synopsis: Verification of Stripe webhook signatures
+category: Web
+
+description:
+    When <https://stripe.com/ Stripe> sends an event to your webhook, it
+    includes an HTTP header named @Stripe-Signature@. You should use this
+    to verify the authenticity of the request to ensure that you are not
+    acting upon forged events originating from some source other than
+    Stripe.
+
+homepage:    https://github.com/typeclasses/stripe
+bug-reports: https://github.com/typeclasses/stripe/issues
+
+author:     Chris Martin
+maintainer: Chris Martin, Julie Moronuki
+
+copyright: 2018 Typeclass Consulting, LLC
+license: MIT
+license-file: license.txt
+
+build-type: Simple
+cabal-version: >=1.10
+tested-with: GHC==8.2.2, GHC==8.4.3, GHC==8.6.1
+
+extra-source-files:
+    changelog.md
+
+library
+    hs-source-dirs: library
+    default-language: Haskell2010
+
+    exposed-modules:
+        Stripe.Signature
+
+    build-depends:
+        base >=4.10 && <5
+      , bytestring
+      , cryptonite
+      , hex-text
+      , memory
+      , stripe-concepts
+      , text
+
+test-suite test
+    default-language: Haskell2010
+    hs-source-dirs: test
+    type: exitcode-stdio-1.0
+    main-is: test.hs
+    ghc-options: -threaded
+
+    build-depends:
+        base >=4.10 && <5
+      , bytestring
+      , stripe-signature
+      , text
diff --git a/test/test.hs b/test/test.hs
new file mode 100644
--- /dev/null
+++ b/test/test.hs
@@ -0,0 +1,40 @@
+{-# OPTIONS_GHC -Wall #-}
+
+import Stripe.Signature
+
+-- base
+import Control.Monad   (unless)
+import Data.Word       (Word8)
+import Numeric.Natural (Natural)
+import System.Exit     (die)
+
+-- bytestring
+import qualified Data.ByteString
+
+-- text
+import qualified Data.Text
+
+test_string :: String
+test_string =
+    "t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e\
+    \77a0e56ff536d0ce8e108d8bd,v0=6ffbb59b2300aae63f27240606\
+    \9a9788598b792a944a07aba816edb039989a39"
+
+test_time :: Natural
+test_time = 1492774577
+
+test_v1Bytes :: [Word8]
+test_v1Bytes =
+    [82,87,168,105,231,236,235,237,163,42,255,166,44,220,163,250
+    ,81,202,215,231,122,14,86,255,83,109,12,232,225,8,216,189]
+
+main :: IO ()
+main =
+    case parseSig (Data.Text.pack test_string) of
+        Nothing -> die "Signature parsing failed"
+        Just sig ->
+          do
+            unless (sigTime sig == test_time) $
+                die ("sigTime: " ++ show (sigTime sig))
+            unless ((Data.ByteString.unpack <$> sigV1 sig) == [test_v1Bytes]) $
+                die ("sigV1: " ++ show (Data.ByteString.unpack <$> sigV1 sig))
