diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py
index 1bc586e1d65f7ba536a0dd0f7e96c33facaa2650..5768b34bf8e9bf3e26aef80c027c6d589bf64e3d 100644
--- a/src/_zkapauthorizer/controller.py
+++ b/src/_zkapauthorizer/controller.py
@@ -150,7 +150,7 @@ class IRedeemer(Interface):
             anonymity property of the system.
         """
 
-    def redeem(voucher, random_tokens):
+    def redeemWithCounter(voucher, counter, random_tokens):
         """
         Redeem a voucher for unblinded tokens which can be used to construct
         passes.
@@ -162,6 +162,13 @@ class IRedeemer(Interface):
 
         :param Voucher voucher: The voucher to redeem.
 
+        :param int counter: The counter to use in this redemption attempt.  To
+            support vouchers which can be redeemed for a larger number of
+            tokens than is practical to handle at once, one voucher can be
+            partially redeemed repeatedly until the complete set of tokens has
+            been received.  Each partial redemption must have a distinct
+            counter value.
+
         :param list[RandomToken] random_tokens: The random tokens to use in
             the redemption process.
 
@@ -204,7 +211,7 @@ class NonRedeemer(object):
     def random_tokens_for_voucher(self, voucher, count):
         return dummy_random_tokens(voucher, count)
 
-    def redeem(self, voucher, random_tokens):
+    def redeemWithCounter(self, voucher, counter, random_tokens):
         # Don't try to redeem them.
         return Deferred()
 
@@ -234,7 +241,7 @@ class ErrorRedeemer(object):
     def random_tokens_for_voucher(self, voucher, count):
         return dummy_random_tokens(voucher, count)
 
-    def redeem(self, voucher, random_tokens):
+    def redeemWithCounter(self, voucher, counter, random_tokens):
         return fail(Exception(self.details))
 
     def tokens_to_passes(self, message, unblinded_tokens):
@@ -257,7 +264,7 @@ class DoubleSpendRedeemer(object):
     def random_tokens_for_voucher(self, voucher, count):
         return dummy_random_tokens(voucher, count)
 
-    def redeem(self, voucher, random_tokens):
+    def redeemWithCounter(self, voucher, counter, random_tokens):
         return fail(AlreadySpent(voucher))
 
 
@@ -275,7 +282,7 @@ class UnpaidRedeemer(object):
     def random_tokens_for_voucher(self, voucher, count):
         return dummy_random_tokens(voucher, count)
 
-    def redeem(self, voucher, random_tokens):
+    def redeemWithCounter(self, voucher, counter, random_tokens):
         return fail(Unpaid(voucher))
 
 
@@ -317,7 +324,7 @@ class DummyRedeemer(object):
         """
         return dummy_random_tokens(voucher, count)
 
-    def redeem(self, voucher, random_tokens):
+    def redeemWithCounter(self, voucher, counter, random_tokens):
         """
         :return: An already-fired ``Deferred`` that has a list of
           ``UnblindedToken`` instances wrapping meaningless values.
@@ -434,7 +441,7 @@ class RistrettoRedeemer(object):
         )
 
     @inlineCallbacks
