diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py
index 9d9663b92b0b9a8f7c4b24a83db3653c27801ba1..df0c81dd3fabd08635220d44419dd1cf41177364 100644
--- a/src/_zkapauthorizer/controller.py
+++ b/src/_zkapauthorizer/controller.py
@@ -145,6 +145,8 @@ class IRedeemer(Interface):
         :param Voucher voucher: The voucher the tokens will be associated
             with.
 
+        :param int counter: See ``redeemWithCounter``.
+
         :param int count: The number of random tokens to generate.
 
         :return list[RandomToken]: The generated tokens.  Random tokens must
@@ -203,6 +205,26 @@ class IRedeemer(Interface):
         """
 
 
+@attr.s
+@implementer(IRedeemer)
+class IndexedRedeemer(object):
+    """
+    A ``IndexedRedeemer`` delegates redemption to a redeemer chosen to
+    correspond to the redemption counter given.
+    """
+    redeemers = attr.ib()
+
+    def random_tokens_for_voucher(self, voucher, counter, count):
+        return dummy_random_tokens(voucher, counter, count)
+
+    def redeemWithCounter(self, voucher, counter, random_tokens):
+        return self.redeemers[counter].redeemWithCounter(
+            voucher,
+            counter,
+            random_tokens,
+        )
+
+
 @implementer(IRedeemer)
 class NonRedeemer(object):
     """
@@ -636,6 +658,15 @@ class PaymentController(object):
         which have recently failed a redemption attempt due to an unpaid
         response from the redemption server to timestamps when the failure was
         observed.
+
+    :ivar int num_redemption_groups: The number of groups into which to divide
+        tokens during the redemption process, with each group being redeemed
+        separately from the rest.  This value needs to agree with the value
+        the PaymentServer is configured with.
+
+        TODO: Retrieve this value from the PaymentServer or from the
+        ZKAPAuthorizer configuration instead of just hard-coding a duplicate
+        value in this implementation.
     """
     _log = Logger()
 
@@ -643,6 +674,8 @@ class PaymentController(object):
     redeemer = attr.ib()
     default_token_count = attr.ib()
 
+    num_redemption_groups = attr.ib(default=16)
+
     _clock = attr.ib(
         default=attr.Factory(partial(namedAny, "twisted.internet.reactor")),
     )
@@ -730,17 +763,16 @@ class PaymentController(object):
             lambda: self.redeemer.redeemWithCounter(voucher.number, counter, random_tokens),
         )
         d.addCallbacks(
-            partial(self._redeemSuccess, voucher.number),
-            partial(self._redeemFailure, voucher.number),
+            partial(self._redeem_success, voucher.number, counter),
+            partial(self._redeem_failure, voucher.number),
         )
-        d.addErrback(partial(self._finalRedeemError, voucher.number))
+        d.addErrback(partial(self._final_redeem_error, voucher.number))
         return d
 
-    def _get_random_tokens_for_voucher(self, voucher, num_tokens):
+    def _get_random_tokens_for_voucher(self, voucher, counter, num_tokens):
         """
         Generate or load random tokens for a redemption attempt of a voucher.
         """
-        counter = 0
         def get_tokens():
             self._log.info(
                 "Generating random tokens for a voucher ({voucher}).",
@@ -754,34 +786,46 @@ class PaymentController(object):
 
         return self.store.add(voucher, counter, get_tokens)
 
+    @inlineCallbacks
     def redeem(self, voucher, num_tokens=None):
         """
         :param unicode voucher: A voucher to redeem.
 
         :param int num_tokens: A number of tokens to redeem.
         """
-        # Pre-generate the random tokens to use when redeeming the voucher.
-        # These are persisted with the voucher so the redemption can be made
-        # idempotent.  We don't want to lose the value if we fail after the
-        # server deems the voucher redeemed but before we persist the result.
-        # With a stable set of tokens, we can re-submit them and the server
-        # can re-sign them without fear of issuing excess passes.  Whether the
-        # server signs a given set of random tokens once or many times, the
-        # number of passes that can be constructed is still only the size of
-        # the set of random tokens.
         if num_tokens is None:
             num_tokens = self.default_token_count
-        tokens = self._get_random_tokens_for_voucher(voucher, num_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.
+        # num_redemption_groups 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(self.store.get(voucher), 0, tokens)
-
-    def _redeemSuccess(self, voucher, result):
+        for counter in range(0, self.num_redemption_groups):
+            # Pre-generate the random tokens to use when redeeming the voucher.
+            # These are persisted with the voucher so the redemption can be made
+            # idempotent.  We don't want to lose the value if we fail after the
+            # server deems the voucher redeemed but before we persist the result.
+            # With a stable set of tokens, we can re-submit them and the server
+            # can re-sign them without fear of issuing excess passes.  Whether the
+            # server signs a given set of random tokens once or many times, the
+            # number of passes that can be constructed is still only the size of
+            # the set of random tokens.
+            token_count = token_count_for_group(self.num_redemption_groups, num_tokens, counter)
+            tokens = self._get_random_tokens_for_voucher(voucher, counter, token_count)
+
+            # Reload state before each iteration.  We expect it to change each time.
+            voucher_obj = self.store.get(voucher)
+
+            if not voucher_obj.state.should_start_redemption():
+                # An earlier iteration may have encountered a fatal error.
+                break
+
+            yield self._perform_redeem(voucher_obj, counter, tokens)
+
+    def _redeem_success(self, voucher, counter, result):
         """
         Update the database state to reflect that a voucher was redeemed and to
         store the resulting unblinded tokens (which can be used to construct
@@ -795,9 +839,10 @@ class PaymentController(object):
             voucher,
             result.public_key,
             result.unblinded_tokens,
+            completed=(counter + 1 == self.num_redemption_groups),
         )
 
