diff --git a/CHANGES.md b/CHANGES.md
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,6 +1,34 @@
 User-visible changes in hledger-web.
 See also the hledger changelog.
 
+# 1.14 2019-03-01
+
+- serve the same JSON-providing routes as in hledger-api:
+  ```
+  /accountnames
+  /transactions
+  /prices
+  /commodities
+  /accounts
+  /accounttransactions/ACCT
+  ```
+  And allow adding a new transaction by PUT'ing JSON (similar to the
+  output of /transactions) to /add. This requires the `add` capability
+  (which is enabled by default). Here's how to test with curl:
+  ```
+  $ curl -s http://127.0.0.1:5000/add -X PUT -H 'Content-Type: application/json' --data-binary @in.json; echo
+  ```
+  (#316)
+
+- fix unbalanced transaction prevention in the add form
+
+- fix transaction-showing tooltips (#927)
+
+- manual updates: document --capabilities/--capabilities-header and
+  editing/uploading/downloading.
+
+- use hledger 1.14
+
 # 1.13 (2019/02/01)
 
 - use hledger 1.13
diff --git a/Hledger/Web/Application.hs b/Hledger/Web/Application.hs
--- a/Hledger/Web/Application.hs
+++ b/Hledger/Web/Application.hs
@@ -15,13 +15,13 @@
 import Yesod.Default.Config
 
 import Hledger.Data (Journal, nulljournal)
-import Hledger.Web.Handler.AddR (getAddR, postAddR)
-import Hledger.Web.Handler.Common
-       (getDownloadR, getFaviconR, getManageR, getRobotsR, getRootR)
-import Hledger.Web.Handler.EditR (getEditR, postEditR)
-import Hledger.Web.Handler.UploadR (getUploadR, postUploadR)
-import Hledger.Web.Handler.JournalR (getJournalR)
-import Hledger.Web.Handler.RegisterR (getRegisterR)
+
+import Hledger.Web.Handler.AddR
+import Hledger.Web.Handler.MiscR 
+import Hledger.Web.Handler.EditR
+import Hledger.Web.Handler.UploadR
+import Hledger.Web.Handler.JournalR
+import Hledger.Web.Handler.RegisterR
 import Hledger.Web.Import
 import Hledger.Web.WebOptions (WebOpts(serve_))
 
diff --git a/Hledger/Web/Handler/AddR.hs b/Hledger/Web/Handler/AddR.hs
--- a/Hledger/Web/Handler/AddR.hs
+++ b/Hledger/Web/Handler/AddR.hs
@@ -7,13 +7,21 @@
 module Hledger.Web.Handler.AddR
   ( getAddR
   , postAddR
+  , putAddR
   ) where
 
+import Data.Aeson.Types (Result(..))
+import qualified Data.Text as T
+import Network.HTTP.Types.Status (status400)
+import Text.Blaze.Html (preEscapedToHtml)
+import Yesod
+
 import Hledger
-import Hledger.Cli.Commands.Add (appendToJournalFileOrStdout)
+import Hledger.Cli.Commands.Add (appendToJournalFileOrStdout, journalAddTransaction)
 import Hledger.Web.Import
+import Hledger.Web.Json ()
+import Hledger.Web.WebOptions (WebOpts(..))
 import Hledger.Web.Widget.AddForm (addForm)
-import Hledger.Web.Widget.Common (fromFormSuccess)
 
 getAddR :: Handler ()
 getAddR = postAddR
@@ -24,12 +32,19 @@
   when (CapAdd `notElem` caps) (permissionDenied "Missing the 'add' capability")
 
   ((res, view), enctype) <- runFormPost $ addForm j today
-  t <- txnTieKnot <$> fromFormSuccess (showForm view enctype) res
-  -- XXX(?) move into balanceTransaction
-  liftIO $ ensureJournalFileExists (journalFilePath j)
-  liftIO $ appendToJournalFileOrStdout (journalFilePath j) (showTransaction t)
-  setMessage "Transaction added."
-  redirect JournalR
+  case res of
+    FormSuccess res' -> do
+      let t = txnTieKnot res'
+      -- XXX(?) move into balanceTransaction
+      liftIO $ ensureJournalFileExists (journalFilePath j)
+      -- XXX why not journalAddTransaction ?
+      liftIO $ appendToJournalFileOrStdout (journalFilePath j) (showTransaction t)
+      setMessage "Transaction added."
+      redirect JournalR
+    FormMissing -> showForm view enctype
+    FormFailure errs -> do
+      mapM_ (setMessage . preEscapedToHtml . T.replace "\n" "<br>") errs
+      showForm view enctype
   where
     showForm view enctype =
       sendResponse =<< defaultLayout [whamlet|
@@ -38,3 +53,17 @@
           <form#addform.form.col-xs-12.col-md-8 method=post enctype=#{enctype}>
             ^{view}
       |]
+
+-- Add a single new transaction, send as JSON via PUT, to the journal.
+-- The web form handler above should probably use PUT as well.  
+putAddR :: Handler RepJson
+putAddR = do
+  VD{caps, j, opts} <- getViewData
+  when (CapAdd `notElem` caps) (permissionDenied "Missing the 'add' capability")
+
+  (r :: Result Transaction) <- parseCheckJsonBody
+  case r of
+    Error err -> sendStatusJSON status400 ("could not parse json: " ++ err ::String)
+    Success t -> do
+      void $ liftIO $ journalAddTransaction j (cliopts_ opts) t
+      sendResponseCreated TransactionsR 
diff --git a/Hledger/Web/Handler/Common.hs b/Hledger/Web/Handler/Common.hs
deleted file mode 100644
--- a/Hledger/Web/Handler/Common.hs
+++ /dev/null
@@ -1,38 +0,0 @@
-{-# LANGUAGE NamedFieldPuns #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE QuasiQuotes #-}
-{-# LANGUAGE TemplateHaskell #-}
-
-module Hledger.Web.Handler.Common
-  ( getDownloadR
-  , getFaviconR
-  , getManageR
-  , getRobotsR
-  , getRootR
-  ) where
-
-import qualified Data.Text as T
-import Yesod.Default.Handlers (getFaviconR, getRobotsR)
-
-import Hledger (jfiles)
-import Hledger.Web.Import
-import Hledger.Web.Widget.Common (journalFile404)
-
-getRootR :: Handler Html
-getRootR = redirect JournalR
-
-getManageR :: Handler Html
-getManageR = do
-  VD{caps, j} <- getViewData
-  when (CapManage `notElem` caps) (permissionDenied "Missing the 'manage' capability")
-  defaultLayout $ do
-    setTitle "Manage journal"
-    $(widgetFile "manage")
-
-getDownloadR :: FilePath -> Handler TypedContent
-getDownloadR f = do
-  VD{caps, j} <- getViewData
-  when (CapManage `notElem` caps) (permissionDenied "Missing the 'manage' capability")
-  (f', txt) <- journalFile404 f j
-  addHeader "Content-Disposition" ("attachment; filename=\"" <> T.pack f' <> "\"")
-  sendResponse ("text/plain" :: ByteString, toContent txt)
diff --git a/Hledger/Web/Handler/MiscR.hs b/Hledger/Web/Handler/MiscR.hs
new file mode 100644
--- /dev/null
+++ b/Hledger/Web/Handler/MiscR.hs
@@ -0,0 +1,98 @@
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+{-# LANGUAGE FlexibleInstances   #-}
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE TemplateHaskell #-}
+
+module Hledger.Web.Handler.MiscR
+  ( getAccountnamesR       
+  , getTransactionsR       
+  , getPricesR             
+  , getCommoditiesR        
+  , getAccountsR           
+  , getAccounttransactionsR
+  , getDownloadR
+  , getFaviconR
+  , getManageR
+  , getRobotsR
+  , getRootR
+  ) where
+
+import qualified Data.Map as M
+import qualified Data.Text as T
+import Yesod.Default.Handlers (getFaviconR, getRobotsR)
+
+import Hledger
+import Hledger.Web.Json ()
+import Hledger.Web.Import
+import Hledger.Web.Widget.Common (journalFile404)
+
+getRootR :: Handler Html
+getRootR = redirect JournalR
+
+getManageR :: Handler Html
+getManageR = do
+  VD{caps, j} <- getViewData
+  when (CapManage `notElem` caps) (permissionDenied "Missing the 'manage' capability")
+  defaultLayout $ do
+    setTitle "Manage journal"
+    $(widgetFile "manage")
+
+getDownloadR :: FilePath -> Handler TypedContent
+getDownloadR f = do
+  VD{caps, j} <- getViewData
+  when (CapManage `notElem` caps) (permissionDenied "Missing the 'manage' capability")
+  (f', txt) <- journalFile404 f j
+  addHeader "Content-Disposition" ("attachment; filename=\"" <> T.pack f' <> "\"")
+  sendResponse ("text/plain" :: ByteString, toContent txt)
+
+-- hledger-web equivalents of hledger-api's handlers
+
+getAccountnamesR :: Handler TypedContent
+getAccountnamesR = do
+  VD{caps, j} <- getViewData
+  when (CapView `notElem` caps) (permissionDenied "Missing the 'view' capability")
+  selectRep $ do
+    provideJson $ journalAccountNames j
+
+getTransactionsR :: Handler TypedContent
+getTransactionsR = do
+  VD{caps, j} <- getViewData
+  when (CapView `notElem` caps) (permissionDenied "Missing the 'view' capability")
+  selectRep $ do
+    provideJson $ jtxns j
+
+getPricesR :: Handler TypedContent
+getPricesR = do
+  VD{caps, j} <- getViewData
+  when (CapView `notElem` caps) (permissionDenied "Missing the 'view' capability")
+  selectRep $ do
+    provideJson $ jmarketprices j
+
+getCommoditiesR :: Handler TypedContent
+getCommoditiesR = do
+  VD{caps, j} <- getViewData
+  when (CapView `notElem` caps) (permissionDenied "Missing the 'view' capability")
+  selectRep $ do
+    provideJson $ (M.keys . jinferredcommodities) j
+
+getAccountsR :: Handler TypedContent
+getAccountsR = do
+  VD{caps, j} <- getViewData
+  when (CapView `notElem` caps) (permissionDenied "Missing the 'view' capability")
+  selectRep $ do
+    provideJson $ ledgerTopAccounts $ ledgerFromJournal Any j
+
+getAccounttransactionsR :: Text -> Handler TypedContent
+getAccounttransactionsR a = do
+  VD{caps, j} <- getViewData
+  when (CapView `notElem` caps) (permissionDenied "Missing the 'view' capability")
+  let
+    ropts = defreportopts
+    q = Any --filterQuery (not . queryIsDepth) $ queryFromOpts d ropts'
+    thisacctq = Acct $ accountNameToAccountRegex a -- includes subs
+  selectRep $ do
+    provideJson $ accountTransactionsReport ropts j q thisacctq
+
diff --git a/Hledger/Web/Handler/RegisterR.hs b/Hledger/Web/Handler/RegisterR.hs
--- a/Hledger/Web/Handler/RegisterR.hs
+++ b/Hledger/Web/Handler/RegisterR.hs
@@ -58,3 +58,4 @@
   read (formatTime defaultTimeLocale "%s" t) * 1000 -- XXX read
   where
     t = UTCTime d (secondsToDiffTime 0)
+
diff --git a/Hledger/Web/Json.hs b/Hledger/Web/Json.hs
new file mode 100644
--- /dev/null
+++ b/Hledger/Web/Json.hs
@@ -0,0 +1,160 @@
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+--{-# LANGUAGE CPP                 #-}
+--{-# LANGUAGE DataKinds           #-}
+--{-# LANGUAGE DeriveAnyClass      #-}
+{-# LANGUAGE DeriveGeneric       #-}
+--{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE FlexibleInstances   #-}
+--{-# LANGUAGE NamedFieldPuns #-}
+--{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE OverloadedStrings #-}
+--{-# LANGUAGE PolyKinds           #-}
+--{-# LANGUAGE QuasiQuotes         #-}
+--{-# LANGUAGE QuasiQuotes #-}
+--{-# LANGUAGE Rank2Types #-}
+--{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE RecordWildCards #-}
+--{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE StandaloneDeriving #-}
+--{-# LANGUAGE TemplateHaskell       #-}
+--{-# LANGUAGE TypeFamilies        #-}
+--{-# LANGUAGE TypeOperators       #-}
+
+module Hledger.Web.Json ( 
+  -- * Instances
+  -- * Utilities
+   readJsonFile
+  ,writeJsonFile
+) where
+
+import           Data.Aeson
+--import           Data.Aeson.TH
+import qualified Data.ByteString.Lazy as BL
+import           Data.Decimal
+import           Data.Maybe
+import           GHC.Generics (Generic)
+
+import           Hledger.Data
+
+-- JSON instances. See also hledger-api.
+-- Should they be in hledger-lib Types.hs ?
+
+-- To JSON
+
+instance ToJSON Status
+instance ToJSON GenericSourcePos
+instance ToJSON Decimal
+instance ToJSON Amount
+instance ToJSON AmountStyle
+instance ToJSON Side
+instance ToJSON DigitGroupStyle
+instance ToJSON MixedAmount
+instance ToJSON BalanceAssertion
+instance ToJSON Price
+instance ToJSON MarketPrice
+instance ToJSON PostingType
+instance ToJSON Posting where
+  toJSON Posting{..} = object
+    ["pdate"             .= toJSON pdate
+    ,"pdate2"            .= toJSON pdate2
+    ,"pstatus"           .= toJSON pstatus
+    ,"paccount"          .= toJSON paccount
+    ,"pamount"           .= toJSON pamount
+    ,"pcomment"          .= toJSON pcomment
+    ,"ptype"             .= toJSON ptype
+    ,"ptags"             .= toJSON ptags
+    ,"pbalanceassertion" .= toJSON pbalanceassertion
+    -- To avoid a cycle, show just the parent transaction's index number 
+    -- in a dummy field. When re-parsed, there will be no parent.
+    ,"ptransaction_"     .= toJSON (maybe "" (show.tindex) ptransaction)
+    -- This is probably not wanted in json, we discard it.
+    ,"poriginal"         .= toJSON (Nothing :: Maybe Posting) 
+    ]
+instance ToJSON Transaction
+instance ToJSON Account where
+  toJSON a = object
+    ["aname"        .= toJSON (aname a)
+    ,"aebalance"    .= toJSON (aebalance a)
+    ,"aibalance"    .= toJSON (aibalance a)
+    ,"anumpostings" .= toJSON (anumpostings a)
+    ,"aboring"      .= toJSON (aboring a)
+    -- To avoid a cycle, show just the parent account's name 
+    -- in a dummy field. When re-parsed, there will be no parent.
+    ,"aparent_"     .= toJSON (maybe "" aname $ aparent a)
+    -- To avoid a cycle, drop the subaccounts, showing just their names
+    -- in a dummy field. When re-parsed, there will be no subaccounts.
+    ,"asubs"        .= toJSON ([]::[Account])
+    ,"asubs_"       .= toJSON (map aname $ asubs a)
+    ]
+
+-- From JSON
+
+instance FromJSON Status
+instance FromJSON GenericSourcePos
+instance FromJSON Amount
+instance FromJSON AmountStyle
+instance FromJSON Side
+instance FromJSON DigitGroupStyle
+instance FromJSON MixedAmount
+instance FromJSON BalanceAssertion
+instance FromJSON Price
+instance FromJSON MarketPrice
+instance FromJSON PostingType
+instance FromJSON Posting
+instance FromJSON Transaction
+instance FromJSON AccountDeclarationInfo
+instance FromJSON Account
+
+-- Decimal, various attempts
+--
+-- https://stackoverflow.com/questions/40331851/haskell-data-decimal-as-aeson-type
+----instance FromJSON Decimal where parseJSON = 
+----  A.withScientific "Decimal" (return . right . eitherFromRational . toRational)
+--
+-- https://github.com/bos/aeson/issues/474
+-- http://hackage.haskell.org/package/aeson-1.4.2.0/docs/Data-Aeson-TH.html
+-- $(deriveFromJSON defaultOptions ''Decimal) -- doesn't work
+-- $(deriveFromJSON defaultOptions ''DecimalRaw)  -- works; requires TH, but gives better parse error messages
+--
+-- https://github.com/PaulJohnson/Haskell-Decimal/issues/6
+--deriving instance Generic Decimal
+--instance FromJSON Decimal
+deriving instance Generic (DecimalRaw a)
+instance FromJSON (DecimalRaw Integer)
+--
+-- @simonmichael, I think the code in your first comment should work if it compiles—though “work” doesn’t mean you can parse a JSON number directly into a `Decimal` using the generic instance, as you’ve discovered.
+--
+--Error messages with these extensions are always rather cryptic, but I’d prefer them to Template Haskell. Typically you’ll want to start by getting a generic `ToJSON` instance working, then use that to figure out what the `FromJSON` instance expects to parse: for a correct instance, `encode` and `decode` should give you an isomorphism between your type and a subset of `Bytestring` (up to the `Maybe` wrapper that `decode` returns).
+--
+--I don’t have time to test it right now, but I think it will also work without `DeriveAnyClass`, just using `DeriveGeneric` and `StandAloneDeriving`. It should also work to use the [`genericParseJSON`](http://hackage.haskell.org/package/aeson/docs/Data-Aeson.html#v:genericParseJSON) function to implement the class explicitly, something like this:
+--
+--{-# LANGUAGE DeriveGeneric #-}
+--{-# LANGUAGE StandAloneDeriving #-}
+--import GHC.Generics
+--import Data.Aeson
+--deriving instance Generic Decimal
+--instance FromJSON Decimal where
+--  parseJSON = genericParseJSON defaultOptions
+--
+--And of course you can avoid `StandAloneDeriving` entirely if you’re willing to wrap `Decimal` in your own `newtype`.
+
+
+-- Utilities
+
+-- | Read a json from a file and decode/parse it as the target type, if we can.
+-- Example:
+-- >>> readJsonFile "in.json" :: IO MixedAmount
+readJsonFile :: FromJSON a => FilePath -> IO a
+readJsonFile f = do
+  bs <- BL.readFile f
+  let v = fromMaybe (error "could not decode bytestring as json value") (decode bs :: Maybe Value) 
+  case fromJSON v :: FromJSON a => Result a of
+    Error e   -> error e
+    Success t -> return t
+
+-- | Write some to-JSON-convertible haskell value to a json file, if we can.
+-- Example:
+-- >>> writeJsonFile "out.json" nullmixedamt
+writeJsonFile :: ToJSON a => FilePath -> a -> IO ()
+writeJsonFile f v = BL.writeFile f (encode $ toJSON v)
diff --git a/Hledger/Web/Main.hs b/Hledger/Web/Main.hs
--- a/Hledger/Web/Main.hs
+++ b/Hledger/Web/Main.hs
@@ -38,7 +38,7 @@
 
 hledgerWebDev :: IO (Int, Application)
 hledgerWebDev =
-  withJournalDoWeb defwebopts (\o j -> defaultDevelApp loader $ makeApplication o j)
+  withJournalDo (cliopts_ defwebopts) (defaultDevelApp loader . makeApplication defwebopts)
   where
     loader =
       Yesod.Default.Config.loadConfig
@@ -49,26 +49,7 @@
   | "help"            `inRawOpts` rawopts_ (cliopts_ opts) = putStr (showModeUsage webmode) >> exitSuccess
   | "version"         `inRawOpts` rawopts_ (cliopts_ opts) = putStrLn prognameandversion >> exitSuccess
   | "binary-filename" `inRawOpts` rawopts_ (cliopts_ opts) = putStrLn (binaryfilename progname)
-  | otherwise = withJournalDoWeb opts web
-
--- | A version of withJournalDo specialised for hledger-web.
--- Disallows the special - file to avoid some bug,
--- takes WebOpts rather than CliOpts.
-withJournalDoWeb :: WebOpts -> (WebOpts -> Journal -> IO a) -> IO a
-withJournalDoWeb opts@WebOpts {cliopts_ = copts} cmd = do
-  journalpaths <- journalFilePathFromOpts copts
-
-  -- https://github.com/simonmichael/hledger/issues/202
-  -- -f- gives [Error#yesod-core] <stdin>: hGetContents: illegal operation (handle is closed)
-  -- Also we may try to write to this file. Just disallow -.
-  when ("-" `elem` journalpaths) $  -- always non-empty
-    error' "hledger-web doesn't support -f -, please specify a file path"
-  mapM_ requireJournalFileExists journalpaths
-
-  -- keep synced with withJournalDo  TODO refactor
-  readJournalFiles (inputopts_ copts) journalpaths
-  >>= mapM (journalTransform copts)
-  >>= either error' (cmd opts)
+  | otherwise = withJournalDo (cliopts_ opts) (web opts)
 
 -- | The web command.
 web :: WebOpts -> Journal -> IO ()
diff --git a/Hledger/Web/WebOptions.hs b/Hledger/Web/WebOptions.hs
--- a/Hledger/Web/WebOptions.hs
+++ b/Hledger/Web/WebOptions.hs
@@ -54,13 +54,13 @@
   , flagReq
       ["capabilities"]
       (\s opts -> Right $ setopt "capabilities" s opts)
-      "CAP,CAP2"
-      "enable these capabilities - comma-separated, possible values are: view, add, manage (default: view,add)"
+      "CAP[,CAP..]"
+      "enable the view, add, and/or manage capabilities (default: view,add)"
   , flagReq
       ["capabilities-header"]
       (\s opts -> Right $ setopt "capabilities-header" s opts)
-      "HEADER"
-      "read enabled capabilities from a HTTP header (e.g. X-Sandstorm-Permissions, disabled by default)"
+      "HTTPHEADER"
+      "read capabilities to enable from a HTTP header, like X-Sandstorm-Permissions (default: disabled)"
   ]
 
 webmode :: Mode [(String, String)]
diff --git a/Hledger/Web/Widget/AddForm.hs b/Hledger/Web/Widget/AddForm.hs
--- a/Hledger/Web/Widget/AddForm.hs
+++ b/Hledger/Web/Widget/AddForm.hs
@@ -21,7 +21,7 @@
 import Data.Time (Day)
 import Text.Blaze.Internal (Markup, preEscapedString)
 import Text.JSON
-import Text.Megaparsec (eof, errorBundlePretty, runParser)
+import Text.Megaparsec (bundleErrors, eof, parseErrorTextPretty, runParser)
 import Yesod
 
 import Hledger
@@ -67,13 +67,7 @@
   (descRes, descView) <- mreq textField descFS Nothing
   (acctRes, _) <- mreq listField acctFS Nothing
   (amtRes, _) <- mreq listField amtFS Nothing
-
-  let (msgs', postRes) = case validatePostings <$> acctRes <*> amtRes of
-        FormSuccess (Left es) -> (es, FormFailure ["Postings validation failed"])
-        FormSuccess (Right xs) -> ([], FormSuccess xs)
-        FormMissing -> ([], FormMissing)
-        FormFailure es -> ([], FormFailure es)
-      msgs = zip [(1 :: Int)..] $ msgs' ++ replicate (4 - length msgs') ("", "", Nothing, Nothing)
+  let (postRes, displayRows) = validatePostings acctRes amtRes
 
   let descriptions = sort $ nub $ tdescription <$> jtxns j
       escapeJSSpecialChars = regexReplaceCI "</script>" "<\\/script>" -- #236
@@ -81,11 +75,8 @@
         encode . JSArray . fmap (\a -> JSObject $ toJSObject [("value", showJSON a)])
       journals = fst <$> jfiles j
 
-  pure (makeTransaction <$> dateRes <*> descRes <*> postRes, $(widgetFile "add-form"))
+  pure (validateTransaction dateRes descRes postRes, $(widgetFile "add-form"))
   where
-    makeTransaction date desc postings =
-      nulltransaction {tdate = date, tdescription = desc, tpostings = postings}
-
     dateFS = FieldSettings "date" Nothing Nothing (Just "date")
       [("class", "form-control input-lg"), ("placeholder", "Date")]
     descFS = FieldSettings "desc" Nothing Nothing (Just "description")
@@ -103,42 +94,90 @@
       , fieldEnctype = UrlEncoded
       }
 
-validatePostings :: [Text] -> [Text] -> Either [(Text, Text, Maybe Text, Maybe Text)] [Posting]
-validatePostings a b =
-  case traverse id $ (\(_, _, x) -> x) <$> postings of
-    Left _ -> Left $ foldr catPostings [] postings
-    Right [] -> Left
-      [ ("", "", Just "Missing account", Just "Missing amount")
-      , ("", "", Just "Missing account", Nothing)
-      ]
-    Right [p] -> Left
-      [ (paccount p, T.pack . showMixedAmountWithoutPrice $ pamount p, Nothing, Nothing)
-      , ("", "", Just "Missing account", Nothing)
-      ]
-    Right xs -> Right xs
+validateTransaction ::
+     FormResult Day
+  -> FormResult Text
+  -> FormResult [Posting]
+  -> FormResult Transaction
+validateTransaction dateRes descRes postingsRes =
+  case makeTransaction <$> dateRes <*> descRes <*> postingsRes of
+    FormSuccess txn -> case balanceTransaction Nothing txn of
+      Left e -> FormFailure [T.pack e]
+      Right txn' -> FormSuccess txn'
+    x -> x
   where
-    postings = unfoldr go (True, a, b)
+    makeTransaction date desc postings =
+      nulltransaction {tdate = date, tdescription = desc, tpostings = postings}
 
-    go (_, x:xs, y:ys) = Just ((x, y, zipPosting (validateAccount x) (validateAmount y)), (True, xs, ys))
-    go (True, x:y:xs, []) = Just ((x, "", zipPosting (validateAccount x) (Left "Missing amount")), (True, y:xs, []))
-    go (True, x:xs, []) = Just ((x, "", zipPosting (validateAccount x) (Right missingamt)), (False, xs, []))
-    go (False, x:xs, []) = Just ((x, "", zipPosting (validateAccount x) (Left "Missing amount")), (False, xs, []))
-    go (_, [], y:ys) = Just (("", y, zipPosting (Left "Missing account") (validateAmount y)), (False, [], ys))
-    go (_, [], []) = Nothing
 
-    zipPosting = zipEither (\acc amt -> nullposting {paccount = acc, pamount = Mixed [amt]})
+-- | Parse a list of postings out of a list of accounts and a corresponding list
+-- of amounts
+validatePostings ::
+     FormResult [Text]
+  -> FormResult [Text]
+  -> (FormResult [Posting], [(Int, (Text, Text, Maybe Text, Maybe Text))])
+validatePostings acctRes amtRes = let
 
-    catPostings (t, t', Left (e, e')) xs = (t, t', e, e') : xs
-    catPostings (t, t', Right _) xs = (t, t', Nothing, Nothing) : xs
+  -- Zip accounts and amounts, fill in missing values and drop empty rows.
+  rows :: [(Text, Text)]
+  rows = filter (/= ("", "")) $ zipDefault "" (formSuccess [] acctRes) (formSuccess [] amtRes)
 
-    errorToFormMsg = first (("Invalid value: " <>) . T.pack . errorBundlePretty)
-    validateAccount = errorToFormMsg . runParser (accountnamep <* eof) "" . T.strip
-    validateAmount = errorToFormMsg . runParser (evalStateT (amountp <* eof) mempty) "" . T.strip
+  -- Parse values and check for incomplete rows with only an account or an amount.
+  -- The boolean in unfoldr state is for special handling of 'missingamt', where
+  -- one row may have only an account and not an amount.
+  postings :: [(Text, Text, Either (Maybe Text, Maybe Text) Posting)]
+  postings = unfoldr go (True, rows)
+  go (True, (x, ""):y:xs) = Just ((x, "", zipRow (checkAccount x) (Left "Missing amount")), (True, y:xs))
+  go (True, (x, ""):xs) = Just ((x, "", zipRow (checkAccount x) (Right missingamt)), (False, xs))
+  go (False, (x, ""):xs) = Just ((x, "", zipRow (checkAccount x) (Left "Missing amount")), (False, xs))
+  go (_, ("", y):xs) = Just (("", y, zipRow (Left "Missing account") (checkAmount y)), (False, xs))
+  go (_, (x, y):xs) = Just ((x, y, zipRow (checkAccount x) (checkAmount y)), (True, xs))
+  go (_, []) = Nothing
 
--- Modification of Align, from the `these` package
-zipEither :: (a -> a' -> r) -> Either e a -> Either e' a' -> Either (Maybe e, Maybe e') r
-zipEither f a b = case (a, b) of
-  (Right a', Right b') -> Right (f a' b')
-  (Left a', Right _) -> Left (Just a', Nothing)
-  (Right _, Left b') -> Left (Nothing, Just b')
-  (Left a', Left b') -> Left (Just a', Just b')
+  zipRow (Left e) (Left e') = Left (Just e, Just e')
+  zipRow (Left e) (Right _) = Left (Just e, Nothing)
+  zipRow (Right _) (Left e) = Left (Nothing, Just e)
+  zipRow (Right acct) (Right amt) = Right (nullposting {paccount = acct, pamount = Mixed [amt]})
+
+  errorToFormMsg = first (("Invalid value: " <>) . T.pack .
+                          foldl (\s a -> s <> parseErrorTextPretty a) "" .
+                          bundleErrors)
+  checkAccount = errorToFormMsg . runParser (accountnamep <* eof) "" . T.strip
+  checkAmount = errorToFormMsg . runParser (evalStateT (amountp <* eof) mempty) "" . T.strip
+
+  -- Add errors to forms with zero or one rows if the form is not a FormMissing
+  result :: [(Text, Text, Either (Maybe Text, Maybe Text) Posting)]
+  result = case (acctRes, amtRes) of
+    (FormMissing, FormMissing) -> postings
+    _ -> case postings of
+      [] -> [ ("", "", Left (Just "Missing account", Just "Missing amount"))
+           , ("", "", Left (Just "Missing account", Nothing))
+           ]
+      [x] -> [x, ("", "", Left (Just "Missing account", Nothing))]
+      xs -> xs
+
+  -- Prepare rows for rendering - resolve Eithers into error messages and pad to
+  -- at least four rows
+  display' = flip fmap result $ \(acc, amt, res) -> case res of
+    Left (mAccountErr, mAmountErr) -> (acc, amt, mAccountErr, mAmountErr)
+    Right _ -> (acc, amt, Nothing, Nothing)
+  display = display' ++ replicate (4 - length display') ("", "", Nothing, Nothing)
+
+  -- And finally prepare the final FormResult [Posting]
+  formResult = case traverse (\(_, _, x) -> x) result of
+    Left _ -> FormFailure ["Postings validation failed"]
+    Right xs -> FormSuccess xs
+
+  in (formResult, zip [(1 :: Int)..] display)
+
+
+zipDefault :: a -> [a] -> [a] -> [(a, a)]
+zipDefault def (b:bs) (c:cs) = (b, c):(zipDefault def bs cs)
+zipDefault def (b:bs) [] = (b, def):(zipDefault def bs [])
+zipDefault def [] (c:cs) = (def, c):(zipDefault def [] cs)
+zipDefault _ _ _ = []
+
+formSuccess :: a -> FormResult a -> a
+formSuccess def res = case res of
+  FormSuccess x -> x
+  _ -> def
diff --git a/config/routes b/config/routes
--- a/config/routes
+++ b/config/routes
@@ -5,9 +5,16 @@
 /                RootR           GET
 /journal         JournalR        GET
 /register        RegisterR       GET
-/add             AddR            GET POST
+/add             AddR            GET POST PUT
 
 /manage             ManageR         GET
 /edit/#FilePath     EditR           GET POST
 /upload/#FilePath   UploadR         GET POST
 /download/#FilePath DownloadR       GET
+
+/accountnames                      AccountnamesR         GET
+/transactions                      TransactionsR         GET
+/prices                            PricesR               GET
+/commodities                       CommoditiesR          GET
+/accounts                          AccountsR             GET
+/accounttransactions/#AccountName  AccounttransactionsR  GET
diff --git a/hledger-web.1 b/hledger-web.1
--- a/hledger-web.1
+++ b/hledger-web.1
@@ -1,5 +1,5 @@
 
-.TH "hledger\-web" "1" "February 2019" "hledger\-web 1.13" "hledger User Manuals"
+.TH "hledger\-web" "1" "March 2019" "hledger\-web 1.14" "hledger User Manuals"
 
 
 
@@ -41,54 +41,15 @@
 \f[C]$LEDGER_FILE\f[], or \f[C]$HOME/.hledger.journal\f[] (on windows,
 perhaps \f[C]C:/Users/USER/.hledger.journal\f[]).
 For more about this see hledger(1), hledger_journal(5) etc.
-.PP
-By default, hledger\-web starts the web app in "transient mode" and also
-opens it in your default web browser if possible.
-In this mode the web app will keep running for as long as you have it
-open in a browser window, and will exit after two minutes of inactivity
-(no requests and no browser windows viewing it).
-With \f[C]\-\-serve\f[], it just runs the web app without exiting, and
-logs requests to the console.
-.PP
-By default the server listens on IP address 127.0.0.1, accessible only
-to local requests.
-You can use \f[C]\-\-host\f[] to change this, eg
-\f[C]\-\-host\ 0.0.0.0\f[] to listen on all configured addresses.
-.PP
-Similarly, use \f[C]\-\-port\f[] to set a TCP port other than 5000, eg
-if you are running multiple hledger\-web instances.
-.PP
-You can use \f[C]\-\-base\-url\f[] to change the protocol, hostname,
-port and path that appear in hyperlinks, useful eg for integrating
-hledger\-web within a larger website.
-The default is \f[C]http://HOST:PORT/\f[] using the server\[aq]s
-configured host address and TCP port (or \f[C]http://HOST\f[] if PORT is
-80).
-.PP
-With \f[C]\-\-file\-url\f[] you can set a different base url for static
-files, eg for better caching or cookie\-less serving on high performance
-websites.
-.PP
-Note there is no built\-in access control (aside from listening on
-127.0.0.1 by default).
-So you will need to hide hledger\-web behind an authenticating proxy
-(such as apache or nginx) if you want to restrict who can see and add
-entries to your journal.
+.SH OPTIONS
 .PP
 Command\-line options and arguments may be used to set an initial filter
 on the data.
-This is not shown in the web UI, but it will be applied in addition to
-any search query entered there.
-.PP
-With journal and timeclock files (but not CSV files, currently) the web
-app detects changes made by other means and will show the new data on
-the next request.
-If a change makes the file unparseable, hledger\-web will show an error
-until the file has been fixed.
-.SH OPTIONS
+These filter options are not shown in the web UI, but it will be applied
+in addition to any search query entered there.
 .PP
 Note: if invoking hledger\-web as a hledger subcommand, write
-\f[C]\-\-\f[] before options as shown above.
+\f[C]\-\-\f[] before options, as shown in the synopsis above.
 .TP
 .B \f[C]\-\-serve\f[]
 serve and log requests, don\[aq]t browse or auto\-exit
@@ -119,6 +80,17 @@
 with this.
 .RS
 .RE
+.TP
+.B \f[C]\-\-capabilities=CAP[,CAP..]\f[]
+enable the view, add, and/or manage capabilities (default: view,add)
+.RS
+.RE
+.TP
+.B \f[C]\-\-capabilities\-header=HTTPHEADER\f[]
+read capabilities to enable from a HTTP header, like
+X\-Sandstorm\-Permissions (default: disabled)
+.RS
+.RE
 .PP
 hledger input options:
 .TP
@@ -286,6 +258,111 @@
 A \@FILE argument will be expanded to the contents of FILE, which should
 contain one command line option/argument per line.
 (To prevent this, insert a \f[C]\-\-\f[] argument before.)
+.PP
+By default, hledger\-web starts the web app in "transient mode" and also
+opens it in your default web browser if possible.
+In this mode the web app will keep running for as long as you have it
+open in a browser window, and will exit after two minutes of inactivity
+(no requests and no browser windows viewing it).
+With \f[C]\-\-serve\f[], it just runs the web app without exiting, and
+logs requests to the console.
+.PP
+By default the server listens on IP address 127.0.0.1, accessible only
+to local requests.
+You can use \f[C]\-\-host\f[] to change this, eg
+\f[C]\-\-host\ 0.0.0.0\f[] to listen on all configured addresses.
+.PP
+Similarly, use \f[C]\-\-port\f[] to set a TCP port other than 5000, eg
+if you are running multiple hledger\-web instances.
+.PP
+You can use \f[C]\-\-base\-url\f[] to change the protocol, hostname,
+port and path that appear in hyperlinks, useful eg for integrating
+hledger\-web within a larger website.
+The default is \f[C]http://HOST:PORT/\f[] using the server\[aq]s
+configured host address and TCP port (or \f[C]http://HOST\f[] if PORT is
+80).
+.PP
+With \f[C]\-\-file\-url\f[] you can set a different base url for static
+files, eg for better caching or cookie\-less serving on high performance
+websites.
+.SH PERMISSIONS
+.PP
+By default, hledger\-web allows anyone who can reach it to view the
+journal and to add new transactions, but not to change existing data.
+.PP
+You can restrict who can reach it by
+.IP \[bu] 2
+setting the IP address it listens on (see \f[C]\-\-host\f[] above).
+By default it listens on 127.0.0.1, accessible to all users on the local
+machine.
+.IP \[bu] 2
+putting it behind an authenticating proxy, using eg apache or nginx
+.IP \[bu] 2
+custom firewall rules
+.PP
+You can restrict what the users who reach it can do, by
+.IP \[bu] 2
+using the \f[C]\-\-capabilities=CAP[,CAP..]\f[] flag when you start it,
+enabling one or more of the following capabilities.
+The default value is \f[C]view,add\f[]:
+.RS 2
+.IP \[bu] 2
+\f[C]view\f[] \- allows viewing the journal file and all included files
+.IP \[bu] 2
+\f[C]add\f[] \- allows adding new transactions to the main journal file
+.IP \[bu] 2
+\f[C]manage\f[] \- allows editing, uploading or downloading the main or
+included files
+.RE
+.IP \[bu] 2
+using the \f[C]\-\-capabilities\-header=HTTPHEADER\f[] flag to specify a
+HTTP header from which it will read capabilities to enable.
+hledger\-web on Sandstorm uses the X\-Sandstorm\-Permissions header to
+integrate with Sandstorm\[aq]s permissions.
+This is disabled by default.
+.SH EDITING, UPLOADING, DOWNLOADING
+.PP
+If you enable the \f[C]manage\f[] capability mentioned above, you\[aq]ll
+see a new "spanner" button to the right of the search form.
+Clicking this will let you edit, upload, or download the journal file or
+any files it includes.
+.PP
+Note, unlike any other hledger command, in this mode you (or any
+visitor) can alter or wipe the data files.
+.PP
+Normally whenever a file is changed in this way, hledger\-web saves a
+numbered backup (assuming file permissions allow it, the disk is not
+full, etc.) hledger\-web is not aware of version control systems,
+currently; if you use one, you\[aq]ll have to arrange to commit the
+changes yourself (eg with a cron job or a file watcher like entr).
+.PP
+Changes which would leave the journal file(s) unparseable or non\-valid
+(eg with failing balance assertions) are prevented.
+(Probably.
+This needs re\-testing.)
+.SH RELOADING
+.PP
+hledger\-web detects changes made to the files by other means (eg if you
+edit it directly, outside of hledger\-web), and it will show the new
+data when you reload the page or navigate to a new page.
+If a change makes a file unparseable, hledger\-web will display an error
+message until the file has been fixed.
+.SH JSON API
+.PP
+In addition to the web UI, hledger\-web provides some JSON API routes.
+These are similar to the API provided by the hledger\-api tool, but it
+may be convenient to have them in hledger\-web also.
+.IP
+.nf
+\f[C]
+/accountnames
+/transactions
+/prices
+/commodities
+/accounts
+/accounttransactions/#AccountName
+\f[]
+.fi
 .SH ENVIRONMENT
 .PP
 \f[B]LEDGER_FILE\f[] The journal file path when not specified with
diff --git a/hledger-web.cabal b/hledger-web.cabal
--- a/hledger-web.cabal
+++ b/hledger-web.cabal
@@ -4,10 +4,10 @@
 --
 -- see: https://github.com/sol/hpack
 --
--- hash: b8f9535cea480d164624211cb8b2a0e0cfc7876b3c2c38539c62fe21ef588d14
+-- hash: da546060d989bf42b59988f7db214e93c1ac3e3d166272cbcdbf1c8cbd283187
 
 name:           hledger-web
-version:        1.13
+version:        1.14
 synopsis:       Web interface for the hledger accounting tool
 description:    This is hledger's web interface.
                 It provides a more user-friendly and collaborative UI than the
@@ -27,7 +27,7 @@
 maintainer:     Simon Michael <simon@joyful.com>
 license:        GPL-3
 license-file:   LICENSE
-tested-with:    GHC==7.10.3, GHC==8.0.2, GHC==8.2.2, GHC==8.4.3
+tested-with:    GHC==7.10.3, GHC==8.0.2, GHC==8.2.2, GHC==8.4.3, GHC==8.6.3
 build-type:     Simple
 extra-source-files:
     CHANGES.md
@@ -133,12 +133,13 @@
       Hledger.Web.Application
       Hledger.Web.Foundation
       Hledger.Web.Handler.AddR
-      Hledger.Web.Handler.Common
       Hledger.Web.Handler.EditR
       Hledger.Web.Handler.JournalR
+      Hledger.Web.Handler.MiscR
       Hledger.Web.Handler.RegisterR
       Hledger.Web.Handler.UploadR
       Hledger.Web.Import
+      Hledger.Web.Json
       Hledger.Web.Main
       Hledger.Web.Settings
       Hledger.Web.Settings.StaticFiles
@@ -150,9 +151,11 @@
   hs-source-dirs:
       ./.
   ghc-options: -Wall -fwarn-tabs
-  cpp-options: -DVERSION="1.13"
+  cpp-options: -DVERSION="1.14"
   build-depends:
-      base >=4.8 && <4.13
+      Decimal
+    , aeson
+    , base >=4.8 && <4.13
     , blaze-html
     , blaze-markup
     , bytestring
@@ -161,14 +164,16 @@
     , cmdargs >=0.10
     , conduit
     , conduit-extra >=1.1
+    , containers
     , data-default
     , directory
     , filepath
     , hjsmin
-    , hledger >=1.13 && <1.14
-    , hledger-lib >=1.13 && <1.14
+    , hledger >=1.14 && <1.15
+    , hledger-lib >=1.14 && <1.15
     , http-client
     , http-conduit
+    , http-types
     , json
     , megaparsec >=7.0.0 && <8
     , mtl
@@ -206,7 +211,7 @@
   hs-source-dirs:
       app
   ghc-options: -Wall -fwarn-tabs
-  cpp-options: -DVERSION="1.13"
+  cpp-options: -DVERSION="1.14"
   build-depends:
       base
     , hledger-web
diff --git a/hledger-web.info b/hledger-web.info
--- a/hledger-web.info
+++ b/hledger-web.info
@@ -3,7 +3,7 @@
 
 File: hledger-web.info,  Node: Top,  Next: OPTIONS,  Up: (dir)
 
-hledger-web(1) hledger-web 1.13
+hledger-web(1) hledger-web 1.14
 *******************************
 
 hledger-web is hledger's web interface.  It starts a simple web
@@ -25,57 +25,27 @@
 '$LEDGER_FILE', or '$HOME/.hledger.journal' (on windows, perhaps
 'C:/Users/USER/.hledger.journal').  For more about this see hledger(1),
 hledger_journal(5) etc.
-
-   By default, hledger-web starts the web app in "transient mode" and
-also opens it in your default web browser if possible.  In this mode the
-web app will keep running for as long as you have it open in a browser
-window, and will exit after two minutes of inactivity (no requests and
-no browser windows viewing it).  With '--serve', it just runs the web
-app without exiting, and logs requests to the console.
-
-   By default the server listens on IP address 127.0.0.1, accessible
-only to local requests.  You can use '--host' to change this, eg '--host
-0.0.0.0' to listen on all configured addresses.
-
-   Similarly, use '--port' to set a TCP port other than 5000, eg if you
-are running multiple hledger-web instances.
-
-   You can use '--base-url' to change the protocol, hostname, port and
-path that appear in hyperlinks, useful eg for integrating hledger-web
-within a larger website.  The default is 'http://HOST:PORT/' using the
-server's configured host address and TCP port (or 'http://HOST' if PORT
-is 80).
-
-   With '--file-url' you can set a different base url for static files,
-eg for better caching or cookie-less serving on high performance
-websites.
-
-   Note there is no built-in access control (aside from listening on
-127.0.0.1 by default).  So you will need to hide hledger-web behind an
-authenticating proxy (such as apache or nginx) if you want to restrict
-who can see and add entries to your journal.
-
-   Command-line options and arguments may be used to set an initial
-filter on the data.  This is not shown in the web UI, but it will be
-applied in addition to any search query entered there.
-
-   With journal and timeclock files (but not CSV files, currently) the
-web app detects changes made by other means and will show the new data
-on the next request.  If a change makes the file unparseable,
-hledger-web will show an error until the file has been fixed.
 * Menu:
 
 * OPTIONS::
+* PERMISSIONS::
+* EDITING UPLOADING DOWNLOADING::
+* RELOADING::
+* JSON API::
 
 
-File: hledger-web.info,  Node: OPTIONS,  Prev: Top,  Up: Top
+File: hledger-web.info,  Node: OPTIONS,  Next: PERMISSIONS,  Prev: Top,  Up: Top
 
 1 OPTIONS
 *********
 
-Note: if invoking hledger-web as a hledger subcommand, write '--' before
-options as shown above.
+Command-line options and arguments may be used to set an initial filter
+on the data.  These filter options are not shown in the web UI, but it
+will be applied in addition to any search query entered there.
 
+   Note: if invoking hledger-web as a hledger subcommand, write '--'
+before options, as shown in the synopsis above.
+
 '--serve'
 
      serve and log requests, don't browse or auto-exit
@@ -96,7 +66,15 @@
      normally serves static files itself, but if you wanted to serve
      them from another server for efficiency, you would set the url with
      this.
+'--capabilities=CAP[,CAP..]'
 
+     enable the view, add, and/or manage capabilities (default:
+     view,add)
+'--capabilities-header=HTTPHEADER'
+
+     read capabilities to enable from a HTTP header, like
+     X-Sandstorm-Permissions (default: disabled)
+
    hledger input options:
 
 '-f FILE --file=FILE'
@@ -209,10 +187,129 @@
 should contain one command line option/argument per line.  (To prevent
 this, insert a '--' argument before.)
 
+   By default, hledger-web starts the web app in "transient mode" and
+also opens it in your default web browser if possible.  In this mode the
+web app will keep running for as long as you have it open in a browser
+window, and will exit after two minutes of inactivity (no requests and
+no browser windows viewing it).  With '--serve', it just runs the web
+app without exiting, and logs requests to the console.
+
+   By default the server listens on IP address 127.0.0.1, accessible
+only to local requests.  You can use '--host' to change this, eg '--host
+0.0.0.0' to listen on all configured addresses.
+
+   Similarly, use '--port' to set a TCP port other than 5000, eg if you
+are running multiple hledger-web instances.
+
+   You can use '--base-url' to change the protocol, hostname, port and
+path that appear in hyperlinks, useful eg for integrating hledger-web
+within a larger website.  The default is 'http://HOST:PORT/' using the
+server's configured host address and TCP port (or 'http://HOST' if PORT
+is 80).
+
+   With '--file-url' you can set a different base url for static files,
+eg for better caching or cookie-less serving on high performance
+websites.
+
 
+File: hledger-web.info,  Node: PERMISSIONS,  Next: EDITING UPLOADING DOWNLOADING,  Prev: OPTIONS,  Up: Top
+
+2 PERMISSIONS
+*************
+
+By default, hledger-web allows anyone who can reach it to view the
+journal and to add new transactions, but not to change existing data.
+
+   You can restrict who can reach it by
+
+   * setting the IP address it listens on (see '--host' above).  By
+     default it listens on 127.0.0.1, accessible to all users on the
+     local machine.
+   * putting it behind an authenticating proxy, using eg apache or nginx
+   * custom firewall rules
+
+   You can restrict what the users who reach it can do, by
+
+   * using the '--capabilities=CAP[,CAP..]' flag when you start it,
+     enabling one or more of the following capabilities.  The default
+     value is 'view,add':
+        * 'view' - allows viewing the journal file and all included
+          files
+        * 'add' - allows adding new transactions to the main journal
+          file
+        * 'manage' - allows editing, uploading or downloading the main
+          or included files
+
+   * using the '--capabilities-header=HTTPHEADER' flag to specify a HTTP
+     header from which it will read capabilities to enable.  hledger-web
+     on Sandstorm uses the X-Sandstorm-Permissions header to integrate
+     with Sandstorm's permissions.  This is disabled by default.
+
+
+File: hledger-web.info,  Node: EDITING UPLOADING DOWNLOADING,  Next: RELOADING,  Prev: PERMISSIONS,  Up: Top
+
+3 EDITING, UPLOADING, DOWNLOADING
+*********************************
+
+If you enable the 'manage' capability mentioned above, you'll see a new
+"spanner" button to the right of the search form.  Clicking this will
+let you edit, upload, or download the journal file or any files it
+includes.
+
+   Note, unlike any other hledger command, in this mode you (or any
+visitor) can alter or wipe the data files.
+
+   Normally whenever a file is changed in this way, hledger-web saves a
+numbered backup (assuming file permissions allow it, the disk is not
+full, etc.)  hledger-web is not aware of version control systems,
+currently; if you use one, you'll have to arrange to commit the changes
+yourself (eg with a cron job or a file watcher like entr).
+
+   Changes which would leave the journal file(s) unparseable or
+non-valid (eg with failing balance assertions) are prevented.
+(Probably.  This needs re-testing.)
+
+
+File: hledger-web.info,  Node: RELOADING,  Next: JSON API,  Prev: EDITING UPLOADING DOWNLOADING,  Up: Top
+
+4 RELOADING
+***********
+
+hledger-web detects changes made to the files by other means (eg if you
+edit it directly, outside of hledger-web), and it will show the new data
+when you reload the page or navigate to a new page.  If a change makes a
+file unparseable, hledger-web will display an error message until the
+file has been fixed.
+
+
+File: hledger-web.info,  Node: JSON API,  Prev: RELOADING,  Up: Top
+
+5 JSON API
+**********
+
+In addition to the web UI, hledger-web provides some JSON API routes.
+These are similar to the API provided by the hledger-api tool, but it
+may be convenient to have them in hledger-web also.
+
+/accountnames
+/transactions
+/prices
+/commodities
+/accounts
+/accounttransactions/#AccountName
+
+
 Tag Table:
 Node: Top72
-Node: OPTIONS3154
-Ref: #options3239
+Node: OPTIONS1354
+Ref: #options1459
+Node: PERMISSIONS6549
+Ref: #permissions6688
+Node: EDITING UPLOADING DOWNLOADING7900
+Ref: #editing-uploading-downloading8081
+Node: RELOADING8915
+Ref: #reloading9049
+Node: JSON API9359
+Ref: #json-api9453
 
 End Tag Table
diff --git a/hledger-web.txt b/hledger-web.txt
--- a/hledger-web.txt
+++ b/hledger-web.txt
@@ -35,45 +35,13 @@
        C:/Users/USER/.hledger.journal).  For more about this  see  hledger(1),
        hledger_journal(5) etc.
 
-       By default, hledger-web starts the web app in "transient mode" and also
-       opens it in your default web browser if possible.  In this mode the web
-       app will keep running for as long as you have it open in a browser win-
-       dow, and will exit after two minutes of inactivity (no requests and  no
-       browser  windows  viewing  it).  With --serve, it just runs the web app
-       without exiting, and logs requests to the console.
-
-       By default the server listens on IP address 127.0.0.1, accessible  only
-       to   local   requests.    You   can  use  --host  to  change  this,  eg
-       --host 0.0.0.0 to listen on all configured addresses.
-
-       Similarly, use --port to set a TCP port other than 5000, eg if you  are
-       running multiple hledger-web instances.
-
-       You  can use --base-url to change the protocol, hostname, port and path
-       that appear in hyperlinks, useful eg for integrating hledger-web within
-       a  larger website.  The default is http://HOST:PORT/ using the server's
-       configured host address and TCP port (or http://HOST if PORT is 80).
-
-       With --file-url you can set a different base url for static  files,  eg
-       for better caching or cookie-less serving on high performance websites.
-
-       Note there is no built-in  access  control  (aside  from  listening  on
-       127.0.0.1  by default).  So you will need to hide hledger-web behind an
-       authenticating proxy (such as apache or nginx) if you want to  restrict
-       who can see and add entries to your journal.
-
+OPTIONS
        Command-line options and arguments may be used to set an initial filter
-       on the data.  This is not shown in the web UI, but it will  be  applied
-       in addition to any search query entered there.
-
-       With journal and timeclock files (but not CSV files, currently) the web
-       app detects changes made by other means and will show the new  data  on
-       the  next request.  If a change makes the file unparseable, hledger-web
-       will show an error until the file has been fixed.
+       on the data.  These filter options are not shown in the web UI, but  it
+       will be applied in addition to any search query entered there.
 
-OPTIONS
-       Note: if invoking hledger-web as a hledger subcommand, write --  before
-       options as shown above.
+       Note:  if invoking hledger-web as a hledger subcommand, write -- before
+       options, as shown in the synopsis above.
 
        --serve
               serve and log requests, don't browse or auto-exit
@@ -85,16 +53,24 @@
               listen on this TCP port (default: 5000)
 
        --base-url=URL
-              set  the  base  url  (default:  http://IPADDR:PORT).   You would
+              set the  base  url  (default:  http://IPADDR:PORT).   You  would
               change this when sharing over the network, or integrating within
               a larger website.
 
        --file-url=URL
               set the static files url (default: BASEURL/static).  hledger-web
-              normally serves static files itself, but if you wanted to  serve
-              them  from  another server for efficiency, you would set the url
+              normally  serves static files itself, but if you wanted to serve
+              them from another server for efficiency, you would set  the  url
               with this.
 
+       --capabilities=CAP[,CAP..]
+              enable  the  view,  add,  and/or  manage  capabilities (default:
+              view,add)
+
+       --capabilities-header=HTTPHEADER
+              read capabilities to enable from a  HTTP  header,  like  X-Sand-
+              storm-Permissions (default: disabled)
+
        hledger input options:
 
        -f FILE --file=FILE
@@ -102,7 +78,7 @@
               $LEDGER_FILE or $HOME/.hledger.journal)
 
        --rules-file=RULESFILE
-              Conversion   rules  file  to  use  when  reading  CSV  (default:
+              Conversion  rules  file  to  use  when  reading  CSV   (default:
               FILE.rules)
 
        --separator=CHAR
@@ -143,11 +119,11 @@
               multiperiod/multicolumn report by year
 
        -p --period=PERIODEXP
-              set start date, end date, and/or reporting interval all at  once
+              set  start date, end date, and/or reporting interval all at once
               using period expressions syntax (overrides the flags above)
 
        --date2
-              match  the  secondary  date  instead (see command help for other
+              match the secondary date instead (see  command  help  for  other
               effects)
 
        -U --unmarked
@@ -166,21 +142,21 @@
               hide/aggregate accounts or postings more than NUM levels deep
 
        -E --empty
-              show items with zero amount, normally hidden (and vice-versa  in
+              show  items with zero amount, normally hidden (and vice-versa in
               hledger-ui/hledger-web)
 
        -B --cost
-              convert  amounts  to  their  cost at transaction time (using the
+              convert amounts to their cost at  transaction  time  (using  the
               transaction price, if any)
 
        -V --value
-              convert amounts to their market value on  the  report  end  date
+              convert  amounts  to  their  market value on the report end date
               (using the most recent applicable market price, if any)
 
        --auto apply automated posting rules to modify transactions.
 
        --forecast
-              apply  periodic  transaction  rules  to generate future transac-
+              apply periodic transaction rules  to  generate  future  transac-
               tions, to 6 months from now or report end date.
 
        When a reporting option appears more than once in the command line, the
@@ -200,22 +176,114 @@
               show debug output (levels 1-9, default: 1)
 
        A @FILE argument will be expanded to the contents of FILE, which should
-       contain one command line option/argument per line.  (To  prevent  this,
+       contain  one  command line option/argument per line.  (To prevent this,
        insert a -- argument before.)
 
+       By default, hledger-web starts the web app in "transient mode" and also
+       opens it in your default web browser if possible.  In this mode the web
+       app will keep running for as long as you have it open in a browser win-
+       dow,  and will exit after two minutes of inactivity (no requests and no
+       browser windows viewing it).  With --serve, it just runs  the  web  app
+       without exiting, and logs requests to the console.
+
+       By  default the server listens on IP address 127.0.0.1, accessible only
+       to  local  requests.   You  can  use  --host   to   change   this,   eg
+       --host 0.0.0.0 to listen on all configured addresses.
+
+       Similarly,  use --port to set a TCP port other than 5000, eg if you are
+       running multiple hledger-web instances.
+
+       You can use --base-url to change the protocol, hostname, port and  path
+       that appear in hyperlinks, useful eg for integrating hledger-web within
+       a larger website.  The default is http://HOST:PORT/ using the  server's
+       configured host address and TCP port (or http://HOST if PORT is 80).
+
+       With  --file-url  you can set a different base url for static files, eg
+       for better caching or cookie-less serving on high performance websites.
+
+PERMISSIONS
+       By  default,  hledger-web  allows  anyone  who can reach it to view the
+       journal and to add new transactions, but not to change existing data.
+
+       You can restrict who can reach it by
+
+       o setting the IP address it listens on (see --host above).  By  default
+         it  listens  on  127.0.0.1,  accessible  to  all  users  on the local
+         machine.
+
+       o putting it behind an authenticating proxy, using eg apache or nginx
+
+       o custom firewall rules
+
+       You can restrict what the users who reach it can do, by
+
+       o using the --capabilities=CAP[,CAP..] flag when you start it, enabling
+         one  or  more  of  the  following capabilities.  The default value is
+         view,add:
+
+         o view - allows viewing the journal file and all included files
+
+         o add - allows adding new transactions to the main journal file
+
+         o manage - allows editing,  uploading  or  downloading  the  main  or
+           included files
+
+       o using  the  --capabilities-header=HTTPHEADER  flag  to specify a HTTP
+         header from which it will read capabilities to  enable.   hledger-web
+         on  Sandstorm  uses  the  X-Sandstorm-Permissions header to integrate
+         with Sandstorm's permissions.  This is disabled by default.
+
+EDITING, UPLOADING, DOWNLOADING
+       If you enable the manage capability mentioned above, you'll see  a  new
+       "spanner"  button  to the right of the search form.  Clicking this will
+       let you edit, upload, or download the journal  file  or  any  files  it
+       includes.
+
+       Note,  unlike any other hledger command, in this mode you (or any visi-
+       tor) can alter or wipe the data files.
+
+       Normally whenever a file is changed in this way,  hledger-web  saves  a
+       numbered  backup  (assuming  file permissions allow it, the disk is not
+       full, etc.) hledger-web is not aware of version control  systems,  cur-
+       rently;  if  you  use one, you'll have to arrange to commit the changes
+       yourself (eg with a cron job or a file watcher like entr).
+
+       Changes which would leave the journal file(s) unparseable or  non-valid
+       (eg  with  failing balance assertions) are prevented.  (Probably.  This
+       needs re-testing.)
+
+RELOADING
+       hledger-web detects changes made to the files by other means (eg if you
+       edit  it  directly,  outside  of hledger-web), and it will show the new
+       data when you reload the page or navigate to a new page.  If  a  change
+       makes  a  file  unparseable,  hledger-web will display an error message
+       until the file has been fixed.
+
+JSON API
+       In addition to the web UI, hledger-web provides some JSON  API  routes.
+       These  are  similar to the API provided by the hledger-api tool, but it
+       may be convenient to have them in hledger-web also.
+
+              /accountnames
+              /transactions
+              /prices
+              /commodities
+              /accounts
+              /accounttransactions/#AccountName
+
 ENVIRONMENT
        LEDGER_FILE The journal file path when not specified with -f.  Default:
-       ~/.hledger.journal (on  windows,  perhaps  C:/Users/USER/.hledger.jour-
+       ~/.hledger.journal  (on  windows,  perhaps C:/Users/USER/.hledger.jour-
        nal).
 
 FILES
-       Reads  data from one or more files in hledger journal, timeclock, time-
-       dot,  or  CSV  format  specified   with   -f,   or   $LEDGER_FILE,   or
-       $HOME/.hledger.journal           (on          windows,          perhaps
+       Reads data from one or more files in hledger journal, timeclock,  time-
+       dot,   or   CSV   format   specified   with  -f,  or  $LEDGER_FILE,  or
+       $HOME/.hledger.journal          (on          windows,           perhaps
        C:/Users/USER/.hledger.journal).
 
 BUGS
-       The need to precede options with -- when invoked from hledger  is  awk-
+       The  need  to precede options with -- when invoked from hledger is awk-
        ward.
 
        -f- doesn't work (hledger-web can't read from stdin).
@@ -229,7 +297,7 @@
 
 
 REPORTING BUGS
-       Report  bugs at http://bugs.hledger.org (or on the #hledger IRC channel
+       Report bugs at http://bugs.hledger.org (or on the #hledger IRC  channel
        or hledger mail list)
 
 
@@ -243,7 +311,7 @@
 
 
 SEE ALSO
-       hledger(1),     hledger-ui(1),     hledger-web(1),      hledger-api(1),
+       hledger(1),      hledger-ui(1),     hledger-web(1),     hledger-api(1),
        hledger_csv(5), hledger_journal(5), hledger_timeclock(5), hledger_time-
        dot(5), ledger(1)
 
@@ -251,4 +319,4 @@
 
 
 
-hledger-web 1.13                 February 2019                  hledger-web(1)
+hledger-web 1.14                  March 2019                    hledger-web(1)
diff --git a/templates/add-form.hamlet b/templates/add-form.hamlet
--- a/templates/add-form.hamlet
+++ b/templates/add-form.hamlet
@@ -40,7 +40,7 @@
     <div .col-md-9 .col-xs-6 .col-sm-6>
 
 <div .account-postings>
-  $forall (n, (acc, amt, accE, amtE)) <- msgs
+  $forall (n, (acc, amt, accE, amtE)) <- displayRows
     <div .form-group .row .account-group>
       <div .col-md-8 .col-xs-8 .col-sm-8 :isJust accE:.has-error>
         <input .account-input.form-control.input-lg.typeahead type=text
diff --git a/templates/chart.hamlet b/templates/chart.hamlet
--- a/templates/chart.hamlet
+++ b/templates/chart.hamlet
@@ -38,7 +38,7 @@
             #{simpleMixedAmountQuantity $ triCommodityBalance c i},
             '#{showMixedAmountWithZeroCommodity $ triCommodityAmount c i}',
             '#{showMixedAmountWithZeroCommodity $ triCommodityBalance c i}',
-            '#{concat $ intersperse "\\n" $ lines  $ show $ triOrigTransaction i}',
+            '#{concat $ intersperse "\\n" $ lines  $ showTransaction $ triOrigTransaction i}',
             #{tindex $ triOrigTransaction i}
            ],
           /* [] */
diff --git a/templates/journal.hamlet b/templates/journal.hamlet
--- a/templates/journal.hamlet
+++ b/templates/journal.hamlet
@@ -15,7 +15,7 @@
       <th .amount style="text-align:right;">Amount
 
     $forall (torig, _, split, _, amt, _) <- items
-      <tr .title #transaction-#{tindex torig}>
+      <tr .title #transaction-#{tindex torig} title="#{showTransaction torig}">
         <td .date nowrap>
           #{show (tdate torig)}
         <td colspan=2>
@@ -25,7 +25,7 @@
             ^{mixedAmountAsHtml amt}
 
       $forall Posting { paccount = acc, pamount = amt } <- tpostings torig
-        <tr .posting title="#{show torig}">
+        <tr .posting>
           <td>
           <td>
           <td>
diff --git a/templates/register.hamlet b/templates/register.hamlet
--- a/templates/register.hamlet
+++ b/templates/register.hamlet
@@ -19,11 +19,11 @@
 
     <tbody>
       $forall (torig, tacct, split, acct, amt, bal) <- items
-        <tr ##{tindex torig} title="#{show torig}" style="vertical-align:top;">
+        <tr ##{tindex torig} title="#{showTransaction torig}" style="vertical-align:top;">
           <td .date>
             <a href="@{JournalR}#transaction-#{tindex torig}">
               #{show (tdate tacct)}
-          <td title="#{show torig}">
+          <td>
             #{textElideRight 30 (tdescription tacct)}
           <td .account>
             #{elideRight 40 acct}