-    def redeem(self, voucher, encoded_random_tokens):
+    def redeemWithCounter(self, voucher, counter, encoded_random_tokens):
         random_tokens = list(
             challenge_bypass_ristretto.RandomToken.decode_base64(token.token_value.encode("ascii"))
             for token
@@ -445,6 +452,7 @@ class RistrettoRedeemer(object):
             self._api_root.child(u"v1", u"redeem").to_text(),
             dumps({
                 u"redeemVoucher": voucher.number,
+                u"redeemCounter": counter,
                 u"redeemTokens": list(
                     token.encode_base64()
                     for token
@@ -649,7 +657,7 @@ class PaymentController(object):
                     voucher=voucher.number,
                 )
 
-    def _perform_redeem(self, voucher, random_tokens):
+    def _perform_redeem(self, voucher, counter, random_tokens):
         """
         Use the redeemer to redeem the given voucher and random tokens.
 
@@ -661,7 +669,7 @@ class PaymentController(object):
         d = bracket(
             lambda: setitem(self._active, voucher, self.store.now()),
             lambda: delitem(self._active, voucher),
-            lambda: self.redeemer.redeem(Voucher(voucher), random_tokens),
+            lambda: self.redeemer.redeemWithCounter(Voucher(voucher), counter, random_tokens),
         )
         d.addCallbacks(
             partial(self._redeemSuccess, voucher),
@@ -698,7 +706,14 @@ class PaymentController(object):
         if num_tokens is None:
             num_tokens = self.default_token_count
         tokens = self._get_random_tokens_for_voucher(voucher, num_tokens)
-        return self._perform_redeem(voucher, tokens)
+        # TODO: Actually count up from the voucher's current counter value to
+        # maxCounter instead of only passing 0 here.  Starting at 0 is fine
+        # for a new voucher but if we partially redeemed a voucher on a
+        # previous run and this call comes from `_check_pending_vouchers` then
+        # we should skip any already-redeemed counter values.
+        #
+        # https://github.com/PrivateStorageio/ZKAPAuthorizer/issues/124
+        return self._perform_redeem(voucher, 0, tokens)
 
     def _redeemSuccess(self, voucher, result):
         """
diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py
index aa66032df93a5a6984c5d5edd6c601e234d5c2ed..b648c5c5778e9d7d3d44572ecf5f05657e85c228 100644
--- a/src/_zkapauthorizer/tests/strategies.py
+++ b/src/_zkapauthorizer/tests/strategies.py
@@ -343,6 +343,19 @@ def voucher_objects(states=voucher_states()):
     )
 
 
+def voucher_counters():
+    """
+    Build integers usable as counters in the voucher redemption process.
+    """
+    return integers(
+        min_value=0,
+        # This may or may not be the actual maximum value accepted by a
+        # PaymentServer.  If it is not exactly the maximum, it's probably at
+        # least in the same ballpark.
+        max_value=256,
+    )
+
+
 def byte_strings(label, length, entropy):
     """
     Build byte strings of the given length with at most the given amount of
diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py
index 1c1af180ec01737e5e699da40f4575047273de27..608ef72679232b29a3681b224d2eb61f98f2fe2e 100644
--- a/src/_zkapauthorizer/tests/test_controller.py
+++ b/src/_zkapauthorizer/tests/test_controller.py
@@ -60,6 +60,8 @@ from hypothesis import (
 from hypothesis.strategies import (
     integers,
     datetimes,
+    lists,
+    sampled_from,
 )
 from twisted.python.url import (
     URL,
@@ -74,6 +76,9 @@ from twisted.web.resource import (
     ErrorPage,
     Resource,
 )
+from twisted.web.http_headers import (
+    Headers,
+)
 from twisted.web.http import (
     UNSUPPORTED_MEDIA_TYPE,
     BAD_REQUEST,
@@ -116,6 +121,7 @@ from .strategies import (
     tahoe_configs,
     vouchers,
     voucher_objects,
+    voucher_counters,
     dummy_ristretto_keys,
     clocks,
 )
@@ -302,8 +308,8 @@ class RistrettoRedeemerTests(TestCase):
             Provides([IRedeemer]),
         )
 
-    @given(voucher_objects(), integers(min_value=1, max_value=100))
-    def test_good_ristretto_redemption(self, voucher, num_tokens):
+    @given(voucher_objects(), voucher_counters(), integers(min_value=1, max_value=100))
+    def test_good_ristretto_redemption(self, voucher, counter, num_tokens):
         """
         If the issuer returns a successful result then
         ``RistrettoRedeemer.redeem`` returns a ``Deferred`` that fires with a
@@ -314,8 +320,9 @@ class RistrettoRedeemerTests(TestCase):
         treq = treq_for_loopback_ristretto(issuer)
         redeemer = RistrettoRedeemer(treq, NOWHERE)
         random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens)
-        d = redeemer.redeem(
+        d = redeemer.redeemWithCounter(
             voucher,
+            counter,
             random_tokens,
         )
         self.assertThat(
@@ -335,8 +342,8 @@ class RistrettoRedeemerTests(TestCase):
             ),
         )
 
-    @given(voucher_objects(), integers(min_value=1, max_value=100))
-    def test_redemption_denied_alreadyspent(self, voucher, num_tokens):
+    @given(voucher_objects(), voucher_counters(), integers(min_value=1, max_value=100))
+    def test_redemption_denied_alreadyspent(self, voucher, counter, num_tokens):
         """
         If the issuer declines to allow the voucher to be redeemed and gives a
         reason that the voucher has already been spent, ``RistrettoRedeem``
@@ -347,8 +354,9 @@ class RistrettoRedeemerTests(TestCase):
         treq = treq_for_loopback_ristretto(issuer)
         redeemer = RistrettoRedeemer(treq, NOWHERE)
         random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens)
-        d = redeemer.redeem(
+        d = redeemer.redeemWithCounter(
             voucher,
+            counter,
             random_tokens,
         )
         self.assertThat(
@@ -361,8 +369,8 @@ class RistrettoRedeemerTests(TestCase):
             ),
         )
 
-    @given(voucher_objects(), integers(min_value=1, max_value=100))
-    def test_redemption_denied_unpaid(self, voucher, num_tokens):
+    @given(voucher_objects(), voucher_counters(), integers(min_value=1, max_value=100))
+    def test_redemption_denied_unpaid(self, voucher, counter, num_tokens):
         """
         If the issuer declines to allow the voucher to be redeemed and gives a
         reason that the voucher has not been paid for, ``RistrettoRedeem``
@@ -373,8 +381,9 @@ class RistrettoRedeemerTests(TestCase):
         treq = treq_for_loopback_ristretto(issuer)
         redeemer = RistrettoRedeemer(treq, NOWHERE)
         random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens)
