diff --git a/aleksis/apps/paweljong/forms.py b/aleksis/apps/paweljong/forms.py
index c864f82da15981bb728a2a7ee16344ba70a950c5..c42d5b8f3b833ac300038e5285d9af75b9b4c4ee 100644
--- a/aleksis/apps/paweljong/forms.py
+++ b/aleksis/apps/paweljong/forms.py
@@ -501,3 +501,48 @@ class PersonGroupFormPerson(forms.Form):
         widget=forms.TextInput(attrs={"autofocus": "", "autocomplete": "off"}),
         help_text=_("Please enter a username."),
     )
+
+
+class EventCheckpointForm(forms.Form):
+    class Media:
+        js = ("https://unpkg.com/html5-qrcode", "js/paweljong/qrscanner.js", "js/paweljong/checkpoint.js")
+
+    layout = Layout(
+        "comment", "use_latlon",
+    )
+
+    comment = forms.CharField(
+        required=True,
+        label=_("Comment"),
+        help_text=_("Please enter a comment describing the checkpoint (e.g. Dinner)."),
+    )
+
+    username = forms.CharField(
+        required=True,
+        label=_("Person"),
+        help_text=_("Please enter a username."),
+        widget=forms.HiddenInput(),
+    )
+
+    use_latlon = forms.BooleanField(
+        required=False,
+        label=_("Submit geolocation"),
+        initial=True,
+    )
+
+    lat = forms.DecimalField(
+        required=False,
+        min_value=-90.0,
+        max_value=90.0,
+        max_digits=10,
+        decimal_places=8,
+        widget=forms.HiddenInput(),
+    )
+    lon = forms.DecimalField(
+        required=False,
+        min_value=-180.0,
+        max_value=180.0,
+        max_digits=11,
+        decimal_places=8,
+        widget=forms.HiddenInput(),
+    )
diff --git a/aleksis/apps/paweljong/migrations/0021_checkpoint.py b/aleksis/apps/paweljong/migrations/0021_checkpoint.py
new file mode 100644
index 0000000000000000000000000000000000000000..aa2321d37c0d956b64c440fc58968effed2c48a2
--- /dev/null
+++ b/aleksis/apps/paweljong/migrations/0021_checkpoint.py
@@ -0,0 +1,43 @@
+# Generated by Django 3.2.13 on 2022-06-24 18:31
+
+import django.contrib.sites.managers
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('sites', '0002_alter_domain_unique'),
+        ('core', '0041_update_gender_choices'),
+        ('paweljong', '0020_check_in'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='registrationstate',
+            name='name',
+            field=models.CharField(max_length=255, verbose_name='Name'),
+        ),
+        migrations.CreateModel(
+            name='Checkpoint',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('extended_data', models.JSONField(default=dict, editable=False)),
+                ('comment', models.CharField(max_length=60, verbose_name='Comment')),
+                ('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='Date and time of check')),
+                ('lat', models.DecimalField(blank=True, decimal_places=8, max_digits=10, null=True, verbose_name='Latitude of check')),
+                ('lon', models.DecimalField(blank=True, decimal_places=8, max_digits=11, null=True, verbose_name='Longitude of check')),
+                ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checkpoints', to='paweljong.event', verbose_name='Related event')),
+                ('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_checkpoints', to='core.person', verbose_name='Checked person')),
+                ('checked_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_checkpoints_created', to='core.person', verbose_name='Checked by person')),
+                ('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site')),
+            ],
+            options={
+                'abstract': False,
+            },
+            managers=[
+                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+            ],
+        ),
+    ]
diff --git a/aleksis/apps/paweljong/models.py b/aleksis/apps/paweljong/models.py
index 0e4cd37e18abfacaa9c8014070cb2c9a720c906b..576dbaa05710d500c3070f91a46d522493322aef 100644
--- a/aleksis/apps/paweljong/models.py
+++ b/aleksis/apps/paweljong/models.py
@@ -406,3 +406,16 @@ class EventRegistration(ExtensibleModel):
                 fields=["person", "event"], name="unique_person_registration_per_event"
             )
         ]
+
+
+class Checkpoint(ExtensibleModel):
+    event = models.ForeignKey(Event, verbose_name=_("Related event"), related_name="checkpoints", on_delete=models.CASCADE)
+    person = models.ForeignKey(Person, verbose_name=_("Checked person"), related_name="event_checkpoints", on_delete=models.CASCADE)
+    checked_by = models.ForeignKey(Person, verbose_name=_("Checked by person"), related_name="event_checkpoints_created", on_delete=models.CASCADE)
+
+
+    comment = models.CharField(max_length=60, verbose_name=_("Comment"))
+
+    timestamp = models.DateTimeField(verbose_name=_("Date and time of check"), auto_now_add=True)
+    lat = models.DecimalField(max_digits=10, decimal_places=8, verbose_name=_("Latitude of check"), blank=True, null=True)
+    lon = models.DecimalField(max_digits=11, decimal_places=8, verbose_name=_("Longitude of check"), blank=True, null=True)
diff --git a/aleksis/apps/paweljong/rules.py b/aleksis/apps/paweljong/rules.py
index 7e52d04e137f80870edae358a8ebb5ab663a9824..1389ae879299d6a36c4ae8b3fe56198359cf3f5c 100644
--- a/aleksis/apps/paweljong/rules.py
+++ b/aleksis/apps/paweljong/rules.py
@@ -73,6 +73,10 @@ change_event_predicate = has_person & (
 )
 rules.add_perm("paweljong.change_event_rule", change_event_predicate)
 
