diff --git a/config-example.json b/config-example.json
index cd4f15baf86097c2359cc7cba752da06e6280cdd..962a994f513251c346de38828238ce0ef96f3a1a 100644
--- a/config-example.json
+++ b/config-example.json
@@ -5,5 +5,5 @@
   "STRIPE_TAX_RATE_ID": "txr_1QeddlDSpyjzuj5pPwUcMwTd",
   "VISIT_API_KEY": "<visit api key>",
   "VISIT_EXPO_ID": "18lm2fafttito",
-  "UNPROCESSED_PAYMENT_EMAIL_TO": []
+  "SEND_ERROR_EMAILS_TO": []
 }
\ No newline at end of file
diff --git a/docs/source/development.rst b/docs/source/development.rst
index 78efeab1cd0425fea4e24a81e432e70a92cee0ca..7c2673c328792fb02fb75c9cd72f8f35a8459365 100644
--- a/docs/source/development.rst
+++ b/docs/source/development.rst
@@ -124,7 +124,7 @@ Email
 
 It so happens postfix is configured on the ``stripe-checkout`` vm's to forward to the geant smtp
 servers. So we don't need much additional configuration. We only configure the `DEFAULT_FROM_EMAIL`
-setting. The recipients for these emails can be set using the ``UNPROCESSED_PAYMENT_EMAIL_TO``
+setting. The recipients for these emails can be set using the ``SEND_ERROR_EMAILS_TO``
 setting in the :ref:`config_json`
 
 .. _stripe_webhook_endpoint:
@@ -176,6 +176,13 @@ The redirect to the stripe checkout server in Visit is done using a custom javas
 .. image:: _images/visit-form-redirect.png
   :alt: Visit form redirect instructions
 
+.. warning::
+  There is another method of adding javascript, namely in the form settings rather than the custom
+  page settings. These are the first settings you see when editing the form. Any javascript placed
+  here is activated for the whole form rather than a single page. Also, Javascript placed in the
+  form settings, overrides any javascript placed in page settings, so this will deactivate the
+  automatic redirect.
+
 Redirect URL
 ############
 
diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst
index e87e523e78b6b8ecdbbf40bd4fac6d6fa597319e..fb9fd57bc90bdf6d280bca6e86bd61beaac6c381 100644
--- a/docs/source/introduction.rst
+++ b/docs/source/introduction.rst
@@ -15,15 +15,14 @@ features:
   * Use a specific invoice template
 
 These features are not supported by Visit's implementation of the Stripe integration. This
-service acts as an in-between layer and does support these features
+service acts as an in-between layer and does support these features.
 
 When visitors are registering to TNC2025 in Visit cloud, they are redirected to this service in the
 final step of the registration form. Here they get the opportunity to fill out a purchase order
 and/or VAT number. Upon clicking a confirmation button an invoice is created for them and they are
 redirected to Stripe where they can view the invoice, pay directly by credit card and/or see
 payment instructions for paying by bank transfer. They also receive an e-mail with all payment
-information
-``
+information.
 
 
 .. note::
@@ -41,10 +40,10 @@ Design
 The design of the stripe-checkout service is roughly as following:
 
 Whenever a visitor registers for TNC, they fill out a form in Visit. This form sets their
-  ``registrationType``. While filling out the form, the visitor may also want to purchase some extras,
-  such as access to side meetings or social events. The information for extras is stored in the
-  visitors profile as answers to certain questions. For example, there is a question for "Side
-  meetings" with answers "Friday" and "Sunday"
+``registrationType``. While filling out the form, the visitor may also want to purchase some extras,
+such as access to side meetings or social events. The information for extras is stored in the
+visitors profile as answers to certain questions. For example, there is a question for "Side
+meetings" with answers "Friday" and "Sunday"
 
 * As the final page in the registration form, the visitor is redirected to the stripe checkout
   service. Here they see what they've checked out and how much they need to pay.
