diff --git a/aleksis/apps/kort/api.py b/aleksis/apps/kort/api.py
new file mode 100644
index 0000000000000000000000000000000000000000..08f403e7db51585966dc0689a0fcb404bf544696
--- /dev/null
+++ b/aleksis/apps/kort/api.py
@@ -0,0 +1,72 @@
+from django.contrib import admin
+from django.utils import timezone
+
+from oauth2_provider.contrib.rest_framework import TokenHasScope
+from rest_framework import generics, permissions, serializers
+from rest_framework.permissions import BasePermission
+
+from aleksis.apps.kort.models import CardPrinter
+
+admin.autodiscover()
+
+
+class CorrectPrinterPermission(BasePermission):
+    """Check whether the OAuth2 application belongs to the printer."""
+
+    def has_object_permission(self, request, view, obj) -> bool:
+        token = request.auth
+        if token.application == obj.oauth2_application:
+            return True
+        return False
+
+
+class CardPrinterSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = CardPrinter
+        fields = (
+            "id",
+            "name",
+            "description",
+            "location",
+            "status",
+            "status_label",
+            "status_color",
+            "status_icon",
+            "status_text",
+            "last_seen_at",
+        )
+
+
+class CardPrinterStatusSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = CardPrinter
+        fields = ("status", "status_text")
+
+
+class CardPrinterDetails(generics.RetrieveAPIView):
+    """Show details about the card printer."""
+
+    permission_classes = [permissions.IsAuthenticated, TokenHasScope, CorrectPrinterPermission]
+    required_scopes = ["card_printer"]
+    serializer_class = CardPrinterSerializer
+    queryset = CardPrinter.objects.all()
+
+    def get_object(self):
+        token = self.request.auth
+        return token.application.card_printers.all().first()
+
+
+class CardPrinterUpdateStatus(generics.UpdateAPIView):
+    """Update the status of the card printer."""
+
+    permission_classes = [permissions.IsAuthenticated, TokenHasScope, CorrectPrinterPermission]
+    required_scopes = ["card_printer"]
+    serializer_class = CardPrinterStatusSerializer
+    queryset = CardPrinter.objects.all()
+
+    def update(self, request, *args, **kwargs):
+        r = super().update(request, *args, **kwargs)
+        instance = self.get_object()
+        instance.last_seen_at = timezone.now()
+        instance.save()
+        return r
diff --git a/aleksis/apps/kort/apps.py b/aleksis/apps/kort/apps.py
index 797ede1bc9d1a9db4456910879452dccfc6bdf85..437a5bb8e682058629a89254713ccd3fba0a1468 100644
--- a/aleksis/apps/kort/apps.py
+++ b/aleksis/apps/kort/apps.py
@@ -1,6 +1,3 @@
-from django.apps import apps
-from django.db import models
-from django.db.models import functions
 from django.utils.translation import gettext as _
 
 from aleksis.core.util.apps import AppConfig
@@ -25,18 +22,4 @@ class DefaultConfig(AppConfig):
     @classmethod
     def get_all_scopes(cls) -> dict[str, str]:
         """Return all OAuth scopes and their descriptions for this app."""
-        Card = apps.get_model("kort", "Card")
-        label_prefix = _("Access and manage printer status and print jobs")
-        scopes = dict(
-            Card.objects.annotate(
-                scope=functions.Concat(
-                    models.Value(f"{Card.SCOPE_PREFIX}_"),
-                    models.F("pk"),
-                    output_field=models.CharField(),
-                ),
-                label=functions.Concat(models.Value(f"{label_prefix}: "), models.F("name")),
-            )
-            .values_list("scope", "label")
-            .distinct()
-        )
-        return scopes
+        return {"card_printer": _("Access and manage printer status and print jobs")}
diff --git a/aleksis/apps/kort/forms.py b/aleksis/apps/kort/forms.py
index 6036332b97911d095c50df5b7fec2078f5f5435f..fdf0c520613b565cc3a46e6499b96dff38008c8b 100644
--- a/aleksis/apps/kort/forms.py
+++ b/aleksis/apps/kort/forms.py
@@ -2,7 +2,7 @@ from django import forms
 
 from django_select2.forms import ModelSelect2Widget
 
