diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py
index ec72c8d740b6548facfd5a98f2a09e100ed03c5c..5348713dbd54d193312df82f7216be7bd1d93594 100644
--- a/src/_zkapauthorizer/controller.py
+++ b/src/_zkapauthorizer/controller.py
@@ -20,7 +20,9 @@ for the client side of the storage plugin.
 from functools import (
     partial,
 )
-
+from json import (
+    dumps,
+)
 import attr
 
 from zope.interface import (
@@ -28,9 +30,17 @@ from zope.interface import (
     implementer,
 )
 
+from twisted.python.url import (
+    URL,
+)
 from twisted.internet.defer import (
     Deferred,
     succeed,
+    inlineCallbacks,
+    returnValue,
+)
+from treq import (
+    json_content,
 )
 
 import privacypass
@@ -39,11 +49,20 @@ from .foolscap import (
     TOKEN_LENGTH,
 )
 from .model import (
-    Pass,
     RandomToken,
+    Voucher,
+    Pass,
 )
 
 
+class TransientRedemptionError(Exception):
+    pass
+
+
+class PermanentRedemptionError(Exception):
+    pass
+
+
 class IRedeemer(Interface):
     """
     An ``IRedeemer`` can exchange a voucher for one or more passes.
@@ -96,7 +115,7 @@ class NonRedeemer(object):
     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))
+            RandomToken(u"{}-{}".format(voucher.number, n))
             for n
             in range(count)
         )
@@ -121,7 +140,7 @@ class DummyRedeemer(object):
         """
         # Dummy token generation.
         return list(
-            RandomToken(u"{}-{}".format(voucher, n))
+            RandomToken(u"{}-{}".format(voucher.number, n))
             for n
             in range(count)
         )
@@ -143,7 +162,8 @@ class DummyRedeemer(object):
 @implementer(IRedeemer)
 @attr.s
 class RistrettoRedeemer(object):
-    _agent = attr.ib()
+    _treq = attr.ib()
+    _api_root = attr.ib(validator=attr.validators.instance_of(URL))
 
     def random_tokens_for_voucher(self, voucher, count):
         return list(
@@ -152,14 +172,83 @@ class RistrettoRedeemer(object):
             in range(count)
         )
 
-    def redeem(self, voucher, random_tokens):
-        # The wrong implementation, of course.
-        return succeed(list(
-            Pass(text=u"tok-" + token.token_value)
+    @inlineCallbacks
+    def redeem(self, voucher, encoded_random_tokens):
+        random_tokens = list(
+            privacypass.RandomToken.decode_base64(token.token_value.encode("ascii"))
             for token
-            in random_tokens
+            in encoded_random_tokens
+        )
+        blinded_tokens = list(token.blind() for token in random_tokens)
+        response = yield self._treq.post(
+            self._api_root.child(u"v1", u"redeem").to_text(),
+            dumps({
+                u"redeemVoucher": voucher.number,
+                u"redeemTokens": list(
+                    token.encode_base64()
+                    for token
+                    in blinded_tokens
+                ),
+            }),
+        )
+        result = yield json_content(response)
+        marshaled_signed_tokens = result[u"signatures"]
+        marshaled_proof = result[u"proof"]
+        marshaled_public_key = result[u"public-key"]
+
+        public_key = privacypass.PublicKey.decode_base64(
+            marshaled_public_key.encode("ascii"),
+        )
+        clients_signed_tokens = list(
+            privacypass.SignedToken.decode_base64(
+                marshaled_signed_token.encode("ascii"),
+            )
+            for marshaled_signed_token
+            in marshaled_signed_tokens
+        )
+        clients_proof = privacypass.BatchDLEQProof.decode_base64(
+            marshaled_proof.encode("ascii"),
+        )
+        clients_unblinded_tokens = clients_proof.invalid_or_unblind(
+            random_tokens,
+            blinded_tokens,
+            clients_signed_tokens,
+            public_key,
+        )
+        returnValue(list(
+            Pass(text=unblinded_token.encode_base64().decode("ascii"))
+            for unblinded_token
+            in clients_unblinded_tokens
         ))
 
+    def tokens_to_passes(self, message, unblinded_tokens):
+        clients_preimages = list(
+            token.preimage()
+            for token
+            in unblinded_tokens
+        )
+        clients_verification_keys = list(
+            token.derive_verification_key_sha512()
+            for token
+            in unblinded_tokens
+        )
+        clients_passes = zip(
+            clients_preimages, (
+                verification_key.sign_sha512(message)
+                for verification_key
+                in clients_verification_keys
+            ),
+        )
+        marshaled_passes = list(
+            (
+                token_preimage.encode_base64(),
+                sig.encode_base64()
+            )
+            for (token_preimage, sig)
+            in clients_passes
+        )
+        return marshaled_passes
+
 
 @attr.s
 class PaymentController(object):
@@ -183,6 +272,9 @@ class PaymentController(object):
     redeemer = attr.ib()
 
     def redeem(self, voucher):
+        """
+        :param unicode voucher: A voucher 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
@@ -192,13 +284,13 @@ class PaymentController(object):
         # 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)
+        tokens = self.redeemer.random_tokens_for_voucher(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 = self.redeemer.redeem(Voucher(voucher), tokens)
         d.addCallback(
             partial(self._redeemSuccess, voucher),
         )
diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py
index 318d96426bbd2496fb36b11055a14a53b7fe0918..636a7e33cb9ddf77e660e4f89004d4845bfdc96a 100644
--- a/src/_zkapauthorizer/tests/test_controller.py
+++ b/src/_zkapauthorizer/tests/test_controller.py
@@ -23,18 +23,24 @@ from json import (
 from zope.interface import (
     implementer,
 )
+import attr
 from testtools import (
     TestCase,
 )
+from testtools.content import (
+    text_content,
+)
 from testtools.matchers import (
     Equals,
     MatchesAll,
     AllMatch,
     IsInstance,
     HasLength,
+    AfterPreprocessing,
 )
 from testtools.twistedsupport import (
     succeeded,
+    failed,
 )
 
 from fixtures import (
@@ -47,18 +53,32 @@ from hypothesis import (
 from hypothesis.strategies import (
     integers,
 )
+from twisted.python.url import (
+    URL,
+)
 from twisted.internet.defer import (
     fail,
+    succeed,
 )
 from twisted.web.iweb import (
     IAgent,
+    IBodyProducer,
 )
 from twisted.web.resource import (
     Resource,
 )
 from treq.testing import (
-    RequestTraversalAgent,
+    StubTreq,
 )
+
+from privacypass import (
+    SecurityException,
+    PublicKey,
+    BlindedToken,
+    BatchDLEQProof,
+    random_signing_key,
+)
+
 from ..controller import (
     IRedeemer,
     NonRedeemer,
@@ -70,6 +90,7 @@ from ..controller import (
 from ..model import (
     memory_connect,
     VoucherStore,
+    Voucher,
     Pass,
 )
 
@@ -134,6 +155,8 @@ class PaymentControllerTests(TestCase):
         )
 
 
+NOWHERE = URL.from_text(u"https://127.0.0.1/")
+
 class RistrettoRedeemerTests(TestCase):
     """
     Tests for ``RistrettoRedeemer``.
@@ -142,39 +165,28 @@ class RistrettoRedeemerTests(TestCase):
         """
         An ``RistrettoRedeemer`` instance provides ``IRedeemer``.
         """
-        redeemer = RistrettoRedeemer(stub_agent())
+        redeemer = RistrettoRedeemer(stub_agent(), NOWHERE)
         self.assertThat(
             redeemer,
             Provides([IRedeemer]),
         )
 
-    @given(vouchers(), integers(min_value=1, max_value=100))
-    def test_redemption(self, voucher, num_tokens):
+    @given(vouchers().map(Voucher), integers(min_value=1, max_value=100))
+    def test_good_ristretto_redemption(self, voucher, num_tokens):
         """
-        ``RistrettoRedeemer.redeem`` returns a ``Deferred`` that fires with a list
-        of ``Pass`` instances.
+        If the issuer returns a successful result then
+        ``RistrettoRedeemer.redeem`` returns a ``Deferred`` that fires with a
+        list of ``Pass`` instances.
         """
-        public_key = u"pub foo-bar"
-        signatures = list(u"sig-{}".format(n) for n in range(num_tokens))
-        proof = u"proof bar-foo"
-
-        issuer = SuccessfulRedemption(public_key, signatures, proof)
-        agent = agent_for_loopback_ristretto(issuer)
-        redeemer = RistrettoRedeemer(agent)
+        signing_key = random_signing_key()
+        issuer = RistrettoRedemption(signing_key)
+        treq = treq_for_loopback_ristretto(issuer)
+        redeemer = RistrettoRedeemer(treq, NOWHERE)
         random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens)
-        # The redeemer gives back the requested number of tokens.
-        self.expectThat(
-            len(random_tokens),
-            Equals(num_tokens),
-        )
         d = redeemer.redeem(
             voucher,
             random_tokens,
         )
-        # Perform some very basic checks on the results.  We won't verify the
-        # crypto here since we don't have a real Ristretto server.  Such
-        # checks would fail.  Some integration tests will verify that part of
-        # things.
         self.assertThat(
             d,
             succeeded(
@@ -187,16 +199,46 @@ class RistrettoRedeemerTests(TestCase):
             ),
         )
 
+    @given(vouchers().map(Voucher), integers(min_value=1, max_value=100))
+    def test_bad_ristretto_redemption(self, voucher, num_tokens):
+        """
+        If the issuer returns a successful result then
+        ``RistrettoRedeemer.redeem`` returns a ``Deferred`` that fires with a
+        list of ``Pass`` instances.
+        """
+        signing_key = random_signing_key()
+        issuer = RistrettoRedemption(signing_key)
+        # Make it lie about the public key it is using.
+        issuer.public_key = PublicKey.from_signing_key(random_signing_key())
+
+        treq = treq_for_loopback_ristretto(issuer)
+        redeemer = RistrettoRedeemer(treq, NOWHERE)
+        random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens)
+        d = redeemer.redeem(
+            voucher,
+            random_tokens,
+        )
+        self.addDetail(u"redeem Deferred", text_content(str(d)))
+        self.assertThat(
+            d,
+            failed(
+                AfterPreprocessing(
+                    lambda f: f.value,
+                    IsInstance(SecurityException),
+                ),
+            ),
+        )
+
 
-def agent_for_loopback_ristretto(local_issuer):
+def treq_for_loopback_ristretto(local_issuer):
     """
-    Create an ``IAgent`` which can dispatch to a local issuer.
+    Create a ``treq``-alike which can dispatch to a local issuer.
     """
     v1 = Resource()
     v1.putChild(b"redeem", local_issuer)
     root = Resource()
     root.putChild(b"v1", v1)
-    return RequestTraversalAgent(root)
+    return StubTreq(root)
 
 
 class SuccessfulRedemption(Resource):
@@ -228,3 +270,45 @@ class _StubAgent(object):
 
 def stub_agent():
     return _StubAgent()
+
+
+class RistrettoRedemption(Resource):
+    def __init__(self, signing_key):
+        Resource.__init__(self)
+        self.signing_key = signing_key
+        self.public_key = PublicKey.from_signing_key(signing_key)
+
+    def render_POST(self, request):
+        request_body = loads(request.content.read())
+        marshaled_blinded_tokens = request_body[u"redeemTokens"]
+        servers_blinded_tokens = list(
+            BlindedToken.decode_base64(marshaled_blinded_token.encode("ascii"))
+            for marshaled_blinded_token
+            in marshaled_blinded_tokens
+        )
+        servers_signed_tokens = list(
+            self.signing_key.sign(blinded_token)
+            for blinded_token
+            in servers_blinded_tokens
+        )
+        marshaled_signed_tokens = list(
+            signed_token.encode_base64()
+            for signed_token
+            in servers_signed_tokens
+        )
+        servers_proof = BatchDLEQProof.create(
+            self.signing_key,
+            servers_blinded_tokens,
+            servers_signed_tokens,
+        )
+        try:
+            marshaled_proof = servers_proof.encode_base64()
+        finally:
+            servers_proof.destroy()
+
+        return dumps({
+            u"success": True,
+            u"public-key": self.public_key.encode_base64(),
+            u"signatures": marshaled_signed_tokens,
+            u"proof": marshaled_proof,
+        })