diff --git a/src/_zkapauthorizer/resource.py b/src/_zkapauthorizer/resource.py
index db317900a2e629a5937c8f3150b9694c1188dde6..20f1519d0196268b1886b0b3e9938c807886dbac 100644
--- a/src/_zkapauthorizer/resource.py
+++ b/src/_zkapauthorizer/resource.py
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
 # Copyright 2019 PrivateStorage.io, LLC
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
@@ -39,6 +40,10 @@ from twisted.logger import (
 )
 from twisted.web.http import (
     BAD_REQUEST,
+    NOT_ALLOWED,
+)
+from twisted.web.server import (
+    NOT_DONE_YET,
 )
 from twisted.web.resource import (
     IResource,
@@ -55,6 +60,12 @@ from ._base64 import (
     urlsafe_b64decode,
 )
 
+from .storage_common import (
+    required_passes,
+    get_configured_pass_value,
+    get_configured_lease_duration,
+)
+
 from .controller import (
     PaymentController,
     get_redeemer,
@@ -108,20 +119,27 @@ def from_configuration(node_config, store, redeemer=None, default_token_count=No
     controller = PaymentController(store, redeemer, default_token_count)
     root = create_private_tree(
         lambda: node_config.get_private_config(b"api_auth_token"),
-        authorizationless_resource_tree(store, controller),
+        authorizationless_resource_tree(
+            store,
+            controller,
+            get_configured_pass_value(node_config),
+            get_configured_lease_duration(node_config),
+        ),
     )
     root.store = store
     root.controller = controller
     return root
 
 
-def authorizationless_resource_tree(store, controller):
+def authorizationless_resource_tree(store, controller, pass_value, lease_duration):
     """
     Create the full ZKAPAuthorizer client plugin resource hierarchy with no
     authorization applied.
 
     :param VoucherStore store: The store to use.
     :param PaymentController controller: The payment controller to use.
+    :param int pass_value: The bytes component of the bytes×time value of a single pass.
+    :param int lease_duration: The number of seconds a lease will be valid.
 
     :return IResource: The root of the resource hierarchy.
     """
@@ -146,7 +164,7 @@ def authorizationless_resource_tree(store, controller):
     )
     root.putChild(
         b"calculate-price",
-        _CalculatePrice(),
+        _CalculatePrice(pass_value, lease_duration),
     )
     return root
 
@@ -155,6 +173,86 @@ class _CalculatePrice(Resource):
     """
     This resource exposes a storage price calculator.
     """
+    allowedMethods = [b"POST"]
+
+    render_HEAD = render_GET = None
+
+    def __init__(self, pass_value, lease_period):
+        """
+        :param pass_value: See ``authorizationless_resource_tree``
+        :param lease_period: See ``authorizationless_resource_tree``
+        """
+        self._pass_value = pass_value
+        self._lease_period = lease_period
+        Resource.__init__(self)
+
+    def render_POST(self, request):
+        """
+        Calculate the price in ZKAPs to store or continue storing files specified
+        sizes.
+        """
+        if wrong_content_type(request, u"application/json"):
+            return NOT_DONE_YET
+
+        application_json(request)
+        payload = request.content.read()
+        try:
+            body_object = loads(payload)
+        except ValueError:
+            request.setResponseCode(BAD_REQUEST)
+            return dumps({
+                "error": "could not parse request body",
+            })
+
+        try:
+            version = body_object[u"version"]
+            sizes = body_object[u"sizes"]
+        except (TypeError, KeyError):
+            request.setResponseCode(BAD_REQUEST)
+            return dumps({
+                "error": "could not read `version` and `sizes` properties",
+            })
+
+        if version != 1:
+            request.setResponseCode(BAD_REQUEST)
+            return dumps({
+                "error": "did not find required version number 1 in request",
+            })
+
+        if not isinstance(sizes, list) or not all(isinstance(size, (int, long)) and size >= 0 for size in sizes):
+            request.setResponseCode(BAD_REQUEST)
+            return dumps({
+                "error": "did not find required positive integer sizes list in request",
+            })
+
+        application_json(request)
+        return dumps({
+            u"price": required_passes(self._pass_value, sizes),
+            u"period": self._lease_period,
+        })
+
+
+def wrong_content_type(request, required_type):
+    """
+    Check the content-type of a request and respond if it is incorrect.
+
+    :param request: The request object to check.
+
+    :param unicode required_type: The required content-type (eg
+        ``u"application/json"``).
+
+    :return bool: ``True`` if the content-type is wrong and an error response
+        has been generated.  ``False`` otherwise.
+    """
+    actual_type = request.requestHeaders.getRawHeaders(
+        u"content-type",
+        [None],
+    )[0]
+    if actual_type != required_type:
+        request.setResponseCode(NOT_ALLOWED)
+        request.finish()
+        return True
+    return False
 
 
 def application_json(request):
diff --git a/src/_zkapauthorizer/storage_common.py b/src/_zkapauthorizer/storage_common.py
index 1f429dd50a924557d73950c458f071bd4e9f9721..e30149ce4797179f42be12ddf531be90b5dabe6c 100644
--- a/src/_zkapauthorizer/storage_common.py
+++ b/src/_zkapauthorizer/storage_common.py
@@ -88,6 +88,18 @@ def get_configured_pass_value(node_config):
         default=BYTES_PER_PASS,
     ))
 
