diff --git a/default.nix b/default.nix
index bc6a0884559432f6b8c0c65473530de99154bbd5..8400053d9a5bb70d72475c3bef67806346585aa4 100644
--- a/default.nix
+++ b/default.nix
@@ -31,6 +31,7 @@ in
       # This is kind of round-about but it seems to be the best way to
       # convince mach-nix to use a specific package for a specific dependency.
       tahoe-lafs = "nixpkgs";
+      zkap-spending-service = "nixpkgs";
 
       # Make sure we use an sdist of zfec so that our patch to zfec's setup.py
       # to remove its argparse dependency can be applied.  If we get a wheel,
diff --git a/nix/sources.json b/nix/sources.json
index 658a129cece37115b20115201342083508e51732..593ceb1121bbd4c204ccd37e3601203ac5393167 100644
--- a/nix/sources.json
+++ b/nix/sources.json
@@ -76,5 +76,13 @@
         "type": "tarball",
         "url": "https://github.com/tahoe-lafs/tahoe-lafs/archive/5e8cc06e93e7b23dae0f1c3e0bc158b49a9e14e6.tar.gz",
         "url_template": "https://github.com/<owner>/<repo>/archive/<rev>.tar.gz"
+    },
+    "zkap-spending-service": {
+        "owner": "tomprince",
+        "rev": "c959529a7d416e5c943614dc47a97ad770a8cd46",
+        "sha256": "14311w257p2dcjc7dam8fqam2jr73ywfm7xzgc6bnbjqv0i0d3z3",
+        "type": "tarball",
+        "url": "https://whetstone.private.storage/tomprince/zkap-spending-service/-/archive/c959529a7d416e5c943614dc47a97ad770a8cd46/zkap-spending-service.zip",
+        "url_template": "https://whetstone.private.storage/<owner>/zkap-spending-service/-/archive/<rev>/zkap-spending-service.zip"
     }
 }
diff --git a/requirements/test.in b/requirements/test.in
index f8f848c4c8f9dbed99d1f42e98c79aa2e9f5a2e4..5fc456fbc0cd0c12509d5e6b5f3cf01b5af928a9 100644
--- a/requirements/test.in
+++ b/requirements/test.in
@@ -4,6 +4,7 @@ testtools
 testresources
 hypothesis
 openapi_spec_validator
+zkap-spending-service@https://whetstone.private.storage/tomprince/zkap-spending-service/-/archive/c959529a7d416e5c943614dc47a97ad770a8cd46/zkap-spending-service.zip
 
 # Lint requirements
 # Pin these narrowly so that lint rules only change when we specifically
