diff --git a/docs/source/index.rst b/docs/source/index.rst
index fd3924b8df3198d362136841eb9bf8aad5702fef..58bf2107b2c7e5a2b482b018d3eb9aa2165339c6 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -12,6 +12,7 @@ Welcome to Secure Access Token Authorizer's documentation!
 
    code_of_conduct
    CONTRIBUTING
+   interface
 
 Indices and tables
 ==================
diff --git a/docs/source/interface.rst b/docs/source/interface.rst
new file mode 100644
index 0000000000000000000000000000000000000000..e48f900634ab6bb7d78bf6c48794aab4d4b020fc
--- /dev/null
+++ b/docs/source/interface.rst
@@ -0,0 +1,50 @@
+Interface
+=========
+
+Client
+------
+
+When enabled in a Tahoe-LAFS client node,
+SecureAccessTokenAuthorizer publishes an HTTP-based interface inside the main Tahoe-LAFS web interface.
+
+``PUT /storage-plugins/privatestorageio-satauthz-v1/payment-reference-number``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+This endpoint allows an external agent which has submitted a payment to cause the plugin to redeem the payment reference for tokens.
+The request body for this endpoint must have the ``application/json`` content-type.
+The request body contains a simple json object containing the payment reference number::
+
+  {"payment-reference-number": "<payment reference number>"}
+
+The endpoint responds to such a request with an **OK** HTTP response code if the payment reference number is accepted for processing.
+If the payment reference number cannot be accepted at the time of the request then the response code will be anything other than **OK**.
+
+If the response is **OK** then a repeated request with the same body will have no effect.
+If the response is not **OK** then a repeated request with the same body will try to accept the number again.
+
+``GET /storage-plugins/privatestorageio-satauthz-v1/payment-reference-number/<payment reference number>``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+This endpoint allows an external agent to monitor the status of the redemption of a payment reference number.
+This endpoint accepts no request body.
+
+If the payment reference number is not known then the response is **NOT FOUND**.
+For any payment reference number which has previously been submitted,
+the response is **OK** with an ``application/json`` content-type response body like::
+
+  {"value": <string>}
+
+The ``value`` property merely indicates the payment reference number which was requested.
+Further properties will be added to this response in the near future.
+
+``GET /storage-plugins/privatestorageio-satauthz-v1/payment-reference-number``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+This endpoint allows an external agent to retrieve the status of all payment reference numbers.
+This endpoint accepts no request body.
+
+The response is **OK** with ``application/json`` content-type response body like::
+
+  {"payment-reference-numbers": [<payment reference status object>, ...]}
+
+The elements of the list are objects like the one returned by issuing a **GET** to a child of this collection resource.
diff --git a/requirements-tests.in b/requirements-tests.in
index eb7961896371c2ea899ea51bd04f514eba1fb4d9..eb1dc70dd9adc9cb00f8adabc2c5084c42d22440 100644
--- a/requirements-tests.in
+++ b/requirements-tests.in
@@ -4,3 +4,4 @@ attrs
 hypothesis
 testtools
 fixtures
+treq
diff --git a/requirements-tests.txt b/requirements-tests.txt
index 6f1599c6585731861f897f41cdfe76a0f3b3aa29..b3280eee0b954b167f9cfc9ecf1949a5e3bec39d 100644
--- a/requirements-tests.txt
+++ b/requirements-tests.txt
@@ -8,6 +8,10 @@ argparse==1.4.0 \
     --hash=sha256:62b089a55be1d8949cd2bc7e0df0bddb9e028faefc8c32038cc84862aefdd6e4 \
     --hash=sha256:c31647edb69fd3d465a847ea3157d37bed1f95f19760b11a47aa91c04b666314 \
     # via unittest2
+asn1crypto==0.24.0 \
+    --hash=sha256:2f1adbb7546ed199e3c90ef23ec95c5cf3585bac7d11fb7eb562a3fe89c64e87 \
+    --hash=sha256:9d5c20441baf0cb60a4ac34cc447c6c189024b6b4c6cd7877034f4965c464e49 \
+    # via cryptography
 attrs==19.1.0 \
     --hash=sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79 \
     --hash=sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399
@@ -19,6 +23,36 @@ certifi==2019.6.16 \
     --hash=sha256:046832c04d4e752f37383b628bc601a7ea7211496b4638f6514d0e5b9acc4939 \
     --hash=sha256:945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695 \
     # via requests
+cffi==1.12.3 \
+    --hash=sha256:041c81822e9f84b1d9c401182e174996f0bae9991f33725d059b771744290774 \
+    --hash=sha256:046ef9a22f5d3eed06334d01b1e836977eeef500d9b78e9ef693f9380ad0b83d \
+    --hash=sha256:066bc4c7895c91812eff46f4b1c285220947d4aa46fa0a2651ff85f2afae9c90 \
+    --hash=sha256:066c7ff148ae33040c01058662d6752fd73fbc8e64787229ea8498c7d7f4041b \
+    --hash=sha256:2444d0c61f03dcd26dbf7600cf64354376ee579acad77aef459e34efcb438c63 \
+    --hash=sha256:300832850b8f7967e278870c5d51e3819b9aad8f0a2c8dbe39ab11f119237f45 \
+    --hash=sha256:34c77afe85b6b9e967bd8154e3855e847b70ca42043db6ad17f26899a3df1b25 \
+    --hash=sha256:46de5fa00f7ac09f020729148ff632819649b3e05a007d286242c4882f7b1dc3 \
+    --hash=sha256:4aa8ee7ba27c472d429b980c51e714a24f47ca296d53f4d7868075b175866f4b \
+    --hash=sha256:4d0004eb4351e35ed950c14c11e734182591465a33e960a4ab5e8d4f04d72647 \
+    --hash=sha256:4e3d3f31a1e202b0f5a35ba3bc4eb41e2fc2b11c1eff38b362de710bcffb5016 \
+    --hash=sha256:50bec6d35e6b1aaeb17f7c4e2b9374ebf95a8975d57863546fa83e8d31bdb8c4 \
+    --hash=sha256:55cad9a6df1e2a1d62063f79d0881a414a906a6962bc160ac968cc03ed3efcfb \
+    --hash=sha256:5662ad4e4e84f1eaa8efce5da695c5d2e229c563f9d5ce5b0113f71321bcf753 \
+    --hash=sha256:59b4dc008f98fc6ee2bb4fd7fc786a8d70000d058c2bbe2698275bc53a8d3fa7 \
+    --hash=sha256:73e1ffefe05e4ccd7bcea61af76f36077b914f92b76f95ccf00b0c1b9186f3f9 \
+    --hash=sha256:a1f0fd46eba2d71ce1589f7e50a9e2ffaeb739fb2c11e8192aa2b45d5f6cc41f \
+    --hash=sha256:a2e85dc204556657661051ff4bab75a84e968669765c8a2cd425918699c3d0e8 \
+    --hash=sha256:a5457d47dfff24882a21492e5815f891c0ca35fefae8aa742c6c263dac16ef1f \
+    --hash=sha256:a8dccd61d52a8dae4a825cdbb7735da530179fea472903eb871a5513b5abbfdc \
+    --hash=sha256:ae61af521ed676cf16ae94f30fe202781a38d7178b6b4ab622e4eec8cefaff42 \
+    --hash=sha256:b012a5edb48288f77a63dba0840c92d0504aa215612da4541b7b42d849bc83a3 \
+    --hash=sha256:d2c5cfa536227f57f97c92ac30c8109688ace8fa4ac086d19d0af47d134e2909 \
+    --hash=sha256:d42b5796e20aacc9d15e66befb7a345454eef794fdb0737d1af593447c6c8f45 \
+    --hash=sha256:dee54f5d30d775f525894d67b1495625dd9322945e7fee00731952e0368ff42d \
+    --hash=sha256:e070535507bd6aa07124258171be2ee8dfc19119c28ca94c9dfb7efd23564512 \
+    --hash=sha256:e1ff2748c84d97b065cc95429814cdba39bcbd77c9c85c89344b317dc0d9cbff \
+    --hash=sha256:ed851c75d1e0e043cbf5ca9a8e1b13c4c90f3fbd863dacb01c0808e2b5204201 \
+    # via cryptography
 chardet==3.0.4 \
     --hash=sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae \
     --hash=sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691 \
@@ -63,12 +97,30 @@ coverage==4.5.3 \
     --hash=sha256:f8019c5279eb32360ca03e9fac40a12667715546eed5c5eb59eb381f2f501260 \
     --hash=sha256:fc5f4d209733750afd2714e9109816a29500718b32dd9a5db01c0cb3a019b96a \
     # via codecov
