diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py
index ba9089e9244280932f4c5e31b5608671fa7a99a8..c2a6f79b47dc6e0aeae12933dd91ce89c7b20e9a 100644
--- a/src/_zkapauthorizer/model.py
+++ b/src/_zkapauthorizer/model.py
@@ -126,7 +126,10 @@ def open_and_initialize(path, required_schema_version, connect=None):
                 [created] text,                     -- An ISO8601 date+time string.
                 [state] text DEFAULT "pending",     -- pending, double-spend, redeemed
 
-                [token-count] num DEFAULT NULL,     -- Set in the redeemed state to  the number
+                [finished] text DEFAULT NULL,       -- ISO8601 date+time string when
+                                                    -- the current terminal state was entered.
+
+                [token-count] num DEFAULT NULL,     -- Set in the redeemed state to the number
                                                     -- of tokens received on this voucher's
                                                     -- redemption.
 
@@ -224,7 +227,7 @@ class VoucherStore(object):
         cursor.execute(
             """
             SELECT
-                [number], [created], [state], [token-count]
+                [number], [created], [state], [finished], [token-count]
             FROM
                 [vouchers]
             WHERE
@@ -282,7 +285,10 @@ class VoucherStore(object):
         """
         cursor.execute(
             """
-            SELECT [number], [created], [state], [token-count] FROM [vouchers]
+            SELECT
+                [number], [created], [state], [finished], [token-count]
+            FROM
+                [vouchers]
             """,
         )
         refs = cursor.fetchall()
@@ -320,9 +326,10 @@ class VoucherStore(object):
             UPDATE [vouchers]
             SET [state] = "redeemed"
               , [token-count] = ?
+              , [finished] = ?
             WHERE [number] = ?
             """,
-            (len(unblinded_tokens), voucher),
+            (len(unblinded_tokens), self.now(), voucher),
         )
 
     @with_cursor
@@ -335,10 +342,11 @@ class VoucherStore(object):
             """
             UPDATE [vouchers]
             SET [state] = "double-spend"
+              , [finished] = ?
             WHERE [number] = ?
               AND [state] = "pending"
             """,
-            (voucher,)
+            (self.now(), voucher),
         )
         if cursor.rowcount == 0:
             # Was there no matching voucher or was it in the wrong state?
@@ -456,6 +464,22 @@ class RandomToken(object):
     token_value = attr.ib(validator=attr.validators.instance_of(unicode))
 
 
+@attr.s(frozen=True)
+class Pending(object):
+    pass
+
+
+@attr.s(frozen=True)
+class Redeemed(object):
+    finished = attr.ib(validator=attr.validators.instance_of(datetime))
+    token_count = attr.ib(validator=attr.validators.instance_of((int, long)))
+
+
+@attr.s(frozen=True)
+class DoubleSpend(object):
+    finished = attr.ib(validator=attr.validators.instance_of(datetime))
+
+
 @attr.s
 class Voucher(object):
     """
@@ -473,22 +497,38 @@ class Voucher(object):
         redeemed.
     """
     number = attr.ib()
-    created = attr.ib(default=None, validator=attr.validators.optional(attr.validators.instance_of(datetime)))
-    state = attr.ib(default=u"pending", validator=attr.validators.in_((u"pending", u"double-spend", u"redeemed")))
-    token_count = attr.ib(default=None, validator=attr.validators.optional(attr.validators.instance_of((int, long))))
+    created = attr.ib(
+        default=None,
+        validator=attr.validators.optional(attr.validators.instance_of(datetime)),
+    )
+    state = attr.ib(default=Pending())
 
     @classmethod
     def from_row(cls, row):
+        def state_from_row(state, row):
+            if state == u"pending":
+                return Pending()
+            if state == u"double-spend":
+                return DoubleSpend(
+                    parse_datetime(row[0], delimiter=u" "),
+                )
+            if state == u"redeemed":
+                return Redeemed(
+                    parse_datetime(row[0], delimiter=u" "),
+                    row[1],
+                )
+            raise ValueError("Unknown voucher state {}".format(state))
+
+        number, created, state = row[:3]
         return cls(
-            row[0],
+            number,
             # All Python datetime-based date/time libraries fail to handle
             # leap seconds.  This parse call might raise an exception of the
             # value represents a leap second.  However, since we also use
             # Python to generate the data in the first place, it should never
             # represent a leap second... I hope.
-            parse_datetime(row[1], delimiter=u" "),
-            row[2],
-            row[3],
+            parse_datetime(created, delimiter=u" "),
+            state_from_row(state, row[3:])
         )
 
     @classmethod
@@ -500,11 +540,26 @@ class Voucher(object):
 
     @classmethod
     def from_json_v1(cls, values):
+        state_json = values[u"state"]
+        state_name = state_json[u"name"]
+        if state_name == u"pending":
+            state = Pending()
+        elif state_name == u"double-spend":
+            state = DoubleSpend(
+                finished=parse_datetime(state_json[u"finished"]),
+            )
+        elif state_name == u"redeemed":
+            state = Redeemed(
+                finished=parse_datetime(state_json[u"finished"]),
+                token_count=state_json[u"token-count"],
+            )
+        else:
+            raise ValueError("Unrecognized state {}".format(state_json))
+
         return cls(
             number=values[u"number"],
             created=None if values[u"created"] is None else parse_datetime(values[u"created"]),
-            state=values[u"state"],
-            token_count=values[u"token-count"],
+            state=state,
         )
 
 
@@ -517,10 +572,24 @@ class Voucher(object):
 
 
     def to_json_v1(self):
+        if isinstance(self.state, Pending):
+            state = {
+                u"name": u"pending",
+            }
+        elif isinstance(self.state, DoubleSpend):
+            state = {
+                u"name": u"double-spend",
+                u"finished": self.state.finished.isoformat(),
+            }
+        elif isinstance(self.state, Redeemed):
+            state = {
+                u"name": u"redeemed",
+                u"finished": self.state.finished.isoformat(),
+                u"token-count": self.state.token_count,
+            }
         return {
             u"number": self.number,
             u"created": None if self.created is None else self.created.isoformat(),
-            u"state": self.state,
-            u"token-count": self.token_count,
+            u"state": state,
             u"version": 1,
         }
diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py
index 47c1c8f6186a2f5cf75b9de185d00d8ec51b5eb8..d990256a2e745db3e3f1debb1fc89e811e02bfb4 100644
--- a/src/_zkapauthorizer/tests/strategies.py
+++ b/src/_zkapauthorizer/tests/strategies.py
@@ -56,6 +56,9 @@ from ..model import (
     RandomToken,
     UnblindedToken,
     Voucher,
+    Pending,
+    DoubleSpend,
+    Redeemed,
 )
 
 
@@ -233,9 +236,16 @@ def voucher_states():
     Build unicode strings giving states a Voucher can be in.
     """
     return one_of(
-        just(u"pending"),
-        just(u"double-spend"),
-        just(u"redeemed"),
+        just(Pending()),
+        builds(
+            DoubleSpend,
+            finished=datetimes(),
+        ),
+        builds(
+            Redeemed,
+            finished=datetimes(),
+            token_count=one_of(integers(min_value=1)),
+        ),
     )
 
 
@@ -248,7 +258,6 @@ def voucher_objects():
         number=vouchers(),
         created=one_of(none(), datetimes()),
         state=voucher_states(),
-        token_count=one_of(none(), integers(min_value=1)),
     )
 
 
diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py
index fdfeed07a9177bc268760ffcb362f0b0cf56e999..c63961aed8f0469670921db93686016f605ea82d 100644
--- a/src/_zkapauthorizer/tests/test_client_resource.py
+++ b/src/_zkapauthorizer/tests/test_client_resource.py
@@ -53,7 +53,6 @@ from testtools.matchers import (
     IsInstance,
     ContainsDict,
     AfterPreprocessing,
-    Is,
     Equals,
     Always,
     GreaterThan,
@@ -113,6 +112,9 @@ from treq.testing import (
 
 from ..model import (
     Voucher,
+    Pending,
+    Redeemed,
+    DoubleSpend,
     VoucherStore,
     memory_connect,
 )
@@ -609,8 +611,7 @@ class VoucherTests(TestCase):
             MatchesStructure(
                 number=Equals(voucher),
                 created=Equals(now),
-                state=Equals(u"pending"),
-                token_count=Is(None),
+                state=Equals(Pending()),
             ),
         )
 
@@ -629,10 +630,12 @@ class VoucherTests(TestCase):
             MatchesStructure(
                 number=Equals(voucher),
                 created=Equals(now),
-                state=Equals(u"redeemed"),
-                # Value duplicated from PaymentController.redeem default.
-                # Should do this better.
-                token_count=Equals(100),
+                state=Equals(Redeemed(
+                    finished=now,
+                    # Value duplicated from PaymentController.redeem default.
+                    # Should do this better.
+                    token_count=100,
+                )),
             ),
         )
 