+# Checkpoint
+checkpoint_predicate = change_event_predicate
+rules.add_perm("paweljong.event_checkpoint_rule", checkpoint_predicate)
+
 # View event
 view_event_predicate = (
     is_event_published | (has_person & is_organiser) | has_object_perm("paweljong.view_event")
diff --git a/aleksis/apps/paweljong/static/js/paweljong/checkpoint.js b/aleksis/apps/paweljong/static/js/paweljong/checkpoint.js
new file mode 100644
index 0000000000000000000000000000000000000000..6ca40adbb2ae228eba2cd658acbb457850864521
--- /dev/null
+++ b/aleksis/apps/paweljong/static/js/paweljong/checkpoint.js
@@ -0,0 +1,18 @@
+function getCheckpointCoords() {
+    navigator.geolocation.getCurrentPosition(setCheckpointCoords);
+}
+
+function setCheckpointCoords(position) {
+    $("[name='lat']").val(position.coords.latitude);
+    $("[name='lon']").val(position.coords.longitude);
+
+    window.setTimeout(function() {
+        navigator.geolocation.getCurrentPosition(setCheckpointCoords);
+    }, 3000);
+}
+
+$(document).ready(function($) {
+    if (navigator.geolocation) {
+        getCheckpointCoords();
+    }
+});
diff --git a/aleksis/apps/paweljong/static/js/paweljong/qrscanner.js b/aleksis/apps/paweljong/static/js/paweljong/qrscanner.js
new file mode 100644
index 0000000000000000000000000000000000000000..85b5e4521e41a3c8592a7c00f91b3b5f5c4d00a8
--- /dev/null
+++ b/aleksis/apps/paweljong/static/js/paweljong/qrscanner.js
@@ -0,0 +1,20 @@
+const scannerDivId = "qr-reader";
+const scannerDiv = $("div#" + scannerDivId);
+const scanner = new Html5Qrcode(scannerDivId);
+
+function onScanSuccess(decodedText, decodedResult) {
+    let targetId = scannerDiv.data("target-input");
+    let target = $("[name='" + targetId + "']");
+
+    scanner.stop();
+
+    target.val(decodedText);
+    target.closest("form").submit();
+}
+
+$(document).ready(function($) {
+    let cameraConfig = { facingMode: "environment" };
+    let scannerConfig = { fps: 10, qrbox: {width: 250, height: 250} };
+
+    scanner.start(cameraConfig, scannerConfig, onScanSuccess);
+});
diff --git a/aleksis/apps/paweljong/templates/paweljong/event/checkpoint.html b/aleksis/apps/paweljong/templates/paweljong/event/checkpoint.html
new file mode 100644
index 0000000000000000000000000000000000000000..deef5ccf5adf4104a031397c249169490c7064fa
--- /dev/null
+++ b/aleksis/apps/paweljong/templates/paweljong/event/checkpoint.html
@@ -0,0 +1,23 @@
+{% extends "core/base.html" %}
+{% load material_form i18n any_js %}
+
+{% block page_title %}{% blocktrans %}Checkpoint{% endblocktrans %}{% endblock %}
+{% block browser_title %}{% blocktrans %}Checkpoint{% endblocktrans %}{% endblock %}
+
+{% block extra_head %}
+    {{ form.media.css }}
+{% endblock %}
+
+{% block content %}
+
+  <form method="post">
+    {% csrf_token %}
+    {% form form=form %}{% form %}
+    {% include "core/partials/save_button.html" %}
+  </form>
+
+  <div id="qr-reader" data-target-input="username"></div>
+
+  {{ form.media.js }}
+
+{% endblock %}
diff --git a/aleksis/apps/paweljong/templates/paweljong/event/detail.html b/aleksis/apps/paweljong/templates/paweljong/event/detail.html
index 93ee3dad2341011009c09a54a0c2ed0c715ec649..032661c4d1bbf8880ec040542861e47f746bf2e6 100644
--- a/aleksis/apps/paweljong/templates/paweljong/event/detail.html
+++ b/aleksis/apps/paweljong/templates/paweljong/event/detail.html
@@ -15,15 +15,22 @@
   <h4>{{ event }}</h4>
 
   {% has_perm 'paweljong.change_event_rule' user event as can_change_event_rule %}
+  {% has_perm 'paweljong.event_checkpoint_rulw' user event as can_checkpoint_rule %}
 
-  {% if can_change_event_rule %}
     <p>
+     {% if can_change_event_rule %}
       <a href="{% url 'edit_event_by_slug' event.slug %}" class="btn waves-effect waves-light">
         <i class="material-icons left iconify" data-icon="mdi:edit"></i>
         {% trans "Edit" %}
       </a>
+     {% endif %}
+     {% if can_checkpoint %}
+       <a href="{% url 'event_by_name_checkpoint' event.slug %}" class="btn waves-effect waves-light">
+         <i class="material-icons left iconify" data-icon="mdi:access-point-check"></i>
+         {% trans "Checkpoint" %}
+       </a>
+     {% endif %}
     </p>
-  {% endif %}
 
     <div class="card">
       <div class="card-content">
diff --git a/aleksis/apps/paweljong/urls.py b/aleksis/apps/paweljong/urls.py
index a47f695f70ac5c038d3c0e4e16b795a3e1627fad..a83430272092efded259e35760b0306e544c37d9 100644
--- a/aleksis/apps/paweljong/urls.py
+++ b/aleksis/apps/paweljong/urls.py
@@ -50,6 +50,7 @@ urlpatterns = [
     ),
     path("event/<slug:slug>", views.EventFullView.as_view(), name="event_by_name"),
     path("event/<slug:slug>/detail", views.EventDetailView.as_view(), name="event_detail_by_name"),
+    path("event/<slug:slug>/checkpoint", views.EventCheckpointView.as_view(), name="event_by_name_checkpoint"),
     path(
         "event/<slug:slug>/start",
         views.RegisterEventStart.as_view(),
diff --git a/aleksis/apps/paweljong/views.py b/aleksis/apps/paweljong/views.py
index 47d228db56ab15f035c5deceb98719748c4f795f..23a067b5079395d00bd53b1e3d4c2815d34aac1f 100644
--- a/aleksis/apps/paweljong/views.py
+++ b/aleksis/apps/paweljong/views.py
@@ -5,10 +5,11 @@ from django.contrib.auth import get_user_model
 from django.contrib.syndication.views import Feed
 from django.core.exceptions import ValidationError
 from django.http import HttpRequest, HttpResponse
-from django.shortcuts import redirect, render
+from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse, reverse_lazy
 from django.utils import timezone
 from django.utils.decorators import method_decorator
+from django.utils.http import urlencode
 from django.utils.text import slugify
 from django.utils.translation import ugettext as _
 from django.views.decorators.cache import never_cache
@@ -36,12 +37,13 @@ from .forms import (
     EditInfoMailingForm,
     EditTermForm,
     EditVoucherForm,
+    EventCheckpointForm,
     GenerateListForm,
     PersonGroupFormPerson,
     RegistrationNotificationForm,
     RegistrationStatesForm,
 )
-from .models import Event, EventRegistration, InfoMailing, RegistrationState, Terms, Voucher
+from .models import Checkpoint, Event, EventRegistration, InfoMailing, RegistrationState, Terms, Voucher
 from .tables import (
     AdditionalFieldsTable,
     ChildGroupsTable,
@@ -969,6 +971,43 @@ class PersonGroupView(PermissionRequiredMixin, FormView):
         return reverse("add_persons_to_group", kwargs={"pk": self.kwargs["pk"]})
 
 
+class EventCheckpointView(PermissionRequiredMixin, FormView):
+
+    template_name = "paweljong/event/checkpoint.html"
+    permission_required = "paweljong.can_checkpoint"
+    form_class = EventCheckpointForm
+
+    def get_initial(self):
+        initial = super().get_initial() or {}
+        if "comment" in self.request.GET:
+            initial["comment"] = self.request.GET.get("comment")
+        return initial
+
+    def form_valid(self, form):
+        checkpoint = Checkpoint()
+
+        checkpoint.event = get_object_or_404(Event, slug=self.kwargs["slug"])
+        try:
+            checkpoint.person = Person.objects.get(user__username=form.cleaned_data["username"])
+        except Person.DoesNotExist:
+            messages.error(self.request, _("The provided username is not linked to a person."))
+        checkpoint.checked_by = self.request.user.person
+
+        checkpoint.comment = form.cleaned_data["comment"]
+        checkpoint.timestamp = timezone.now()
+        if form.cleaned_data["use_latlon"]:
+            checkpoint.lat = form.cleaned_data["lat"]
+            checkpoint.lon = form.cleaned_data["lon"]
+
+        checkpoint.save()
+        messages.success(self.request, _("{} successfully checked for {}.").format(str(checkpoint.person), str(checkpoint.comment)))
+        self._comment = checkpoint.comment
+        return super().form_valid(self)
+
+    def get_success_url(self):
+        return reverse("event_by_name_checkpoint", kwargs={"slug": self.kwargs["slug"]}) + "?" + urlencode({"comment": self._comment})
+
+
 class ViewTerms(PermissionRequiredMixin, DetailView):
 
     context_object_name = "event"