+cryptography==2.7 \
+    --hash=sha256:24b61e5fcb506424d3ec4e18bca995833839bf13c59fc43e530e488f28d46b8c \
+    --hash=sha256:25dd1581a183e9e7a806fe0543f485103232f940fcfc301db65e630512cce643 \
+    --hash=sha256:3452bba7c21c69f2df772762be0066c7ed5dc65df494a1d53a58b683a83e1216 \
+    --hash=sha256:41a0be220dd1ed9e998f5891948306eb8c812b512dc398e5a01846d855050799 \
+    --hash=sha256:5751d8a11b956fbfa314f6553d186b94aa70fdb03d8a4d4f1c82dcacf0cbe28a \
+    --hash=sha256:5f61c7d749048fa6e3322258b4263463bfccefecb0dd731b6561cb617a1d9bb9 \
+    --hash=sha256:72e24c521fa2106f19623a3851e9f89ddfdeb9ac63871c7643790f872a305dfc \
+    --hash=sha256:7b97ae6ef5cba2e3bb14256625423413d5ce8d1abb91d4f29b6d1a081da765f8 \
+    --hash=sha256:961e886d8a3590fd2c723cf07be14e2a91cf53c25f02435c04d39e90780e3b53 \
+    --hash=sha256:96d8473848e984184b6728e2c9d391482008646276c3ff084a1bd89e15ff53a1 \
+    --hash=sha256:ae536da50c7ad1e002c3eee101871d93abdc90d9c5f651818450a0d3af718609 \
+    --hash=sha256:b0db0cecf396033abb4a93c95d1602f268b3a68bb0a9cc06a7cff587bb9a7292 \
+    --hash=sha256:cfee9164954c186b191b91d4193989ca994703b2fff406f71cf454a2d3c7327e \
+    --hash=sha256:e6347742ac8f35ded4a46ff835c60e68c22a536a8ae5c4422966d06946b6d4c6 \
+    --hash=sha256:f27d93f0139a3c056172ebb5d4f9056e770fdf0206c2f422ff2ebbad142e09ed \
+    --hash=sha256:f57b76e46a58b63d1c6375017f4564a28f19a5ca912691fd2e4261b3414b618d \
+    # via pyopenssl, service-identity
 enum34==1.1.6 \
     --hash=sha256:2d81cbbe0e73112bdfe6ef8576f2238f2ba27dd0d55752a776c41d38b7da2850 \
     --hash=sha256:644837f692e5f550741432dd3f223bbb9852018674981b1664e5dc339387588a \
     --hash=sha256:6bd0f6ad48ec2aa117d3d141940d484deccda84d4fcd884f5c3d93c23ecd8c79 \
     --hash=sha256:8ad8c4783bf61ded74527bffb48ed9b54166685e4230386a9ed9b1279e2df5b1 \
-    # via hypothesis
+    # via cryptography, hypothesis
 extras==1.0.0 \
     --hash=sha256:132e36de10b9c91d5d4cc620160a476e0468a88f16c9431817a6729611a81b4e \
     --hash=sha256:f689f08df47e2decf76aa6208c081306e7bd472630eb1ec8a875c67de2366e87 \
@@ -86,11 +138,15 @@ hypothesis==4.27.0 \
 idna==2.8 \
     --hash=sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407 \
     --hash=sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c \
-    # via hyperlink, requests
+    # via hyperlink, requests, twisted
 incremental==17.5.0 \
     --hash=sha256:717e12246dddf231a349175f48d74d93e2897244939173b01974ab6661406b9f \
     --hash=sha256:7b751696aaf36eebfab537e458929e194460051ccad279c72b755a167eebd4b3 \
-    # via twisted
+    # via treq, twisted
+ipaddress==1.0.22 \
+    --hash=sha256:64b28eec5e78e7510698f6d4da08800a5c575caa4a286c93d651c5d3ff7b6794 \
+    --hash=sha256:b146c751ea45cad6188dd6cf2d9b757f6f4f8d6ffb96a023e6f2e26eea02a72c \
+    # via cryptography, service-identity
 linecache2==1.0.0 \
     --hash=sha256:4b26ff4e7110db76eeb6f5a7b64a82623839d595c2038eeda662f2a2db78e97c \
     --hash=sha256:e78be9c0a0dfcbac712fe04fbf92b96cddae80b1b842f24248214c8496f006ef \
@@ -99,10 +155,25 @@ pbr==5.4.0 \
     --hash=sha256:36ebd78196e8c9588c972f5571230a059ff83783fabbbbedecc07be263ccd7e6 \
     --hash=sha256:5a03f59455ad54f01a94c15829b8b70065462b7bd8d5d7e983306b59127fc841 \
     # via fixtures, testtools
+pyasn1-modules==0.2.6 \
+    --hash=sha256:43c17a83c155229839cc5c6b868e8d0c6041dba149789b6d6e28801c64821722 \
+    --hash=sha256:e30199a9d221f1b26c885ff3d87fd08694dbbe18ed0e8e405a2a7126d30ce4c0 \
+    # via service-identity
+pyasn1==0.4.6 \
+    --hash=sha256:3bb81821d47b17146049e7574ab4bf1e315eb7aead30efe5d6a9ca422c9710be \
+    --hash=sha256:b773d5c9196ffbc3a1e13bdf909d446cad80a039aa3340bcad72f395b76ebc86 \
+    # via pyasn1-modules, service-identity
+pycparser==2.19 \
+    --hash=sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3 \
+    # via cffi
 pyhamcrest==1.9.0 \
     --hash=sha256:6b672c02fdf7470df9674ab82263841ce8333fb143f32f021f6cb26f0e512420 \
     --hash=sha256:8ffaa0a53da57e89de14ced7185ac746227a8894dbd5a3c718bf05ddbd1d56cd \
     # via twisted
+pyopenssl==19.0.0 \
+    --hash=sha256:aeca66338f6de19d1aa46ed634c3b9ae519a64b458f8468aec688e7e3c20f200 \
+    --hash=sha256:c727930ad54b10fc157015014b666f2d8b41f70c0d03e83ab67624fd3dd5d1e6 \
+    # via twisted
 python-mimeparse==1.6.0 \
     --hash=sha256:76e4b03d700a641fd7761d3cd4fdbbdcd787eade1ebfac43f877016328334f78 \
     --hash=sha256:a295f03ff20341491bfe4717a39cd0a8cc9afad619ba44b77e86b0ab8a2b8282 \
@@ -110,11 +181,15 @@ python-mimeparse==1.6.0 \
 requests==2.22.0 \
     --hash=sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4 \
     --hash=sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31 \
-    # via codecov
+    # via codecov, treq
+service-identity==18.1.0 \
+    --hash=sha256:001c0707759cb3de7e49c078a7c0c9cd12594161d3bf06b9c254fdcb1a60dc36 \
+    --hash=sha256:0858a54aabc5b459d1aafa8a518ed2081a285087f349fe3e55197989232e2e2d \
+    # via twisted
 six==1.12.0 \
     --hash=sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c \
     --hash=sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73 \
-    # via automat, fixtures, pyhamcrest, testtools, unittest2
+    # via automat, cryptography, fixtures, pyhamcrest, pyopenssl, testtools, treq, unittest2
 testtools==2.3.0 \
     --hash=sha256:5827ec6cf8233e0f29f51025addd713ca010061204fdea77484a2934690a0559 \
     --hash=sha256:a2be448869171b6e0f26d9544088b8b98439ec180ce272040236d570a40bcbed
@@ -122,7 +197,10 @@ traceback2==1.4.0 \
     --hash=sha256:05acc67a09980c2ecfedd3423f7ae0104839eccb55fc645773e1caa0951c3030 \
     --hash=sha256:8253cebec4b19094d67cc5ed5af99bf1dba1285292226e98a31929f87a5d6b23 \
     # via testtools, unittest2
-twisted==19.2.1 \
+treq==18.6.0 \
+    --hash=sha256:91e09ff6b524cc90aa5e934b909c8d0d1a9d36ebd618b6c38e37b17013e69f48 \
+    --hash=sha256:b936621beee3af98eca6dd74e0a8f939d8163c50714466a4994d22983a713ec2
+twisted[tls]==19.2.1 \
     --hash=sha256:fa2c04c2d68a9be7fc3975ba4947f653a57a656776f24be58ff0fe4b9aaf3e52
 unittest2==1.1.0 \
     --hash=sha256:13f77d0875db6d9b435e1d4f41e74ad4cc2eb6e1d5c824996092b3430f088bb8 \
diff --git a/requirements.in b/requirements.in
index a65a2be8d945c9391c604f71ab21c8e36ec0fe07..b35f3c038dc93795c97b4fff635f32a687f6ad6d 100644
--- a/requirements.in
+++ b/requirements.in
@@ -1,4 +1,4 @@
 attrs
 zope.interface
 twisted