@@ -652,8 +655,9 @@ class VoucherTests(TestCase):
             MatchesStructure(
                 number=Equals(voucher),
                 created=Equals(now),
-                state=Equals(u"double-spend"),
-                token_count=Is(None),
+                state=Equals(DoubleSpend(
+                    finished=now,
+                )),
             ),
         )
 
@@ -765,11 +769,13 @@ class VoucherTests(TestCase):
                                     Voucher(
                                         voucher,
                                         created=now,
-                                        state=u"redeemed",
-                                        # Value duplicated from
-                                        # PaymentController.redeem default.
-                                        # Should do this better.
-                                        token_count=100,
+                                        state=Redeemed(
+                                            finished=now,
+                                            # Value duplicated from
+                                            # PaymentController.redeem
+                                            # default.  Should do this better.
+                                            token_count=100,
+                                        ),
                                     ).marshal()
                                     for voucher
                                     in vouchers
diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py
index c357ca4d8491d343ab42028f34c36cf0f606b5a9..31be5bf25240bcb663e260c297d3bfbc419522b4 100644
--- a/src/_zkapauthorizer/tests/test_controller.py
+++ b/src/_zkapauthorizer/tests/test_controller.py
@@ -106,6 +106,9 @@ from ..model import (
     memory_connect,
     VoucherStore,
     UnblindedToken,
+    Pending,
+    DoubleSpend,
+    Redeemed,
 )
 
 from .strategies import (
@@ -145,7 +148,7 @@ class PaymentControllerTests(TestCase):
         persisted_voucher = store.get(voucher)
         self.assertThat(
             persisted_voucher.state,
-            Equals(u"pending"),
+            Equals(Pending()),
         )
 
     @given(tahoe_configs(), datetimes(), vouchers())
@@ -171,7 +174,10 @@ class PaymentControllerTests(TestCase):
         persisted_voucher = store.get(voucher)
         self.assertThat(
             persisted_voucher.state,
-            Equals(u"redeemed"),
+            Equals(Redeemed(
+                finished=now,
+                token_count=100,
+            )),
         )
 
     @given(tahoe_configs(), datetimes(), vouchers())
@@ -199,7 +205,9 @@ class PaymentControllerTests(TestCase):
         self.assertThat(
             persisted_voucher,
             MatchesStructure(
-                state=Equals(u"double-spend"),
+                state=Equals(DoubleSpend(
+                    finished=now,
+                )),
             ),
         )
 
diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py
index 2abed45fe8b4fbee625a261d45bba8210a2dea82..144999c137e15bc358955627dde554f028ab4bdf 100644
--- a/src/_zkapauthorizer/tests/test_model.py
+++ b/src/_zkapauthorizer/tests/test_model.py
@@ -65,6 +65,9 @@ from ..model import (
     StoreOpenError,
     VoucherStore,
     Voucher,
+    Pending,
+    DoubleSpend,
+    Redeemed,
     open_and_initialize,
     memory_connect,
 )
@@ -122,7 +125,7 @@ class VoucherStoreTests(TestCase):
             store.get(voucher),
             MatchesStructure(
                 number=Equals(voucher),
-                state=Equals(u"pending"),
+                state=Equals(Pending()),
                 created=Equals(now),
             ),
         )
@@ -141,8 +144,7 @@ class VoucherStoreTests(TestCase):
             MatchesStructure(
                 number=Equals(voucher),
                 created=Equals(now),
-                state=Equals(u"pending"),
-                token_count=Equals(None),
+                state=Equals(Pending()),
             ),
         )
 
@@ -302,8 +304,10 @@ class UnblindedTokenStoreTests(TestCase):
         self.assertThat(
             loaded_voucher,
             MatchesStructure(
-                state=Equals(u"redeemed"),
-                token_count=Equals(num_tokens),
+                state=Equals(Redeemed(
+                    finished=now,
+                    token_count=num_tokens,
+                )),
             ),
         )
 
@@ -325,7 +329,9 @@ class UnblindedTokenStoreTests(TestCase):
         self.assertThat(
             voucher,
             MatchesStructure(
-                state=Equals(u"double-spend"),
+                state=Equals(DoubleSpend(
+                    finished=now,
+                )),
             ),
         )