+
+def get_configured_lease_duration(node_config):
+    """
+    Just kidding.  Lease duration is hard-coded.
+
+    :return int: The number of seconds after which a newly acquired lease will
+        be valid.
+    """
+    # See lots of places in Tahoe-LAFS, eg src/allmydata/storage/server.py
+    return 31 * 24 * 60 * 60
+
+
 def required_passes(bytes_per_pass, share_sizes):
     """
     Calculate the number of passes that are required to store ``stored_bytes``
diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py
index 4db2af522273a16d20b7a11370013af882117721..0bbc9bb6418d335d1f93bb5712ace73301024b70 100644
--- a/src/_zkapauthorizer/tests/test_client_resource.py
+++ b/src/_zkapauthorizer/tests/test_client_resource.py
@@ -88,6 +88,9 @@ from hypothesis.strategies import (
     binary,
     text,
     datetimes,
+    builds,
+    tuples,
+    dictionaries,
 )
 
 from twisted.python.filepath import (
@@ -106,7 +109,6 @@ from twisted.web.http import (
     UNAUTHORIZED,
     NOT_FOUND,
     BAD_REQUEST,
-    NOT_ALLOWED,
     NOT_IMPLEMENTED,
 )
 from twisted.web.http_headers import (
@@ -145,6 +147,8 @@ from ..resource import (
 
 from ..storage_common import (
     required_passes,
+    get_configured_pass_value,
+    get_configured_lease_duration,
 )
 
 from .strategies import (
@@ -163,6 +167,7 @@ from .strategies import (
 from .matchers import (
     Provides,
     matches_response,
+    between,
 )
 from .json import (
     loads,
@@ -271,7 +276,7 @@ def root_from_config(config, now):
     )
 
 
-def authorized_request(api_auth_token, agent, method, uri, data=None):
+def authorized_request(api_auth_token, agent, method, uri, headers=None, data=None):
     """
     Issue a request with the required token-based authorization header value.
 
@@ -283,6 +288,10 @@ def authorized_request(api_auth_token, agent, method, uri, data=None):
 
     :param bytes uri: The URI for the request.
 
+    :param ({bytes: [bytes]})|None headers: If not ``None``, extra request
+        headers to include.  The **Authorization** header will be overwritten
+        if it is present.
+
     :param BytesIO|None data: If not ``None``, the request body.
 
     :return: A ``Deferred`` like the one returned by ``IAgent.request``.
@@ -291,12 +300,18 @@ def authorized_request(api_auth_token, agent, method, uri, data=None):
         bodyProducer = None
     else:
         bodyProducer = FileBodyProducer(data, cooperator=uncooperator())
+    if headers is None:
+        headers = Headers()
+    else:
+        headers = Headers(headers)
+    headers.setRawHeaders(
+        u"authorization",
+        [b"tahoe-lafs {}".format(api_auth_token)],
+    )
     return agent.request(
         method,
         uri,
-        headers=Headers({
-            "authorization": ["tahoe-lafs {}".format(api_auth_token)],
-        }),
+        headers=headers,
         bodyProducer=bodyProducer,
     )
 
@@ -1312,20 +1327,195 @@ class VoucherTests(TestCase):
         )
 
 