diff --git a/src/_zkapauthorizer/server/spending.py b/src/_zkapauthorizer/server/spending.py
index 66fe819a3b768ac48842b7b649aec52b4f987a62..aba1ad7a32073abf1b9c08733c18da93c52da343 100644
--- a/src/_zkapauthorizer/server/spending.py
+++ b/src/_zkapauthorizer/server/spending.py
@@ -1,18 +1,60 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019 PrivateStorage.io, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A client for the ZKAP Spending Service, which records spent ZKAPs.
+"""
+
+from __future__ import annotations
+
+import json
 from typing import Any
 
-import attr
+import attrs
 from challenge_bypass_ristretto import PublicKey
-from prometheus_client import CollectorRegistry
+from eliot import start_action
+from eliot.twisted import inline_callbacks
+from hyperlink import URL
+from prometheus_client import CollectorRegistry, Counter
+from treq.client import HTTPClient
+from treq.testing import StubTreq
 from twisted.internet.interfaces import IReactorTime
+from twisted.web import http
+from twisted.web.client import Agent
 from zope.interface import Interface, implementer
 
+from ..eliot import register_attr_exception
+
+
+@register_attr_exception
+@attrs.define
+class UnexpectedResponse(Exception):
+    """
+    The issuer responded in an unexpected and unhandled way.
+    """
+
+    code: int
+    body: bytes
+
 
 class ISpender(Interface):
     """
     An ``ISpender`` can records spent ZKAPs and reports double spends.
     """
 
-    def mark_as_spent(public_key, passes):
+    def mark_as_spent(public_key, token_preimages):
         # type: (PublicKey, list[bytes]) -> None
         """
         Record the given ZKAPs (associated to the given public key as having
@@ -25,40 +67,124 @@ class ISpender(Interface):
         """
 
 
-@attr.s
-class _SpendingData(object):
-    spent_tokens = attr.ib(init=False, factory=dict)
+def counter_attr(name, description, labels=()):
+    """
+    Return an attrs attribute that is a prometheus :py:`Counter` metric registered
+    with the ``_registry`` on the instance.
+    """
+    attrib = attrs.field(
+        init=False,
+        metadata={
+            "metric-labels": labels,
+            "metric-name": "{}".format(name),
+        },
+    )
+
+    @attrib.default
+    def make_counter(self):
+        return Counter(name, description, labelnames=labels, registry=self._registry)
 
-    def reset(self):
-        self.spent_tokens.clear()
+    return attrib
 
 
 @implementer(ISpender)
-@attr.s
-class RecordingSpender(object):
+@attrs.define
+class Spender(object):
     """
-    An in-memory :py:`ISpender` implementation that exposes the spent tokens
-    for testing purposes.
+    An :py:`ISpender` that talks to a ZKAP Spending Service.
     """
 
-    _recorder = attr.ib(validator=attr.validators.instance_of(_SpendingData))
+    _treq: HTTPClient = attrs.field(
+        validator=attrs.validators.instance_of((HTTPClient, StubTreq))
+    )
+    _api_root: URL = attrs.field(validator=attrs.validators.instance_of(URL))
+    _registry: CollectorRegistry = attrs.field()
 
     @classmethod
-    def make(cls):
-        # type: () -> (_SpendingData, ISpender)
-        recorder = _SpendingData()
-        return recorder, cls(recorder)
-
-    def mark_as_spent(self, public_key, passes):
-        self._recorder.spent_tokens.setdefault(public_key.encode_base64(), []).extend(
-            passes
+    def make(
+        cls, config: dict[str, Any], reactor: IReactorTime, registry: CollectorRegistry
+    ) -> ISpender:
+        spending_service_url = config.pop("spending-service-url")
+        return cls(
+            HTTPClient(Agent(reactor)),
+            URL.from_text(spending_service_url),
+            registry,
         )
 
+    @inline_callbacks
+    def ping(self) -> None:
+        response = yield self._treq.get(self._api_root.child("v1", "_ping").to_text())
 
-def get_spender(config, reactor, registry):
-    # type: (dict[str, Any], IReactorTime, CollectorRegistry) -> ISpender
-    """
-    Return an :py:`ISpender` to be used with the given storage server configuration.
-    """
-    recorder, spender = RecordingSpender.make()
-    return spender
+        response_body = yield response.content()
+        if response.code != http.OK:
+            raise Exception("Not ok")
+
+        try:
+            result = json.loads(response_body)
+        except ValueError:
+            raise UnexpectedResponse(response.code, response_body)
+
+        if result.get("status") != "ok":
+            raise Exception("Didn't get ping from spending service.")
+
+    SPEND_PASSES = counter_attr("zkapauthorizer_spend_passes_total", "FIXME: DESC")
+    SPEND_PASSES_ERRORS = counter_attr(
+        "zkapauthorizer_spend_passes_error_total",
+        "FIXME: DESC",
+        labels=("reason", "code"),
+    )
+    SPEND_PASSES_FAILURES = counter_attr(
+        "zkapauthorizer_spend_passes_failures_total",
+        "FIXME: DESC",
+    )
+    SPEND_PASSES_SUCCESSES = counter_attr(
+        "zkapauthorizer_spend_passes_successes_total",
+        "FIXME: DESC",
+    )
+
+    @inline_callbacks
+    def mark_as_spent(self, public_key, token_preimages):
+        """
+        Takes a dictionary mapping public keys to lists of spend tokens,
+        and reports them as spent.
+        """
+        self.SPEND_PASSES.inc()
+        try:
+            with start_action(
+                action_type="zkapauthorizer:server:spend-passes"
+            ) as action:
+                response = yield self._treq.post(
+                    self._api_root.child("v1", "spend").to_text(),
+                    json.dumps(
+                        {
+                            "tokens": {
+                                public_key.encode_base64().decode("ascii"): [
+                                    token_preimage.decode("ascii")
+                                    for token_preimage in token_preimages
+                                ]
+                            },
+                            "force": True,
+                        }
+                    ),
+                    headers={b"content-type": b"application/json"},
+                )
+                try:
+                    result = yield response.json()
+                except:
+                    raise UnexpectedResponse(response.code, "")
+
+                if response.code != http.OK:
+                    self.SPEND_PASSES_ERRORS.labels(
+                        reason=result["reason"], code=response.code
+                    ).inc()
+                else:
+                    action.add_success_fields(code=response.code, body=result)
+                    self.SPEND_PASSES_SUCCESSES.inc()
+
+        except Exception:
+            self.SPEND_PASSES_FAILURES.inc()
+            # eliot will have logged any exception above.  Since we want to
+            # fail open, we consume exceptions here.
+
+
+get_spender = Spender.make
diff --git a/src/_zkapauthorizer/tests/fixtures.py b/src/_zkapauthorizer/tests/fixtures.py
index b5fb82186f840c6106dfb247bc3e3822f5bd63e8..bb909441aa341823deb6ebd90d38ce030632f091 100644
--- a/src/_zkapauthorizer/tests/fixtures.py
+++ b/src/_zkapauthorizer/tests/fixtures.py
@@ -22,6 +22,8 @@ import attr
 from allmydata.storage.server import StorageServer
 from attrs import define, field
 from fixtures import Fixture, TempDir
+from hyperlink import URL
+from prometheus_client import CollectorRegistry
 from testtools import TestCase
 from treq.client import HTTPClient
 from twisted.internet.defer import Deferred, inlineCallbacks
@@ -29,9 +31,11 @@ from twisted.internet.interfaces import IReactorTime
 from twisted.internet.task import Clock, deferLater
 from twisted.python.filepath import FilePath
 from twisted.web.client import Agent, HTTPConnectionPool
+from zss.testing import InMemoryBackend, make_in_memory_client
 
 from ..controller import DummyRedeemer, PaymentController
 from ..model import VoucherStore, memory_connect, open_and_initialize
+from ..server.spending import ISpender, Spender
 
 
 @attr.s(auto_attribs=True)
@@ -186,3 +190,15 @@ class Treq(Fixture):
         # reactor) seems to be enough.  If it's not, sorry.
         yield deferLater(self.reactor, 0, lambda: None)
         yield deferLater(self.reactor, 0, lambda: None)
+
+
+def make_in_memory_spender(
+    registry: CollectorRegistry = None,
+) -> (InMemoryBackend, ISpender):
+
+    backend, treq = make_in_memory_client()
+    return backend, Spender(
+        treq,
+        URL.from_text("http://spender.invalid/"),
+        CollectorRegistry(),
+    )
diff --git a/src/_zkapauthorizer/tests/matchers.py b/src/_zkapauthorizer/tests/matchers.py
index 3a3399c8cefc409d62ab66ec8139bb09adb11c44..78e1a62d548239b92ce1b2f4b59e487907d98531 100644
--- a/src/_zkapauthorizer/tests/matchers.py
+++ b/src/_zkapauthorizer/tests/matchers.py
@@ -12,6 +12,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from __future__ import annotations
+
 """
 Testtools matchers useful for the test suite.
 """
@@ -26,8 +28,11 @@ __all__ = [
 ]
 
 from datetime import datetime
+from typing import Any
 
 import attr
+import zss.store
+from prometheus_client import CollectorRegistry
 from testtools.matchers import (
     AfterPreprocessing,
     AllMatch,
@@ -48,7 +53,6 @@ from testtools.twistedsupport import succeeded
 from treq import content
 
 from ..model import Pass
-from ..server.spending import _SpendingData
 from ._exception import raises
 
 
@@ -212,14 +216,20 @@ def matches_response(
     )
 
 
-def matches_spent_passes(public_key_hash, spent_passes):
-    # type: (bytes, list[Pass]) -> Matcher[_SpendingData]
+def matches_spent_passes(
+    public_key_hash: bytes, spent_passes: list[Pass]
+) -> Matcher[zss.store.InMemoryBackend]:
     """
     Returns a matcher for _SpendingData that checks whether the
     spent pass match the given public key and passes.
     """
     return AfterPreprocessing(
-        lambda spending_recorder: spending_recorder.spent_tokens,
+        lambda spending_recorder: {
+            public_key.encode("ascii"): [
+                preimage.encode("ascii") for preimage in token_preimages
+            ]
+            for public_key, token_preimages in spending_recorder.spent_tokens.items()
+        },
         MatchesDict(
             {
                 public_key_hash: MatchesSetwise(
@@ -228,3 +238,28 @@ def matches_spent_passes(public_key_hash, spent_passes):
             }
         ),
     )
+
+
+def matches_metrics(
+    expected: dict[tuple[type, str], Any],
+) -> Matcher[CollectorRegistry]:
+    """
+    Returns a matcher for :py:`CollectorRegistry`, that checks whether the specified
+    metrics have the given values.
+
+    :param expected: Dictionary that maps tuples of attrs-class and :py:`_zkapauthorizer.server.spending.counter_attr` names to the expected value of the given metric.
+    """
+    expected = {
+        (attr.fields_dict(cls)[attrib].metadata["metric-name"], ()): value
+        for (cls, attrib), value in expected.items()
+    }
+
+    def summarize(registry: CollectorRegistry):
+        metrics = list(registry.collect())
+        return {
+            (sample.name, tuple(sample.labels.items())): sample.value
+            for metric in metrics
+            for sample in metric.samples
+        }
+
+    return AfterPreprocessing(summarize, ContainsDict(expected))
diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py
index 86d933d1e00565bc5928fc2bffc2524ae39e8b09..93b694fc330c525daaebbfc0e95d672cbb5913e9 100644
--- a/src/_zkapauthorizer/tests/test_controller.py
+++ b/src/_zkapauthorizer/tests/test_controller.py
@@ -30,6 +30,7 @@ from challenge_bypass_ristretto import (
     VerificationSignature,
     random_signing_key,
 )
+from hyperlink import URL
 from hypothesis import assume, given
 from hypothesis.strategies import datetimes, integers, lists, randoms, sampled_from
 from testtools import TestCase
@@ -49,7 +50,6 @@ from testtools.twistedsupport import failed, has_no_result, succeeded
 from treq.testing import StubTreq
 from twisted.internet.defer import fail, succeed
 from twisted.internet.task import Clock
-from twisted.python.url import URL
 from twisted.web.http import BAD_REQUEST, INTERNAL_SERVER_ERROR, UNSUPPORTED_MEDIA_TYPE
 from twisted.web.http_headers import Headers
 from twisted.web.iweb import IAgent
diff --git a/src/_zkapauthorizer/tests/test_storage_protocol.py b/src/_zkapauthorizer/tests/test_storage_protocol.py
index 6ca4a6e6ae90fb458ff7144d7c95b96187a69656..836a4a77fdb244f3cdd450fca535f8bee5a47692 100644
--- a/src/_zkapauthorizer/tests/test_storage_protocol.py
+++ b/src/_zkapauthorizer/tests/test_storage_protocol.py
@@ -49,14 +49,13 @@ from ..api import (
     ZKAPAuthorizerStorageServer,
 )
 from ..foolscap import ShareStat
-from ..server.spending import RecordingSpender
 from ..storage_common import (
     allocate_buckets_message,
     get_implied_data_length,
     required_passes,
 )
 from .common import skipIf
-from .fixtures import AnonymousStorageServer
+from .fixtures import AnonymousStorageServer, make_in_memory_spender
 from .foolscap import LocalRemote
 from .matchers import matches_spent_passes, matches_version_dictionary
 from .storage_common import (
@@ -158,7 +157,7 @@ class ShareTests(TestCase):
             AnonymousStorageServer(self.clock),
         ).storage_server
 
-        self.spending_recorder, spender = RecordingSpender.make()
+        self.spending_recorder, spender = make_in_memory_spender()
         self.server = ZKAPAuthorizerStorageServer(
             self.anonymous_storage_server,
             self.pass_value,
diff --git a/src/_zkapauthorizer/tests/test_storage_server.py b/src/_zkapauthorizer/tests/test_storage_server.py
index a0237bb7b82cf5c6069d7ae6f243bb37479f22d1..dd1f7427c66ea24d19377034db2259ce17bdda21 100644
--- a/src/_zkapauthorizer/tests/test_storage_server.py
+++ b/src/_zkapauthorizer/tests/test_storage_server.py
@@ -32,7 +32,6 @@ from twisted.python.runtime import platform
 
 from .._storage_server import NewLengthRejected, _ValidationResult
 from ..api import MorePassesRequired, ZKAPAuthorizerStorageServer
-from ..server.spending import RecordingSpender
 from ..storage_common import (
     add_lease_message,
     allocate_buckets_message,
@@ -43,7 +42,7 @@ from ..storage_common import (
     summarize,
 )
 from .common import skipIf
-from .fixtures import AnonymousStorageServer
+from .fixtures import AnonymousStorageServer, make_in_memory_spender
 from .matchers import matches_spent_passes, raises
 from .storage_common import get_passes, reset_storage_server, write_toy_shares
 from .strategies import (
@@ -189,7 +188,7 @@ class PassValidationTests(TestCase):
     def setUp(self):
         super(PassValidationTests, self).setUp()
         self.clock = Clock()
-        self.spending_recorder, spender = RecordingSpender.make()
+        self.spending_recorder, spender = make_in_memory_spender()
         # anonymous_storage_server uses time.time() so get our Clock close to
         # the same time so we can do lease expiration calculations more
         # easily.
diff --git a/tests.nix b/tests.nix
index a3bf6878d70864224deedd1c0290d6ec9200a575..1aaaadc0a43f082f0cc248cdf238d9d689c4426c 100644
--- a/tests.nix
+++ b/tests.nix
@@ -8,6 +8,7 @@ let
     "testSuite"
     "trialArgs"
   ];
+  sources = import nix/sources.nix;
 in
 { privatestorage ? import ./. (fixArgs args)
 , hypothesisProfile ? null
@@ -26,11 +27,22 @@ let
     extraTrialArgs = builtins.concatStringsSep " " trialArgs';
     testSuite' = if testSuite == null then "_zkapauthorizer" else testSuite;
 
+    zss = import sources.zkap-spending-service {
+      inherit pkgs mach-nix;
+    };
+
     python = mach-nix.mkPython {
       inherit (zkapauthorizer.meta.mach-nix) python providers;
       requirements =
         builtins.readFile ./requirements/test.in;
       packagesExtra = [ zkapauthorizer ];
+      overridesPre = [
+        (
+          self: super: {
+            zkap-spending-service = zss;
+          }
+        )
+      ];
       _.hypothesis.postUnpack = "";
     };