-https://github.com/tahoe-lafs/tahoe-lafs/archive/48bd16a8d9109910122cc2e2c85eb4f378390135.zip#egg=tahoe-lafs
+https://github.com/tahoe-lafs/tahoe-lafs/archive/6c1a37c95188c1d9a877286ef726280a68d38a4b.zip#egg=tahoe-lafs
diff --git a/requirements.txt b/requirements.txt
index de68811252e40ec68f47411fa725f3114e410428..3542f6171328df3b3708c3b5b28628573cca3df3 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -148,13 +148,12 @@ magic-wormhole==0.11.2 \
 nevow==0.14.4 \
     --hash=sha256:0c6f3d897403216619a6eb8402f1248fbbade7b29c91a70a89092a06bbcb09be \
     --hash=sha256:2299a0d2a0c1312040705599d5d571acfea74df82b968c0b9264f6f45266cf6e
-pyasn1-modules==0.2.5 \
-    --hash=sha256:ef721f68f7951fab9b0404d42590f479e30d9005daccb1699b0a51bb4177db96 \
-    --hash=sha256:f309b6c94724aeaf7ca583feb1cc70430e10d7551de5e36edfc1ae6909bcfb3c \
-    # via service-identity
-pyasn1==0.4.5 \
-    --hash=sha256:da2420fe13a9452d8ae97a0e478adde1dee153b11ba832a95b223a2ba01c10f7 \
-    --hash=sha256:da6b43a8c9ae93bc80e2739efb38cc776ba74a886e3e9318d65fe81a8b8a2c6e \
+pyasn1-modules==0.2.6 \
+    --hash=sha256:43c17a83c155229839cc5c6b868e8d0c6041dba149789b6d6e28801c64821722 \
+    --hash=sha256:e30199a9d221f1b26c885ff3d87fd08694dbbe18ed0e8e405a2a7126d30ce4c0
+pyasn1==0.4.6 \
+    --hash=sha256:3bb81821d47b17146049e7574ab4bf1e315eb7aead30efe5d6a9ca422c9710be \
+    --hash=sha256:b773d5c9196ffbc3a1e13bdf909d446cad80a039aa3340bcad72f395b76ebc86 \
     # via pyasn1-modules, service-identity, twisted
 pycparser==2.19 \
     --hash=sha256:a988718abfad80b6b157acce7bf130a30876d27603738ac39f140993246b25b3 \
@@ -220,8 +219,8 @@ spake2==0.8 \
     --hash=sha256:c17a614b29ee4126206e22181f70a406c618d3c6c62ca6d6779bce95e9c926f4 \
     --hash=sha256:ce80705f8516c54364931f3b2c9a917ba001500d7f2fc76a0e8cf3bcaf0e30f7 \
     # via magic-wormhole
-https://github.com/tahoe-lafs/tahoe-lafs/archive/48bd16a8d9109910122cc2e2c85eb4f378390135.zip#egg=tahoe-lafs \
-    --hash=sha256:982e67ee3e515f400ab88b7ce02dba8a7c0dd897eb07f3111a80a06778e2b6e4
+https://github.com/tahoe-lafs/tahoe-lafs/archive/6c1a37c95188c1d9a877286ef726280a68d38a4b.zip#egg=tahoe-lafs \
+    --hash=sha256:fda66824f274f003d4cb98cf59104549a4e572f943c25383f2db56615aa6d105
 tqdm==4.32.2 \
     --hash=sha256:14a285392c32b6f8222ecfbcd217838f88e11630affe9006cd0e94c7eff3cb61 \
     --hash=sha256:25d4c0ea02a305a688e7e9c2cdc8f862f989ef2a4701ab28ee963295f5b109ab \