-from aleksis.apps.kort.models import Card
+from aleksis.apps.kort.models import Card, CardPrinter
 
 
 class CardForm(forms.ModelForm):
@@ -19,3 +19,9 @@ class CardForm(forms.ModelForm):
                 attrs={"data-minimum-input-length": 0, "class": "browser-default"},
             ),
         }
+
+
+class CardPrinterForm(forms.ModelForm):
+    class Meta:
+        model = CardPrinter
+        fields = ["name", "location", "description"]
diff --git a/aleksis/apps/kort/menus.py b/aleksis/apps/kort/menus.py
index 812fb3894999177feb8f04c7b2644c635756e5e1..2116a83244bdcf51f4870a6b21c3c193d4cd8fb6 100644
--- a/aleksis/apps/kort/menus.py
+++ b/aleksis/apps/kort/menus.py
@@ -23,6 +23,17 @@ MENUS = {
                         )
                     ],
                 },
+                {
+                    "name": _("Card Printers"),
+                    "url": "card_printers",
+                    "svg_icon": "mdi:printer-outline",
+                    "validators": [
+                        (
+                            "aleksis.core.util.predicates.permission_validator",
+                            "core.view_cardprinters_rule",
+                        )
+                    ],
+                },
             ],
         }
     ]
diff --git a/aleksis/apps/kort/models.py b/aleksis/apps/kort/models.py
index 11e2e03d521202339c1c5f155b6a05d3b97ee60d..0dcbf56d9f3d94d220e86d78ca41e8a6e397bbbe 100644
--- a/aleksis/apps/kort/models.py
+++ b/aleksis/apps/kort/models.py
@@ -1,5 +1,6 @@
-from typing import Union
+from typing import Any, Union
 
+from django.conf import settings
 from django.core.exceptions import ValidationError
 from django.core.validators import FileExtensionValidator
 from django.db import models
@@ -19,6 +20,31 @@ class CardPrinterStatus(models.TextChoices):
     WITH_ERRORS = "with_errors", _("With errors")
     NOT_REGISTERED = "not_registered", _("Not registered")
 
+    @classmethod
+    def get_color(cls, value):
+        _colors = {
+            CardPrinterStatus.ONLINE.value: "green",
+            CardPrinterStatus.OFFLINE.value: "red",
+            CardPrinterStatus.WITH_ERRORS.value: "orange",
+            CardPrinterStatus.NOT_REGISTERED.value: "grey",
+        }
+        return _colors.get(value)
+
+    @classmethod
+    def get_icon(cls, value):
+        _icons = {
+            CardPrinterStatus.ONLINE.value: "mdi:printer-check",
+            CardPrinterStatus.OFFLINE.value: "mdi:printer-off",
+            CardPrinterStatus.WITH_ERRORS.value: "mdi:printer-alert",
+            CardPrinterStatus.NOT_REGISTERED.value: "mdi:printer-search",
+        }
+        return _icons.get(value)
+
+    @classmethod
+    def get_label(cls, value):
+        _labels = {x: y for x, y in cls.choices}
+        return _labels.get(value)
+
 
 class PrintStatus(models.TextChoices):
     REGISTERED = "registered", _("Registered")
@@ -47,6 +73,7 @@ class CardPrinter(ExtensibleModel):
         verbose_name=_("OAuth2 application"),
         blank=True,
         null=True,
+        related_name="card_printers",
     )
 
     def save(self, *args, **kwargs):
@@ -55,6 +82,7 @@ class CardPrinter(ExtensibleModel):
                 client_type=OAuthApplication.CLIENT_CONFIDENTIAL,
                 authorization_grant_type=OAuthApplication.GRANT_AUTHORIZATION_CODE,
                 name=f"Card printer: {self.name}",
+                redirect_uris="urn:ietf:wg:oauth:2.0:oob",
             )
             application.save()
             self.oauth2_application = application
@@ -64,6 +92,35 @@ class CardPrinter(ExtensibleModel):
     def __str__(self):
         return self.name
 