-        d = redeemer.redeem(
+        d = redeemer.redeemWithCounter(
             voucher,
+            counter,
             random_tokens,
         )
         self.assertThat(
@@ -387,8 +396,8 @@ class RistrettoRedeemerTests(TestCase):
             ),
         )
 
-    @given(voucher_objects(), integers(min_value=1, max_value=100))
-    def test_bad_ristretto_redemption(self, voucher, num_tokens):
+    @given(voucher_objects(), voucher_counters(), integers(min_value=1, max_value=100))
+    def test_bad_ristretto_redemption(self, voucher, counter, num_tokens):
         """
         If the issuer returns a successful result with an invalid proof then
         ``RistrettoRedeemer.redeem`` returns a ``Deferred`` that fires with a
@@ -405,8 +414,9 @@ class RistrettoRedeemerTests(TestCase):
         treq = treq_for_loopback_ristretto(issuer)
         redeemer = RistrettoRedeemer(treq, NOWHERE)
         random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens)
-        d = redeemer.redeem(
+        d = redeemer.redeemWithCounter(
             voucher,
+            counter,
             random_tokens,
         )
         self.addDetail(u"redeem Deferred", text_content(str(d)))
@@ -420,8 +430,8 @@ class RistrettoRedeemerTests(TestCase):
             ),
         )
 
-    @given(voucher_objects(), integers(min_value=1, max_value=100))
-    def test_ristretto_pass_construction(self, voucher, num_tokens):
+    @given(voucher_objects(), voucher_counters(), integers(min_value=1, max_value=100))
+    def test_ristretto_pass_construction(self, voucher, counter, num_tokens):
         """
         The passes constructed using unblinded tokens and messages pass the
         Ristretto verification check.
@@ -434,8 +444,9 @@ class RistrettoRedeemerTests(TestCase):
         redeemer = RistrettoRedeemer(treq, NOWHERE)
 
         random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens)