diff --git a/secure-access-token-authorizer.nix b/secure-access-token-authorizer.nix
index b76b884c87c66730b85cd8b1aa9eda2aa4e6339f..e0b7508acfb0ff8b60cde29ba17d075fa108229d 100644
--- a/secure-access-token-authorizer.nix
+++ b/secure-access-token-authorizer.nix
@@ -1,11 +1,12 @@
 { buildPythonPackage, sphinx, circleci-cli
 , attrs, zope_interface, twisted, tahoe-lafs
-, fixtures, testtools, hypothesis, pyflakes
+, fixtures, testtools, hypothesis, pyflakes, treq, coverage
 }:
 buildPythonPackage rec {
   version = "0.0";
   name = "secure-access-token-authorizer-${version}";
   src = ./.;
+
   depsBuildBuild = [
     sphinx
     circleci-cli
@@ -19,13 +20,16 @@ buildPythonPackage rec {
   ];
 
   checkInputs = [
+    coverage
     fixtures
     testtools
     hypothesis
+    twisted
+    treq
   ];
 
   checkPhase = ''
     ${pyflakes}/bin/pyflakes src/_secureaccesstokenauthorizer
-    ${twisted}/bin/trial _secureaccesstokenauthorizer
+    python -m coverage run --source _secureaccesstokenauthorizer,twisted.plugins.secureaccesstokenauthorizer --module twisted.trial _secureaccesstokenauthorizer
   '';
 }
diff --git a/src/_secureaccesstokenauthorizer/_base64.py b/src/_secureaccesstokenauthorizer/_base64.py
new file mode 100644
index 0000000000000000000000000000000000000000..ed838f8ee1eafe9ee3eb434426635fb910c83883
--- /dev/null
+++ b/src/_secureaccesstokenauthorizer/_base64.py
@@ -0,0 +1,43 @@
+# Copyright 2019 PrivateStorage.io, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+This module implements base64 encoding-related functionality.
+"""
+
+from __future__ import (
+    absolute_import,
+)
+
+from re import (
+    compile as _compile,
+)
+
+from binascii import (
+    Error,
+)
+
+from base64 import (
+    b64decode as _b64decode,
+)
+
+_b64decode_validator = _compile(b'^[A-Za-z0-9-_]*={0,2}$')
+
+def urlsafe_b64decode(s):
+    """
+    Like ``base64.b64decode`` but with validation.
+    """
+    if not _b64decode_validator.match(s):
+        raise Error('Non-base64 digit found')
+    return _b64decode(s, altchars=b"-_")
diff --git a/src/_secureaccesstokenauthorizer/_plugin.py b/src/_secureaccesstokenauthorizer/_plugin.py
index ba09200c97c053c641466f71da01fd93f39290ac..ac1dd02b3c0af55fdc63e7d383ec663b02db45b3 100644
--- a/src/_secureaccesstokenauthorizer/_plugin.py
+++ b/src/_secureaccesstokenauthorizer/_plugin.py
@@ -41,6 +41,10 @@ from ._storage_server import (
     TOKEN_LENGTH,
 )
 
+from .resource import (
+    from_configuration as resource_from_configuration,
+)
+
 @implementer(IAnnounceableStorageServer)
 @attr.s
 class AnnounceableStorageServer(object):
@@ -78,3 +82,7 @@ class SecureAccessTokenAuthorizer(object):
                 lambda: [b"x" * TOKEN_LENGTH],
             )
         )
+
+
+    def get_client_resource(self, node_config):
+        return resource_from_configuration(node_config)
diff --git a/src/_secureaccesstokenauthorizer/controller.py b/src/_secureaccesstokenauthorizer/controller.py
new file mode 100644
index 0000000000000000000000000000000000000000..5b3bea2b4492ac9c0578e0c474cdc6b93c6279c0
--- /dev/null
+++ b/src/_secureaccesstokenauthorizer/controller.py
@@ -0,0 +1,27 @@
+# Copyright 2019 PrivateStorage.io, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+This module implements controllers (in the MVC sense) for the web interface
+for the client side of the storage plugin.
+"""
+
+import attr
+
+@attr.s
+class PaymentController(object):
+    store = attr.ib()
+
+    def redeem(self, prn):
+        self.store.add(prn)
diff --git a/src/_secureaccesstokenauthorizer/model.py b/src/_secureaccesstokenauthorizer/model.py
new file mode 100644
index 0000000000000000000000000000000000000000..fb259f1e4e4377a43a70c740ff87909ebb3d977d
--- /dev/null
+++ b/src/_secureaccesstokenauthorizer/model.py
@@ -0,0 +1,232 @@
+# Copyright 2019 PrivateStorage.io, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+This module implements models (in the MVC sense) for the client side of
+the storage plugin.
+"""
+
+from functools import (
+    wraps,
+)
+from json import (
+    loads,
+    dumps,
+)
+
+from sqlite3 import (
+    OperationalError,
+    connect as _connect,
+)
+
+import attr
+
+from twisted.python.filepath import (
+    FilePath,
+)
+
+
+class StoreOpenError(Exception):
+    """
+    There was a problem opening the underlying data store.
+    """
+    def __init__(self, reason):
+        self.reason = reason
+
+
+class SchemaError(TypeError):
+    pass
+
+
+CONFIG_DB_NAME = u"privatestorageio-satauthz-v1.sqlite3"
+
+def open_and_initialize(path, required_schema_version, connect=None):
+    """
+    Open a SQLite3 database for use as a payment reference store.
+
+    Create the database and populate it with a schema, if it does not already
+    exist.
+
+    :param FilePath path: The location of the SQLite3 database file.
+
+    :param int required_schema_version: The schema version which must be
+        present in the database in order for a SQLite3 connection to be
+        returned.
+
+    :raise SchemaError: If the schema in the database does not match the
+        required schema version.
+
+    :return: A SQLite3 connection object for the database at the given path.
+    """
+    if connect is None:
+        connect = _connect
+    try:
+        path.parent().makedirs(ignoreExistingDirectory=True)
+    except OSError as e:
+        raise StoreOpenError(e)
+
+    dbfile = path.asBytesMode().path
+    try:
+        conn = connect(
+            dbfile,
+            isolation_level="IMMEDIATE",
+        )
+    except OperationalError as e:
+        raise StoreOpenError(e)
+
+    with conn:
+        cursor = conn.cursor()
+        cursor.execute(
+            # This code knows how to create schema version 1.  This is
+            # regardless of what the caller *wants* to find in the database.
+            """
+            CREATE TABLE IF NOT EXISTS [version] AS SELECT 1 AS [version]
+            """
+        )
+        cursor.execute(
+            """
+            SELECT [version] FROM [version]
+            """
+        )
+        [(actual_version,)] = cursor.fetchall()
+        if actual_version != required_schema_version:
+            raise SchemaError(
+                "Unexpected database schema version.  Required {}.  Got {}.".format(
+                    required_schema_version,
+                    actual_version,
+                ),
+            )
+
+        cursor.execute(
+            """
+            CREATE TABLE IF NOT EXISTS [payment-references] (
+                [number] text,
+
+                PRIMARY KEY([number])
+            )
+            """,
+        )
+    return conn
+
+
+def with_cursor(f):
+    @wraps(f)
+    def with_cursor(self, *a, **kw):
+        with self._connection:
+            return f(self, self._connection.cursor(), *a, **kw)
+    return with_cursor
+
+
+def memory_connect(path, *a, **kw):
+    """
+    Always connect to an in-memory SQLite3 database.
+    """
+    return _connect(":memory:", *a, **kw)
+
+
+@attr.s(frozen=True)
+class PaymentReferenceStore(object):
+    """
+    This class implements persistence for payment references.
+
+    :ivar allmydata.node._Config node_config: The Tahoe-LAFS node configuration object for
+        the node that owns the persisted payment preferences.
+    """
+    database_path = attr.ib(type=FilePath)
+    _connection = attr.ib()
+
+    @classmethod
+    def from_node_config(cls, node_config, connect=None):
+        db_path = FilePath(node_config.get_private_path(CONFIG_DB_NAME))
+        conn = open_and_initialize(
+            db_path,
+            required_schema_version=1,
+            connect=connect,
+        )
+        return cls(
+            db_path,
+            conn,
+        )
+
+    @with_cursor
+    def get(self, cursor, prn):
+        cursor.execute(
+            """
+            SELECT
+                ([number])
+            FROM
+                [payment-references]
+            WHERE
+                [number] = ?
+            """,
+            (prn,),
+        )
+        refs = cursor.fetchall()
+        if len(refs) == 0:
+            raise KeyError(prn)
+        return PaymentReference(refs[0][0])
+
+    @with_cursor
+    def add(self, cursor, prn):
+        cursor.execute(
+            """
+            INSERT OR IGNORE INTO [payment-references] VALUES (?)
+            """,
+            (prn,)
+        )
+
+    @with_cursor
+    def list(self, cursor):
+        cursor.execute(
+            """
+            SELECT ([number]) FROM [payment-references]
+            """,
+        )
+        refs = cursor.fetchall()
+
+        return list(
+            PaymentReference(number)
+            for (number,)
+            in refs
+        )
+
+
+@attr.s
+class PaymentReference(object):
+    number = attr.ib()
+
+    @classmethod
+    def from_json(cls, json):
+        values = loads(json)
+        version = values.pop(u"version")
+        return getattr(cls, "from_json_v{}".format(version))(values)
+
+
+    @classmethod
+    def from_json_v1(cls, values):
+        return cls(**values)
+
+
+    def to_json(self):
+        return dumps(self.marshal())
+
+
+    def marshal(self):
+        return self.to_json_v1()
+
+
+    def to_json_v1(self):
+        result = attr.asdict(self)
+        result[u"version"] = 1
+        return result
diff --git a/src/_secureaccesstokenauthorizer/resource.py b/src/_secureaccesstokenauthorizer/resource.py
new file mode 100644
index 0000000000000000000000000000000000000000..d8b3626e862d35dbb034345344af368ebbb947cf
--- /dev/null
+++ b/src/_secureaccesstokenauthorizer/resource.py
@@ -0,0 +1,182 @@
+# Copyright 2019 PrivateStorage.io, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+This module implements views (in the MVC sense) for the web interface for
+the client side of the storage plugin.  This interface allows users to redeem
+payment codes for fresh tokens.
+
+In the future it should also allow users to read statistics about token usage.
+"""
+
+from json import (
+    loads, dumps,
+)
+
+from twisted.web.http import (
+    BAD_REQUEST,
+)
+from twisted.web.resource import (
+    ErrorPage,
+    NoResource,
+    Resource,
+)
+
+from ._base64 import (
+    urlsafe_b64decode,
+)
+
+from .model import (
+    PaymentReferenceStore,
+)
+from .controller import (
+    PaymentController,
+)
+
+def from_configuration(node_config, store=None):
+    """
+    Instantiate the plugin root resource using data from its configuration
+    section in the Tahoe-LAFS configuration file::
+
+        [storageclient.plugins.privatestorageio-satauthz-v1]
+        # nothing yet
+
+    :param _Config node_config: An object representing the overall node
+        configuration.  The plugin configuration can be extracted from this.
+        This is also used to read and write files in the private storage area
+        of the node's persistent state location.
+
+    :param PaymentReferenceStore store: The store to use.  If ``None`` a
+        sensible one is constructed.
+
+    :return IResource: The root of the resource hierarchy presented by the
+        client side of the plugin.
+    """
+    if store is None:
+        store = PaymentReferenceStore.from_node_config(node_config)
+    controller = PaymentController(store)
+    root = Resource()
+    root.putChild(
+        b"payment-reference-number",
+        _PaymentReferenceNumberCollection(
+            store,
+            controller,
+        ),
+    )
+    return root
+
+
+class _PaymentReferenceNumberCollection(Resource):
+    """
+    This class implements redemption of payment reference numbers (PRNs).
+    Users **PUT** such numbers to this resource which delegates redemption
+    responsibilities to the redemption controller.  Child resources of this
+    resource can also be retrieved to monitor the status of previously
+    submitted PRNs.
+    """
+    def __init__(self, store, controller):
+        self._store = store
+        self._controller = controller
+        Resource.__init__(self)
+
+
+    def render_PUT(self, request):
+        """
+        Record a PRN and begin attempting to redeem it.
+        """
+        try:
+            payload = loads(request.content.read())
+        except Exception:
+            return bad_request().render(request)
+        if payload.keys() != [u"payment-reference-number"]:
+            return bad_request().render(request)
+        prn = payload[u"payment-reference-number"]
+        if not is_syntactic_prn(prn):
+            return bad_request().render(request)
+
+        self._controller.redeem(prn)
+        return b""
+
+
+    def render_GET(self, request):
+        request.responseHeaders.setRawHeaders(u"content-type", [u"application/json"])
+        return dumps({
+            u"payment-reference-numbers": list(
+                prn.marshal()
+                for prn
+                in self._store.list()
+            ),
+        })
+
+
+    def getChild(self, segment, request):
+        prn = segment.decode("utf-8")
+        if not is_syntactic_prn(prn):
+            return bad_request()
+        try:
+            payment_reference = self._store.get(prn)
+        except KeyError:
+            return NoResource()
+        return PaymentReferenceView(payment_reference)
+
+
+def is_syntactic_prn(prn):
+    """
+    :param prn: A candidate object to inspect.
+
+    :return bool: ``True`` if and only if ``prn`` is a unicode string
+        containing a syntactically valid payment reference number.  This says
+        **nothing** about the validity of the represented PRN itself.  A
+        ``True`` result only means the unicode string can be **interpreted**
+        as a PRN.
+    """
+    if not isinstance(prn, unicode):
+        return False
+    if len(prn) != 44:
+        # TODO.  44 is the length of 32 bytes base64 encoded.  This model
+        # information presumably belongs somewhere else.
+        return False
+    try:
+        urlsafe_b64decode(prn.encode("ascii"))
+    except Exception:
+        return False
+    return True
+
+
+class PaymentReferenceView(Resource):
+    """
+    This class implements a view for a ``PaymentReference`` instance.
+    """
+    def __init__(self, reference):
+        """
+        :param PaymentReference reference: The model object for which to provide a
+            view.
+        """
+        self._reference = reference
+        Resource.__init__(self)
+
+
+    def render_GET(self, request):
+        request.responseHeaders.setRawHeaders(u"content-type", [u"application/json"])
+        return self._reference.to_json()
+
+
+def bad_request():
+    """
+    :return IResource: A resource which can be rendered to produce a **BAD
+        REQUEST** response.
+    """
+    return ErrorPage(
+        BAD_REQUEST, b"Bad Request", b"Bad Request",
+    )
diff --git a/src/_secureaccesstokenauthorizer/tests/matchers.py b/src/_secureaccesstokenauthorizer/tests/matchers.py
index 16d967a512f1ee976ac845750fdf476fc95958a6..c54c99067b80438f119edbc9521b9d1e84d87499 100644
--- a/src/_secureaccesstokenauthorizer/tests/matchers.py
+++ b/src/_secureaccesstokenauthorizer/tests/matchers.py
@@ -19,6 +19,7 @@ Testtools matchers useful for the test suite.
 import attr
 
 from testtools.matchers import (
+    Matcher,
     Mismatch,
     ContainsDict,
     Always,
@@ -27,7 +28,7 @@ from testtools.matchers import (
 @attr.s
 class Provides(object):
     """
-    Match objects that provide one or more Zope Interface interfaces.
+    Match objects that provide all of a list of Zope Interface interfaces.
     """
     interfaces = attr.ib()
 
@@ -54,3 +55,26 @@ def matches_version_dictionary():
         b'application-version': Always(),
         b'http://allmydata.org/tahoe/protocols/storage/v1': Always(),
     })
+
+
+
+def returns(matcher):
+    """
+    Matches a no-argument callable that returns a value matched by the given
+    matcher.
+    """
+    return _Returns(matcher)
+
+
+
+class _Returns(Matcher):
+    def __init__(self, result_matcher):
+        self.result_matcher = result_matcher
+
+
+    def match(self, matchee):
+        return self.result_matcher.match(matchee())
+
+
+    def __str__(self):
+        return "Returns({})".format(self.result_matcher)
diff --git a/src/_secureaccesstokenauthorizer/tests/strategies.py b/src/_secureaccesstokenauthorizer/tests/strategies.py
index c41f7cca88ebf7cf3d80984e7c26c9df96a9c0f6..4af061defb0f2d34a3912078e55dfd76e580d205 100644
--- a/src/_secureaccesstokenauthorizer/tests/strategies.py
+++ b/src/_secureaccesstokenauthorizer/tests/strategies.py
@@ -16,20 +16,31 @@
 Hypothesis strategies for property testing.
 """
 
+from base64 import (
+    urlsafe_b64encode,
+)
+
 import attr
 
 from hypothesis.strategies import (
     one_of,
     just,
     binary,
+    characters,
+    text,
     integers,
     sets,
     lists,
     tuples,
     dictionaries,
+    fixed_dictionaries,
     builds,
 )
 
+from twisted.web.test.requesthelper import (
+    DummyRequest,
+)
+
 from allmydata.interfaces import (
     StorageIndex,
     LeaseRenewSecret,
@@ -37,13 +48,152 @@ from allmydata.interfaces import (
     WriteEnablerSecret,
 )
 
+from allmydata.client import (
+    config_from_string,
+)
+
+
+def _merge_dictionaries(dictionaries):
+    result = {}
+    for d in dictionaries:
+        result.update(d)
+    return result
+
+
+def _tahoe_config_quote(text):
+    return text.replace(u"%", u"%%")
+
+
+def _config_string_from_sections(divided_sections):
+    sections = _merge_dictionaries(divided_sections)
+    return u"".join(list(
+        u"[{name}]\n{items}\n".format(
+            name=name,
+            items=u"\n".join(
+                u"{key} = {value}".format(key=key, value=_tahoe_config_quote(value))
+                for (key, value)
+                in contents.items()
+            )
+        )
+        for (name, contents) in sections.items()
+    ))
+
+
+def tahoe_config_texts(storage_client_plugins):
+    """
+    Build the text of complete Tahoe-LAFS configurations for a node.
+    """
+    return builds(
+        lambda *sections: _config_string_from_sections(
+            sections,
+        ),
+        fixed_dictionaries(
+            {
+                "storageclient.plugins.{}".format(name): configs
+                for (name, configs)
+                in storage_client_plugins.items()
+            },
+        ),
+        fixed_dictionaries(
+            {
+                "node": fixed_dictionaries(
+                    {
+                        "nickname": node_nicknames(),
+                    },
+                ),
+                "client": fixed_dictionaries(
+                    {
+                        "storage.plugins": just(
+                            u",".join(storage_client_plugins.keys()),
+                        ),
+                    },
+                ),
+            },
+        ),
+    )
+
+
+def tahoe_configs(storage_client_plugins=None):
+    """
+    Build complete Tahoe-LAFS configurations for a node.
+    """
+    if storage_client_plugins is None:
+        storage_client_plugins = {}
+    return tahoe_config_texts(
+        storage_client_plugins,
+    ).map(
+        lambda config_text: lambda basedir, portnumfile: config_from_string(
+            basedir,
+            portnumfile,
+            config_text.encode("utf-8"),
+        ),
+    )
+
+def node_nicknames():
+    """
+    Builds Tahoe-LAFS node nicknames.
+    """
+    return text(
+        min_size=0,
+        max_size=16,
+        alphabet=characters(
+            blacklist_categories={
+                # Surrogates
+                u"Cs",
+                # Unnamed and control characters
+                u"Cc",
+            },
+        ),
+    )
+
+
 def configurations():
     """
-    Build configuration values for the plugin.
+    Build configuration values for the server-side plugin.
     """
     return just({})
 
 
+def client_configurations():
+    """
+    Build configuration values for the client-side plugin.
+    """
+    return just({})
+
+
+def payment_reference_numbers():
+    """
+    Build unicode strings in the format of payment reference numbers.
+    """
+    return binary(
+        min_size=32,
+        max_size=32,
+    ).map(
+        urlsafe_b64encode,
+    ).map(
+        lambda prn: prn.decode("ascii"),
+    )
+
+
+def request_paths():
+    """
+    Build lists of unicode strings that represent the path component of an
+    HTTP request.
+
+    :see: ``requests``
+    """
+
+
+def requests(paths=request_paths()):
+    """
+    Build objects providing ``twisted.web.iweb.IRequest``.
+    """
+    return builds(
+        DummyRequest,
+        paths,
+    )
+
+
 def storage_indexes():
     """
     Build Tahoe-LAFS storage indexes.
@@ -227,7 +377,7 @@ def test_and_write_vectors_for_shares():
         # before assignment.
         min_size=1,
         # Just for practical purposes...
-        max_size=8,
+        max_size=4,
     )
 
 