-    def _redeemFailure(self, voucher, reason):
+    def _redeem_failure(self, voucher, reason):
         if reason.check(AlreadySpent):
             self._log.error(
                 "Voucher {voucher} reported as already spent during redemption.",
@@ -822,7 +867,7 @@ class PaymentController(object):
             )
         return None
 
-    def _finalRedeemError(self, voucher, reason):
+    def _final_redeem_error(self, voucher, reason):
         self._log.failure("Redeeming random tokens for a voucher ({voucher}) encountered error.", reason, voucher=voucher)
         return None
 
diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py
index 0db7e74b7643d770727408fb7228eac226a539b9..8eb85ee47f3a645f110cd3d5de297a5576303c2f 100644
--- a/src/_zkapauthorizer/model.py
+++ b/src/_zkapauthorizer/model.py
@@ -341,7 +341,7 @@ class VoucherStore(object):
         self._insert_unblinded_tokens(cursor, unblinded_tokens)
 
     @with_cursor
-    def insert_unblinded_tokens_for_voucher(self, cursor, voucher, public_key, unblinded_tokens):
+    def insert_unblinded_tokens_for_voucher(self, cursor, voucher, public_key, unblinded_tokens, completed):
         """
         Store some unblinded tokens received from redemption of a voucher.
 
@@ -354,15 +354,23 @@ class VoucherStore(object):
 
         :param list[UnblindedToken] unblinded_tokens: The unblinded tokens to
             store.
+
+        :param bool completed: ``True`` if redemption of this voucher is now
+            complete, ``False`` otherwise.
         """
