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..0dcccabc1590e6974aeb6a7147cc5e7389123907
--- /dev/null
+++ b/docs/source/interface.rst
@@ -0,0 +1,44 @@
+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::
+
+  {"stage": <integer>,
+   "of": <integer>,
+   "stage-name": <string>,
+   "stage-entered-time": <iso8601 timestamp>
+  }
+
+The ``stage`` property indicates how far into redemption the plugin has proceeded.
+The ``of`` property indicates how many steps the process involves in total.
+The ``stage-name`` property gives a human-meaningful description of the current stage.
+The ``stage-entered-time`` property gives the timestamp for the start of the current staged.
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..cbb80959eaac22bbcea95208599a5e636b5403ba 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, configuration):
+        return resource_from_configuration(configuration)
diff --git a/src/_secureaccesstokenauthorizer/controller.py b/src/_secureaccesstokenauthorizer/controller.py
new file mode 100644
index 0000000000000000000000000000000000000000..568ed9cf867bba4e7a092dd446199d2573a06a8c
--- /dev/null
+++ b/src/_secureaccesstokenauthorizer/controller.py
@@ -0,0 +1,30 @@
+# 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):
+        try:
+            self.store.get(prn)
+        except KeyError:
+            self.store.add(prn)
diff --git a/src/_secureaccesstokenauthorizer/model.py b/src/_secureaccesstokenauthorizer/model.py
new file mode 100644
index 0000000000000000000000000000000000000000..915481bb646619f754a582a371165f4e8385261d
--- /dev/null
+++ b/src/_secureaccesstokenauthorizer/model.py
@@ -0,0 +1,120 @@
+# 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 os import (
+    makedirs,
+)
+from errno import (
+    EEXIST,
+)
+from json import (
+    loads,
+    dumps,
+)
+import attr
+
+# XXX
+from allmydata.node import (
+    _Config,
+    MissingConfigEntry,
+)
+
+class StoreAddError(Exception):
+    def __init__(self, reason):
+        self.reason = reason
+
+
+class StoreDirectoryError(Exception):
+    def __init__(self, reason):
+        self.reason = reason
+
+
+@attr.s(frozen=True)
+class PaymentReferenceStore(object):
+    """
+    This class implements persistence for payment references.
+
+    :ivar _Config node_config: The Tahoe-LAFS node configuration object for
+        the node that owns the persisted payment preferences.
+    """
+    _CONFIG_DIR = u"privatestorageio-satauthz-v1"
+    node_config = attr.ib(type=_Config)
+
+    def _config_key(self, prn):
+        return u"{}/{}.prn+json".format(self._CONFIG_DIR, prn)
+
+    def _read_pr_json(self, prn):
+        private_config_item = self._config_key(prn)
+        try:
+            return self.node_config.get_private_config(private_config_item)
+        except MissingConfigEntry:
+            raise KeyError(prn)
+
+    def _write_pr_json(self, prn, pr_json):
+        private_config_item = self._config_key(prn)
+        # XXX Need an API to be able to avoid touching the filesystem directly
+        # here.
+        container = self.node_config.get_private_path(self._CONFIG_DIR)
+        try:
+            makedirs(container)
+        except EnvironmentError as e:
+            if EEXIST != e.errno:
+                raise StoreDirectoryError(e)
+        try:
+            self.node_config.write_private_config(private_config_item, pr_json)
+        except Exception as e:
+            raise StoreAddError(e)
+
+    def get(self, prn):
+        payment_reference_json = self._read_pr_json(prn)
+        return PaymentReference.from_json(payment_reference_json)
+
+    def add(self, prn):
+        # XXX Not *exactly* atomic is it?  Probably want a
+        # write_private_config_if_not_exists or something.
+        try:
+            self._read_pr_json(prn)
+        except KeyError:
+            self._write_pr_json(prn, PaymentReference(prn).to_json())
+
+
+@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.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..f76f0a6fabf072f45af6cba8120f16cd396edd6b
--- /dev/null
+++ b/src/_secureaccesstokenauthorizer/resource.py
@@ -0,0 +1,137 @@
+# 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,
+)
+
+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):
+    """
+    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.
+
+    :return IResource: The root of the resource hierarchy presented by the
+        client side of the plugin.
+    """
+    store = PaymentReferenceStore(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):
+        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 isinstance(prn, unicode):
+            return bad_request().render(request)
+        try:
+            urlsafe_b64decode(prn.encode("ascii"))
+        except Exception:
+            return bad_request().render(request)
+
+        self._controller.redeem(prn)
+        return b""
+
+
+    def getChild(self, segment, request):
+        prn = segment
+        try:
+            urlsafe_b64decode(prn)
+        except Exception:
+            return bad_request()
+        try:
+            payment_reference = self._store.get(prn)
+        except KeyError:
+            return NoResource()
+        return PaymentReferenceView(payment_reference)
+
+
+
+class PaymentReferenceView(Resource):
+    def __init__(self, reference):
+        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 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..c36c50796f5ebabf6da188bb2a939f4906df55c5 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,
@@ -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..7da47c2b72185eacd304f0ca27bf69597f15dcc9 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,150 @@ 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,
+    )
+
+
+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 +375,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..30b58c0004da7053b6a84b4d587a66cc7c3cb759
--- /dev/null
+++ b/src/_secureaccesstokenauthorizer/tests/test_client_resource.py
@@ -0,0 +1,407 @@
+# 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 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,
+)
+from hypothesis.strategies import (
+    one_of,
+    just,
+    fixed_dictionaries,
+    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 ..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):
+    try:
+        loads(bytestring)
+    except:
+        return True
+    return False
+
+def not_payment_reference_numbers():
+    return text().filter(
+        lambda t: (
+            # exclude / because it changes url dispatch and makes tests fail
+            # differently.
+            u"/" not in t and not is_urlsafe_base64(t)
+        ),
+    )
+
+def is_urlsafe_base64(text):
+    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),
+        fixed_dictionaries({
+            u"payment-reference-number": integers(),
+        }).map(dumps),
+        # Not even JSON
+        binary().filter(is_not_json),
+    )
+
+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.tempdir = self.useFixture(TempDir())
+        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``.
+        """
+        root = from_configuration(
+            get_config(self.tempdir.join(b"tahoe.ini"), b"tub.port"),
+        )
+
+        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.
+        """
+        root = from_configuration(
+            get_config(self.tempdir.join(b"tahoe.ini"), b"tub.port"),
+        )
+        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*.
+        """
+        root = from_configuration(
+            get_config(self.tempdir.join(b"tahoe.ini"), b"tub.port"),
+        )
+        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, payment_reference_numbers())
+    def test_get_invalid_prn(self, get_config, prn):
+        """
+        When a syntactically invalid PRN is requested with a ``GET`` to a child of
+        ``PaymentReferenceNumberCollection`` the response is **BAD REQUEST**.
+        """
+        not_prn = prn[1:]
+        root = from_configuration(
+            get_config(self.tempdir.join(b"tahoe.ini"), b"tub.port"),
+        )
+
+        agent = RequestTraversalAgent(root)
+        requesting = agent.request(
+            b"GET",
+            u"http://127.0.0.1/payment-reference-number/{}".format(not_prn).encode("utf-8"),
+        )
+        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``.
+        """
+        root = from_configuration(
+            get_config(self.tempdir.join(b"tahoe.ini"), b"tub.port"),
+        )
+
+        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.
+        """
+        root = from_configuration(
+            get_config(self.tempdir.join(b"tahoe.ini"), b"tub.port"),
+        )
+
+        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(prn).encode("ascii"),
+        )
+
+        self.assertThat(
+            getting,
+            succeeded(
+                MatchesAll(
+                    ok_response(headers=application_json()),
+                    AfterPreprocessing(
+                        json_content,
+                        succeeded(
+                            Equals({
+                                u"version": 1,
+                                u"number": prn,
+                            }),
+                        ),
+                    ),
+                ),
+            ),
+        )
+
+
+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_model.py b/src/_secureaccesstokenauthorizer/tests/test_model.py
new file mode 100644
index 0000000000000000000000000000000000000000..2b875b94856ba167f961e2a8e9750755dd4b1501
--- /dev/null
+++ b/src/_secureaccesstokenauthorizer/tests/test_model.py
@@ -0,0 +1,187 @@
+# 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 (
+    chmod,
+    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,
+    assume,
+)
+
+from ..model import (
+    StoreDirectoryError,
+    StoreAddError,
+    PaymentReferenceStore,
+)
+
+from .strategies import (
+    tahoe_configs,
+    payment_reference_numbers,
+)
+
+
+class PaymentReferenceStoreTests(TestCase):
+    """
+    Tests for ``PaymentReferenceStore``.
+    """
+    @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(config)
+        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(config)
+        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(config)
+        store.add(prn)
+        store.add(prn)
+        payment_reference = store.get(prn)
+        self.assertThat(
+            payment_reference,
+            MatchesStructure(
+                number=Equals(prn),
+            ),
+        )
+
+
+    @given(tahoe_configs(), payment_reference_numbers(), payment_reference_numbers())
+    def test_unwriteable_store_directory(self, get_config, prn_a, prn_b):
+        """
+        If the underlying directory in the node configuration is not writeable
+        then ``PaymentReferenceStore.add`` raises ``StoreAddError``.
+        """
+        assume(prn_a != prn_b)
+        tempdir = self.useFixture(TempDir())
+        nodedir = tempdir.join(b"node")
+        config = get_config(nodedir, b"tub.port")
+        store = PaymentReferenceStore(config)
+        # Initialize the underlying directory.
+        store.add(prn_a)
+        # Mess it up
+        chmod(config.get_private_path(store._CONFIG_DIR), 0o500)
+
+        self.assertThat(
+            lambda: store.add(prn_b),
+            Raises(
+                AfterPreprocessing(
+                    lambda (type, exc, tb): exc,
+                    MatchesAll(
+                        IsInstance(StoreAddError),
+                        MatchesStructure(
+                            reason=MatchesAll(
+                                IsInstance(IOError),
+                                MatchesStructure(
+                                    errno=Equals(EACCES),
+                                ),
+                            ),
+                        ),
+                    ),
+                ),
+            ),
+        )
+
+    @given(tahoe_configs(), payment_reference_numbers())
+    def test_uncreateable_store_directory(self, get_config, prn):
+        """
+        If the underlying directory in the node configuration cannot be created
+        then ``PaymentReferenceStore.add`` raises ``StoreDirectoryError``.
+        """
+        tempdir = self.useFixture(TempDir())
+        nodedir = tempdir.join(b"node")
+        config = get_config(nodedir, b"tub.port")
+        store = PaymentReferenceStore(config)
+
+        # Create the node directory without permission to create the
+        # underlying directory.
+        mkdir(nodedir, 0o500)
+
+        self.assertThat(
+            lambda: store.add(prn),
+            Raises(
+                AfterPreprocessing(
+                    lambda (type, exc, tb): exc,
+                    MatchesAll(
+                        IsInstance(StoreDirectoryError),
+                        MatchesStructure(
+                            reason=MatchesAll(
+                                IsInstance(OSError),
+                                MatchesStructure(
+                                    errno=Equals(EACCES),
+                                ),
+                            ),
+                        ),
+                    ),
+                ),
+            ),
+        )
diff --git a/src/_secureaccesstokenauthorizer/tests/test_plugin.py b/src/_secureaccesstokenauthorizer/tests/test_plugin.py
index 14d544bfb361b8e0adcb5ad93d65cf528f62e64c..7d3de2b5b92e18037a86c68427b0a57f1736468a 100644
--- a/src/_secureaccesstokenauthorizer/tests/test_plugin.py
+++ b/src/_secureaccesstokenauthorizer/tests/test_plugin.py
@@ -57,6 +57,9 @@ from twisted.plugin import (
 from twisted.test.proto_helpers import (
     StringTransport,
 )
+from twisted.web.resource import (
+    IResource,
+)
 from twisted.plugins.secureaccesstokenauthorizer import (
     storage_server,
 )
@@ -222,3 +225,19 @@ class ClientPluginTests(TestCase):
             storage_client_deferred,
             succeeded(Provides([IStorageServer])),
         )
+
+
+class ClientResourceTests(TestCase):
+    """
+    Tests for the plugin's implementation of
+    ``IFoolscapStoragePlugin.get_client_resource``.
+    """
+    @given(configurations())
+    def test_interface(self, configuration):
+        """
+        ``get_client_resource`` returns an object that provides ``IResource``.
+        """
+        self.assertThat(
+            storage_server.get_client_resource(configuration),
+            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"),
+        )