diff --git a/src/_secureaccesstokenauthorizer/tests/test_base64.py b/src/_secureaccesstokenauthorizer/tests/test_base64.py
new file mode 100644
index 0000000000000000000000000000000000000000..569cd6994b6c7ce030070927cac978498917ef3d
--- /dev/null
+++ b/src/_secureaccesstokenauthorizer/tests/test_base64.py
@@ -0,0 +1,55 @@
+# Copyright 2019 PrivateStorage.io, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Tests for ``_secureaccesstokenauthorizer._base64``.
+"""
+
+from base64 import (
+    urlsafe_b64encode,
+)
+
+from testtools import (
+    TestCase,
+)
+from testtools.matchers import (
+    Equals,
+)
+
+from hypothesis import (
+    given,
+)
+from hypothesis.strategies import (
+    binary,
+)
+
+from .._base64 import (
+    urlsafe_b64decode,
+)
+
+
+class Base64Tests(TestCase):
+    """
+    Tests for ``urlsafe_b64decode``.
+    """
+    @given(binary())
+    def test_roundtrip(self, bytestring):
+        """
+        Byte strings round-trip through ``base64.urlsafe_b64encode`` and
+        ``urlsafe_b64decode``.
+        """
+        self.assertThat(
+            urlsafe_b64decode(urlsafe_b64encode(bytestring)),
+            Equals(bytestring),
+        )
diff --git a/src/_secureaccesstokenauthorizer/tests/test_client_resource.py b/src/_secureaccesstokenauthorizer/tests/test_client_resource.py
new file mode 100644
index 0000000000000000000000000000000000000000..3bd4812c4fc639e510eb0227b809f002f9647645
--- /dev/null
+++ b/src/_secureaccesstokenauthorizer/tests/test_client_resource.py
@@ -0,0 +1,516 @@
+# Copyright 2019 PrivateStorage.io, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Tests for the web resource provided by the client part of the Tahoe-LAFS
+plugin.
+"""
+
+import attr
+
+from .._base64 import (
+    urlsafe_b64decode,
+)
+
+from json import (
+    dumps,
+    loads,
+)
+from io import (
+    BytesIO,
+)
+from urllib import (
+    quote,
+)
+
+from testtools import (
+    TestCase,
+)
+from testtools.matchers import (
+    MatchesStructure,
+    MatchesAll,
+    AfterPreprocessing,
+    Equals,
+    Always,
+)
+from testtools.twistedsupport import (
+    CaptureTwistedLogs,
+    succeeded,
+)
+from testtools.content import (
+    text_content,
+)
+
+from fixtures import (
+    TempDir,
+)
+
+from hypothesis import (
+    given,
+    note,
+)
+from hypothesis.strategies import (
+    one_of,
+    just,
+    fixed_dictionaries,
+    lists,
+    integers,
+    binary,
+    text,
+)
+
+from twisted.internet.task import (
+    Cooperator,
+)
+from twisted.web.http import (
+    OK,
+    NOT_FOUND,
+    BAD_REQUEST,
+)
+from twisted.web.resource import (
+    IResource,
+    getChildForRequest,
+)
+from twisted.web.client import (
+    FileBodyProducer,
+    readBody,
+)
+
+from treq.testing import (
+    RequestTraversalAgent,
+)
+
+from ..model import (
+    PaymentReferenceStore,
+    memory_connect,
+)
+from ..resource import (
+    from_configuration,
+)
+
+from .strategies import (
+    tahoe_configs,
+    client_configurations,
+    payment_reference_numbers,
+    requests,
+)
+from .matchers import (
+    Provides,
+)
+
+# Helper to work-around https://github.com/twisted/treq/issues/161
+def uncooperator(started=True):
+    return Cooperator(
+        # Don't stop consuming the iterator until it's done.
+        terminationPredicateFactory=lambda: lambda: False,
+        scheduler=lambda what: (what(), object())[1],
+        started=started,
+    )
+
+
+
+tahoe_configs_with_client_config = tahoe_configs(storage_client_plugins={
+    u"privatestorageio-satauthz-v1": client_configurations(),
+})
+
+def is_not_json(bytestring):
+    """
+    :param bytes bytestring: A candidate byte string to inspect.
+
+    :return bool: ``False`` if and only if ``bytestring`` is JSON encoded.
+    """
+    try:
+        loads(bytestring)
+    except:
+        return True
+    return False
+
+def not_payment_reference_numbers():
+    """
+    Builds unicode strings which are not legal payment reference numbers.
+    """
+    return one_of(
+        text().filter(
+            lambda t: (
+                not is_urlsafe_base64(t)
+            ),
+        ),
+        payment_reference_numbers().map(
+            # Turn a valid PRN into a PRN that is invalid only by containing a
+            # character from the base64 alphabet in place of one from the
+            # urlsafe-base64 alphabet.
+            lambda prn: u"/" + prn[1:],
+        ),
+    )
+
+def is_urlsafe_base64(text):
+    """
+    :param unicode text: A candidate unicode string to inspect.
+
+    :return bool: ``True`` if and only if ``text`` is urlsafe-base64 encoded
+    """
+    try:
+        urlsafe_b64decode(text)
+    except:
+        return False
+    return True
+
+
+def invalid_bodies():
+    """
+    Build byte strings that ``PUT /payment-reference-number`` considers
+    invalid.
+    """
+    return one_of(
+        # The wrong key but the right kind of value.
+        fixed_dictionaries({
+            u"some-key": payment_reference_numbers(),
+        }).map(dumps),
+        # The right key but the wrong kind of value.
+        fixed_dictionaries({
+            u"payment-reference-number": one_of(
+                integers(),
+                not_payment_reference_numbers(),
+            ),
+        }).map(dumps),
+        # Not even JSON
+        binary().filter(is_not_json),
+    )
+
+
+def root_from_config(config):
+    """
+    Create a client root resource from a Tahoe-LAFS configuration.
+
+    :param _Config config: The Tahoe-LAFS configuration.
+
+    :return IResource: The root client resource.
+    """
+    return from_configuration(
+        config,
+        PaymentReferenceStore.from_node_config(
+            config,
+            memory_connect,
+        ),
+    )
+
+
+class PaymentReferenceNumberTests(TestCase):
+    """
+    Tests relating to ``/payment-reference-number`` as implemented by the
+    ``_secureaccesstokenauthorizer.resource`` module and its handling of
+    payment reference numbers (PRNs).
+    """
+    def setUp(self):
+        super(PaymentReferenceNumberTests, self).setUp()
+        self.useFixture(CaptureTwistedLogs())
+
+
+    @given(tahoe_configs_with_client_config, requests(just([u"payment-reference-number"])))
+    def test_reachable(self, get_config, request):
+        """
+        A resource is reachable at the ``payment-reference-number`` child of a the
+        resource returned by ``from_configuration``.
+        """
+        tempdir = self.useFixture(TempDir())
+        config = get_config(tempdir.join(b"tahoe"), b"tub.port")
+        root = root_from_config(config)
+        self.assertThat(
+            getChildForRequest(root, request),
+            Provides([IResource]),
+        )
+
+
+    @given(tahoe_configs_with_client_config, payment_reference_numbers())
+    def test_put_prn(self, get_config, prn):
+        """
+        When a PRN is sent in a ``PUT`` to ``PaymentReferenceNumberCollection`` it
+        is passed in to the PRN redemption model object for handling and an
+        ``OK`` response is returned.
+        """
+        tempdir = self.useFixture(TempDir())
+        config = get_config(tempdir.join(b"tahoe"), b"tub.port")
+        root = root_from_config(config)
+        agent = RequestTraversalAgent(root)
+        producer = FileBodyProducer(
+            BytesIO(dumps({u"payment-reference-number": prn})),
+            cooperator=uncooperator(),
+        )
+        requesting = agent.request(
+            b"PUT",
+            b"http://127.0.0.1/payment-reference-number",
+            bodyProducer=producer,
+        )
+        self.addDetail(
+            u"requesting result",
+            text_content(u"{}".format(vars(requesting.result))),
+        )
+        self.assertThat(
+            requesting,
+            succeeded(
+                ok_response(),
+            ),
+        )
+
+    @given(tahoe_configs_with_client_config, invalid_bodies())
+    def test_put_invalid_body(self, get_config, body):
+        """
+        If the body of a ``PUT`` to ``PaymentReferenceNumberCollection`` does not
+        consist of an object with a single *payment-reference-number* property
+        then the response is *BAD REQUEST*.
+        """
+        tempdir = self.useFixture(TempDir())
+        config = get_config(tempdir.join(b"tahoe"), b"tub.port")
+        root = root_from_config(config)
+        agent = RequestTraversalAgent(root)
+        producer = FileBodyProducer(
+            BytesIO(body),
+            cooperator=uncooperator(),
+        )
+        requesting = agent.request(
+            b"PUT",
+            b"http://127.0.0.1/payment-reference-number",
+            bodyProducer=producer,
+        )
+        self.addDetail(
+            u"requesting result",
+            text_content(u"{}".format(vars(requesting.result))),
+        )
+        self.assertThat(
+            requesting,
+            succeeded(
+                bad_request_response(),
+            ),
+        )
+
+    @given(tahoe_configs_with_client_config, not_payment_reference_numbers())
+    def test_get_invalid_prn(self, get_config, not_prn):
+        """
+        When a syntactically invalid PRN is requested with a ``GET`` to a child of
+        ``PaymentReferenceNumberCollection`` the response is **BAD REQUEST**.
+        """
+        tempdir = self.useFixture(TempDir())
+        config = get_config(tempdir.join(b"tahoe"), b"tub.port")
+        root = root_from_config(config)
+        agent = RequestTraversalAgent(root)
+        url = u"http://127.0.0.1/payment-reference-number/{}".format(
+            quote(
+                not_prn.encode("utf-8"),
+                safe=b"",
+            ).decode("utf-8"),
+        ).encode("ascii")
+        requesting = agent.request(
+            b"GET",
+            url,
+        )
+        self.assertThat(
+            requesting,
+            succeeded(
+                bad_request_response(),
+            ),
+        )
+
+
+    @given(tahoe_configs_with_client_config, payment_reference_numbers())
+    def test_get_unknown_prn(self, get_config, prn):
+        """
+        When a PRN is requested with a ``GET`` to a child of
+        ``PaymentReferenceNumberCollection`` the response is **NOT FOUND** if
+        the PRN hasn't previously been submitted with a ``PUT``.
+        """
+        tempdir = self.useFixture(TempDir())
+        config = get_config(tempdir.join(b"tahoe"), b"tub.port")
+        root = root_from_config(config)
+        agent = RequestTraversalAgent(root)
+        requesting = agent.request(
+            b"GET",
+            u"http://127.0.0.1/payment-reference-number/{}".format(prn).encode("ascii"),
+        )
+        self.assertThat(
+            requesting,
+            succeeded(
+                not_found_response(),
+            ),
+        )
+
+
+    @given(tahoe_configs_with_client_config, payment_reference_numbers())
+    def test_get_known_prn(self, get_config, prn):
+        """
+        When a PRN is first ``PUT`` and then later a ``GET`` is issued for the
+        same PRN then the response code is **OK** and details about the PRN
+        are included in a json-encoded response body.
+        """
+        tempdir = self.useFixture(TempDir())
+        config = get_config(tempdir.join(b"tahoe"), b"tub.port")
+        root = root_from_config(config)
+        agent = RequestTraversalAgent(root)
+
+        producer = FileBodyProducer(
+            BytesIO(dumps({u"payment-reference-number": prn})),
+            cooperator=uncooperator(),
+        )
+        putting = agent.request(
+            b"PUT",
+            b"http://127.0.0.1/payment-reference-number",
+            bodyProducer=producer,
+        )
+        self.assertThat(
+            putting,
+            succeeded(
+                ok_response(),
+            ),
+        )
+
+        getting = agent.request(
+            b"GET",
+            u"http://127.0.0.1/payment-reference-number/{}".format(
+                quote(
+                    prn.encode("utf-8"),
+                    safe=b"",
+                ).decode("utf-8"),
+            ).encode("ascii"),
+        )
+
+        self.assertThat(
+            getting,
+            succeeded(
+                MatchesAll(
+                    ok_response(headers=application_json()),
+                    AfterPreprocessing(
+                        json_content,
+                        succeeded(
+                            Equals({
+                                u"version": 1,
+                                u"number": prn,
+                            }),
+                        ),
+                    ),
+                ),
+            ),
+        )
+
+    @given(tahoe_configs_with_client_config, lists(payment_reference_numbers(), unique=True))
+    def test_list_prns(self, get_config, prns):
+        """
+        A ``GET`` to the ``PaymentReferenceNumberCollection`` itself returns a
+        list of existing payment reference numbers.
+        """
+        # Hypothesis causes our test case instances to be re-used many times
+        # between setUp and tearDown.  Avoid re-using the same temporary
+        # directory for every Hypothesis iteration because this test leaves
+        # state behind that invalidates future iterations.
+        tempdir = self.useFixture(TempDir())
+        config = get_config(tempdir.join(b"tahoe"), b"tub.port")
+        root = root_from_config(config)
+        agent = RequestTraversalAgent(root)
+
+        note("{} PRNs".format(len(prns)))
+
+        for prn in prns:
+            producer = FileBodyProducer(
+                BytesIO(dumps({u"payment-reference-number": prn})),
+                cooperator=uncooperator(),
+            )
+            putting = agent.request(
+                b"PUT",
+                b"http://127.0.0.1/payment-reference-number",
+                bodyProducer=producer,
+            )
+            self.assertThat(
+                putting,
+                succeeded(
+                    ok_response(),
+                ),
+            )
+
+        getting = agent.request(
+            b"GET",
+            b"http://127.0.0.1/payment-reference-number",
+        )
+
+        self.assertThat(
+            getting,
+            succeeded(
+                MatchesAll(
+                    ok_response(headers=application_json()),
+                    AfterPreprocessing(
+                        json_content,
+                        succeeded(
+                            Equals({
+                                u"payment-reference-numbers": list(
+                                    {u"version": 1, u"number": prn}
+                                    for prn
+                                    in prns
+                                ),
+                            }),
+                        ),
+                    ),
+                ),
+            ),
+        )
+
+
+def application_json():
+    return AfterPreprocessing(
+        lambda h: h.getRawHeaders(u"content-type"),
+        Equals([u"application/json"]),
+    )
+
+
+def json_content(response):
+    reading = readBody(response)
+    reading.addCallback(loads)
+    return reading
+
+
+def ok_response(headers=None):
+    return match_response(OK, headers)
+
+
+def not_found_response(headers=None):
+    return match_response(NOT_FOUND, headers)
+
+
+def bad_request_response(headers=None):
+    return match_response(BAD_REQUEST, headers)
+
+
+def match_response(code, headers):
+    if headers is None:
+        headers = Always()
+    return _MatchResponse(
+        code=Equals(code),
+        headers=headers,
+    )
+
+
+@attr.s
+class _MatchResponse(object):
+    code = attr.ib()
+    headers = attr.ib()
+    _details = attr.ib(default=attr.Factory(dict))
+
+    def match(self, response):
+        self._details.update({
+            u"code": response.code,
+            u"headers": response.headers.getAllRawHeaders(),
+        })
+        return MatchesStructure(
+            code=self.code,
+            headers=self.headers,
+        ).match(response)
+
+    def get_details(self):
+        return self._details
diff --git a/src/_secureaccesstokenauthorizer/tests/test_matchers.py b/src/_secureaccesstokenauthorizer/tests/test_matchers.py
new file mode 100644
index 0000000000000000000000000000000000000000..ab95ee31b6a7f402406f40ca31dccd443a9c9d56
--- /dev/null
+++ b/src/_secureaccesstokenauthorizer/tests/test_matchers.py
@@ -0,0 +1,106 @@
+# Copyright 2019 PrivateStorage.io, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Tests for ``_secureaccesstokenauthorizer.tests.matchers``.
+"""
+
+from zope.interface import (
+    Interface,
+    implementer,
+)
+
+from testtools import (
+    TestCase,
+)
+from testtools.matchers import (
+    Not,
+    Is,
+)
+
+from .matchers import (
+    Provides,
+    returns,
+)
+
+
+class IX(Interface):
+    pass
+
+
+class IY(Interface):
+    pass
+
+
+@implementer(IX, IY)
+class X(object):
+    pass
+
+
+@implementer(IY)
+class Y(object):
+    pass
+
+
+class ProvidesTests(TestCase):
+    """
+    Tests for ``Provides``.
+    """
+    def test_match(self):
+        """
+        ``Provides.match`` returns ``None`` when the given object provides all of
+        the configured interfaces.
+        """
+        self.assertThat(
+            Provides([IX, IY]).match(X()),
+            Is(None),
+        )
+
+    def test_mismatch(self):
+        """
+        ``Provides.match`` does not return ``None`` when the given object provides
+        none of the configured interfaces.
+        """
+        self.assertThat(
+            Provides([IX, IY]).match(Y()),
+            Not(Is(None)),
+        )
+
+
+class ReturnsTests(TestCase):
+    """
+    Tests for ``returns``.
+    """
+    def test_match(self):
+        """
+        ``returns(m)`` returns a matcher that matches when the given object
+        returns a value matched by ``m``.
+        """
+        result = object()
+        self.assertThat(
+            returns(Is(result)).match(lambda: result),
+            Is(None),
+        )
+
+    def test_mismatch(self):
+        """
+        ``returns(m)`` returns a matcher that does not match when the given object
+        returns a value not matched by ``m``.
+        """
+        result = object()
+        other = object()
+        self.assertThat(
+            returns(Is(result)).match(lambda: other),
+            Not(Is(None)),
+        )
diff --git a/src/_secureaccesstokenauthorizer/tests/test_model.py b/src/_secureaccesstokenauthorizer/tests/test_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..3ef54d46d16e960fc2728f2abf46309bf1c49fc9
--- /dev/null
+++ b/src/_secureaccesstokenauthorizer/tests/test_model.py
@@ -0,0 +1,257 @@
+# coding: utf-8
+# Copyright 2019 PrivateStorage.io, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Tests for ``_secureaccesstokenauthorizer.model``.
+"""
+
+from os import (
+    mkdir,
+)
+from errno import (
+    EACCES,
+)
+
+from testtools import (
+    TestCase,
+)
+from testtools.matchers import (
+    AfterPreprocessing,
+    MatchesStructure,
+    MatchesAll,
+    Equals,
+    Raises,
+    IsInstance,
+    raises,
+)
+
+from fixtures import (
+    TempDir,
+)
+
+from hypothesis import (
+    given,
+)
+from hypothesis.strategies import (
+    lists,
+)
+
+from twisted.python.filepath import (
+    FilePath,
+)
+
+from ..model import (
+    SchemaError,
+    StoreOpenError,
+    PaymentReferenceStore,
+    PaymentReference,
+    open_and_initialize,
+    memory_connect,
+)
+
+from .strategies import (
+    tahoe_configs,
+    payment_reference_numbers,
+)
+
+
+class PaymentReferenceStoreTests(TestCase):
+    """
+    Tests for ``PaymentReferenceStore``.
+    """
+    def test_create_mismatched_schema(self):
+        """
+        ``open_and_initialize`` raises ``SchemaError`` if asked for a database
+        with a schema version other than it can create.
+        """
+        tempdir = self.useFixture(TempDir())
+        dbpath = tempdir.join(b"db.sqlite3")
+        self.assertThat(
+            lambda: open_and_initialize(
+                FilePath(dbpath),
+                required_schema_version=100,
+            ),
+            raises(SchemaError),
+        )
+
+
+    @given(tahoe_configs(), payment_reference_numbers())
+    def test_get_missing(self, get_config, prn):
+        """
+        ``PaymentReferenceStore.get`` raises ``KeyError`` when called with a
+        payment reference number not previously added to the store.
+        """
+        tempdir = self.useFixture(TempDir())
+        config = get_config(tempdir.join(b"node"), b"tub.port")
+        store = PaymentReferenceStore.from_node_config(
+            config,
+            memory_connect,
+        )
+        self.assertThat(
+            lambda: store.get(prn),
+            raises(KeyError),
+        )
+
+    @given(tahoe_configs(), payment_reference_numbers())
+    def test_add(self, get_config, prn):
+        """
+        ``PaymentReferenceStore.get`` returns a ``PaymentReference`` representing
+        a payment reference previously added to the store with
+        ``PaymentReferenceStore.add``.
+        """
+        tempdir = self.useFixture(TempDir())
+        config = get_config(tempdir.join(b"node"), b"tub.port")
+        store = PaymentReferenceStore.from_node_config(
+            config,
+            memory_connect,
+        )
+        store.add(prn)
+        payment_reference = store.get(prn)
+        self.assertThat(
+            payment_reference,
+            MatchesStructure(
+                number=Equals(prn),
+            ),
+        )
+
+    @given(tahoe_configs(), payment_reference_numbers())
+    def test_add_idempotent(self, get_config, prn):
+        """
+        More than one call to ``PaymentReferenceStore.add`` with the same argument
+        results in the same state as a single call.
+        """
+        tempdir = self.useFixture(TempDir())
+        config = get_config(tempdir.join(b"node"), b"tub.port")
+        store = PaymentReferenceStore.from_node_config(
+            config,
+            memory_connect,
+        )
+        store.add(prn)
+        store.add(prn)
+        payment_reference = store.get(prn)
+        self.assertThat(
+            payment_reference,
+            MatchesStructure(
+                number=Equals(prn),
+            ),
+        )
+
+
+    @given(tahoe_configs(), lists(payment_reference_numbers()))
+    def test_list(self, get_config, prns):
+        """
+        ``PaymentReferenceStore.list`` returns a ``list`` containing a
+        ``PaymentReference`` object for each payment reference number
+        previously added.
+        """
+        tempdir = self.useFixture(TempDir())
+        nodedir = tempdir.join(b"node")
+        config = get_config(nodedir, b"tub.port")
+        store = PaymentReferenceStore.from_node_config(
+            config,
+            memory_connect,
+        )
+
+        for prn in prns:
+            store.add(prn)
+
+        self.assertThat(
+            store.list(),
+            AfterPreprocessing(
+                lambda refs: set(ref.number for ref in refs),
+                Equals(set(prns)),
+            ),
+        )
+
+
+    @given(tahoe_configs())
+    def test_uncreateable_store_directory(self, get_config):
+        """
+        If the underlying directory in the node configuration cannot be created
+        then ``PaymentReferenceStore.from_node_config`` raises
+        ``StoreOpenError``.
+        """
+        tempdir = self.useFixture(TempDir())
+        nodedir = tempdir.join(b"node")
+
+        # Create the node directory without permission to create the
+        # underlying directory.
+        mkdir(nodedir, 0o500)
+
+        config = get_config(nodedir, b"tub.port")
+
+        self.assertThat(
+            lambda: PaymentReferenceStore.from_node_config(
+                config,
+                memory_connect,
+            ),
+            Raises(
+                AfterPreprocessing(
+                    lambda (type, exc, tb): exc,
+                    MatchesAll(
+                        IsInstance(StoreOpenError),
+                        MatchesStructure(
+                            reason=MatchesAll(
+                                IsInstance(OSError),
+                                MatchesStructure(
+                                    errno=Equals(EACCES),
+                                ),
+                            ),
+                        ),
+                    ),
+                ),
+            ),
+        )
+
+
+    @given(tahoe_configs())
+    def test_unopenable_store(self, get_config):
+        """
+        If the underlying database file cannot be opened then
+        ``PaymentReferenceStore.from_node_config`` raises ``StoreOpenError``.
+        """
+        tempdir = self.useFixture(TempDir())
+        nodedir = tempdir.join(b"node")
+
+        config = get_config(nodedir, b"tub.port")
+
+        # Create the underlying database file.
+        store = PaymentReferenceStore.from_node_config(config)
+
+        # Prevent further access to it.
+        store.database_path.chmod(0o000)
+
+        self.assertThat(
+            lambda: PaymentReferenceStore.from_node_config(
+                config,
+            ),
+            raises(StoreOpenError),
+        )
+
+
+class PaymentReferenceTests(TestCase):
+    """
+    Tests for ``PaymentReference``.
+    """
+    @given(payment_reference_numbers())
+    def test_json_roundtrip(self, prn):
+        """
+        ``PaymentReference.to_json . PaymentReference.from_json → id``
+        """
+        ref = PaymentReference(prn)
+        self.assertThat(
+            PaymentReference.from_json(ref.to_json()),
+            Equals(ref),
+        )
diff --git a/src/_secureaccesstokenauthorizer/tests/test_plugin.py b/src/_secureaccesstokenauthorizer/tests/test_plugin.py
index 14d544bfb361b8e0adcb5ad93d65cf528f62e64c..a7b9dceca6ac9f768d8d9b0f7722f4b25dc7a643 100644
--- a/src/_secureaccesstokenauthorizer/tests/test_plugin.py
+++ b/src/_secureaccesstokenauthorizer/tests/test_plugin.py
@@ -20,6 +20,10 @@ from zope.interface import (
     implementer,
 )
 
