diff --git a/src/Vervis/Handler/Inbox.hs b/src/Vervis/Handler/Inbox.hs
new file mode 100644
index 0000000..e07ed54
--- /dev/null
+++ b/src/Vervis/Handler/Inbox.hs
@@ -0,0 +1,234 @@
+{- This file is part of Vervis.
+ -
+ - Written in 2019 by fr33domlover <fr33domlover@riseup.net>.
+ -
+ - ♡ Copying is an act of love. Please copy, reuse and share.
+ -
+ - The author(s) have dedicated all copyright and related and neighboring
+ - rights to this software to the public domain worldwide. This software is
+ - distributed without any warranty.
+ -
+ - You should have received a copy of the CC0 Public Domain Dedication along
+ - with this software. If not, see
+ - <http://creativecommons.org/publicdomain/zero/1.0/>.
+ -}
+
+module Vervis.Handler.Inbox
+    ( getInboxR
+    , postInboxR
+    )
+where
+
+import Prelude
+
+import Control.Applicative ((<|>))
+import Control.Concurrent.STM.TVar (readTVarIO, modifyTVar')
+import Control.Exception (displayException)
+import Control.Monad.IO.Class (liftIO)
+import Control.Monad.STM (atomically)
+import Control.Monad.Trans.Except (ExceptT (ExceptT), runExceptT)
+import Crypto.Error (CryptoFailable (..))
+import Crypto.PubKey.Ed25519 (publicKey, signature, verify)
+import Data.Aeson (Value (String, Object))
+import Data.Aeson.Encode.Pretty (encodePretty)
+import Data.Bifunctor (first, second)
+import Data.HashMap.Strict (HashMap)
+import Data.PEM (pemContent)
+import Data.Text (Text)
+import Data.Text.Lazy.Encoding (decodeUtf8)
+import Data.Time.Clock (UTCTime, getCurrentTime)
+import Data.Time.Interval (TimeInterval, toTimeUnit)
+import Data.Time.Units (Second)
+import Network.HTTP.Client (Manager, HttpException, requestFromURI)
+import Network.HTTP.Simple (httpJSONEither, getResponseBody)
+import Network.URI (URI (uriFragment), parseURI)
+import Text.Blaze.Html (Html)
+import UnliftIO.Exception (try)
+import Yesod.Core (ContentType, defaultLayout, whamlet)
+import Yesod.Core.Json (requireJsonBody)
+import Yesod.Core.Handler
+
+import qualified Data.ByteString.Char8 as BC (unpack)
+import qualified Data.CaseInsensitive as CI (mk)
+import qualified Data.HashMap.Strict as M (lookup)
+import qualified Data.Text as T (unpack)
+import qualified Data.Vector as V (length, cons, init)
+import qualified Network.Wai as W (requestMethod, rawPathInfo, requestHeaders)
+
+import Network.HTTP.Signature hiding (Algorithm (..))
+
+import qualified Network.HTTP.Signature as S (Algorithm (..))
+
+import Vervis.ActivityPub
+import Vervis.Foundation (App (..), Handler)
+import Vervis.Settings (AppSettings (appHttpSigTimeLimit))
+
+getInboxR :: Handler Html
+getInboxR = do
+    acts <- liftIO . readTVarIO =<< getsYesod appActivities
+    defaultLayout
+        [whamlet|
+            <p>
+              Welcome to the ActivityPub inbox test page! It's the beginning of
+              federation support in Vervis. Currently POSTing activities
+              doesn't do anything, they're just verified and the results are
+              displayed on this page. Here's how to POST an activity
+              successfully:
+            <p>
+              (NOTE: Currently only Ed25519 signatures are supported, which is
+              incompatible with the default RSA-SHA256 used on the Fediverse)
+            <ol>
+              <li>
+                Publish an actor JSON document. That's like a regular
+                ActivityPub actor, except its <var>publicKey</var> object
+                should have one extra field named
+                <code>https://forgefed.angeley.es/ns#algorithm</code> and its
+                value should be
+                <code>https://forgefed.angeley.es/ns#ed25519</code>. The actual
+                key PEM should indeed be an Ed25519 public key, rather than
+                RSA.
+              <li>
+                Prepare an activity JSON document.
+              <li>
+                POST it to this page's URL, with an HTTP signature in a
+                Signature header, and use at least the headers Host, Date and
+                (request-target).
+            <p>
+              I'm aware these instructions aren't exactly clear and
+              self-contained. Soon I'll either clarify them or further
+              development will make things easier. In particular, by using one
+              Vervis instance to POST an activity to another Vervis instance.
+            <p>Last 10 activities posted:
+            <ul>
+              $forall (time, result) <- acts
+                <li>
+                  <div>#{show time}
+                  $case result
+                    $of Left e
+                      <div>#{e}
+                    $of Right (ct, o)
+                      <div><code>#{BC.unpack ct}
+                      <div><pre>#{decodeUtf8 o}
+        |]
+
+postInboxR :: Handler ()
+postInboxR = do
+    now <- liftIO getCurrentTime
+    r <- runExceptT $ getActivity now
+    let item = (now, second (second encodePretty) r)
+    acts <- getsYesod appActivities
+    liftIO $ atomically $ modifyTVar' acts $ \ vec ->
+        let vec' = item `V.cons` vec
+        in  if V.length vec' > 10
+                then V.init vec'
+                else vec'
+    case r of
+        Right _ -> return ()
+        Left _ -> notAuthenticated
+    where
+    liftE = ExceptT . pure
+    verifyActivity :: UTCTime -> ExceptT String Handler URI
+    verifyActivity now = do
+        site <- getYesod
+        wr <- waiRequest
+        let request = Request
+                { requestMethod  = CI.mk $ W.requestMethod wr
+                , requestPath    = W.rawPathInfo wr
+                , requestHeaders = W.requestHeaders wr
+                }
+            toSeconds :: TimeInterval -> Second
+            toSeconds = toTimeUnit
+        (malgo, KeyId keyid, input, Signature sig) <-
+            liftE $
+            first show $
+            prepareToVerify
+                [HeaderTarget, HeaderName "Host"]
+                (fromIntegral . toSeconds . appHttpSigTimeLimit . appSettings $ site)
+                now
+                request
+        liftE $ case malgo of
+            Nothing -> Right ()
+            Just algo ->
+                case algo of
+                    S.AlgorithmEd25519 -> Right ()
+                    S.AlgorithmOther _ -> Left "Unsupported algo in Sig header"
+        u <- liftE $ case parseURI $ BC.unpack keyid of
+            Nothing  -> Left "keyId in Sig header isn't a valid absolute URI"
+            Just uri -> Right uri
+        response <- ExceptT $ first (displayException :: HttpException -> String) <$> (try $ httpJSONEither =<< requestFromURI u)
+        liftE $ do
+            actor <- first displayException $ getResponseBody response
+            let uActor = u { uriFragment = "" }
+            if uActor == actorId actor
+                then Right ()
+                else Left "Actor ID doesn't match the keyid URI we fetched"
+            let pkey = actorPublicKey actor
+            if publicKeyId pkey == u
+                then Right ()
+                else Left "Actor's publicKey's ID doesn't match the keyid URI"
+            if publicKeyOwner pkey == actorId actor
+                then Right ()
+                else Left "Actor's publicKey's owner doesn't match the actor's ID"
+            case publicKeyAlgo pkey of
+                Nothing ->
+                    Left $
+                    case malgo of
+                        Nothing -> "Algo not given in Sig nor actor"
+                        Just _  -> "Algo mismatch, Ed25519 in Sig but none in actor"
+                Just algo ->
+                    case algo of
+                        AlgorithmEd25519 -> Right ()
+                        AlgorithmOther _ ->
+                            Left $
+                            case malgo of
+                                Nothing -> "No algo in Sig, unsupported algo in actor"
+                                Just _  -> "Algo mismatch, Ed25519 in Sig but unsupported algo in actor"
+            key <- case publicKey $ pemContent $ publicKeyPem pkey of
+                CryptoPassed k -> Right k
+                CryptoFailed e -> Left "Parsing Ed25519 public key failed"
+            signature <- case signature sig of
+                CryptoPassed s -> Right s
+                CryptoFailed e -> Left "Parsing Ed25519 signature failed"
+            if verify key input signature
+                then Right uActor
+                else Left "Ed25519 sig verification says not valid"
+    getActivity :: UTCTime -> ExceptT String Handler (ContentType, HashMap Text Value)
+    getActivity now = do
+        contentType <- do
+            ctypes <- lookupHeaders "Content-Type"
+            liftE $ case ctypes of
+                [] -> Left "Content-Type not specified"
+                [x] -> case x of
+                    "application/activity+json" -> Right x
+                    "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" -> Right x
+                    _ -> Left "Unknown Content-Type"
+                _ -> Left "More than one Content-Type given"
+        uActor <- verifyActivity now
+        o <- requireJsonBody
+        activityActor <-
+            liftE $
+            case M.lookup "actor" o of
+                Nothing -> Left "Activity has no actor member"
+                Just v -> case v of
+                    String t -> case parseURI $ T.unpack t of
+                        Nothing -> Left "Activity actor URI parsing failed"
+                        Just uri -> Right uri
+                    _ -> Left "Activity actor isn't a JSON string"
+        liftE $ if activityActor == uActor
+            then Right ()
+            else Left "Activity's actor != Signature key's actor"
+        liftE $ case M.lookup "object" o of
+            Nothing -> Right ()
+            Just v -> case v of
+                Object obj -> case M.lookup "actor" obj <|> M.lookup "attributedTo" obj of
+                    Nothing -> Right ()
+                    Just v' -> case v' of
+                        String t -> case parseURI $ T.unpack t of
+                            Nothing -> Left "Activity actor URI parsing failed"
+                            Just uri ->
+                                if uri == uActor
+                                    then Right ()
+                                    else Left "Activity object's actor doesn't match activity's actor"
+                        _ -> Left "Activity actor isn't a JSON string"
+                _ -> Left "Activity's object isn't a JSON object"
+        return (contentType, o)