+    @property
+    def status_label(self) -> str:
+        """Return the verbose name of the status."""
+        return CardPrinterStatus.get_label(self.status)
+
+    @property
+    def status_color(self) -> str:
+        """Return a color for the status."""
+        return CardPrinterStatus.get_color(self.status)
+
+    @property
+    def status_icon(self) -> str:
+        """Return an iconify icon for the status."""
+        return CardPrinterStatus.get_icon(self.status)
+
+    def generate_config(self) -> dict[str, Any]:
+        """Generate the configuration for the printer client."""
+        config = {
+            "base_url": settings.BASE_URL,
+            "client_id": self.oauth2_application.client_id,
+            "client_secret": self.oauth2_application.client_secret,
+        }
+        return config
+
+    @property
+    def config_filename(self) -> str:
+        """Return the filename for the printer client configuration."""
+        return f"card-printer-config-{self.pk}.json"
+
     class Meta:
         verbose_name = _("Card printer")
         verbose_name_plural = _("Card printers")
diff --git a/aleksis/apps/kort/tables.py b/aleksis/apps/kort/tables.py
index 5b53edde95c511f73605f25b3adc094ee3b674e6..fabd05a3d38136e4ce634f5a0d8f3084d2a29025 100644
--- a/aleksis/apps/kort/tables.py
+++ b/aleksis/apps/kort/tables.py
@@ -1,7 +1,15 @@
 from django.template.loader import render_to_string
 from django.utils.translation import gettext as _
 
-from django_tables2 import A, BooleanColumn, Column, LinkColumn, RelatedLinkColumn, Table
+from django_tables2 import (
+    A,
+    BooleanColumn,
+    Column,
+    DateTimeColumn,
+    LinkColumn,
+    RelatedLinkColumn,
+    Table,
+)
 
 
 class CardTable(Table):
@@ -27,3 +35,28 @@ class CardTable(Table):
 
     def render_actions(self, value, record):
         return render_to_string("kort/card/actions.html", dict(pk=value, card=record))
