diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017 SEEK
+
+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,55 @@
+# Serverless Haskell
+
+Deploying Haskell code onto [AWS Lambda] using [Serverless].
+
+## Usage
+
+* Initialise a Serverless project in the same directory as your Stack-enabled
+  package.
+
+* Install `serverless-haskell` plugin (_Warning_: not uploaded to NPM registry
+  yet, install manually by cloning this repository and specifying its
+  `serverless-plugin` directory to `npm install`).
+
+* Add the following to `serverless.yml`:
+
+  ```yaml
+  provider:
+    name: aws
+    runtime: nodejs6.10
+
+  functions:
+    myfunc:
+      handler: mypackage.myfunc
+      # Here, mypackage is the Haskell package name and myfunc is the executable
+      # name as defined in the Cabal file
+
+  plugins:
+    - serverless-haskell
+  ```
+
+* Write your `main` function:
+
+  ```haskell
+  import qualified Data.Aeson as Aeson
+
+  import AWSLambda
+
+  main = lambdaMain handler
+
+  handler :: Aeson.Value -> IO [Int]
+  handler evt = do
+    putStrLn "This should go to logs"
+    print evt
+    pure [1, 2, 3]
+  ```
+
+* Use `sls deploy` to deploy the executable to AWS Lambda. **Note**: `sls deploy
+  function` is not supported.
+
+See
+[AWSLambda](https://github.com/seek-oss/serverless-haskell/blob/master/src/AWSLambda.hs)
+for the documentation.
+
+[AWS Lambda]: https://aws.amazon.com/lambda/
+[Serverless]: https://serverless.com/framework/
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/serverless-haskell.cabal b/serverless-haskell.cabal
new file mode 100644
--- /dev/null
+++ b/serverless-haskell.cabal
@@ -0,0 +1,86 @@
+-- This file has been generated from package.yaml by hpack version 0.21.2.
+--
+-- see: https://github.com/sol/hpack
+--
+-- hash: ada4461bd3cea5ba304057b51d6d137378385405f12344e8d4c77aebd7cb26f9
+
+name:           serverless-haskell
+version:        0.0.0
+synopsis:       Deploying Haskell code onto AWS Lambda using Serverless
+description:    Utilities to help process the events from AWS Lambda when deployed with the serverless-haskell plugin.
+category:       AWS, Cloud, Network
+homepage:       https://github.com/seek-oss/serverless-haskell#readme
+bug-reports:    https://github.com/seek-oss/serverless-haskell/issues
+maintainer:     akotlyarov@seek.com.au
+license:        MIT
+license-file:   LICENSE
+build-type:     Simple
+cabal-version:  >= 1.10
+
+extra-source-files:
+    README.md
+
+source-repository head
+  type: git
+  location: https://github.com/seek-oss/serverless-haskell
+
+library
+  exposed-modules:
+      AWSLambda
+      AWSLambda.Events
+      AWSLambda.Events.KinesisEvent
+      AWSLambda.Events.Records
+      AWSLambda.Events.S3Event
+      AWSLambda.Events.SNSEvent
+      AWSLambda.Handler
+      AWSLambda.Orphans
+      Data.Aeson.Alternative
+  other-modules:
+      Paths_serverless_haskell
+  hs-source-dirs:
+      src
+  ghc-options: -Wall
+  build-depends:
+      aeson
+    , aeson-casing
+    , amazonka-core
+    , amazonka-kinesis
+    , amazonka-s3
+    , base >=4.7 && <5
+    , bytestring
+    , lens
+    , text
+    , time
+    , unix
+    , unordered-containers
+  default-language: Haskell2010
+
+test-suite tests
+  type: exitcode-stdio-1.0
+  main-is: Spec.hs
+  other-modules:
+      AWSLambda.Events.KinesisEventSpec
+      AWSLambda.Events.S3EventSpec
+      AWSLambda.Events.SNSEventSpec
+      Data.Aeson.AlternativeSpec
+      Paths_serverless_haskell
+  hs-source-dirs:
+      test
+  build-depends:
+      aeson
+    , aeson-casing
+    , aeson-qq
+    , amazonka-core
+    , amazonka-kinesis
+    , amazonka-s3
+    , base >=4.7 && <5
+    , bytestring
+    , hspec
+    , hspec-discover
+    , lens
+    , serverless-haskell
+    , text
+    , time
+    , unix
+    , unordered-containers
+  default-language: Haskell2010
diff --git a/src/AWSLambda.hs b/src/AWSLambda.hs
new file mode 100644
--- /dev/null
+++ b/src/AWSLambda.hs
@@ -0,0 +1,56 @@
+{-|
+Module      : AWSLambda.Handler
+Stability   : experimental
+Portability : POSIX
+
+Tools for running Haskell on AWS Lambda.
+
+= Usage
+
+To deploy a Haskell function on AWS Lambda:
+
+* Initialise a Serverless project in the same directory as your Stack-enabled
+  package.
+
+* Install @serverless-haskell@ plugin (/Warning/: not uploaded to NPM registry
+  yet, install manually by cloning this repository and specifying its
+  @serverless-plugin@ directory to @npm install@).
+
+* Add the following to @serverless.yml@:
+
+  > provider:
+  >   name: aws
+  >   runtime: nodejs6.10
+  >
+  > functions:
+  >   myfunc:
+  >     handler: mypackage.myfunc
+  >     # Here, mypackage is the Haskell package name and myfunc is the executable
+  >     # name as defined in the Cabal file
+  >
+  > plugins:
+  >   - serverless-haskell
+
+* Write your @main@ function using 'AWSLambda.lambdaMain'.
+
+* Use @sls deploy@ to deploy the executable to AWS Lambda. __Note__: @sls deploy
+  function@ is not supported.
+
+= Additional features
+
+To add flags to @stack build@, add the following key to @serverless.yml@:
+
+> custom:
+>   haskell:
+>     stackBuildArgs:
+>       - --pedantic
+>       - --allow-different-user
+-}
+module AWSLambda
+  ( Handler.lambdaMain
+  , module AWSLambda.Events
+  ) where
+
+import qualified AWSLambda.Handler as Handler
+
+import AWSLambda.Events
diff --git a/src/AWSLambda/Events.hs b/src/AWSLambda/Events.hs
new file mode 100644
--- /dev/null
+++ b/src/AWSLambda/Events.hs
@@ -0,0 +1,67 @@
+{-# LANGUAGE TemplateHaskell #-}
+
+module AWSLambda.Events where
+
+import           Control.Applicative           ((<|>))
+import           Control.Lens.TH
+import           Data.Aeson                    (FromJSON (..), Value)
+
+import           AWSLambda.Events.KinesisEvent
+import           AWSLambda.Events.S3Event
+import           AWSLambda.Events.SNSEvent
+
+data DynamoDBEvent
+
+data SESEvent
+
+data CognitoEvent
+
+data CloudFormationEvent
+
+data CloudWatchLogsEvent
+
+data CloudWatchEventsEvent
+
+data CodeCommitEvent
+
+data ConfigEvent
+
+data AlexaEvent
+
+data LexEvent
+
+data APIGatewayEvent
+
+data IoTButtonEvent
+
+data CloudFrontEvent
+
+data FirehoseEvent
+
+data InvokeEvent
+
+data LambdaEvent
+  = S3 !S3Event
+  | DynamoDB !DynamoDBEvent
+  | KinesisStream !KinesisEvent
+  | SNS !SNSEvent
+  | SES !SESEvent
+  | Cognito !CognitoEvent
+  | CloudFormation !CloudFormationEvent
+  | CloudWatchLogs !CloudWatchLogsEvent
+  | CloudWatchEvents !CloudWatchEventsEvent
+  | CodeCommit !CodeCommitEvent
+  | Config !ConfigEvent
+  | Alexa !AlexaEvent
+  | Lex !LexEvent
+  | APIGateway !APIGatewayEvent
+  | IoTButton !IoTButtonEvent
+  | CloudFront !CloudFrontEvent
+  | Firehose !FirehoseEvent
+  | Invoke !InvokeEvent
+  | Custom !Value
+
+instance FromJSON LambdaEvent where
+  parseJSON v = S3 <$> parseJSON v <|> pure (Custom v)
+
+$(makePrisms ''LambdaEvent)
diff --git a/src/AWSLambda/Events/KinesisEvent.hs b/src/AWSLambda/Events/KinesisEvent.hs
new file mode 100644
--- /dev/null
+++ b/src/AWSLambda/Events/KinesisEvent.hs
@@ -0,0 +1,54 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards   #-}
+{-# LANGUAGE TemplateHaskell   #-}
+
+{-|
+Module: AWSLambda.Events.KinesisEvent
+Description: Types for Kinesis Lambda events
+
+Based on https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.KinesisEvents
+-}
+module AWSLambda.Events.KinesisEvent where
+
+import           Control.Lens.TH
+import           Data.Aeson                (FromJSON (..), withObject, (.:))
+import           Data.Aeson.Casing         (aesonDrop, camelCase)
+import           Data.Aeson.TH             (deriveFromJSON)
+import           Data.Text                 (Text)
+import           Network.AWS.Data.Base64   (Base64 (..))
+import qualified Network.AWS.Kinesis.Types as Kinesis
+import qualified Network.AWS.Types         as AWS
+
+import           AWSLambda.Events.Records
+
+data KinesisRecord = KinesisRecord
+  { _krRecord               :: !Kinesis.Record
+  , _krKinesisSchemaVersion :: !Text
+  } deriving (Eq, Show)
+
+instance FromJSON KinesisRecord where
+  parseJSON =
+    withObject "KinesisRecord" $
+    \o -> do
+      _krKinesisSchemaVersion <- o .: "kinesisSchemaVersion"
+      dataBase64 <- o .: "data"
+      _krRecord <-
+        Kinesis.record <$> (o .: "sequenceNumber") <*> pure (unBase64 dataBase64) <*>
+        (o .: "partitionKey")
+      return KinesisRecord {..}
+$(makeLenses ''KinesisRecord)
+
+data KinesisEventRecord = KinesisEventRecord
+  { _kerKinesis           :: !KinesisRecord
+  , _kerEventSource       :: !Text
+  , _kerEventID           :: !Text
+  , _kerInvokeIdentityArn :: !Text
+  , _kerEventVersion      :: !Text
+  , _kerEventName         :: !Text
+  , _kerEventSourceARN    :: !Text
+  , _kerAwsRegion         :: !AWS.Region
+  } deriving (Eq, Show)
+$(deriveFromJSON (aesonDrop 4 camelCase) ''KinesisEventRecord)
+$(makeLenses ''KinesisEventRecord)
+
+type KinesisEvent = RecordsEvent KinesisEventRecord
diff --git a/src/AWSLambda/Events/Records.hs b/src/AWSLambda/Events/Records.hs
new file mode 100644
--- /dev/null
+++ b/src/AWSLambda/Events/Records.hs
@@ -0,0 +1,14 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TemplateHaskell   #-}
+
+module AWSLambda.Events.Records where
+
+import           Control.Lens.TH (makeLenses)
+import           Data.Aeson      (FromJSON (..), withObject, (.:))
+
+newtype RecordsEvent a = RecordsEvent { _reRecords :: [a] } deriving (Eq, Show)
+
+instance FromJSON a => FromJSON (RecordsEvent a) where
+  parseJSON = withObject "RecordsEvent" $ \o -> RecordsEvent <$> o .: "Records"
+
+$(makeLenses ''RecordsEvent)
diff --git a/src/AWSLambda/Events/S3Event.hs b/src/AWSLambda/Events/S3Event.hs
new file mode 100644
--- /dev/null
+++ b/src/AWSLambda/Events/S3Event.hs
@@ -0,0 +1,109 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards   #-}
+{-# LANGUAGE TemplateHaskell   #-}
+
+{-|
+Module: AWSLambda.Events.S3Event
+Description: Types for S3 Lambda events
+
+Based on https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.S3Events
+-}
+module AWSLambda.Events.S3Event where
+
+import           Control.Lens.TH
+import           Control.Monad            (guard)
+import           Data.Aeson               (FromJSON (..), withObject, (.:))
+import           Data.Aeson.Casing        (aesonDrop, camelCase)
+import           Data.Aeson.TH            (deriveFromJSON)
+import           Data.Text                (Text)
+import           Data.Time.Clock          (UTCTime)
+import qualified Network.AWS.S3           as S3
+import qualified Network.AWS.Types        as AWS
+
+import           AWSLambda.Events.Records
+import           AWSLambda.Orphans        ()
+
+newtype UserIdentityEntity = UserIdentityEntity
+  { _uiePrincipalId :: Text
+  } deriving (Eq, Show)
+
+$(deriveFromJSON (aesonDrop 4 camelCase) ''UserIdentityEntity)
+$(makeLenses ''UserIdentityEntity)
+
+data S3BucketEntity = S3BucketEntity
+  { _sbeArn           :: !Text
+  , _sbeName          :: !S3.BucketName
+  , _sbeOwnerIdentity :: !UserIdentityEntity
+  } deriving (Eq, Show)
+
+$(deriveFromJSON (aesonDrop 4 camelCase) ''S3BucketEntity)
+$(makeLenses ''S3BucketEntity)
+
+data S3ObjectEntity = S3ObjectEntity
+  { _soeETag      :: !(Maybe S3.ETag)
+  , _soeKey       :: !S3.ObjectKey
+  , _soeSize      :: !(Maybe Integer)
+  , _soeSequencer :: !Text
+  , _soeVersionId :: !(Maybe Text)
+  } deriving (Eq, Show)
+
+$(deriveFromJSON (aesonDrop 4 camelCase) ''S3ObjectEntity)
+$(makeLenses ''S3ObjectEntity)
+
+newtype RequestParametersEntity = RequestParametersEntity
+  { _rpeSourceIPAddress :: Text
+  } deriving (Eq, Show)
+
+$(deriveFromJSON (aesonDrop 4 camelCase) ''RequestParametersEntity)
+$(makeLenses ''RequestParametersEntity)
+
+data ResponseElementsEntity = ResponseElementsEntity
+  { _reeXAmzId2       :: !Text
+  , _reeXAmzRequestId :: !Text
+  } deriving (Eq, Show)
+
+instance FromJSON ResponseElementsEntity where
+  parseJSON =
+    withObject "ResponseElementsEntity" $
+    \o ->
+       ResponseElementsEntity <$> o .: "x-amz-id-2" <*> o .: "x-amz-request-id"
+$(makeLenses ''ResponseElementsEntity)
+
+data S3Entity = S3Entity
+  { _seBucket          :: !S3BucketEntity
+  , _seConfigurationId :: !Text
+  , _seObject          :: !S3ObjectEntity
+  , _seS3SchemaVersion :: !Text
+  } deriving (Eq, Show)
+
+$(deriveFromJSON (aesonDrop 3 camelCase) ''S3Entity)
+$(makeLenses ''S3Entity)
+
+data S3EventNotification = S3EventNotification
+  { _senAwsRegion         :: !AWS.Region
+  , _senEventName         :: !S3.Event
+  , _senEventSource       :: !Text
+  , _senEventTime         :: !UTCTime
+  , _senEventVersion      :: !Text
+  , _senRequestParameters :: !RequestParametersEntity
+  , _senResponseElements  :: !ResponseElementsEntity
+  , _senS3                :: !S3Entity
+  , _senUserIdentity      :: !UserIdentityEntity
+  } deriving (Eq, Show)
+
+instance FromJSON S3EventNotification where
+  parseJSON = withObject "S3EventNotification" $ \o -> do
+    _senEventSource <- o .: "eventSource"
+    guard $ _senEventSource == "aws:s3"
+    _senAwsRegion <- o .: "awsRegion"
+    _senEventName <- o .: "eventName"
+    _senEventTime <- o .: "eventTime"
+    _senEventVersion <- o .: "eventVersion"
+    _senRequestParameters <- o .: "requestParameters"
+    _senResponseElements <- o .: "responseElements"
+    _senS3 <- o .: "s3"
+    _senUserIdentity <- o .: "userIdentity"
+    return S3EventNotification {..}
+$(makeLenses ''S3EventNotification)
+
+type S3Event = RecordsEvent S3EventNotification
diff --git a/src/AWSLambda/Events/SNSEvent.hs b/src/AWSLambda/Events/SNSEvent.hs
new file mode 100644
--- /dev/null
+++ b/src/AWSLambda/Events/SNSEvent.hs
@@ -0,0 +1,57 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards   #-}
+{-# LANGUAGE TemplateHaskell   #-}
+
+{-|
+Module: AWSLambda.Events.SNSEvent
+Description: Types for SNS Lambda events
+
+Based on https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.SNSEvents
+-}
+module AWSLambda.Events.SNSEvent where
+
+import           Control.Lens.TH
+import           Data.Aeson.Casing        (aesonDrop, pascalCase)
+import           Data.Aeson.TH            (deriveFromJSON)
+import           Data.HashMap.Strict      (HashMap)
+import           Data.Text                (Text)
+import           Data.Time.Clock          (UTCTime)
+
+import           AWSLambda.Events.Records
+
+data MessageAttribute = MessageAttribute
+  { _maType :: !Text
+  , _maValue :: !Text
+  } deriving (Eq, Show)
+
+$(deriveFromJSON (aesonDrop 3 pascalCase) ''MessageAttribute)
+$(makeLenses ''MessageAttribute)
+
+data SNSMessage = SNSMessage
+  { _smMessage           :: !Text
+  , _smMessageAttributes :: !(HashMap Text MessageAttribute)
+  , _smMessageId         :: !Text
+  , _smSignature         :: !Text
+  , _smSignatureVersion  :: !Text
+  , _smSigningCertUrl    :: !Text
+  , _smSubject           :: !Text
+  , _smTimestamp         :: !UTCTime
+  , _smTopicArn          :: !Text
+  , _smType              :: !Text
+  , _smUnsubscribeUrl    :: !Text
+  } deriving (Eq, Show)
+
+$(deriveFromJSON (aesonDrop 3 pascalCase) ''SNSMessage)
+$(makeLenses ''SNSMessage)
+
+data SNSRecord = SNSRecord
+  { _srEventVersion         :: !Text
+  , _srEventSubscriptionArn :: !Text
+  , _srEventSource          :: !Text
+  , _srSns                  :: !SNSMessage
+  } deriving (Eq, Show)
+
+$(deriveFromJSON (aesonDrop 3 pascalCase) ''SNSRecord)
+$(makeLenses ''SNSRecord)
+
+type SNSEvent = RecordsEvent SNSRecord
diff --git a/src/AWSLambda/Handler.hs b/src/AWSLambda/Handler.hs
new file mode 100644
--- /dev/null
+++ b/src/AWSLambda/Handler.hs
@@ -0,0 +1,103 @@
+{-|
+Module      : AWSLambda.Handler
+Stability   : experimental
+Portability : POSIX
+
+Entry point for AWS Lambda handlers deployed with @serverless-haskell@ plugin.
+-}
+module AWSLambda.Handler
+  ( lambdaMain
+  ) where
+
+import Control.Exception (bracket)
+
+import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.Text as Aeson
+
+import qualified Data.ByteString as ByteString
+
+import qualified Data.Text.Lazy.IO as Text
+
+import GHC.IO.Handle (Handle, hClose)
+
+import System.Environment (lookupEnv)
+import System.IO (stdout)
+import System.Posix.IO (fdToHandle)
+import System.Posix.Types (Fd(..))
+
+-- | Process incoming events from @serverless-haskell@ using a provided
+-- function.
+--
+-- The handler receives the input event given to the AWS Lambda function, and
+-- its return value is returned from the function.
+--
+-- This is intended to be used as @main@, for example:
+--
+-- > import qualified Data.Aeson as Aeson
+-- >
+-- > import AWSLambda
+-- >
+-- > main = lambdaMain handler
+-- >
+-- > handler :: Aeson.Value -> IO [Int]
+-- > handler evt = do
+-- >   putStrLn "This should go to logs"
+-- >   print evt
+-- >   pure [1, 2, 3]
+--
+-- The handler function can receive arbitrary JSON values from custom
+-- invocations, or one of the events from the "AWSLambda.Events" module, such as
+-- 'AWSLambda.Events.S3Event':
+--
+-- > import AWSLambda.Events.S3Event
+-- >
+-- > handler :: S3Event -> IO ()
+-- > handler evt = do
+-- >   print $ records evt
+--
+-- If the Lambda function needs to process several types of events, use
+-- 'Data.Aeson.Alternative' to combine several handlers:
+--
+-- > import AWSLambda
+-- > import AWSLambda.Events.S3Event
+-- > import Data.Aeson
+-- > import Data.Aeson.Alternative
+-- >
+-- > main = lambdaMain $ handlerS3 `alternative` handlerCustom
+-- >
+-- > handlerS3 :: S3Event -> IO ()
+-- > handlerS3 = _
+-- >
+-- > handlerCustom :: Value -> IO ()
+-- > handlerCustom = _
+--
+-- When run outside the AWS Lambda environment, the input is read as JSON from
+-- the command line, and the result of the execution is printed, also as JSON,
+-- to the standard output.
+lambdaMain ::
+     (Aeson.FromJSON event, Aeson.ToJSON res)
+  => (event -> IO res) -- ^ Function to process the event
+  -> IO ()
+lambdaMain act =
+  withResultChannel $ \resultChannel -> do
+    input <- ByteString.getLine
+    case Aeson.eitherDecodeStrict input of
+      Left err -> error err
+      Right event -> do
+        result <- act event
+        Text.hPutStrLn resultChannel $ Aeson.encodeToLazyText result
+        pure ()
+
+-- | Invoke an action with the handle to write the results to. On AWS Lambda,
+-- use the channel opened by the JavaScript wrapper, otherwise use standard
+-- output.
+withResultChannel :: (Handle -> IO r) -> IO r
+withResultChannel act = do
+  lambdaFunctionName <- lookupEnv "AWS_LAMBDA_FUNCTION_NAME"
+  case lambdaFunctionName of
+    Just _ -> bracket (fdToHandle communicationFd) hClose act
+    Nothing -> act stdout
+
+-- | File descriptor opened by the JavaScript wrapper to listen for the results
+communicationFd :: Fd
+communicationFd = Fd 3
diff --git a/src/AWSLambda/Orphans.hs b/src/AWSLambda/Orphans.hs
new file mode 100644
--- /dev/null
+++ b/src/AWSLambda/Orphans.hs
@@ -0,0 +1,27 @@
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE OverloadedStrings          #-}
+{-# LANGUAGE StandaloneDeriving         #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+module AWSLambda.Orphans where
+
+import           Data.Aeson
+import           Data.Monoid           ((<>))
+import qualified Data.Text             as Text
+import           Network.AWS.Data.Text (fromText)
+import qualified Network.AWS.S3        as S3
+
+deriving instance FromJSON S3.BucketName
+
+deriving instance FromJSON S3.ObjectKey
+
+instance FromJSON S3.ETag where
+  parseJSON = withText "ETag" $ either fail return . fromText
+
+instance FromJSON S3.Event where
+  parseJSON = withText "Event" $ either fail return . fromText . addS3Prefix
+    where
+      s3Prefix = "s3:"
+      addS3Prefix s =
+        if s3Prefix `Text.isPrefixOf` s
+          then s
+          else s3Prefix <> s
diff --git a/src/Data/Aeson/Alternative.hs b/src/Data/Aeson/Alternative.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Aeson/Alternative.hs
@@ -0,0 +1,29 @@
+{-|
+Module      :  Data.Aeson.Alternative
+Stability   : experimental
+
+Utilities for decoding JSON into one of the possible types and handling the
+resulting sum type.
+-}
+module Data.Aeson.Alternative
+  ( AlternativeJSON
+  , alternative
+  ) where
+
+import Control.Applicative
+
+import Data.Aeson
+
+-- | One of the two values that has been parsed from JSON
+data AlternativeJSON a b
+  = FirstJSON a
+  | SecondJSON b
+  deriving (Eq, Ord, Show)
+
+instance (FromJSON a, FromJSON b) => FromJSON (AlternativeJSON a b) where
+  parseJSON v = FirstJSON <$> parseJSON v <|> SecondJSON <$> parseJSON v
+
+-- | Handle either of the two types that have been parsed from JSON
+alternative :: (a -> r) -> (b -> r) -> AlternativeJSON a b -> r
+alternative f _ (FirstJSON a) = f a
+alternative _ g (SecondJSON b) = g b
diff --git a/test/AWSLambda/Events/KinesisEventSpec.hs b/test/AWSLambda/Events/KinesisEventSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/AWSLambda/Events/KinesisEventSpec.hs
@@ -0,0 +1,69 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes       #-}
+
+module AWSLambda.Events.KinesisEventSpec where
+
+import           AWSLambda.Events.KinesisEvent
+import           AWSLambda.Events.Records
+
+import           Data.Aeson
+import           Data.Aeson.QQ
+import           Data.ByteString               (ByteString)
+import qualified Network.AWS.Kinesis.Types     as Kinesis
+import           Network.AWS.Types             (Region (..))
+
+import           Test.Hspec
+
+spec :: Spec
+spec =
+  describe "KinesisEvent" $
+    it "parses sample event" $
+      fromJSON sampleKinesisJSON `shouldBe` Success sampleKinesisEvent
+
+sampleKinesisJSON :: Value
+sampleKinesisJSON = [aesonQQ|
+{
+    "Records": [
+        {
+            "kinesis": {
+                "partitionKey": "partitionKey-3",
+                "kinesisSchemaVersion": "1.0",
+                "data": "SGVsbG8sIHRoaXMgaXMgYSB0ZXN0IDEyMy4=",
+                "sequenceNumber": "49545115243490985018280067714973144582180062593244200961"
+            },
+            "eventSource": "aws:kinesis",
+            "eventID": "shardId-000000000000:49545115243490985018280067714973144582180062593244200961",
+            "invokeIdentityArn": "arn:aws:iam::account-id:role/testLEBRole",
+            "eventVersion": "1.0",
+            "eventName": "aws:kinesis:record",
+            "eventSourceARN": "arn:aws:kinesis:us-west-2:35667example:stream/examplestream",
+            "awsRegion": "us-west-2"
+        }
+    ]
+}
+|]
+
+sampleKinesisEvent :: KinesisEvent
+sampleKinesisEvent =
+  RecordsEvent
+    [ KinesisEventRecord
+      { _kerKinesis =
+        KinesisRecord
+        { _krRecord =
+          Kinesis.record
+            "49545115243490985018280067714973144582180062593244200961"
+            "Hello, this is a test 123."
+            "partitionKey-3"
+        , _krKinesisSchemaVersion = "1.0"
+        }
+      , _kerEventSource = "aws:kinesis"
+      , _kerEventID =
+        "shardId-000000000000:49545115243490985018280067714973144582180062593244200961"
+      , _kerInvokeIdentityArn = "arn:aws:iam::account-id:role/testLEBRole"
+      , _kerEventVersion = "1.0"
+      , _kerEventName = "aws:kinesis:record"
+      , _kerEventSourceARN =
+        "arn:aws:kinesis:us-west-2:35667example:stream/examplestream"
+      , _kerAwsRegion = Oregon
+      }
+    ]
diff --git a/test/AWSLambda/Events/S3EventSpec.hs b/test/AWSLambda/Events/S3EventSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/AWSLambda/Events/S3EventSpec.hs
@@ -0,0 +1,207 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes       #-}
+
+module AWSLambda.Events.S3EventSpec where
+
+import           AWSLambda.Events.Records
+import           AWSLambda.Events.S3Event
+
+import           Data.Aeson
+import           Data.Aeson.QQ
+import           Data.ByteString           (ByteString)
+import qualified Data.HashMap.Strict       as HashMap
+import           Data.Time.Calendar
+import           Data.Time.Clock
+import           Network.AWS.S3
+
+import           Test.Hspec
+
+spec :: Spec
+spec =
+  describe "S3Event" $ do
+    it "parses sample Put event" $
+      fromJSON sampleS3PutJSON `shouldBe` Success sampleS3PutEvent
+    it "parses sample Delete event" $
+      fromJSON sampleS3DeleteJSON `shouldBe` Success sampleS3DeleteEvent
+
+sampleS3PutJSON :: Value
+sampleS3PutJSON = [aesonQQ|
+{
+  "Records": [
+    {
+      "eventVersion": "2.0",
+      "eventTime": "1970-01-01T00:00:00.000Z",
+      "requestParameters": {
+        "sourceIPAddress": "127.0.0.1"
+      },
+      "s3": {
+        "configurationId": "testConfigRule",
+        "object": {
+          "eTag": "0123456789abcdef0123456789abcdef",
+          "sequencer": "0A1B2C3D4E5F678901",
+          "key": "HappyFace.jpg",
+          "size": 1024
+        },
+        "bucket": {
+          "arn": "bucketarn",
+          "name": "sourcebucket",
+          "ownerIdentity": {
+            "principalId": "EXAMPLE"
+          }
+        },
+        "s3SchemaVersion": "1.0"
+      },
+      "responseElements": {
+        "x-amz-id-2": "EXAMPLE123/5678abcdefghijklambdaisawesome/mnopqrstuvwxyzABCDEFGH",
+        "x-amz-request-id": "EXAMPLE123456789"
+      },
+      "awsRegion": "us-east-1",
+      "eventName": "ObjectCreated:Put",
+      "userIdentity": {
+        "principalId": "EXAMPLE"
+      },
+      "eventSource": "aws:s3"
+    }
+  ]
+}
+|]
+
+sampleS3PutEvent :: S3Event
+sampleS3PutEvent =
+  RecordsEvent
+  { _reRecords =
+    [ S3EventNotification
+      { _senAwsRegion = NorthVirginia
+      , _senEventName = S3ObjectCreatedPut
+      , _senEventSource = "aws:s3"
+      , _senEventTime = UTCTime (fromGregorian 1970 1 1) (secondsToDiffTime 0)
+      , _senEventVersion = "2.0"
+      , _senRequestParameters =
+        RequestParametersEntity
+        { _rpeSourceIPAddress = "127.0.0.1"
+        }
+      , _senResponseElements =
+        ResponseElementsEntity
+        { _reeXAmzId2 =
+          "EXAMPLE123/5678abcdefghijklambdaisawesome/mnopqrstuvwxyzABCDEFGH"
+        , _reeXAmzRequestId = "EXAMPLE123456789"
+        }
+      , _senS3 =
+        S3Entity
+        { _seBucket =
+          S3BucketEntity
+          { _sbeArn = "bucketarn"
+          , _sbeName = BucketName "sourcebucket"
+          , _sbeOwnerIdentity =
+            UserIdentityEntity
+            { _uiePrincipalId = "EXAMPLE"
+            }
+          }
+        , _seConfigurationId = "testConfigRule"
+        , _seObject =
+          S3ObjectEntity
+          { _soeETag = Just (ETag "0123456789abcdef0123456789abcdef")
+          , _soeKey = ObjectKey "HappyFace.jpg"
+          , _soeSize = Just 1024
+          , _soeSequencer = "0A1B2C3D4E5F678901"
+          , _soeVersionId = Nothing
+          }
+        , _seS3SchemaVersion = "1.0"
+        }
+      , _senUserIdentity =
+        UserIdentityEntity
+        { _uiePrincipalId = "EXAMPLE"
+        }
+      }
+    ]
+  }
+
+sampleS3DeleteJSON :: Value
+sampleS3DeleteJSON = [aesonQQ|
+  {
+  "Records": [
+    {
+      "eventVersion": "2.0",
+      "eventTime": "1970-01-01T00:00:00.000Z",
+      "requestParameters": {
+        "sourceIPAddress": "127.0.0.1"
+      },
+      "s3": {
+        "configurationId": "testConfigRule",
+        "object": {
+          "sequencer": "0A1B2C3D4E5F678901",
+          "key": "HappyFace.jpg"
+        },
+        "bucket": {
+          "arn": "bucketarn",
+          "name": "sourcebucket",
+          "ownerIdentity": {
+            "principalId": "EXAMPLE"
+          }
+        },
+        "s3SchemaVersion": "1.0"
+      },
+      "responseElements": {
+        "x-amz-id-2": "EXAMPLE123/5678abcdefghijklambdaisawesome/mnopqrstuvwxyzABCDEFGH",
+        "x-amz-request-id": "EXAMPLE123456789"
+      },
+      "awsRegion": "us-east-1",
+      "eventName": "ObjectRemoved:Delete",
+      "userIdentity": {
+        "principalId": "EXAMPLE"
+      },
+      "eventSource": "aws:s3"
+    }
+  ]
+}
+|]
+
+sampleS3DeleteEvent :: S3Event
+sampleS3DeleteEvent =
+  RecordsEvent
+  { _reRecords =
+    [ S3EventNotification
+      { _senAwsRegion = NorthVirginia
+      , _senEventName = S3ObjectRemovedDelete
+      , _senEventSource = "aws:s3"
+      , _senEventTime = UTCTime (fromGregorian 1970 1 1) (secondsToDiffTime 0)
+      , _senEventVersion = "2.0"
+      , _senRequestParameters =
+        RequestParametersEntity
+        { _rpeSourceIPAddress = "127.0.0.1"
+        }
+      , _senResponseElements =
+        ResponseElementsEntity
+        { _reeXAmzId2 =
+          "EXAMPLE123/5678abcdefghijklambdaisawesome/mnopqrstuvwxyzABCDEFGH"
+        , _reeXAmzRequestId = "EXAMPLE123456789"
+        }
+      , _senS3 =
+        S3Entity
+        { _seBucket =
+          S3BucketEntity
+          { _sbeArn = "bucketarn"
+          , _sbeName = BucketName "sourcebucket"
+          , _sbeOwnerIdentity =
+            UserIdentityEntity
+            { _uiePrincipalId = "EXAMPLE"
+            }
+          }
+        , _seConfigurationId = "testConfigRule"
+        , _seObject =
+          S3ObjectEntity
+          { _soeETag = Nothing
+          , _soeKey = ObjectKey "HappyFace.jpg"
+          , _soeSize = Nothing
+          , _soeSequencer = "0A1B2C3D4E5F678901"
+          , _soeVersionId = Nothing
+          }
+        , _seS3SchemaVersion = "1.0"
+        }
+      , _senUserIdentity =
+        UserIdentityEntity
+        { _uiePrincipalId = "EXAMPLE"
+        }
+      }
+    ]
+  }
diff --git a/test/AWSLambda/Events/SNSEventSpec.hs b/test/AWSLambda/Events/SNSEventSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/AWSLambda/Events/SNSEventSpec.hs
@@ -0,0 +1,94 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes       #-}
+
+module AWSLambda.Events.SNSEventSpec where
+
+import           AWSLambda.Events.Records
+import           AWSLambda.Events.SNSEvent
+
+import           Data.Aeson
+import           Data.Aeson.QQ
+import           Data.ByteString           (ByteString)
+import qualified Data.HashMap.Strict       as HashMap
+import           Data.Time.Clock
+import           Data.Time.Calendar
+import           Network.AWS.Types         (Region (..))
+
+import           Test.Hspec
+
+spec :: Spec
+spec =
+  describe "SNSEvent" $
+    it "parses sample event" $
+      fromJSON sampleSNSJSON `shouldBe` Success sampleSNSEvent
+
+sampleSNSJSON :: Value
+sampleSNSJSON = [aesonQQ|
+{
+  "Records": [
+    {
+      "EventVersion": "1.0",
+      "EventSubscriptionArn": "eventsubscriptionarn",
+      "EventSource": "aws:sns",
+      "Sns": {
+        "SignatureVersion": "1",
+        "Timestamp": "1970-01-01T00:00:00.000Z",
+        "Signature": "EXAMPLE",
+        "SigningCertUrl": "EXAMPLE",
+        "MessageId": "95df01b4-ee98-5cb9-9903-4c221d41eb5e",
+        "Message": "Hello from SNS!",
+        "MessageAttributes": {
+          "Test": {
+            "Type": "String",
+            "Value": "TestString"
+          },
+          "TestBinary": {
+            "Type": "Binary",
+            "Value": "TestBinary"
+          }
+        },
+        "Type": "Notification",
+        "UnsubscribeUrl": "EXAMPLE",
+        "TopicArn": "topicarn",
+        "Subject": "TestInvoke"
+      }
+    }
+  ]
+}
+|]
+
+sampleSNSEvent :: SNSEvent
+sampleSNSEvent =
+  RecordsEvent
+    [ SNSRecord
+      { _srEventVersion = "1.0"
+      , _srEventSubscriptionArn = "eventsubscriptionarn"
+      , _srEventSource = "aws:sns"
+      , _srSns =
+        SNSMessage
+        { _smMessage = "Hello from SNS!"
+        , _smMessageAttributes =
+          HashMap.fromList
+            [ ( "Test"
+              , MessageAttribute
+                { _maType = "String"
+                , _maValue = "TestString"
+                })
+            , ( "TestBinary"
+              , MessageAttribute
+                { _maType = "Binary"
+                , _maValue = "TestBinary"
+                })
+            ]
+        , _smMessageId = "95df01b4-ee98-5cb9-9903-4c221d41eb5e"
+        , _smSignature = "EXAMPLE"
+        , _smSignatureVersion = "1"
+        , _smSigningCertUrl = "EXAMPLE"
+        , _smSubject = "TestInvoke"
+        , _smTimestamp = UTCTime (fromGregorian 1970 1 1) (secondsToDiffTime 0)
+        , _smTopicArn = "topicarn"
+        , _smType = "Notification"
+        , _smUnsubscribeUrl = "EXAMPLE"
+        }
+      }
+    ]
diff --git a/test/Data/Aeson/AlternativeSpec.hs b/test/Data/Aeson/AlternativeSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Data/Aeson/AlternativeSpec.hs
@@ -0,0 +1,23 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Data.Aeson.AlternativeSpec where
+
+import Data.Aeson
+import Data.Aeson.Alternative
+
+import Test.Hspec
+
+spec :: Spec
+spec =
+  describe "AlternativeJSON" $ do
+    let handler1 :: [Int] -> Int
+        handler1 = sum
+    let handler2 :: Bool -> Int
+        handler2 = fromEnum
+    let handler = handler1 `alternative` handler2
+    it "parses the first alternative" $
+      (handler <$> decode "[1, 2, 3]") `shouldBe` (Just 6)
+    it "parses the second alternative" $
+      (handler <$> decode "true") `shouldBe` (Just 1)
+    it "fails to parse anything else" $
+      (handler <$> decode "{}") `shouldBe` Nothing
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,1 @@
+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
