diff --git a/misc/load-test.py b/misc/load-test.py
index f952d6cdbb8012daf3a6e113f10e4189d55a782a..2144bc6084062c6391bf244b1e7b08e23e42928f 100755
--- a/misc/load-test.py
+++ b/misc/load-test.py
@@ -13,19 +13,33 @@
 #
 #   $ for n in $(seq 0 30); do sqlite3 vouchers.db "insert into vouchers (name) values ('aaa$n')"; done
 #
-# Then the test can be run as many times as necessary.  Repeated redemptions
-# are allowed since the same tokens are used on every run.
+# Then the test can be run as many times as necessary.
+#
+# The `redeemed` table must be cleared before each test run.  Random tokens
+# are generated for each test run and the server will reject tokens from a new
+# run if tokens from an old run are still present.
+#
+#  $ sqlite3 vouchers.db "delete from redeemed"
 #
 # Originally written for https://github.com/PrivateStorageio/PaymentServer/issues/60
 
 from __future__ import division
 
+from os import (
+    urandom,
+)
+
+from base64 import (
+    b64encode,
+)
+
 from time import (
     time,
 )
 
 from json import (
     dumps,
+    loads,
 )
 
 from treq.client import (
@@ -45,29 +59,79 @@ from twisted.internet.defer import (
     returnValue,
 )
 
-PARALLELISM = 30
+PARALLELISM = 50
+ITERATIONS = 16
+NUM_TOKENS = 5000
+
+
+def a_random_token():
+    return b64encode(urandom(32))
+
+
+def tokens_for_voucher(key, cache={}):
+    if key not in cache:
+        print("Generating tokens for {}".format(key))
+        cache[key] = list(
+            a_random_token()
+            for _
+            in range(NUM_TOKENS)
+        )
+    else:
+        print("Using cached tokens for {}".format(key))
+    return cache[key]
+
 
 @inlineCallbacks
-def redeem(client, index):
-    times = []
-    for i in range(16):
+def redeem_with_retry(client, data, headers):
+    """
+    Attempt a redemption.  Retry if it fails.
+
+    :return: A ``Deferred`` that fires with (duration of successful request,
+        number of failed requests).
+    """
+    errors = 0
+    while True:
         before = time()
         response = yield client.post(
             url="http://127.0.0.1:8080/v1/redeem",
+            data=data,
+            headers=headers,
+        )
+        after = time()
+        duration = int((after - before) * 1000)
+        body = yield readBody(response)
+        if response.code == 200:
+            print("Request complete in {}ms".format(duration))
+            returnValue((duration, errors))
+
+        errors += 1
+        try:
+            reason = loads(body)["reason"]
+        except ValueError:
+            reason = body
+
+        print("Request failed: {} {}".format(response.code, reason))
+
+
+@inlineCallbacks
+def redeem(client, index):
+    times = []
+    total_errors = 0
+    voucher = "aaa{}".format(index)
+    for i in range(ITERATIONS):
+        tokens = tokens_for_voucher((voucher, i))
+        duration, errors = yield redeem_with_retry(
+            client,
             data=dumps({
-                "redeemVoucher": "aaa{}".format(index),
-                "redeemTokens": ["foo-{}-{}".format(index, i)],
+                "redeemVoucher": voucher,
+                "redeemTokens": tokens,
                 "redeemCounter": i,
             }),
             headers={"content-type": "application/json"},
         )
-        after = time()
-        duration = int((after - before) * 1000)
-        print("Request complete in {}ms".format(duration))
-        body = yield readBody(response)
-        assert response.code == 200, (response.code, body)
         times.append(duration)
-    returnValue(times)
+        total_errors += errors
+    returnValue((times, total_errors))
 
 
 def mean(xs):
@@ -75,7 +139,7 @@ def mean(xs):
 
 
 def percentile(n, xs):
-    return sorted(xs)[int(len(xs) / 100 * 95)]
+    return sorted(xs)[int(len(xs) / 100 * n)]
 
 
 def median(xs):
@@ -94,11 +158,17 @@ def main(reactor):
     )
 
     times = []
-    for result in (yield gatherResults(ds)):
+    total_errors = 0
+    for (result, errors) in (yield gatherResults(ds)):
         times.extend(result)
+        total_errors += errors
 
     print("min: {}".format(min(times)))
     print("max: {}".format(max(times)))
     print("mean: {}".format(mean(times)))
     print("median: {}".format(median(times)))
     print("95th: {}".format(percentile(95, times)))
+    print("errors: {}".format(total_errors))
+    print("error rate: {}".format(
+        total_errors / (total_errors + PARALLELISM * ITERATIONS),
+    ))
diff --git a/src/PaymentServer/Redemption.hs b/src/PaymentServer/Redemption.hs
index fa2e3a4be2f33199703d4dc63b03eab2a2a38f65..6c0cf7bbab8ad85e875fff20826331c9cb6e2e8a 100644
--- a/src/PaymentServer/Redemption.hs
+++ b/src/PaymentServer/Redemption.hs
@@ -11,6 +11,10 @@ module PaymentServer.Redemption
   , redemptionServer
   ) where
 
+import Prelude hiding
+  ( concat
+  )
+
 import GHC.Generics
   ( Generic
   )
@@ -27,6 +31,7 @@ import Control.Monad.IO.Class
 import Data.Text
   ( Text
   , pack
+  , concat
   )
 import Data.Text.Encoding
   ( encodeUtf8
@@ -211,7 +216,4 @@ redeem issue database (Redeem voucher tokens counter) =
 -- be used as an identifier for this exact sequence of tokens.
 fingerprintFromTokens :: [BlindedToken] -> Fingerprint
 fingerprintFromTokens =
-  let
-    hash = pack . show . hashWith SHA3_512 . encodeUtf8
-  in
-    foldl (\b a -> hash $ a `mappend` b) "" . map hash
+  pack . show . hashWith SHA3_512 . encodeUtf8 . concat