diff --git a/morph/lib/issuer.nix b/morph/lib/issuer.nix
index 86c46a14762f0b9e1aba6602140bc0a2bc245e7c..69d44d3104d03553f384b2b2d2e98a34557d2d84 100644
--- a/morph/lib/issuer.nix
+++ b/morph/lib/issuer.nix
@@ -46,6 +46,14 @@ in {
           permissions = "0400";
           action = ["sudo" "systemctl" "restart" "zkapissuer.service"];
         };
+        "stripe-webhook-secret-key" = {
+          destination = "/run/keys/stripe.webhook-secret-key";
+          source = "${privateKeyPath}/stripe.webhook-secret";
+          owner.user = "zkapissuer";
+          owner.group = "zkapissuer";
+          permissions = "0400";
+          action = ["sudo" "systemctl" "restart" "zkapissuer.service"];
+        };
       };
     };
     services.private-storage-issuer = {
@@ -53,6 +61,7 @@ in {
       tls = true;
       ristrettoSigningKeyPath = config.deployment.secrets.ristretto-signing-key.destination;
       stripeSecretKeyPath = config.deployment.secrets.stripe-secret-key.destination;
+      stripeWebhookSecretKeyPath = config.deployment.secrets.stripe-webhook-secret-key.destination;
       database = "SQLite3";
       databasePath = "${config.fileSystems."zkapissuer-data".mountPoint}/vouchers.sqlite3";
       inherit (config.grid) letsEncryptAdminEmail;
diff --git a/nixos/modules/issuer.nix b/nixos/modules/issuer.nix
index b032100f1f20f04f174aa8f03faf5bf22a97b226..375f064c63c65b1b0b0c5fa94a121a4d68bba781 100644
--- a/nixos/modules/issuer.nix
+++ b/nixos/modules/issuer.nix
@@ -62,6 +62,13 @@ in {
         and payment management.
       '';
     };
+    services.private-storage-issuer.stripeWebhookSecretKeyPath = lib.mkOption {
+      type = lib.types.path;
+      description = ''
+        The path to a file containing a Stripe "webhook" secret key to use for
+        charge and payment management.
+      '';
+    };
     services.private-storage-issuer.stripeEndpointDomain = lib.mkOption {
       type = lib.types.str;
       description = ''
@@ -216,6 +223,7 @@ in {
 
           stripeArgs =
             "--stripe-key-path ${cfg.stripeSecretKeyPath} " +
+            "--stripe-webhook-key-path ${cfg.stripeWebhookSecretKeyPath} " +
             "--stripe-endpoint-domain ${cfg.stripeEndpointDomain} " +
             "--stripe-endpoint-scheme ${cfg.stripeEndpointScheme} " +
             "--stripe-endpoint-port ${toString cfg.stripeEndpointPort}";
diff --git a/nixos/pkgs/zkapissuer/repo.json b/nixos/pkgs/zkapissuer/repo.json
index 2963aafabef1503608195f7903fe202f11dd70a8..68718de38f9dc5031b97690651af0a8337710933 100644
--- a/nixos/pkgs/zkapissuer/repo.json
+++ b/nixos/pkgs/zkapissuer/repo.json
@@ -1,8 +1,8 @@
 {
   "owner": "PrivateStorageio",
   "repo": "PaymentServer",
-  "rev": "b1bd39ffb7269e5334bf068263733472b27b38ef",
+  "rev": "60631a8e6adb2696f4b5cf49c05a5e5d3729d2d8",
   "branch": "main",
   "outputHashAlgo": "sha512",
-  "outputHash": "1jncsdyz83xphairk4rx47wrlkdn7s48dk8l3mnimal4h4x7zb34lyx2anm1nhxm7s52jv28rv4mvggc2dhsa21dsqhga3qggcpfi2m"
+  "outputHash": "2ll0n1srhhc18wl1s5imavxjdaj0r56b4lgh6mki5c5lwnm5a2rgpf982yqcz7gxgnzizs2sa8myzrlwcmwh23mcv2503024jsqgkqv"
 }
\ No newline at end of file
diff --git a/nixos/tests/get-passes.py b/nixos/tests/get-passes.py
index 63b59e5700e0fa0c659f04df8fa889a0501f125f..206e8900e496f7b08967fb11715043fedeaa3f5d 100755
--- a/nixos/tests/get-passes.py
+++ b/nixos/tests/get-passes.py
@@ -12,14 +12,21 @@ from json import dumps
 from time import sleep
 
 def main():
-    if len(argv) != 5:
+    if len(argv) == 4:
+        # If no issuer is given then we just won't make the charge request.
+        # This is useful for following the webhook-based workflow.
+        clientAPIRoot, clientAPITokenPath, voucher = argv[1:]
+        issuerAPIRoot = None
+    elif len(argv) == 5:
+        clientAPIRoot, clientAPITokenPath, issuerAPIRoot, voucher = argv[1:]
+    else:
         raise SystemExit(
-            "usage: %s <client api root> <client api token path> <issuer api root> <voucher>",
+            "usage: %s <client api root> <client api token path> [<issuer api root>] <voucher>",
         )
-    clientAPIRoot, clientAPITokenPath, issuerAPIRoot, voucher = argv[1:]
+
     if not clientAPIRoot.endswith("/"):
         clientAPIRoot += "/"
-    if not issuerAPIRoot.endswith("/"):
+    if issuerAPIRoot is not None and not issuerAPIRoot.endswith("/"):
         issuerAPIRoot += "/"
 
     zkapauthz = clientAPIRoot + "storage-plugins/privatestorageio-zkapauthz-v1"
@@ -27,15 +34,16 @@ def main():
     with open(clientAPITokenPath) as p:
         clientAPIToken = p.read().strip()
 
-    # Submit a charge to the issuer (which is also the PaymentServer).
-    charge_response = post(
-        issuerAPIRoot + "v1/stripe/charge",
-        dumps(charge_json(voucher)),
-        headers={
-            "content-type": "application/json",
-        },
-    )
-    charge_response.raise_for_status()
+    if issuerAPIRoot is not None:
+        # Submit a charge to the issuer (which is also the PaymentServer).
+        charge_response = post(
+            issuerAPIRoot + "v1/stripe/charge",
+            dumps(charge_json(voucher)),
+            headers={
+                "content-type": "application/json",
+            },
+        )
+        charge_response.raise_for_status()
 
     # Tell the client to redeem the voucher.
     response = put(
diff --git a/nixos/tests/private-storage.nix b/nixos/tests/private-storage.nix
index e02f9c49550d7fa4994c3d6099eda1e9374a278c..b593a18ef84947bdfe299e1ec6388987f4296491 100644
--- a/nixos/tests/private-storage.nix
+++ b/nixos/tests/private-storage.nix
@@ -40,6 +40,9 @@ let
     in
       pkgs.writeText basename key;
 
+  stripeWebhookSecretKey = "whsec_e302402f2f4b5d8241fe494cd693464345bf28c4d7312516d6c1ce69cd0c1e1d";
+  stripeWebhookSecretKeyPath = pkgs.writeText "stripe-webhook.secret" stripeWebhookSecretKey;
+
   # Here are the preconstructed secrets which we can assign to the introducer.
   # This is a lot easier than having the introducer generate them and then
   # discovering and configuring the other nodes with them.
@@ -122,7 +125,7 @@ in {
         letsEncryptAdminEmail = "user@example.invalid";
         allowedChargeOrigins = [ "http://unused.invalid" ];
 
-        inherit stripeSecretKeyPath;
+        inherit stripeSecretKeyPath stripeWebhookSecretKeyPath;
         stripeEndpointDomain = "api_stripe_com";
         stripeEndpointScheme = "HTTP";
         stripeEndpointPort = 80;
@@ -159,7 +162,7 @@ in {
   testScript = ourpkgs.lib.testing.makeTestScript {
     testpath = ./test_privatestorage.py;
     kwargs = {
-      inherit sshPrivateKeyFile pemFile introducerPort introducerFURL issuerURL ristrettoPublicKey voucher tokenCount;
+      inherit sshPrivateKeyFile pemFile introducerPort introducerFURL issuerURL ristrettoPublicKey voucher tokenCount stripeWebhookSecretKey;
       # Supply some helper programs to help the tests stay a bit higher level.
       run_introducer = ./run-introducer.py;
       run_client = ./run-client.py;
diff --git a/nixos/tests/test_privatestorage.py b/nixos/tests/test_privatestorage.py
index df2aeb9372042d9368736579090e70ab90097f43..0429ac9257f7b587f4efd1d1ecc53375ed9ee4d3 100644
--- a/nixos/tests/test_privatestorage.py
+++ b/nixos/tests/test_privatestorage.py
@@ -1,4 +1,8 @@
-def runOnNode(node, argv):
+import hmac
+from shlex import quote
+from time import time
+
+def runOnNode(node, argvs):
     """
     Run a shell command on one of the nodes.  The first argument is the name
     of the node.  The second is a list of the argv to run.
@@ -6,12 +10,13 @@ def runOnNode(node, argv):
     The program's output is piped to systemd-cat and the python fragment
     evaluates to success if the command exits with a success status.
     """
-    try:
-        node.succeed('set -eo pipefail; {} | systemd-cat'.format(" ".join(argv)))
-    except Exception as e:
-        code, output = node.execute('cat /tmp/stdout /tmp/stderr')
-        introducer.log(output)
-        raise
+    for argv in argvs:
+        try:
+            node.succeed('set -eo pipefail; {} | systemd-cat'.format(" ".join(map(quote, argv))))
+        except Exception as e:
+            code, output = node.execute('cat /tmp/stdout /tmp/stderr')
+            node.log(output)
+            raise
 
 def ssh(username, sshPrivateKeyFile, hostname):
     """
@@ -19,10 +24,144 @@ def ssh(username, sshPrivateKeyFile, hostname):
     host.
     """
     return [
-        "cp", sshPrivateKeyFile, "/tmp/ssh_key", ";",
-        "chmod", "0400", "/tmp/ssh_key", ";",
-        "ssh", "-oStrictHostKeyChecking=no", "-i", "/tmp/ssh_key",
-        "{username}@{hostname}".format(username=username, hostname=hostname), ":",
+        ["cp", sshPrivateKeyFile, "/tmp/ssh_key"],
+        ["chmod", "0400", "/tmp/ssh_key"],
+        ["ssh", "-oStrictHostKeyChecking=no", "-i", "/tmp/ssh_key",
+         "{username}@{hostname}".format(username=username, hostname=hostname), ":"],
+    ]
+
+def checkout_session_completed(voucher: str) -> str:
+    """
+    Return a request body string which represents the payment completed event
+    for the given voucher.
+    """
+    return """\
+{
+  "id": "evt_1LxcsdBHXBAMm9bPSq6UWAZe",
+  "object": "event",
+  "api_version": "2019-11-05",
+  "created": 1666903247,
+  "data": {
+    "object": {
+      "id": "cs_test_a1kWLWGoXZPa6ywyVnuib8DPA3BqXCWZX5UEjLfKh7gLjdZy2LD3F5mEp3",
+      "object": "checkout.session",
+      "after_expiration": null,
+      "allow_promotion_codes": null,
+      "amount_subtotal": 3000,
+      "amount_total": 3000,
+      "automatic_tax": {
+        "enabled": false,
+        "status": null
+      },
+      "billing_address_collection": null,
+      "cancel_url": "https://httpbin.org/post",
+      "client_reference_id": "%(voucher)s",
+      "consent": null,
+      "consent_collection": null,
+      "created": 1666903243,
+      "currency": "usd",
+      "customer": "cus_Mh0u62xtelUehD",
+      "customer_creation": "always",
+      "customer_details": {
+        "address": {
+          "city": null,
+          "country": null,
+          "line1": null,
+          "line2": null,
+          "postal_code": null,
+          "state": null
+        },
+        "email": "stripe@example.com",
+        "name": null,
+        "phone": null,
+        "tax_exempt": "none",
+        "tax_ids": [
+
+        ]
+      },
+      "customer_email": null,
+      "display_items": [
+        {
+          "amount": 1500,
+          "currency": "usd",
+          "custom": {
+            "description": "comfortable cotton t-shirt",
+            "images": null,
+            "name": "t-shirt"
+          },
+          "quantity": 2,
+          "type": "custom"
+        }
+      ],
+      "expires_at": 1666989643,
+      "livemode": false,
+      "locale": null,
+      "metadata": {
+      },
+      "mode": "payment",
+      "payment_intent": "pi_3LxcsZBHXBAMm9bP1daBGoPV",
+      "payment_link": null,
+      "payment_method_collection": "always",
+      "payment_method_options": {
+      },
+      "payment_method_types": [
+        "card"
+      ],
+      "payment_status": "paid",
+      "phone_number_collection": {
+        "enabled": false
+      },
+      "recovered_from": null,
+      "setup_intent": null,
+      "shipping": null,
+      "shipping_address_collection": null,
+      "shipping_options": [
+
+      ],
+      "shipping_rate": null,
+      "status": "complete",
+      "submit_type": null,
+      "subscription": null,
+      "success_url": "https://httpbin.org/post",
+      "total_details": {
+        "amount_discount": 0,
+        "amount_shipping": 0,
+        "amount_tax": 0
+      },
+      "url": null
+    }
+  },
+  "livemode": false,
+  "pending_webhooks": 2,
+  "request": {
+    "id": null,
+    "idempotency_key": null
+  },
+  "type": "checkout.session.completed"
+}
+""" % dict(voucher=voucher)
+
+def stripe_signature(key: str, body: str) -> str:
+    """
+    Construct a valid value for the ``Stripe-Signature`` header item.
+    """
+    timestamp = int(time())
+    v1 = hmac.new(key.encode("utf-8"), f"{timestamp}.{body}".encode("utf-8"), "sha256").hexdigest()
+    return f"t={timestamp},v1={v1}"
+
+def pay_for_voucher(url: str, webhook_secret, voucher: str) -> list[str]:
+    """
+    Return a command to run to report to the issuer that payment for the given
+    voucher has been received.
+    """
+    body = checkout_session_completed(voucher)
+    return [
+        "curl",
+        "-X", "POST",
+        "--header", "content-type: application/json; charset=utf-8",
+        "--header", f"stripe-signature: {stripe_signature(webhook_secret, body)}",
+        "--data-binary", body,
+        url + "v1/stripe/webhook",
     ]
 
 def test(
@@ -36,6 +175,7 @@ def test(
         introducerFURL,
         issuerURL,
         ristrettoPublicKey,
+        stripeWebhookSecretKey,
         voucher,
         tokenCount,
 ):
@@ -69,8 +209,7 @@ def test(
 
     # Set up a Tahoe-LAFS introducer.
     introducer.copy_from_host(pemFile, '/tmp/node.pem')
-
-    runOnNode(introducer, [run_introducer, "/tmp/node.pem", str(introducerPort), introducerFURL])
+    runOnNode(introducer, [[run_introducer, "/tmp/node.pem", str(introducerPort), introducerFURL]])
 
     #
     # Get a Tahoe-LAFS storage server up.
@@ -96,10 +235,13 @@ def test(
     # Make sure the issuer is ready to accept connections.
     issuer.wait_for_open_port(80)
 
+    # Pretend to be Stripe and report that our voucher has been paid for.
+    runOnNode(issuer, [pay_for_voucher("http://localhost/", stripeWebhookSecretKey, voucher)])
+
     #
     # Storage appears to be working so try to get a client to speak with it.
     #
-    runOnNode(client, [run_client, "/tmp/client", introducerFURL, issuerURL, ristrettoPublicKey, str(tokenCount)])
+    runOnNode(client, [[run_client, "/tmp/client", introducerFURL, issuerURL, ristrettoPublicKey, str(tokenCount)]])
     client.wait_for_open_port(3456)
 
     # Make sure the fake Stripe API server is ready for requests.
@@ -112,13 +254,12 @@ def test(
 
     # Get some ZKAPs from the issuer.
     try:
-        runOnNode(client, [
+        runOnNode(client, [[
             get_passes,
             "http://127.0.0.1:3456",
             "/tmp/client/private/api_auth_token",
-            issuerURL,
             voucher,
-        ])
+        ]])
     except:
         # Dump the fake Stripe API server logs, too, since the error may arise
         # from a PaymentServer/Stripe interaction.
@@ -129,19 +270,19 @@ def test(
         raise
 
     # The client should be prepped now.  Make it try to use some storage.
-    runOnNode(client, [exercise_storage, "/tmp/client"])
+    runOnNode(client, [[exercise_storage, "/tmp/client"]])
 
     # It should be possible to restart the storage service without the
     # storage node fURL changing.
     furlfile = '/var/db/tahoe-lafs/storage/private/storage-plugin.privatestorageio-zkapauthz-v1.furl'
     before = storage.execute('cat ' + furlfile)
-    runOnNode(storage, ["systemctl", "restart", "tahoe.storage"])
+    runOnNode(storage, [["systemctl", "restart", "tahoe.storage"]])
     after = storage.execute('cat ' + furlfile)
     if (before != after):
         raise Exception('fURL changes after storage node restart')
 
     # The client should actually still work, too.
-    runOnNode(client, [exercise_storage, "/tmp/client"])
+    runOnNode(client, [[exercise_storage, "/tmp/client"]])
 
     # The issuer metrics should be accessible from the monitoring network.
     issuer.execute('ifconfig lo:fauxvpn 172.23.23.2/24')