-        voucher_state = u"redeemed"
+        if  completed:
+            voucher_state = u"redeemed"
+        else:
+            voucher_state = u"pending"
+
         cursor.execute(
             """
             UPDATE [vouchers]
             SET [state] = ?
-              , [token-count] = ?
+              , [token-count] = COALESCE([token-count], 0) + ?
               , [finished] = ?
               , [public-key] = ?
+              , [counter] = [counter] + 1
             WHERE [number] = ?
             """,
             (
@@ -901,9 +909,7 @@ class Voucher(object):
     def from_row(cls, row):
         def state_from_row(state, row):
             if state == u"pending":
-                # TODO: The 0 here should be row[3] but I can't write a test
-                # to prove it yet.
-                return Pending(counter=0)
+                return Pending(counter=row[3])
             if state == u"double-spend":
                 return DoubleSpend(
                     parse_datetime(row[0], delimiter=u" "),
diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py
index 678d0d7d6c25524413c248d9048cc0b948dd8409..2b3e8921f96aa535768bbe162ebb3cf806c9a4b6 100644
--- a/src/_zkapauthorizer/tests/test_controller.py
+++ b/src/_zkapauthorizer/tests/test_controller.py
@@ -52,6 +52,7 @@ from testtools.matchers import (
 )
 from testtools.twistedsupport import (
     succeeded,
+    has_no_result,
     failed,
 )
 
@@ -106,6 +107,7 @@ from ..controller import (
     DoubleSpendRedeemer,
     UnpaidRedeemer,
     RistrettoRedeemer,
+    IndexedRedeemer,
     PaymentController,
     AlreadySpent,
     Unpaid,
@@ -230,26 +232,47 @@ class PaymentControllerTests(TestCase):
             Equals(model_Pending(counter=0)),
         )
 
-    @given(tahoe_configs(), datetimes(), vouchers())
-    def test_redeeming(self, get_config, now, voucher):
+    @given(tahoe_configs(), datetimes(), vouchers(), voucher_counters())
+    def test_redeeming(self, get_config, now, voucher, num_successes):
         """
         A ``Voucher`` is marked redeeming while ``IRedeemer.redeem`` is actively
-        working on redeeming it.
+        working on redeeming it with a counter value that reflects the number
+        of successful partial redemptions so far completed.
         """
+        # The voucher counter can be zero (no tries yet succeeded).  We want
+        # at least *one* run through so we'll bump this up to be sure we get
+        # that.
+        counter = num_successes + 1
+
+        success_redeemers = [DummyRedeemer()] * num_successes
+        hang_redeemers = [NonRedeemer()]
+        redeemers = success_redeemers + hang_redeemers
+        # A redeemer which will succeed `num_successes` times and then hang on
+        # the next attempt.
+        redeemer = IndexedRedeemer(redeemers)
+
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
         controller = PaymentController(
             store,
-            NonRedeemer(),
-            default_token_count=100,
+            redeemer,
+            # This will give us one ZKAP per attempt.
+            default_token_count=counter,
+            # Require more success than we're going to get so it doesn't
+            # finish.
+            num_redemption_groups=counter,
+        )
+
+        self.assertThat(
+            controller.redeem(voucher),
+            has_no_result(),
         )
-        controller.redeem(voucher)
 
         controller_voucher = controller.get_voucher(voucher)
         self.assertThat(
             controller_voucher.state,
             Equals(model_Redeeming(
                 started=now,
-                counter=0,
+                counter=num_successes,
             )),
         )
 
diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py
index 3b0a1f52f86252d235b9b9520289fe7f17854a49..0e5ebd3e971de668d3ca3de36e356d943d62531f 100644
--- a/src/_zkapauthorizer/tests/test_model.py
+++ b/src/_zkapauthorizer/tests/test_model.py
@@ -57,6 +57,7 @@ from hypothesis import (
 
 from hypothesis.strategies import (
     data,
+    booleans,
     lists,
     tuples,
     datetimes,
@@ -345,6 +346,7 @@ class VoucherStoreTests(TestCase):
             voucher_value,
             public_key,
             unblinded_tokens,
+            completed=data.draw(booleans()),
         )
 
         backed_up_tokens = store.backup()[u"unblinded-tokens"]
@@ -484,8 +486,9 @@ class UnblindedTokenStoreTests(TestCase):
         vouchers(),
         dummy_ristretto_keys(),
         lists(unblinded_tokens(), unique=True),
+        booleans(),
     )
-    def test_unblinded_tokens_without_voucher(self, get_config, now, voucher_value, public_key, unblinded_tokens):
+    def test_unblinded_tokens_without_voucher(self, get_config, now, voucher_value, public_key, unblinded_tokens, completed):
         """
         Unblinded tokens for a voucher which has not been added to the store cannot be inserted.
         """
@@ -495,6 +498,7 @@ class UnblindedTokenStoreTests(TestCase):
                 voucher_value,
                 public_key,
                 unblinded_tokens,
+                completed,
             ),
             raises(ValueError),
         )
@@ -504,16 +508,17 @@ class UnblindedTokenStoreTests(TestCase):
         datetimes(),
         vouchers(),
         dummy_ristretto_keys(),
+        booleans(),
         data(),
     )