+
+
+class CardPrinterTable(Table):
+    """Table to list card printers."""
+
+    class Meta:
+        attrs = {"class": "highlight"}
+
+    name = LinkColumn("card_printer", verbose_name=_("Printer name"), args=[A("pk")])
+    location = Column(verbose_name=_("Printer location"))
+
+    current_status = Column(verbose_name=_("Current status"), accessor=A("pk"))
+    last_seen_at = DateTimeColumn(verbose_name=_("Last seen at"))
+    actions = Column(verbose_name=_("Actions"), accessor=A("pk"))
+
+    def render_current_status(self, value, record):
+        return render_to_string(
+            "kort/printer/status.html",
+            dict(
+                printer=record,
+            ),
+        )
+
+    def render_actions(self, value, record):
+        return render_to_string("kort/printer/actions.html", dict(pk=value, printer=record))
diff --git a/aleksis/apps/kort/templates/kort/printer/actions.html b/aleksis/apps/kort/templates/kort/printer/actions.html
new file mode 100644
index 0000000000000000000000000000000000000000..74f1bccbaafe048280e28a20e29195c346b1a54f
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/printer/actions.html
@@ -0,0 +1,26 @@
+{% load i18n %}
+
+<div id="detail-modal-{{ printer.pk }}" class="modal">
+  <div class="modal-content">
+    <h4>{{ printer.name }}</h4>
+    {% include "kort/printer/detail_content.html" %}
+  </div>
+  <div class="modal-footer">
+    <a href="#!" class="modal-close waves-effect waves-green btn-flat">
+      <i class="material-icons left iconify" data-icon="mdi:close"></i>
+      {% trans "Close" %}
+    </a>
+  </div>
+</div>
+<a class="btn-flat waves-effect waves-green green-text modal-trigger" href="#detail-modal-{{ printer.pk }}">
+  <i class="material-icons left iconify" data-icon="mdi:play-box-outline"></i>
+  {% trans "Show" %}
+</a>
+<a class="btn-flat waves-effect waves-orange orange-text modal-trigger" href="{% url "edit_card_printer" printer.pk %}">
+  <i class="material-icons left iconify" data-icon="mdi:pencil-outline"></i>
+  {% trans "Edit" %}
+</a>
+<a class="btn-flat waves-effect waves-red red-text modal-trigger" href="{% url "delete_card_printer" printer.pk %}">
+  <i class="material-icons left iconify" data-icon="mdi:delete-outline"></i>
+  {% trans "Delete" %}
+</a>
\ No newline at end of file
diff --git a/aleksis/apps/kort/templates/kort/printer/create.html b/aleksis/apps/kort/templates/kort/printer/create.html
new file mode 100644
index 0000000000000000000000000000000000000000..086400b8641fa9bf9c1d6118c8a161f266456254
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/printer/create.html
@@ -0,0 +1,24 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load material_form i18n any_js %}
+
+{% block extra_head %}
+  {{ form.media.css }}
+  {% include_css "select2-materialize" %}
+{% endblock %}
+
+{% block browser_title %}{% blocktrans %}Create card{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Create card{% endblocktrans %}{% endblock %}
+
+
+{% block content %}
+  <form method="post" enctype="multipart/form-data">
+    {% csrf_token %}
+    {% form form=form %}{% endform %}
+    {% include "core/partials/save_button.html" %}
+  </form>
+  {% include_js "select2-materialize" %}
+  {{ form.media.js }}
+{% endblock %}
diff --git a/aleksis/apps/kort/templates/kort/printer/delete.html b/aleksis/apps/kort/templates/kort/printer/delete.html
new file mode 100644
index 0000000000000000000000000000000000000000..9fc32ae6baaede5d13e20316484705ffec3f800f
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/printer/delete.html
@@ -0,0 +1,32 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load i18n rules material_form %}
+{% load render_table from django_tables2 %}
+
+{% block browser_title %}{% blocktrans %}Delete Card Printer{% endblocktrans %}{% endblock %}
+{% block no_page_title %}{% endblock %}
+
+{% block content %}
+  <p class="flow-text">{% trans "Do you really want to delete the following card printer?" %}</p>
+  {% include "kort/printer/short.html" with printer=object %}
+  <figure class="alert warning">
+    <i class="material-icons left iconify" data-icon="mdi:alert-octagon-outline"></i>
+    {% blocktrans %}
+      Please pay attention that this also will deactivate the access for the print client and you would have to
+      reconfigure the client if you want to use it further.
+    {% endblocktrans %}
+  </figure>
+  <form method="post" action="">
+    {% csrf_token %}
+    <a href="{% url "card_printers" %}" class="modal-close waves-effect waves-green btn">
+      <i class="material-icons left iconify" data-icon="mdi:arrow-left"></i>
+      {% trans "Go back" %}
+    </a>
+    <button type="submit" name="delete" class="modal-close waves-effect waves-light red btn">
+      <i class="material-icons left iconify" data-icon="mdi:delete-outline"></i>
+      {% trans "Delete" %}
+    </button>
+  </form>
+{% endblock %}
\ No newline at end of file
diff --git a/aleksis/apps/kort/templates/kort/printer/detail.html b/aleksis/apps/kort/templates/kort/printer/detail.html
new file mode 100644
index 0000000000000000000000000000000000000000..ff50526b0146274e045d1a57cd283ce1367feae7
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/printer/detail.html
@@ -0,0 +1,18 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load material_form i18n any_js %}
+
+
+{% block browser_title %}{% blocktrans %}Card Printer{% endblocktrans %}{% endblock %}
+{% block page_title %}{{ object.name }}{% endblock %}
+
+
+{% block content %}
+  <a class="btn waves-effect waves-light secondary margin-bottom" href="{% url "card_printers" %}">
+    <i class="material-icons left iconify" data-icon="mdi:arrow-left"></i>
+    {% trans "Back to all printers" %}
+  </a>
+  {% include "kort/printer/detail_content.html" with printer=object %}
+{% endblock %}
diff --git a/aleksis/apps/kort/templates/kort/printer/detail_content.html b/aleksis/apps/kort/templates/kort/printer/detail_content.html
new file mode 100644
index 0000000000000000000000000000000000000000..84e8ab655c4380073dcf21c44c74dc4c39cf326a
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/printer/detail_content.html
@@ -0,0 +1,79 @@
+{% load i18n %}
+<div class="row no-margin">
+  <div class="col s12 m12 l6 no-padding">
+    <div class="card">
+      <div class="card-content">
+        <div class="card-title">{% trans "Printer details" %}</div>
+        <table>
+          <tr>
+            <th>
+              <i class="material-icons left iconify" data-icon="mdi:printer-outline"></i>
+              {% trans "Name" %}</th>
+            <td>{{ printer.name }}</td>
+          </tr>
+          <tr>
+            <th>
+              <i class="material-icons left iconify" data-icon="mdi:map-marker-outline"></i>
+              {% trans "Location" %}
+            </th>
+            <td>{{ printer.location|default:"–" }}</td>
+          </tr>
+          <tr>
+            <th>
+              <i class="material-icons left iconify" data-icon="mdi:card-text-outline"></i>
+              {% trans "Description" %}
+            </th>
+            <td>{{ printer.description|default:"–" }}</td>
+          </tr>
+          <tr>
+            <th>
+              <i class="material-icons left iconify" data-icon="mdi:clock-check-outline"></i>
+              {% trans "Last seen at" %}
+            </th>
+            <td>{{ printer.last_seen_at|default:_("never seen yet") }}</td>
+          </tr>
+          <tr>
+            <th {% if printer.status_text %}rowspan="2"{% endif %}>
+              <i class="material-icons left iconify" data-icon="mdi:checkbox-blank-badge-outline"></i>
+              {% trans "Status" %}
+            </th>
+            <td>
+              {% include "kort/printer/status.html" %}
+            </td>
+          </tr>
+          {% if printer.status_text %}
+            <tr>
+              <td>
+                {{ printer.status_text }}
+              </td>
+            </tr>
+          {% endif %}
+        </table>
+      </div>
+    </div>
+  </div>
+  <div class="col s12 m12 l6">
+    <!-- Jobs here -->
+    {% if printer.status == "not_registered" %}
+      <div class="card">
+        <div class="card-content">
+          <div class="card-title">{% trans "Setup printer client" %}</div>
+          <p>
+            {% blocktrans %}
+              To enable printing, you have to register the print client on the device
+              which the printer is connected to.
+            {% endblocktrans %}
+          </p>
+          <h6>{% trans "1. Download print client" %}</h6>
+          <h6>{% trans "2. Download configuration file" %}</h6>
+          <a class="btn waves-effect waves-light" href="{% url "card_printer_config" printer.pk %}">
+            <i class="material-icons left iconify" data-icon="mdi:download-outline"></i>
+            {% trans "Download configuration" %}
+          </a>
+          <h6>{% trans "3. Setup client" %}</h6>
+          <code>kort-client setup {{ printer.config_filename }}</code>
+        </div>
+      </div>
+    {% endif %}
+  </div>
+</div>
\ No newline at end of file
diff --git a/aleksis/apps/kort/templates/kort/printer/edit.html b/aleksis/apps/kort/templates/kort/printer/edit.html
new file mode 100644
index 0000000000000000000000000000000000000000..450a2d022f44dcc3581c681f6279f87500f14185
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/printer/edit.html
@@ -0,0 +1,24 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load material_form i18n any_js %}
+
+{% block extra_head %}
+  {{ form.media.css }}
+  {% include_css "select2-materialize" %}
+{% endblock %}
+
+{% block browser_title %}{% blocktrans %}Edit card printer{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Edit card printer{% endblocktrans %}{% endblock %}
+
+
+{% block content %}
+  <form method="post" enctype="multipart/form-data">
+    {% csrf_token %}
+    {% form form=form %}{% endform %}
+    {% include "core/partials/save_button.html" %}
+  </form>
+  {% include_js "select2-materialize" %}
+  {{ form.media.js }}
+{% endblock %}
diff --git a/aleksis/apps/kort/templates/kort/printer/list.html b/aleksis/apps/kort/templates/kort/printer/list.html
new file mode 100644
index 0000000000000000000000000000000000000000..014df87d08e4f8fdadcc590bca0ebb6fe1b87d77
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/printer/list.html
@@ -0,0 +1,22 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load i18n rules material_form any_js %}
+{% load render_table from django_tables2 %}
+
+{% block browser_title %}{% blocktrans %}Card printers{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Card printers{% endblocktrans %}{% endblock %}
+
+{% block content %}
+  {% has_perm 'core.create_cardprinter_rule' user person as can_create_person %}
+
+  {% if can_create_person %}
+    <a class="btn green waves-effect waves-light" href="{% url 'create_card_printer' %}">
+      <i class="material-icons left iconify" data-icon="mdi:plus"></i>
+      {% trans "Register new card printer" %}
+    </a>
+  {% endif %}
+
+  {% render_table table %}
+{% endblock %}
diff --git a/aleksis/apps/kort/templates/kort/printer/short.html b/aleksis/apps/kort/templates/kort/printer/short.html
new file mode 100644
index 0000000000000000000000000000000000000000..706a5e14840f8f5cbaea409a0b76b9f77c50235d
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/printer/short.html
@@ -0,0 +1,15 @@
+{% load i18n %}
+<table>
+  <tr>
+    <th>{% trans "Name" %}</th>
+    <td>{{ printer.name }}</td>
+  </tr>
+    <tr>
+    <th>{% trans "Location" %}</th>
+    <td>{{ printer.location|default:"–" }}</td>
+  </tr>
+  <tr>
+    <th>{% trans "Status" %}</th>
+    <td>{% include "kort/printer/status.html" %}</td>
+  </tr>
+</table>
\ No newline at end of file
diff --git a/aleksis/apps/kort/templates/kort/printer/status.html b/aleksis/apps/kort/templates/kort/printer/status.html
new file mode 100644
index 0000000000000000000000000000000000000000..22172068b5f49ea24b47c360286a7764dd0e0a8d
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/printer/status.html
@@ -0,0 +1,5 @@
+{% load i18n %}
+<span class="chip white-text {{ printer.status_color }}">
+  <i class="material-icons left iconify" data-icon="{{ printer.status_icon }}"></i>
+  {{ printer.status_label }}
+</span>
\ No newline at end of file
diff --git a/aleksis/apps/kort/urls.py b/aleksis/apps/kort/urls.py
index 983140bc22fee78ee81eddfa486a3ca36faffb92..df8dd267e5dc9df819f8d0bd259f59f18d89d16e 100644
--- a/aleksis/apps/kort/urls.py
+++ b/aleksis/apps/kort/urls.py
@@ -1,6 +1,6 @@
 from django.urls import path
 
-from . import views
+from . import api, views
 
 urlpatterns = [
     path("test", views.TestPDFView.as_view(), name="test_pdf"),
@@ -14,4 +14,24 @@ urlpatterns = [
     ),
     path("cards/<int:pk>/deactivate/", views.CardDeactivateView.as_view(), name="deactivate_card"),
     path("cards/<int:pk>/delete/", views.CardDeleteView.as_view(), name="delete_card"),
+    path("printers/", views.CardPrinterListView.as_view(), name="card_printers"),
+    path("printers/create/", views.CardPrinterCreateView.as_view(), name="create_card_printer"),
+    path("printers/<int:pk>/", views.CardPrinterDetailView.as_view(), name="card_printer"),
+    path("printers/<int:pk>/edit/", views.CardPrinterEditView.as_view(), name="edit_card_printer"),
+    path(
+        "printers/<int:pk>/delete/",
+        views.CardPrinterDeleteView.as_view(),
+        name="delete_card_printer",
+    ),
+    path(
+        "printers/<int:pk>/config/",
+        views.CardPrinterConfigView.as_view(),
+        name="card_printer_config",
+    ),
+    path("api/v1/printers/", api.CardPrinterDetails.as_view(), name="api_card_printer"),
+    path(
+        "api/v1/printers/<int:pk>/status/",
+        api.CardPrinterUpdateStatus.as_view(),
+        name="api_card_printer_status",
+    ),
 ]
diff --git a/aleksis/apps/kort/views.py b/aleksis/apps/kort/views.py
index 1741e6e3bac558d9373d0b35eed4c600b409011c..445887200f4e027f5cee89b6758729416357ab5d 100644
--- a/aleksis/apps/kort/views.py
+++ b/aleksis/apps/kort/views.py
@@ -1,3 +1,5 @@
+import json
+
 from django.contrib import messages
 from django.http import HttpRequest, HttpResponse
 from django.shortcuts import redirect, render
@@ -10,10 +12,10 @@ from django_tables2 import SingleTableView
 from reversion.views import RevisionMixin
 from rules.contrib.views import PermissionRequiredMixin
 
-from aleksis.apps.kort.forms import CardForm
-from aleksis.apps.kort.models import Card
-from aleksis.apps.kort.tables import CardTable
-from aleksis.core.mixins import AdvancedCreateView, AdvancedDeleteView
+from aleksis.apps.kort.forms import CardForm, CardPrinterForm
+from aleksis.apps.kort.models import Card, CardPrinter
+from aleksis.apps.kort.tables import CardPrinterTable, CardTable
+from aleksis.core.mixins import AdvancedCreateView, AdvancedDeleteView, AdvancedEditView
 from aleksis.core.util.celery_progress import render_progress_page
 from aleksis.core.views import RenderPDFView
 
@@ -110,3 +112,67 @@ class CardGeneratePDFView(PermissionRequiredMixin, RevisionMixin, SingleObjectMi
             button_url=redirect_url,
             button_icon="credit_card",
         )
+
+
+class CardPrinterListView(PermissionRequiredMixin, RevisionMixin, SingleTableView):
+    """List view for all card printers."""
+
+    permission_required = "core.view_cardprinters_rule"
+    template_name = "kort/printer/list.html"
+    model = CardPrinter
+    table_class = CardPrinterTable
+
+
+class CardPrinterCreateView(PermissionRequiredMixin, RevisionMixin, AdvancedCreateView):
+    """View used to create a card printer."""
+
+    permission_required = "core.create_cardprinter_rule"
+    template_name = "kort/printer/create.html"
+    form_class = CardPrinterForm
+    model = CardPrinter
+    success_message = _("The card printer has been created successfully.")
+
+    def get_success_url(self):
+        return reverse("card_printer", args=[self.object.pk])
+
+
+class CardPrinterEditView(PermissionRequiredMixin, RevisionMixin, AdvancedEditView):
+    """View used to edit a card printer."""
+
+    permission_required = "core.edit_cardprinter_rule"
+    template_name = "kort/printer/edit.html"
+    form_class = CardPrinterForm
+    model = CardPrinter
+    success_message = _("The card printer has been changed successfully.")
+
+    def get_success_url(self):
+        return reverse("card_printer", args=[self.object.pk])
+
+
+class CardPrinterDeleteView(PermissionRequiredMixin, RevisionMixin, AdvancedDeleteView):
+    """View used to delete a card printer."""
+
+    permission_required = "core.delete_cardprinter_rule"
+    success_url = reverse_lazy("card_printers")
+    template_name = "kort/printer/delete.html"
+    model = CardPrinter
+    success_message = _("The card printer has been deleted successfully.")
+
+
+class CardPrinterDetailView(PermissionRequiredMixin, RevisionMixin, DetailView):
+    permission_required = "core.view_cardprinter_rule"
+    model = CardPrinter
+    template_name = "kort/printer/detail.html"
+
+
+class CardPrinterConfigView(PermissionRequiredMixin, RevisionMixin, SingleObjectMixin, View):
+    permission_required = "core.view_cardprinter_rule"
+    model = CardPrinter
+
+    def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+        self.object = self.get_object()
+        response = HttpResponse(
+            json.dumps(self.object.generate_config()), content_type="application/json"
+        )
+        response["Content-Disposition"] = f'attachment; filename="{self.object.config_filename}"'
+        return response