diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py
index 7e3302d7953be1cc1e462f7caab63253a4f0b4c7..f6af30064c04fcf777152c7634d78b0830299040 100644
--- a/src/_zkapauthorizer/controller.py
+++ b/src/_zkapauthorizer/controller.py
@@ -17,11 +17,164 @@ This module implements controllers (in the MVC sense) for the web interface
 for the client side of the storage plugin.
 """
 
+from functools import (
+    partial,
+)
+
 import attr
 
+from zope.interface import (
+    Interface,
+    implementer,
+)
+
+from twisted.internet.defer import (
+    Deferred,
+    succeed,
+)
+
+from .model import (
+    Pass,
+    RandomToken,
+)
+
+
+class IRedeemer(Interface):
+    """
+    An ``IRedeemer`` can exchange a voucher for one or more passes.
+    """
+    def random_tokens_for_voucher(voucher, count):
+        """
+        Generate a number of random tokens to use in the redemption process for
+        the given voucher.
+
+        :param Voucher voucher: The voucher the tokens will be associated
+            with.
+
+        :param int count: The number of random tokens to generate.
+
+        :return list[RandomToken]: The generated tokens.  Random tokens must
+            be unique over the lifetime of the Tahoe-LAFS node where this
+            plugin is being used but the same tokens *may* be generated for
+            the same voucher.  The tokens must be kept secret to preserve the
+            anonymity property of the system.
+        """
+
+    def redeem(voucher, random_tokens):
+        """
+        Redeem a voucher for passes.
+
+        Implementations of this method do not need to be fault tolerant.  If a
+        redemption attempt is interrupted before it completes, it is the
+        caller's responsibility to call this method again with the same
+        arguments.
+
+        :param Voucher voucher: The voucher to redeem.
+
+        :param list[RandomToken] random_tokens: The random tokens to use in
+            the redemption process.
+
+        :return: A ``Deferred`` which fires with a list of ``Pass`` instances
+            on successful redemption or which fails with
+            ``TransientRedemptionError`` on any error which may be resolved by
+            simply trying again later or which fails with
+            ``PermanentRedemptionError`` on any error which is definitive and
+            final.
+        """
+
+
+@implementer(IRedeemer)
+class NonRedeemer(object):
+    """
+    A ``NonRedeemer`` never tries to redeem vouchers for ZKAPs.
+    """
+    def random_tokens_for_voucher(self, voucher, count):
+        # It doesn't matter because we're never going to try to redeem them.
+        return list(
+            RandomToken(u"{}-{}".format(voucher, n))
+            for n
+            in range(count)
+        )
+
+    def redeem(self, voucher, random_tokens):
+        # Don't try to redeem them.
+        return Deferred()
+
+
+@implementer(IRedeemer)
+@attr.s
+class DummyRedeemer(object):
+    """
+    A ``DummyRedeemer`` pretends to redeem vouchers for ZKAPs.  Instead of
+    really redeeming them, it makes up some fake ZKAPs and pretends those are
+    the result.
+    """
+    def random_tokens_for_voucher(self, voucher, count):
+        """
+        Generate some number of random tokens to submit along with a voucher for
+        redemption.
+        """
+        # Dummy token generation.
+        return list(
+            RandomToken(u"{}-{}".format(voucher, n))
+            for n
+            in range(count)
+        )
+
+    def redeem(self, voucher, random_tokens):
+        """
+        :return: An already-fired ``Deferred`` that has a list of ``Pass``
+            instances wrapping meaningless values.
+        """
+        return succeed(
+            list(
+                Pass(u"pass-" + token.token_value)
+                for token
+                in random_tokens
+            ),
+        )
+
+
 @attr.s
 class PaymentController(object):
+    """
+    The ``PaymentController`` coordinates the process of turning a voucher
+    into a collection of ZKAPs:
+
+      1. A voucher to be consumed is handed to the controller.
+         Once a voucher is handed over to the controller the controller takes all responsibility for it.
+
+      2. The controller tells the data store to remember the voucher.
+         The data store provides durability for the voucher which represents an investment (ie, a purchase) on the part of the client.
+
+      3. The controller tells the store to hand all currently idle vouchers to a redeemer.
+         In normal operation, only the newly added voucher will be idle.
+
+
+    """
     store = attr.ib()
+    redeemer = attr.ib()
 
     def redeem(self, voucher):
-        self.store.add(voucher)
+        # 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.
+        tokens = self.redeemer.random_tokens_for_voucher(voucher, 100)
+
+        # Persist the voucher and tokens so they're available if we fail.
+        self.store.add(voucher, tokens)
+
+        # Ask the redeemer to do the real task of redemption.
+        d = self.redeemer.redeem(voucher, tokens)
+        d.addCallback(
+            partial(self._redeemSuccess, voucher),
+        )
+
+    def _redeemSuccess(self, voucher, passes):
+        self.store.insert_passes_for_voucher(voucher, passes)
diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py
index ec534fd4c4801cffc072c6af5f7149bd7724a253..2921a68ff6cceb8d01f808e715f5e27211c1433d 100644
--- a/src/_zkapauthorizer/model.py
+++ b/src/_zkapauthorizer/model.py
@@ -85,6 +85,8 @@ def open_and_initialize(path, required_schema_version, connect=None):
     except OperationalError as e:
         raise StoreOpenError(e)
 
+    conn.execute("PRAGMA foreign_keys = ON")
+
     with conn:
         cursor = conn.cursor()
         cursor.execute(
@@ -112,11 +114,23 @@ def open_and_initialize(path, required_schema_version, connect=None):
             """
             CREATE TABLE IF NOT EXISTS [vouchers] (
                 [number] text,
+                [redeemed] num DEFAULT 0,
 
                 PRIMARY KEY([number])
             )
             """,
         )
+        cursor.execute(
+            """
+            CREATE TABLE IF NOT EXISTS [tokens] (
+                [text] text, -- The random string that defines the token.
+                [voucher] text, -- Reference to the voucher these tokens go with.
+
+                PRIMARY KEY([text])
+                FOREIGN KEY([voucher]) REFERENCES [vouchers]([number])
+            )
+            """,
+        )
         cursor.execute(
             """
             CREATE TABLE IF NOT EXISTS [passes] (
@@ -152,7 +166,7 @@ class VoucherStore(object):
     :ivar allmydata.node._Config node_config: The Tahoe-LAFS node configuration object for
         the node that owns the persisted vouchers.
     """
-    database_path = attr.ib(type=FilePath)
+    database_path = attr.ib(validator=attr.validators.instance_of(FilePath))
     _connection = attr.ib()
 
     @classmethod
@@ -173,7 +187,7 @@ class VoucherStore(object):
         cursor.execute(
             """
             SELECT
-                ([number])
+                [number], [redeemed]
             FROM
                 [vouchers]
             WHERE
@@ -184,37 +198,54 @@ class VoucherStore(object):
         refs = cursor.fetchall()
         if len(refs) == 0:
             raise KeyError(voucher)
-        return Voucher(refs[0][0])
+        return Voucher(refs[0][0], bool(refs[0][1]))
 
     @with_cursor
-    def add(self, cursor, voucher):
+    def add(self, cursor, voucher, tokens):
         cursor.execute(
             """
-            INSERT OR IGNORE INTO [vouchers] VALUES (?)
+            INSERT OR IGNORE INTO [vouchers] ([number]) VALUES (?)
             """,
             (voucher,)
         )
+        if cursor.rowcount:
+            # Something was inserted.  Insert the tokens, too.  It's okay to
+            # drop the tokens in the other case.  They've never been used.
+            # What's *already* in the database, on the other hand, may already
+            # have been submitted in a redeem attempt and must not change.
+            cursor.executemany(
+                """
+                INSERT INTO [tokens] ([voucher], [text]) VALUES (?, ?)
+                """,
+                list(
+                    (voucher, token.token_value)
+                    for token
+                    in tokens
+                ),
+            )
 
     @with_cursor
     def list(self, cursor):
         cursor.execute(
             """
-            SELECT ([number]) FROM [vouchers]
+            SELECT [number], [redeemed] FROM [vouchers]
             """,
         )
         refs = cursor.fetchall()
 
         return list(
-            Voucher(number)
-            for (number,)
+            Voucher(number, bool(redeemed))
+            for (number, redeemed)
             in refs
         )
 
     @with_cursor
-    def insert_passes(self, cursor, passes):
+    def insert_passes_for_voucher(self, cursor, voucher, passes):
         """
         Store some passes.
 
+        :param unicode voucher: The voucher associated with the passes.
+
         :param list[Pass] passes: The passes to store.
         """
         cursor.executemany(
@@ -223,6 +254,12 @@ class VoucherStore(object):
             """,
             list((p.text,) for p in passes),
         )
+        cursor.execute(
+            """
+            UPDATE [vouchers] SET [redeemed] = 1 WHERE [number] = ?
+            """,
+            (voucher,),
+        )
 
     @with_cursor
     def extract_passes(self, cursor, count):
@@ -250,7 +287,7 @@ class VoucherStore(object):
         )
         cursor.execute(
             """
-            SELECT ([text]) FROM [extracting-passes]
+            SELECT [text] FROM [extracting-passes]
             """,
         )
         texts = cursor.fetchall()
@@ -278,12 +315,18 @@ class Pass(object):
         text should be kept secret.  If pass text is divulged to third-parties
         the anonymity property may be compromised.
     """
-    text = attr.ib(type=unicode)
+    text = attr.ib(validator=attr.validators.instance_of(unicode))
+
+
+@attr.s(frozen=True)
+class RandomToken(object):
+    token_value = attr.ib(validator=attr.validators.instance_of(unicode))
 
 
 @attr.s
 class Voucher(object):
     number = attr.ib()
+    redeemed = attr.ib(default=False, validator=attr.validators.instance_of(bool))
 
     @classmethod
     def from_json(cls, json):
diff --git a/src/_zkapauthorizer/resource.py b/src/_zkapauthorizer/resource.py
index e13b81b2260ded02c90aa496eab06158ef452f10..979e40f04f8d760ba2bb39caffd73433d6d612b0 100644
--- a/src/_zkapauthorizer/resource.py
+++ b/src/_zkapauthorizer/resource.py
@@ -42,9 +42,10 @@ from .model import (
 )
 from .controller import (
     PaymentController,
+    NonRedeemer,
 )
 
-def from_configuration(node_config, store=None):
+def from_configuration(node_config, store=None, redeemer=None):
     """
     Instantiate the plugin root resource using data from its configuration
     section in the Tahoe-LAFS configuration file::
@@ -60,12 +61,17 @@ def from_configuration(node_config, store=None):
     :param VoucherStore store: The store to use.  If ``None`` a sensible one
         is constructed.
 
+    :param IRedeemer redeemer: The voucher redeemer to use.  If ``None`` a
+        sensible one is constructed.
+
     :return IResource: The root of the resource hierarchy presented by the
         client side of the plugin.
     """
     if store is None:
         store = VoucherStore.from_node_config(node_config)
-    controller = PaymentController(store)
+    if redeemer is None:
+        redeemer = NonRedeemer()
+    controller = PaymentController(store, redeemer)
     root = Resource()
     root.putChild(
         b"voucher",
diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py
index 3b6fc63deeab705795a5d7555b5a453dd6e16035..8f6e74ed6c4a71cff35f75630b7b26fb3232c543 100644
--- a/src/_zkapauthorizer/tests/strategies.py
+++ b/src/_zkapauthorizer/tests/strategies.py
@@ -54,6 +54,7 @@ from allmydata.client import (
 
 from ..model import (
     Pass,
+    RandomToken,
 )
 
 
@@ -179,6 +180,20 @@ def vouchers():
     )
 
 
+def random_tokens():
+    """
+    Build random tokens as unicode strings.
+    """
+    return binary(
+        min_size=32,
+        max_size=32,
+    ).map(
+        urlsafe_b64encode,
+    ).map(
+        lambda token: RandomToken(token.decode("ascii")),
+    )
+
+
 def zkaps():
     """
     Build random ZKAPs as ``Pass` instances.
diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py
index dad75bb63b094f71201f99a8dfdbf1cee3888b13..203e44ca3ca557788daefd667fbbea3dc73de4e5 100644
--- a/src/_zkapauthorizer/tests/test_client_resource.py
+++ b/src/_zkapauthorizer/tests/test_client_resource.py
@@ -92,6 +92,7 @@ from treq.testing import (
 )
 
 from ..model import (
+    Voucher,
     VoucherStore,
     memory_connect,
 )
@@ -390,10 +391,7 @@ class VoucherTests(TestCase):
                     AfterPreprocessing(
                         json_content,
                         succeeded(
-                            Equals({
-                                u"version": 1,
-                                u"number": voucher,
-                            }),
+                            Equals(Voucher(voucher).marshal()),
                         ),
                     ),
                 ),
@@ -449,7 +447,7 @@ class VoucherTests(TestCase):
                         succeeded(
                             Equals({
                                 u"vouchers": list(
-                                    {u"version": 1, u"number": voucher}
+                                    Voucher(voucher).marshal()
                                     for voucher
                                     in vouchers
                                 ),
diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py
index 11d730440ebb90a09a2a9559766f9fe90442bb1d..1a6ac3cf944dc53b3b6ad554ea85addd170b9aaa 100644
--- a/src/_zkapauthorizer/tests/test_model.py
+++ b/src/_zkapauthorizer/tests/test_model.py
@@ -64,6 +64,7 @@ from ..model import (
 from .strategies import (
     tahoe_configs,
     vouchers,
+    random_tokens,
     zkaps,
 )
 
@@ -105,8 +106,8 @@ class VoucherStoreTests(TestCase):
             raises(KeyError),
         )
 
-    @given(tahoe_configs(), vouchers())
-    def test_add(self, get_config, voucher):
+    @given(tahoe_configs(), vouchers(), lists(random_tokens(), unique=True))
+    def test_add(self, get_config, voucher, tokens):
         """
         ``VoucherStore.get`` returns a ``Voucher`` representing a voucher
         previously added to the store with ``VoucherStore.add``.
@@ -117,16 +118,17 @@ class VoucherStoreTests(TestCase):
             config,
             memory_connect,
         )
-        store.add(voucher)
+        store.add(voucher, tokens)
         self.assertThat(
             store.get(voucher),
             MatchesStructure(
                 number=Equals(voucher),
+                redeemed=Equals(False),
             ),
         )
 
-    @given(tahoe_configs(), vouchers())
-    def test_add_idempotent(self, get_config, voucher):
+    @given(tahoe_configs(), vouchers(), lists(random_tokens(), unique=True))
+    def test_add_idempotent(self, get_config, voucher, tokens):
         """
         More than one call to ``VoucherStore.add`` with the same argument results
         in the same state as a single call.
@@ -137,8 +139,8 @@ class VoucherStoreTests(TestCase):
             config,
             memory_connect,
         )
-        store.add(voucher)
-        store.add(voucher)
+        store.add(voucher, tokens)
+        store.add(voucher, [])
         self.assertThat(
             store.get(voucher),
             MatchesStructure(
@@ -162,17 +164,17 @@ class VoucherStoreTests(TestCase):
         )
 
         for voucher in vouchers:
-            store.add(voucher)
+            store.add(voucher, [])
 
         self.assertThat(
             store.list(),
-            AfterPreprocessing(
-                lambda refs: set(ref.number for ref in refs),
-                Equals(set(vouchers)),
-            ),
+            Equals(list(
+                Voucher(number)
+                for number
+                in vouchers
+            )),
         )
 
-
     @given(tahoe_configs())
     def test_uncreateable_store_directory(self, get_config):
         """
@@ -257,8 +259,8 @@ class ZKAPStoreTests(TestCase):
     """
     Tests for ZKAP-related functionality of ``VoucherStore``.
     """
-    @given(tahoe_configs(), lists(zkaps(), unique=True))
-    def test_zkaps_round_trip(self, get_config, passes):
+    @given(tahoe_configs(), vouchers(), lists(zkaps(), unique=True))
+    def test_zkaps_round_trip(self, get_config, voucher_value, passes):
         """
         ZKAPs that are added to the store can later be retrieved.
         """
@@ -268,10 +270,26 @@ class ZKAPStoreTests(TestCase):
             config,
             memory_connect,
         )
-        store.insert_passes(passes)
+        store.insert_passes_for_voucher(voucher_value, passes)
         retrieved_passes = store.extract_passes(len(passes))
         self.expectThat(passes, Equals(retrieved_passes))
 
         # After extraction, the passes are no longer available.
         more_passes = store.extract_passes(1)
         self.expectThat([], Equals(more_passes))
+
+    @given(tahoe_configs(), vouchers(), random_tokens(), zkaps())
+    def test_mark_vouchers_redeemed(self, get_config, voucher_value, token, one_pass):
+        """
+        The voucher for ZKAPs that are added to the store are marked as redeemed.
+        """
+        tempdir = self.useFixture(TempDir())
+        config = get_config(tempdir.join(b"node"), b"tub.port")
+        store = VoucherStore.from_node_config(
+            config,
+            memory_connect,
+        )
+        store.add(voucher_value, [token])
+        store.insert_passes_for_voucher(voucher_value, [one_pass])
+        loaded_voucher = store.get(voucher_value)
+        self.assertThat(loaded_voucher.redeemed, Equals(True))