diff --git a/cli/Main.hs b/cli/Main.hs
--- a/cli/Main.hs
+++ b/cli/Main.hs
@@ -72,9 +72,5 @@
     cnt    <- getContent (args `O.isPresent` O.longOption "file") text
     case getExpiration expire of
       Nothing -> die "invalid value for expiration"
-      Just e  -> do
-        rc <- share bin e cnt
-        case rc of
-          Left err -> die err
-          Right uri -> putStrLn uri
+      Just e  -> share bin e cnt >>= putStrLn
 
diff --git a/src/Web/ZeroBin.hs b/src/Web/ZeroBin.hs
--- a/src/Web/ZeroBin.hs
+++ b/src/Web/ZeroBin.hs
@@ -1,13 +1,29 @@
+{-|
+High-level functions for posting to 0bin services like
+<http://0bin.net> or <http://paste.ec>.
+
+ >>> import Web.ZeroBin
+ >>> import Data.ByteString.Char8
+ >>> share "http://0bin.net" Day (pack "hello")
+"http://0bin.net/paste/ZH6VyKXjDHAiPT8J#C6LLidGyHO7xt3xuDtsNHjZ77luualukEuJ25S6w/K1m"
+
+-}
+
+{-# LANGUAGE DeriveDataTypeable #-}
 {-# LANGUAGE DeriveGeneric #-}
 
 module Web.ZeroBin (
   Expiration(..),
+  ZeroBinError(..),
   share
 ) where
 
+import Control.Exception (Exception)
+import Control.Exception.Base (throwIO)
 import Data.ByteString (ByteString)
 import Data.ByteString.Base64 (encode)
 import Data.Maybe (fromJust)
+import Data.Typeable (Typeable)
 import GHC.Generics (Generic)
 import Web.ZeroBin.SJCL (encrypt, Content)
 import Web.ZeroBin.Utils (makePassword)
@@ -23,12 +39,22 @@
   } deriving (Generic, Show)
 instance JSON.FromJSON Response
 
+-- | 0bin error message
+data ZeroBinError = ZeroBinError String
+  deriving (Show, Typeable)
+instance Exception ZeroBinError
+
+-- | Expiration of a paste.
+--   "Burn after reading" really means "burn after two readings",
+--   because we do not redirect to the paste like a browser does.
+--   You can verify your paste before sharing the link.
+--   Original <http://0bin.net> does not support 'Week'.
 data Expiration
-  = Once 
-  | Day
-  | Week
-  | Month
-  | Never
+  = Once      -- ^ burn after reading
+  | Day       -- ^ keep for 24 hours
+  | Week      -- ^ for 7 days
+  | Month     -- ^ for 30 days
+  | Never     -- ^ for 100 years
 
 form :: Expiration -> String
 form Once  = "burn_after_reading"
@@ -37,7 +63,7 @@
 form Month = "1_month"
 form Never = "never"
 
-post :: String -> Expiration -> Content -> IO (Either String String)
+post :: String -> Expiration -> Content -> IO String
 post bin ex ct = do
   req' <- HTTP.parseUrl $ bin ++ "/paste/create"
   let req = HTTP.urlEncodedBody
@@ -48,17 +74,20 @@
   response <- HTTP.httpLbs req manager
   let resp = fromJust . JSON.decode $ HTTP.responseBody response
   case status resp of
-    "ok" -> return . Right $
-            bin ++ "/paste/" ++ (fromJust . paste) resp
-    _    -> return . Left $
-            (fromJust . message) resp
+    "ok" -> return $ bin ++ "/paste/" ++ (fromJust . paste) resp
+    _    -> throwIO . ZeroBinError $ (fromJust . message) resp
 
-share :: String -> Expiration -> ByteString -> IO (Either String String)
+
+-- | Encrypts the plain data with a random password,
+--   post to 0bin and return the URI of a new paste.
+--   Can throw 'ZeroBinError' or 'Network.HTTP.Conduit.HttpException'.
+share :: String      -- ^ the address of 0bin, e. g. <http://0bin.net> or <https://paste.ec>
+      -> Expiration
+      -> ByteString  -- ^ the plain data to encrypt and paste
+      -> IO String   -- ^ the URI of paste
 share bin ex txt = do
   pwd  <- makePassword 33
-  c    <- encrypt pwd (encode txt)
-  append pwd `fmap` post bin ex c
-  where
-    append _ (Left e) = Left e
-    append p (Right u) = Right $ u ++ "#" ++ p
+  cnt  <- encrypt pwd (encode txt)
+  uri  <- post bin ex cnt
+  return $ uri ++ "#" ++ pwd
 
diff --git a/src/Web/ZeroBin/SJCL.hs b/src/Web/ZeroBin/SJCL.hs
--- a/src/Web/ZeroBin/SJCL.hs
+++ b/src/Web/ZeroBin/SJCL.hs
@@ -1,3 +1,12 @@
+{-|
+Encryption compatible with <https://crypto.stanford.edu/sjcl/ SJCL>
+
+ >>> import Web.ZeroBin.SJCL
+ >>> import Data.ByteString.Char8
+ >>> encrypt "secret-word" (pack "hello")
+Content {iv = "VxyuJRVtKJqhG2iR/sPjAQ", salt = "AhnDuP1CkTCBlQTHgw", ct = "cqr7/pMRXrcROmcgwA"}
+-}
+
 {-# LANGUAGE DeriveGeneric #-}
 
 module Web.ZeroBin.SJCL (
@@ -23,10 +32,11 @@
 import qualified Data.ByteString as BS
 import qualified Data.ByteString.Char8 as C
 
+-- | Encrypted content. Each field is a 'toWeb'-encoded byte-string
 data Content = Content {
-    iv   :: String
-  , salt :: String
-  , ct   :: String
+    iv   :: String -- ^ random initialization vector (IV)
+  , salt :: String -- ^ random salt
+  , ct   :: String -- ^ encrypted data
   } deriving (Generic, Show)
 
 -- FIXME: http://stackoverflow.com/questions/33045350/unexpected-haskell-aeson-warning-no-explicit-implementation-for-tojson
@@ -36,16 +46,11 @@
 makeCipher :: ByteString -> IO AES256
 makeCipher = throwCryptoErrorIO . cipherInit
 
--- SJCL uses PBKDF2-HMAC-SHA256 with 1000 iterations, 32 bytes length,
--- but the output is truncated down to 16 bytes.
 -- https://github.com/bitwiseshiftleft/sjcl/blob/master/core/pbkdf2.js
--- TODO: this is default, we can specify it explicitly
---       for forward compatibility
+-- TODO: this is default, we can specify it explicitly for forward compatibility
 makeKey :: ByteString -> ByteString -> ByteString
-makeKey pwd slt = BS.take 16 $ PBKDF2.generate (prfHMAC SHA256)
-  PBKDF2.Parameters {PBKDF2.iterCounts = 1000, PBKDF2.outputLength = 32}
-  pwd slt
-
+makeKey = PBKDF2.generate (prfHMAC SHA256)
+  PBKDF2.Parameters {PBKDF2.iterCounts = 1000, PBKDF2.outputLength = 16}
 
 chunks :: Int -> ByteString -> [ByteString]
 chunks sz = split
@@ -58,9 +63,13 @@
 lengthOf :: Int -> Word8
 lengthOf = ceiling . (logBase 256 :: Float -> Float) . fromIntegral
 
--- Ref. https://tools.ietf.org/html/rfc3610
--- SJCL uses 64-bit tag (8 bytes)
-encrypt :: String -> ByteString -> IO Content
+-- | <https://crypto.stanford.edu/sjcl/ SJCL>-compatible encryption function.
+--   Follows <https://tools.ietf.org/html/rfc3610 RFC3610> with a 8-bytes tag.
+--   Uses 16-bytes cipher key generated from the password and a random 'salt'
+--   by PBKDF2-HMAC-SHA256 with 1000 iterations.
+encrypt :: String     -- ^ the password
+        -> ByteString -- ^ the plain data to encrypt
+        -> IO Content
 encrypt password plaintext = do
   ivd    <- getEntropy 16 -- XXX it is truncated to get the nonce below
   slt    <- getEntropy 13 -- arbitrary length
diff --git a/src/Web/ZeroBin/Utils.hs b/src/Web/ZeroBin/Utils.hs
--- a/src/Web/ZeroBin/Utils.hs
+++ b/src/Web/ZeroBin/Utils.hs
@@ -1,3 +1,7 @@
+{-|
+Various utility functions
+-}
+
 module Web.ZeroBin.Utils (
   toWeb
 , makePassword
@@ -7,13 +11,14 @@
 import Data.ByteString (ByteString)
 import Data.ByteString.Base64 (encode)
 import Data.ByteString.Char8 (unpack)
-import Data.Char (isAlphaNum)
 
-
-toWeb :: ByteString -> String
+-- | Encodes to base64 and drops padding '='.
+toWeb :: ByteString -- ^ the data to encode
+      -> String     -- ^ base64 string without padding
 toWeb = takeWhile (/= '=') . unpack . encode
 
-makePassword :: Int -> IO String
-makePassword n = (map (\c -> if isAlphaNum c then c else 'X')
-                  . toWeb) `fmap` getEntropy n
+-- | Makes a random password
+makePassword :: Int       -- ^ the number of bytes of entropy
+             -> IO String -- ^ random byte-string encoded by 'toWeb'
+makePassword n = toWeb `fmap` getEntropy n
 
diff --git a/zerobin.cabal b/zerobin.cabal
--- a/zerobin.cabal
+++ b/zerobin.cabal
@@ -1,5 +1,5 @@
 name: zerobin
-version: 1.2.0
+version: 1.5.0
 synopsis: Post to 0bin services
 description: Post encrypted content to 0bin sites
   like http://0bin.net or https://paste.ec