diff --git a/setup.py b/setup.py
index 19ae8e4d62602888fc57dea9595e2f30b3c80961..4fdf54eb02d036c444bdf7f8d3e50abe901419e3 100644
--- a/setup.py
+++ b/setup.py
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
 
 setup(
     name="stripe-checkout",
-    version="0.11",
+    version="0.12",
     author="GEANT",
     author_email="swd@geant.org",
     description="Stripe custom checkout support service",
diff --git a/stripe_checkout/config.py b/stripe_checkout/config.py
index bda88c5a1e815a5632721af44862a354c4d25974..8d64fd969d17d54abf936a99eb4ab33dc3201e53 100644
--- a/stripe_checkout/config.py
+++ b/stripe_checkout/config.py
@@ -13,7 +13,7 @@ CONFIG_SCHEMA = {
         "STRIPE_TAX_RATE_ID": {"type": "string"},
         "VISIT_API_KEY": {"type": "string"},
         "VISIT_EXPO_ID": {"type": "string"},
-        "UNPROCESSED_PAYMENT_EMAIL_TO": {
+        "SEND_ERROR_EMAILS_TO": {
             "type": "array",
             "items": {"type": "string"},
         },
diff --git a/stripe_checkout/settings/base.py b/stripe_checkout/settings/base.py
index 0338f49cf539fc69d750b4d6b79ee999c8bffa3f..3ac913fdfdf56111a0189af15fec1255378f6187 100644
--- a/stripe_checkout/settings/base.py
+++ b/stripe_checkout/settings/base.py
@@ -132,4 +132,4 @@ LOGGING = {
 STRIPE_WEBHOOK_ALLOWED_IPS = ["*"]
 
 DEFAULT_FROM_EMAIL = "noreply@geant.org"
-UNPROCESSED_PAYMENT_EMAIL_TO = []
+SEND_ERROR_EMAILS_TO = []
diff --git a/stripe_checkout/stripe_checkout/compare_visit_stripe.py b/stripe_checkout/stripe_checkout/compare_visit_stripe.py
new file mode 100644
index 0000000000000000000000000000000000000000..a6a0648e00a4d8e526a6cc552ab2023721a557c0
--- /dev/null
+++ b/stripe_checkout/stripe_checkout/compare_visit_stripe.py
@@ -0,0 +1,119 @@
+import stripe
+
+from stripe_checkout.stripe_checkout import visit
+from stripe_checkout.stripe_checkout.generate_report import (
+    CSVReporter,
+    ReportField,
+    Serializer,
+    StripeWrapper,
+)
+from stripe_checkout.stripe_checkout.utils import list_all_stripe_items
+
+
+class VisitorWrapper(StripeWrapper):
+    inner: visit.Visitor
+
+    def __init__(self, inner: visit.Visitor, all_objects):
+        self.all_visitors = all_objects["visit"]
+        super().__init__(inner, all_objects["stripe"])
+
+    @property
+    def invoice_count(self):
+        return len(
+            [
+                obj
+                for obj_id, obj in self.all_objects.items()
+                if obj_id.startswith("in_")
+                and obj.get("customer_email") == self.inner.email
+            ]
+        )
+
+    @classmethod
+    def iter_items(cls, all_objects):
+        for visitor in all_objects["visit"]:
+            yield cls(visitor, all_objects)
+
+
+class PossibleErrorReporter(CSVReporter):
+    def iter_report_items(self, data):
+        seen = set()
+        for obj in super().iter_report_items(data):
+            seen.add(obj["Email address"])
+            if self._is_suspect(obj):
+                yield obj
+        unseen_invoices: dict[str, list[dict]] = {}
+        for obj_id, obj in data["stripe"].items():
+            email = obj["customer_email"]
+            if obj_id.startswith("in_") and email not in seen:
+                unseen_invoices.setdefault(email, []).append(obj)
+        for email, invoices in unseen_invoices.items():
+            yield {
+                "Email address": email,
+                "Invoice Count": len(invoices),
+                "Paid": (
+                    "PAID"
+                    if any(invoice["status"] == "paid" for invoice in invoices)
+                    else ""
+                ),
+            }
+
+    @staticmethod
+    def _is_suspect(obj: dict):
+        if obj["Registration Type"] in {"VIP", "Staff"}:
+            return False
+        return obj["Invoice Count"] != 1
+
+
+def get_all_data(log_progress=None):
+    return {
+        "visit": get_all_visitors(log_progress=log_progress),
+        "stripe": get_all_invoices(log_progress=log_progress),
+    }
+
+
+def get_all_visitors(log_progress=None):
+    client = visit.VisitorAPI()
+    return sorted(
+        client.list_all_visitors(log_progress=log_progress),
+        key=lambda v: v.email.lower(),
+    )
+
+
+def get_all_invoices(log_progress=None):
+    return {
+        o["id"]: o
+        for o in list_all_stripe_items(stripe.Invoice, log_progress=log_progress)
+    }
+
+
+VISITOR_SERIALIZER = Serializer(
+    wrapper_cls=VisitorWrapper,
+    fields=[
+        ReportField("Email address", "email"),
+        ReportField("Visitor ID", "id"),
+        ReportField("Registration Type", "registrationType.name"),
+        ReportField("Registration State", "registrationState"),
+        ReportField("Invoice Count", "invoice_count"),
+        ReportField("Paid", "paid", transform=lambda v: "PAID" if v else ""),
+    ],
+)
+
+
+reporter = PossibleErrorReporter([VISITOR_SERIALIZER], get_data=get_all_data)
+
+
+def main():
+    import logging
+    import pathlib
+
+    logger = logging.getLogger()
+    logging.basicConfig(level=logging.INFO)
+
+    path = pathlib.Path.cwd() / "comparison.csv"
+    with open(path, "w") as file:
+        length = reporter.write_csv(file, log_progress=logger.info)
+    logger.info(f"Wrote {length} items to {path}")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/stripe_checkout/stripe_checkout/generate_report.py b/stripe_checkout/stripe_checkout/generate_report.py
index 15aae80b4be27c52ff1f28bd7729a83ee7782354..c58403f9ae7e14ca35e8563a7be6460289deefcf 100644
--- a/stripe_checkout/stripe_checkout/generate_report.py
+++ b/stripe_checkout/stripe_checkout/generate_report.py
@@ -24,7 +24,7 @@ import datetime
 import io
 import itertools
 import re
-from typing import Any, Callable, Iterable, Optional, Type
+from typing import Any, Callable, Iterable, Optional, Sequence, Type
 
 import stripe
 
@@ -35,15 +35,13 @@ from stripe_checkout.stripe_checkout.utils import list_all_stripe_items
 class Serializer:
     wrapper_cls: Type[StripeWrapper]
     fields: Iterable[ReportField]
-    id_prefix: str
 
     def serialize(self, wrapper: StripeWrapper):
         return {field.display_name: field.get_value(wrapper) for field in self.fields}
 
     def iter_items(self, all_objects: dict[str, Any]):
-        for obj_id, obj in all_objects.items():
-            if obj_id.startswith(self.id_prefix):
-                yield self.serialize(self.wrapper_cls(obj, all_objects=all_objects))
+        for wrapped in self.wrapper_cls.iter_items(all_objects):
+            yield self.serialize(wrapped)
 
 
 class StripeWrapper:
@@ -54,10 +52,18 @@ class StripeWrapper:
     the foreign_key method
     """
 
+    id_prefix: str = ""
+
     def __init__(self, inner, all_objects):
         self.inner = inner
         self.all_objects = all_objects
 
+    @classmethod
+    def iter_items(cls, all_objects: dict[str, dict]):
+        for obj_id, obj in all_objects.items():
+            if obj_id.startswith(cls.id_prefix):
+                yield cls(obj, all_objects=all_objects)
+
     def __getattr__(self, attr):
         try:
             super().__getattr__(attr)
@@ -67,6 +73,9 @@ class StripeWrapper:
     def foreign_key(
         self, field: str, cls: Optional[Type[StripeWrapper]] = None, required=True
     ):
+        """Lookup an attribute value as a key in the all_objects dictionary and return
+        that object as a StripeWrapper if found
+        """
         if (fk := self.inner[field]) is None:
             return None
         obj = self.all_objects.get(fk)
@@ -80,6 +89,8 @@ class StripeWrapper:
 
 
 class InvoiceWrapper(StripeWrapper):
+    id_prefix: str = "in_"
+
     @property
     def kind(self):
         return "invoice"
@@ -98,6 +109,8 @@ class InvoiceWrapper(StripeWrapper):
 
 
 class CreditNoteWrapper(StripeWrapper):
+    id_prefix: str = "cn_"
+
     @property
     def kind(self):
         return "credit_note"
@@ -127,11 +140,23 @@ class PaymentIntentWrapper(StripeWrapper):
 
 @dataclasses.dataclass
 class ReportField:
+    """Use this to declaratively define a column that should be present in the csv
+    report.
+
+    :param display_name: the column name to be shown in the csv
+    :param path: a dotted path to a nested attribute in a ``StripeWrapper``. Both
+        attribute access and key/item lookup will be attempted to traverse the path
+        through ``StripeWrapper``\s. If a path cannot be resolved, ``None`` will be
+        returned.
+    :param transform: an optional callable to transform the retrieved attribute value.If
+        given,transform will only be called if the value is not ``None``
+    """
+
     display_name: str
     path: str
     transform: Optional[Callable] = None
 
-    def get_value(self, reader: InvoiceWrapper):
+    def get_value(self, reader: StripeWrapper):
         value = getattr_or_none(reader, self.path.split("."))
         if value is not None and self.transform:
             value = self.transform(value)
@@ -178,9 +203,50 @@ def extract_gbp_vat(val):
     return ""
 
 
+class CSVReporter:
+    def __init__(self, serializers: Sequence[Serializer], get_data: Callable):
+        self.serializers = serializers
+        self.get_data = get_data
+
+    def write_csv(
+        self, buffer: io.StringIO, log_progress: Optional[Callable] = None, **kwargs
+    ):
+        data = self.get_data(log_progress)
+
+        writer = csv.DictWriter(
+            buffer, fieldnames=list(self.iter_field_names()), **kwargs
+        )
+        writer.writeheader()
+        length = 0
+        for item in self.iter_report_items(data):
+            length += 1
+            writer.writerow(item)
+        return length
+
+    def iter_fieldnames(self):
+        seen = set()
+        for field in itertools.chain.from_iterable(s.fields for s in self.serializers):
+            if field.display_name in seen:
+                continue
+            seen.add(field.display_name)
+            yield field.display_name
+
+    def iter_field_names(self):
+        seen = set()
+        for field in itertools.chain.from_iterable(s.fields for s in self.serializers):
+            if field.display_name in seen:
+                continue
+            seen.add(field.display_name)
+            yield field.display_name
+
+    def iter_report_items(self, data):
+        yield from itertools.chain.from_iterable(
+            s.iter_items(data) for s in self.serializers
+        )
+
+
 INVOICE_SERIALIZER = Serializer(
     wrapper_cls=InvoiceWrapper,
-    id_prefix="in_",
     fields=[
         ReportField("Kind", "kind"),
         ReportField("Stripe Reference Number", "id"),
@@ -217,7 +283,6 @@ INVOICE_SERIALIZER = Serializer(
 
 CREDIT_NOTE_SERIALIZER = Serializer(
     wrapper_cls=CreditNoteWrapper,
-    id_prefix="cn_",
     fields=[
         ReportField("Kind", "kind"),
         ReportField("Stripe Reference Number", "id"),
@@ -247,32 +312,6 @@ CREDIT_NOTE_SERIALIZER = Serializer(
 )
 
 
-def all_fieldnames():
-    seen = set()
-    for field in itertools.chain(
-        INVOICE_SERIALIZER.fields, CREDIT_NOTE_SERIALIZER.fields
-    ):
-        if field.display_name in seen:
-            continue
-        seen.add(field.display_name)
-        yield field.display_name
-
-
-def write_report(buffer: io.StringIO, all_objects: dict[str, stripe.StripeObject]):
-    writer = csv.DictWriter(buffer, fieldnames=list(all_fieldnames()))
-    writer.writeheader()
-    length = 0
-    for item in iter_report_items(all_objects):
-        length += 1
-        writer.writerow(item)
-    return length
-
-
-def iter_report_items(all_objects: Iterable[stripe.StripeObject]):
-    yield from INVOICE_SERIALIZER.iter_items(all_objects)
-    yield from CREDIT_NOTE_SERIALIZER.iter_items(all_objects)
-
-
 def get_all_objects(log_progress: Callable):
     def _iter_all_items():
         yield from list_all_stripe_items(stripe.Customer, log_progress)
@@ -283,6 +322,11 @@ def get_all_objects(log_progress: Callable):
     return {o["id"]: o for o in _iter_all_items()}
 
 
+reporter = CSVReporter(
+    [INVOICE_SERIALIZER, CREDIT_NOTE_SERIALIZER], get_data=get_all_objects
+)
+
+
 def main():
     import logging
     import os
@@ -294,9 +338,7 @@ def main():
 
     path = pathlib.Path.cwd() / "report.csv"
     with open(path, "w") as file:
-        length = write_report(
-            file, all_objects=get_all_objects(log_progress=logger.info)
-        )
+        length = reporter.write_csv(file, log_progress=logger.info)
     logger.info(f"Wrote {length} items to {path}")
 
 
diff --git a/stripe_checkout/stripe_checkout/management/commands/generatereport.py b/stripe_checkout/stripe_checkout/management/commands/generatereport.py
index 52b665b4721004b9f990fb0a7076b3f49719b7b1..1e4c1af408501300e47326a28dc0d281ea8fd770 100644
--- a/stripe_checkout/stripe_checkout/management/commands/generatereport.py
+++ b/stripe_checkout/stripe_checkout/management/commands/generatereport.py
@@ -16,10 +16,7 @@ import stripe
 from django.conf import settings
 from django.core.management import BaseCommand
 
-from stripe_checkout.stripe_checkout.generate_report import (
-    get_all_objects,
-    write_report,
-)
+from stripe_checkout.stripe_checkout.generate_report import reporter
 from stripe_checkout.stripe_checkout.models import Report
 
 
@@ -28,8 +25,7 @@ class Command(BaseCommand):
         stripe.api_key = settings.STRIPE_API_KEY
 
         buffer = io.StringIO()
-        all_objects = get_all_objects(log_progress=self.stdout.write)
-        length = write_report(buffer, all_objects=all_objects)
+        length = reporter.write_csv(buffer, log_progress=self.stdout.write)
 
         report = self.get_report_object()
         report.data = buffer.getvalue()
diff --git a/stripe_checkout/stripe_checkout/management/commands/processevents.py b/stripe_checkout/stripe_checkout/management/commands/processevents.py
index fa1c0eaa53f21731d7017a4c281b3154448ab2f4..25c09568a7587a8e4f57df23ed7cb1d36a72a0a5 100644
--- a/stripe_checkout/stripe_checkout/management/commands/processevents.py
+++ b/stripe_checkout/stripe_checkout/management/commands/processevents.py
@@ -101,7 +101,7 @@ class Command(BaseCommand):
 
     def _notify_unprocessed_payment(self, payment_intent: dict):
         self.stdout.write(f"Unrecognized payment {payment_intent['id']}")
-        send_mail_to = settings.UNPROCESSED_PAYMENT_EMAIL_TO
+        send_mail_to = settings.SEND_ERROR_EMAILS_TO
         if not send_mail_to:
             return False
         if not isinstance(send_mail_to, list):
diff --git a/stripe_checkout/stripe_checkout/management/commands/sendpossibleerrors.py b/stripe_checkout/stripe_checkout/management/commands/sendpossibleerrors.py
new file mode 100644
index 0000000000000000000000000000000000000000..02389d164999e769e97d2edd843c0be8e82bce66
--- /dev/null
+++ b/stripe_checkout/stripe_checkout/management/commands/sendpossibleerrors.py
@@ -0,0 +1,58 @@
+"""
+Generate a CSV report with all Visitors that have zero or more than one invoices in
+Stripe, but should have (at least) one, and send this report to the
+``SEND_ERROR_EMAILS_TO`` setting recipients
+
+Usage:
+
+* Set the ``SEND_ERROR_EMAILS_TO`` setting
+* Invoke this command using `django-admin sendpossibleerrors` (or other manage.py
+    variant)
+* Check your email
+"""
+
+from __future__ import annotations
+
+import io
+
+import stripe
+from django.conf import settings
+from django.core.management import BaseCommand
+from django.core.mail import EmailMessage
+
+from stripe_checkout.stripe_checkout.compare_visit_stripe import reporter
+
+EMAIL_TEMPLATE = """\
+Hi!
+
+This is your daily report with possible errors in the connection between
+Visit and Stripe
+
+Please see the attached CSV file
+"""
+
+
+class Command(BaseCommand):
+    def handle(self, *args, **options):
+        stripe.api_key = settings.STRIPE_API_KEY
+
+        buffer = io.StringIO()
+        reporter.write_csv(buffer, log_progress=self.stdout.write, delimiter=";")
+        data = buffer.getvalue()
+        self.send_report(data)
+
+    def send_report(self, content):
+        send_mail_to = settings.SEND_ERROR_EMAILS_TO
+        if not send_mail_to:
+            self.stdout.write(
+                self.style.WARNING("SEND_ERROR_EMAILS_TO not set, not sending report")
+            )
+            return
+        email = EmailMessage(
+            subject="Daily possible Stripe/Visit error report",
+            body=EMAIL_TEMPLATE,
+            from_email=None,
+            to=send_mail_to,
+        )
+        email.attach("possible_errors.csv", content=content, mimetype="text/csv")
+        email.send()
diff --git a/stripe_checkout/stripe_checkout/static/main.css b/stripe_checkout/stripe_checkout/static/main.css
index 5a75fcde4ffa889b6e9182cac3adb2239d653ea5..35a00eadf0a770c5800da21d954ab8fc78653f38 100644
--- a/stripe_checkout/stripe_checkout/static/main.css
+++ b/stripe_checkout/stripe_checkout/static/main.css
@@ -94,7 +94,7 @@ body {
 }
 
 .card-header {
-  margin: 0 0 1rem 0;
+  margin: 0 0 0rem 0;
   text-align: center;
   width: 100%;
 }
@@ -105,7 +105,9 @@ body {
   flex-direction: column;
   gap: 2rem;
 }
-
+.leader {
+  margin-bottom: 1rem;
+}
 .button {
   border: none;
   background-color: var(--primary);
@@ -198,7 +200,6 @@ body {
 
   .card-header {
     text-align: left;
-    margin-bottom: 2.5rem;
   }
 
   .card-content {
diff --git a/stripe_checkout/stripe_checkout/templates/checkout.html b/stripe_checkout/stripe_checkout/templates/checkout.html
index ed2305569b13062e0f5b9392074d5628be5fa0df..c6e56b79eee5c014dc25fe781186ce37f98cf8ee 100644
--- a/stripe_checkout/stripe_checkout/templates/checkout.html
+++ b/stripe_checkout/stripe_checkout/templates/checkout.html
@@ -5,9 +5,13 @@
 {% endblock title %}
 {% block content %}
     <div class="checkout flex center">
-        <div class="card flex center column">
+        <div class="card flex center column gap">
             {% if shopping_cart|length > 0 %}
                 <h1 class="card-header primary">Please confirm your payment</h1>
+                <div class="leader">Please finalize your registration by clicking CONFIRM below and choosing your
+                payment method. Only after you have confirmed, and we have received your payment, is your
+                registration for TNC2025 complete.
+                </div>
                 <div class="card-content center gap">
                     <div>
                         <div class="checkout-summary">
diff --git a/stripe_checkout/stripe_checkout/visit.py b/stripe_checkout/stripe_checkout/visit.py
index 1832d2144755a20753c3c4246d39919b2348f604..fb0df5005d8c5b99c1fc48f981b2509b1486525e 100644
--- a/stripe_checkout/stripe_checkout/visit.py
+++ b/stripe_checkout/stripe_checkout/visit.py
@@ -1,5 +1,5 @@
 from __future__ import annotations
-from typing import Optional, Union
+from typing import Callable, Optional, Union
 from django.http import Http404
 import requests
 from requests.auth import HTTPBasicAuth
@@ -24,18 +24,45 @@ class VisitorAPI:
         response.raise_for_status()
         return response.json()
 
-    def list_visitors(self):
-        all_visitors = self._request(
+    def _request_visitors(self, from_revision=None):
+        params = {"showDeleted": "false"}
+        if from_revision is not None:
+            params["fromRevision"] = from_revision
+        return self._request(
             "get",
             f"{BASE_URL}/visitors/{self.expo_id}",
-            params={"showDeleted": "false"},
+            params=params,
         )
+
+    def list_visitors(self):
+        all_visitors = self._request_visitors()
         return [
             Visitor.from_api(data)
             for data in filter(lambda v: not v["deleted"], all_visitors)
         ]
 
-    def get_visitor(self, visitor_id: str):
+    def list_all_visitors(self, log_progress: Callable[[str]]):
+        last_revision = 0
+        next_revision = None
+        visitors_by_id = {}
+        while True:
+            response = self._request_visitors(from_revision=next_revision)
+            next_revision = response[-1]["revision"]
+            for visitor in response:
+                visitors_by_id[visitor["id"]] = visitor
+
+            if log_progress is not None:
+                log_progress(f"Retrieved {len(visitors_by_id)} visitors")
+
+            if next_revision == last_revision:
+                break
+            last_revision = next_revision
+        return [
+            Visitor.from_api(data)
+            for data in filter(lambda v: not v["deleted"], visitors_by_id.values())
+        ]
+
+    def get_visitor(self, visitor_id: str, allow_deleted=False):
         result = self._request(
             "get", f"{BASE_URL}/visitors/{self.expo_id}/{visitor_id}"
         )
@@ -45,7 +72,8 @@ class VisitorAPI:
         # properly raise
         if not isinstance(result, dict):
             raise Http404()
-
+        if result["deleted"] and not allow_deleted:
+            raise Http404()
         return Visitor.from_api(result)
 
     def update_visitor(
@@ -82,8 +110,8 @@ class Visitor(dict):
         return f"{self.first_name} {self.last_name}".strip()
 
     @property
-    def email(self):
-        return self["contact"].get("email")
+    def email(self) -> str:
+        return self["contact"].get("email", "")
 
     @property
     def billing_address(self):
diff --git a/test/conftest.py b/test/conftest.py
index cc449131fa641cc4e248b12c7253575706c9c4aa..ad96e81673397a715dd7b56fc9b298c88f21b0b8 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -246,7 +246,7 @@ def config_file(stripe_api_key, visit_api_key, visit_expo_id, tmp_path):
                 "STRIPE_SIGNING_SECRET": "stripe-signing-secret",
                 "STRIPE_INVOICE_TEMPLATE_ID": "stripe-invoice-template-id",
                 "STRIPE_TAX_RATE_ID": "stripe-tax-rate-id",
-                "UNPROCESSED_PAYMENT_EMAIL_TO": ["test@geant.org"],
+                "SEND_ERROR_EMAILS_TO": ["test@geant.org"],
             }
         )
     )
diff --git a/test/test_visit_client.py b/test/test_visit_client.py
index 1e00118e0e9aacb66fa8ab460841b348b8d3ac0b..88a0e9a91f623672b648542b4c5302424b8b6ba0 100644
--- a/test/test_visit_client.py
+++ b/test/test_visit_client.py
@@ -29,3 +29,21 @@ def test_special_fake_visitor_id_that_return_list(api):
     )
     with pytest.raises(Http404):
         api.get_visitor("fakevisitor")
+
+
+@responses.activate
+def test_raises_on_deleted_visitor(api):
+    responses.reset()
+    responses.add(
+        responses.GET,
+        re.compile(r"https://api.visitcloud.com/create/v2/visitors/[^/]+/[^/]+$"),
+        json={
+            "deleted": True,
+            "expo": {"id": "18lm2fafttito", "name": "TNC25", "reference": ""},
+            "id": "0keoqgpagjw9e",
+            "reference": "",
+            "revision": 11539734023,
+        },
+    )
+    with pytest.raises(Http404):
+        api.get_visitor("0keoqgpagjw9e")