-    def test_unblinded_tokens_round_trip(self, get_config, now, voucher_value, public_key, data):
+    def test_unblinded_tokens_round_trip(self, get_config, now, voucher_value, public_key, completed, data):
         """
         Unblinded tokens that are added to the store can later be retrieved.
         """
         random_tokens, unblinded_tokens = paired_tokens(data)
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
         store.add(voucher_value, 0, lambda: random_tokens)
-        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded_tokens)
+        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded_tokens, completed)
         retrieved_tokens = store.extract_unblinded_tokens(len(random_tokens))
 
         self.expectThat(
@@ -559,7 +564,7 @@ class UnblindedTokenStoreTests(TestCase):
 
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
         store.add(voucher_value, 0, lambda: random)
-        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded)
+        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded, completed=True)
         loaded_voucher = store.get(voucher_value)
         self.assertThat(
             loaded_voucher,
@@ -626,7 +631,7 @@ class UnblindedTokenStoreTests(TestCase):
         )
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
         store.add(voucher_value, 0, lambda: random)
-        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded)
+        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded, completed=True)
         self.assertThat(
             lambda: store.mark_voucher_double_spent(voucher_value),
             raises(ValueError),
@@ -652,11 +657,12 @@ class UnblindedTokenStoreTests(TestCase):
         datetimes(),
         vouchers(),
         dummy_ristretto_keys(),
+        booleans(),
         integers(min_value=1, max_value=100),
         integers(min_value=1),
         data(),
     )
-    def test_not_enough_unblinded_tokens(self, get_config, now, voucher_value, public_key, num_tokens, extra, data):
+    def test_not_enough_unblinded_tokens(self, get_config, now, voucher_value, public_key, completed, num_tokens, extra, data):
         """
         ``extract_unblinded_tokens`` raises ``NotEnoughTokens`` if ``count`` is
         greater than the number of unblinded tokens in the store.
@@ -679,7 +685,7 @@ class UnblindedTokenStoreTests(TestCase):
         )
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
         store.add(voucher_value, 0, lambda: random)
-        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded)
+        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded, completed)
 
         self.assertThat(
             lambda: store.extract_unblinded_tokens(num_tokens + extra),
diff --git a/src/_zkapauthorizer/tests/test_plugin.py b/src/_zkapauthorizer/tests/test_plugin.py
index 48f8cfcee9ceed36b72dc9df7dd42953c1eee11f..18dcebc3ddaef76e26fbd04386acda5d66503bbe 100644
--- a/src/_zkapauthorizer/tests/test_plugin.py
+++ b/src/_zkapauthorizer/tests/test_plugin.py
@@ -418,12 +418,17 @@ class ClientPluginTests(TestCase):
             b"tub.port",
         )
 
+        # Give it enough for the allocate_buckets call below.
+        token_count = required_passes(BYTES_PER_PASS, [size] * len(sharenums))
+        # And few enough redemption groups given the number of tokens.
+        num_redemption_groups = token_count
+
         store = VoucherStore.from_node_config(node_config, lambda: now)
         controller = PaymentController(
             store,
             DummyRedeemer(),
-            # Give it enough for the allocate_buckets call below.
-            required_passes(BYTES_PER_PASS, [size] * len(sharenums)),
+            default_token_count=token_count,
+            num_redemption_groups=num_redemption_groups,
         )
         # Get a token inserted into the store.
         redeeming = controller.redeem(voucher)