+def mime_types(blacklist=None):
+    """
+    Build MIME types as b"major/minor" byte strings.
+
+    :param set|None blacklist: If not ``None``, MIME types to exclude from the
+        result.
+    """
+    if blacklist is None:
+        blacklist = set()
+    return tuples(
+        text(),
+        text(),
+    ).map(
+        b"/".join,
+    ).filter(
+        lambda content_type: content_type not in blacklist,
+    )
+
+
+@attr.s
+class Request(object):
+    """
+    Represent some of the parameters of an HTTP request.
+    """
+    method = attr.ib()
+    headers = attr.ib()
+    data = attr.ib()
+
+
+def bad_calculate_price_requests():
+    """
+    Build Request instances describing requests which are not allowed at the
+    ``/calculate-price`` endpoint.
+    """
+    good_methods = just(b"POST")
+    bad_methods = sampled_from([
+        b"GET",
+        b"HEAD",
+        b"PUT",
+        b"PATCH",
+        b"OPTIONS",
+        b"FOO",
+    ])
+
+    good_headers = just({b"content-type": [b"application/json"]})
+    bad_headers = fixed_dictionaries({
+        b"content-type": mime_types(
+            blacklist={b"application/json"},
+        ).map(
+            lambda content_type: [content_type],
+        ),
+    })
+
+    good_version = just(1)
+    bad_version = one_of(
+        text(),
+        lists(integers()),
+        integers(max_value=0),
+        integers(min_value=2),
+    )
+
+    good_sizes = lists(integers(min_value=0))
+    bad_sizes = one_of(
+        integers(),
+        text(),
+        lists(text(), min_size=1),
+        dictionaries(text(), text()),
+        lists(integers(max_value=-1), min_size=1),
+    )
+
+    good_data = fixed_dictionaries({
+        u"version": good_version,
+        u"sizes": good_sizes,
+    }).map(dumps)
+
+    bad_data_version = fixed_dictionaries({
+        u"version": bad_version,
+        u"sizes": good_sizes,
+    }).map(dumps)
+
+    bad_data_sizes = fixed_dictionaries({
+        u"version": good_version,
+        u"sizes": bad_sizes,
+    }).map(dumps)
+
+    bad_data_other = dictionaries(
+        text(),
+        integers(),
+    ).map(dumps)
+
+    bad_data_junk = binary()
+
+    good_fields = {
+        "method": good_methods,
+        "headers": good_headers,
+        "data": good_data,
+    }
+
+    bad_choices = [
+        ("method", bad_methods),
+        ("headers", bad_headers),
+        ("data", bad_data_version),
+        ("data", bad_data_sizes),
+        ("data", bad_data_other),
+        ("data", bad_data_junk),
+    ]
+
+    def merge(fields, key, value):
+        fields = fields.copy()
+        fields[key] = value
+        return fields
+
+    return sampled_from(
+        bad_choices,
+    ).flatmap(
+        lambda bad_choice: builds(
+            Request,
+            **merge(good_fields, *bad_choice)
+        ),
+    )
+
+
 class CalculatePriceTests(TestCase):
     """
     Tests relating to ``/calculate-price`` as implemented by the
     ``_zkapauthorizer.resource`` module.
     """
+    url = b"http://127.0.0.1/calculate-price"
+
+    @given(
+        tahoe_configs(),
+        api_auth_tokens(),
+        bad_calculate_price_requests(),
+    )
+    def test_bad_request(self, get_config, api_auth_token, bad_request):
+        """
+        When approached with:
+
+          * a method other than POST
+          * a content-type other than **application/json**
+          * a request body which is not valid JSON
+          * a JSON request body without version and sizes properties
+          * a JSON request body without a version of 1
+          * a JSON request body with other properties
+          * or a JSON request body with sizes other than a list of integers
+
+        response code is not in the 200 range.
+        """
+        config = get_config_with_api_token(
+            self.useFixture(TempDir()),
+            get_config,
+            api_auth_token,
+        )
+        root = root_from_config(config, datetime.now)
+        agent = RequestTraversalAgent(root)
+        self.assertThat(
+            authorized_request(
+                api_auth_token,
+                agent,
+                bad_request.method,
+                self.url,
+                headers=bad_request.headers,
+                data=BytesIO(bad_request.data),
+            ),
+            succeeded(
+                matches_response(
+                    code_matcher=MatchesAny(
+                        # It is fine to signal client errors
+                        between(400, 499),
+                        # It is fine to say we didn't implement the request
+                        # method (I guess - Twisted Web sort of forces it on
+                        # us, I'd rather have NOT ALLOWED for this case
+                        # instead...).  We don't want INTERNAL SERVER ERROR
+                        # though.
+                        Equals(NOT_IMPLEMENTED),
+                    ),
+                ),
+            ),
+        )
+
     @given(
         tahoe_configs(),
         api_auth_tokens(),
-        sampled_from([b"GET", b"PUT", b"PATCH", b"OPTIONS", b"FOO"]),
+        lists(integers(min_value=0)),
     )
-    def test_wrong_method(self, get_config, api_auth_token, method):
+    def test_calculated_price(self, get_config, api_auth_token, sizes):
         """
-        When approached with a method other than **POST** the response code is
-        METHOD NOT ALLOWED.
+        A well-formed request returns the price in ZKAPs as an integer and the
+        storage period (the minimum allowed) that they pay for.
         """
         config = get_config_with_api_token(
             self.useFixture(TempDir()),
@@ -1334,16 +1524,32 @@ class CalculatePriceTests(TestCase):
         )
         root = root_from_config(config, datetime.now)
         agent = RequestTraversalAgent(root)
+
+        expected_price = required_passes(
+            get_configured_pass_value(config),
+            sizes,
+        )
+
         self.assertThat(
             authorized_request(
                 api_auth_token,
                 agent,
-                method,
-                b"http://127.0.0.1/calculate-price",
+                b"POST",
+                self.url,
+                headers={b"content-type": [b"application/json"]},
+                data=BytesIO(dumps({u"version": 1, u"sizes": sizes})),
             ),
             succeeded(
                 matches_response(
-                    code_matcher=MatchesAny(Equals(NOT_ALLOWED), Equals(NOT_IMPLEMENTED)),
+                    code_matcher=Equals(OK),
+                    headers_matcher=application_json(),
+                    body_matcher=AfterPreprocessing(
+                        loads,
+                        Equals({
+                            u"price": expected_price,
+                            u"period": get_configured_lease_duration(config),
+                        }),
+                    ),
                 ),
             ),
         )