diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py
index 1bc586e1d65f7ba536a0dd0f7e96c33facaa2650..d2c6db49625d80d56a592379a2125bbf3d3c44e2 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,8 @@ 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 0 to maxCounter instead of only passing 0 here.
+        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..608b8aebfec242aa86dec9ffd5cc720aba0e647c 100644
--- a/src/_zkapauthorizer/tests/test_controller.py
+++ b/src/_zkapauthorizer/tests/test_controller.py
@@ -116,6 +116,7 @@ from .strategies import (
     tahoe_configs,
     vouchers,
     voucher_objects,
+    voucher_counters,
     dummy_ristretto_keys,
     clocks,
 )
@@ -302,8 +303,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 +315,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 +337,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 +349,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 +364,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 +376,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 +391,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 +409,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 +425,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 +439,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 +547,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 +561,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 +575,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 +614,30 @@ class RistrettoRedemption(Resource):
         })
 
 
+def check_redemption_request(request):
+    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)
+
+    request_body = loads(content)
+    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")