-        d = redeemer.redeem(
+        d = redeemer.redeemWithCounter(
             voucher,
+            counter,
             random_tokens,
         )
         def unblinded_tokens_to_passes(result):
@@ -541,8 +552,9 @@ class AlreadySpentRedemption(Resource):
     that the voucher has already been redeemed.
     """
     def render_POST(self, request):
-        if request.requestHeaders.getRawHeaders(b"content-type") != ["application/json"]:
-            return bad_content_type(request)
+        request_error = check_redemption_request(request)
+        if request_error is not None:
+            return request_error
 
         return bad_request(request, {u"success": False, u"reason": u"double-spend"})
 
@@ -554,8 +566,9 @@ class UnpaidRedemption(Resource):
     the voucher has not been paid for.
     """
     def render_POST(self, request):
-        if request.requestHeaders.getRawHeaders(b"content-type") != ["application/json"]:
-            return bad_content_type(request)
+        request_error = check_redemption_request(request)
+        if request_error is not None:
+            return request_error
 
         return bad_request(request, {u"success": False, u"reason": u"unpaid"})
 
@@ -567,8 +580,9 @@ class RistrettoRedemption(Resource):
         self.public_key = PublicKey.from_signing_key(signing_key)
 
     def render_POST(self, request):
-        if request.requestHeaders.getRawHeaders(b"content-type") != ["application/json"]:
-            return bad_content_type(request)
+        request_error = check_redemption_request(request)
+        if request_error is not None:
+            return request_error
 
         request_body = loads(request.content.read())
         marshaled_blinded_tokens = request_body[u"redeemTokens"]
@@ -605,6 +619,118 @@ class RistrettoRedemption(Resource):
         })
 
 
+class CheckRedemptionRequestTests(TestCase):
+    """
+    Tests for ``check_redemption_request``.
+    """
+    def test_content_type(self):
+        """
+        If the request content-type is not application/json, the response is
+        **Unsupported Media Type**.
+        """
+        issuer = UnpaidRedemption()
+        treq = treq_for_loopback_ristretto(issuer)
+        d = treq.post(
+            NOWHERE.child(u"v1", u"redeem").to_text().encode("ascii"),
+            b"{}",
+        )
+        self.assertThat(
+            d,
+            succeeded(
+                AfterPreprocessing(
+                    lambda response: response.code,
+                    Equals(UNSUPPORTED_MEDIA_TYPE),
+                ),
+            ),
+        )
+
+    def test_not_json(self):
+        """
+        If the request body cannot be decoded as json, the response is **Bad
+        Request**.
+        """
+        issuer = UnpaidRedemption()
+        treq = treq_for_loopback_ristretto(issuer)
+        d = treq.post(
+            NOWHERE.child(u"v1", u"redeem").to_text().encode("ascii"),
+            b"foo",
+            headers=Headers({u"content-type": [u"application/json"]}),
+        )
+        self.assertThat(
+            d,
+            succeeded(
+                AfterPreprocessing(
+                    lambda response: response.code,
+                    Equals(BAD_REQUEST),
+                ),
+            ),
+        )
+
+    @given(
+        lists(
+            sampled_from(
+                [u"redeemVoucher", u"redeemCounter", u"redeemTokens"],
+            ),
+            # Something must be missing if the length is no longer than 2
+            # because there are 3 required properties.
+            max_size=2,
+            unique=True,
+        ),
+    )
+    def test_missing_properties(self, properties):
+        """
+        If the JSON object in the request body does not include all the necessary
+        properties, the response is **Bad Request**.
+        """
+        issuer = UnpaidRedemption()
+        treq = treq_for_loopback_ristretto(issuer)
+        d = treq.post(
+            NOWHERE.child(u"v1", u"redeem").to_text().encode("ascii"),
+            dumps(dict.fromkeys(properties)),
+            headers=Headers({u"content-type": [u"application/json"]}),
+        )
+        self.assertThat(
+            d,
+            succeeded(
+                AfterPreprocessing(
+                    lambda response: response.code,
+                    Equals(BAD_REQUEST),
+                ),
+            ),
+        )
+
+def check_redemption_request(request):
+    """
+    Verify that the given request conforms to the redemption server's public
+    interface.
+    """
+    if request.requestHeaders.getRawHeaders(b"content-type") != ["application/json"]:
+        return bad_content_type(request)
+
+    p = request.content.tell()
+    content = request.content.read()
+    request.content.seek(p)
+
+    try:
+        request_body = loads(content)
+    except ValueError:
+        return bad_request(request, None)
+
+    expected_keys = {u"redeemVoucher", u"redeemCounter", u"redeemTokens"}
+    actual_keys = set(request_body.keys())
+    if expected_keys != actual_keys:
+        return bad_request(
+            request, {
+                u"success": False,
+                u"reason": u"{} != {}".format(
+                    expected_keys,
+                    actual_keys,
+                ),
+            },
+        )
+    return None
+
+
 def bad_request(request, body_object):
     request.setResponseCode(BAD_REQUEST)
     request.setHeader(b"content-type", b"application/json")