+from fixtures import (
+    TempDir,
+)
+
 from testtools import (
     TestCase,
 )
@@ -57,11 +61,15 @@ from twisted.plugin import (
 from twisted.test.proto_helpers import (
     StringTransport,
 )
+from twisted.web.resource import (
+    IResource,
+)
 from twisted.plugins.secureaccesstokenauthorizer import (
     storage_server,
 )
 
 from .strategies import (
+    tahoe_configs,
     configurations,
     announcements,
 )
@@ -222,3 +230,22 @@ class ClientPluginTests(TestCase):
             storage_client_deferred,
             succeeded(Provides([IStorageServer])),
         )
+
+
+class ClientResourceTests(TestCase):
+    """
+    Tests for the plugin's implementation of
+    ``IFoolscapStoragePlugin.get_client_resource``.
+    """
+    @given(tahoe_configs())
+    def test_interface(self, get_config):
+        """
+        ``get_client_resource`` returns an object that provides ``IResource``.
+        """
+        tempdir = self.useFixture(TempDir())
+        nodedir = tempdir.join(b"node")
+        config = get_config(nodedir, b"tub.port")
+        self.assertThat(
+            storage_server.get_client_resource(config),
+            Provides([IResource]),
+        )
diff --git a/src/_secureaccesstokenauthorizer/tests/test_strategies.py b/src/_secureaccesstokenauthorizer/tests/test_strategies.py
new file mode 100644
index 0000000000000000000000000000000000000000..22f5aacfa67f0a06f886122f4d30856b718b8eac
--- /dev/null
+++ b/src/_secureaccesstokenauthorizer/tests/test_strategies.py
@@ -0,0 +1,59 @@
+# Copyright 2019 PrivateStorage.io, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Tests for our custom Hypothesis strategies.
+"""
+
+from testtools import (
+    TestCase,
+)
+
+from fixtures import (
+    TempDir,
+)
+
+from hypothesis import (
+    given,
+    note,
+)
+from hypothesis.strategies import (
+    data,
+)
+
+from allmydata.client import (
+    config_from_string,
+)
+
+from .strategies import (
+    tahoe_config_texts,
+)
+
+class TahoeConfigsTests(TestCase):
+    """
+    Tests for ``tahoe_configs``.
+    """
+    @given(data())
+    def test_parses(self, data):
+        """
+        Configurations built by the strategy can be parsed.
+        """
+        tempdir = self.useFixture(TempDir())
+        config_text = data.draw(tahoe_config_texts({}))
+        note(config_text)
+        config_from_string(
+            tempdir.join(b"tahoe.ini"),
+            b"tub.port",
+            config_text.encode("utf-8"),
+        )