diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7c2fb1bef..81a615e3e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,8 +16,9 @@ A minimal file template [is available](https://framagit.org/framasoft/mobilizon/
 Also make sure to remove the `EnvironmentFile=` line from the systemd service and set `Environment=MIX_ENV=prod` instead. See [the updated file](https://framagit.org/framasoft/mobilizon/blob/master/support/systemd/mobilizon.service).
 
 ### Added
-- Possibility to participate anonymously to an event
+- Possibility to participate to an event without an account
 - Possibility to participate to a remote event (being redirected by providing federated identity)
+- Possibility to add a note as a participant when event participation is manually validated (required when participating without an account)
 - Possibility to change email address for the account
 - Possibility to delete your account
 
@@ -26,6 +27,7 @@ Also make sure to remove the `EnvironmentFile=` line from the systemd service an
 - Signature validation also now checks if `Date` header has acceptable values
 - Actor profiles are now stale after two days and have to be refetched
 - Actor keys are rotated some time after sending a `Delete` activity
+- Improved event participations managing interface
 
 ### Fixed
 - Fixed URL search
diff --git a/docs/contribute/activity_pub.md b/docs/contribute/activity_pub.md
index 8f8b901ca..da2756680 100644
--- a/docs/contribute/activity_pub.md
+++ b/docs/contribute/activity_pub.md
@@ -21,7 +21,8 @@ Supported Activity | Supported Object
 `Create` | `Note`, `Event`
 `Delete` | `Object`
 `Flag` | `Object`
-`Follow` | `Object`  
+`Follow` | `Object`
+`Join` | `Event`
 `Reject` | `Follow`, `Join`
 `Remove` | `Note`, `Event`
 `Undo` | `Announce`, `Follow`
@@ -155,4 +156,28 @@ We add [an `address` property](https://schema.org/address), which we assume to b
     },
   "type": "Event"
 }
+```
+
+### Join
+
+#### participationMessage
+
+We add a `participationMessage` property on a `Join` activity so that participants may transmit a note to event organizers, to motivate their participation when event participations are manually approved. This field is restricted to plain text.
+
+```json
+{
+  "type": "Join",
+  "object": "http://mobilizon.test/events/some-uuid",
+  "id": "http://mobilizon2.test/@admin/join/event/1",
+  "actor": "http://mobilizon2.test/@admin",
+  "participationMessage": "I want to join !",
+  "@context": [
+    {
+      "participationMessage": {
+        "@id": "mz:participationMessage",
+        "@type": "sc:Text"
+      }
+    }
+  ]
+}
 ```
\ No newline at end of file
diff --git a/js/src/components/Admin/Followers.vue b/js/src/components/Admin/Followers.vue
index bda7bbc66..7f44e9407 100644
--- a/js/src/components/Admin/Followers.vue
+++ b/js/src/components/Admin/Followers.vue
@@ -6,7 +6,6 @@
                 :loading="$apollo.queries.relayFollowers.loading"
                 ref="table"
                 :checked-rows.sync="checkedRows"
-                :is-row-checkable="(row) => row.id !== 3"
                 detailed
                 :show-detail-icon="false"
                 paginated
diff --git a/js/src/components/Event/ParticipationButton.vue b/js/src/components/Event/ParticipationButton.vue
index 9af503210..6e59e01c0 100644
--- a/js/src/components/Event/ParticipationButton.vue
+++ b/js/src/components/Event/ParticipationButton.vue
@@ -1,3 +1,4 @@
+import {EventJoinOptions} from "@/types/event.model";
 <docs>
 A button to set your participation
 
@@ -80,7 +81,7 @@ A button to set your participation
                         </figure>
                     </div>
                     <div class="media-content">
-                        <span>{{ $t('as {identity}', {identity: currentActor.preferredUsername }) }}</span>
+                        <span>{{ $t('as {identity}', {identity: currentActor.name || `@${currentActor.preferredUsername}` }) }}</span>
                     </div>
                 </div>
             </b-dropdown-item>
@@ -96,14 +97,13 @@ A button to set your participation
 
 <script lang="ts">
 import { Component, Prop, Vue } from 'vue-property-decorator';
-import { EventModel, IEvent, IParticipant, ParticipantRole } from '@/types/event.model';
+import { EventJoinOptions, IEvent, IParticipant, ParticipantRole } from '@/types/event.model';
 import { IPerson, Person } from '@/types/actor';
 import { CURRENT_ACTOR_CLIENT, IDENTITIES } from '@/graphql/actor';
 import { CURRENT_USER_CLIENT } from '@/graphql/user';
 import { CONFIG } from '@/graphql/config';
 import { IConfig } from '@/types/config.model';
 import { RouteName } from '@/router';
-import { FETCH_EVENT } from '@/graphql/event';
 
 @Component({
   apollo: {
@@ -132,7 +132,11 @@ export default class ParticipationButton extends Vue {
   RouteName = RouteName;
 
   joinEvent(actor: IPerson) {
-    this.$emit('joinEvent', actor);
+    if (this.event.joinOptions === EventJoinOptions.RESTRICTED) {
+      this.$emit('joinEventWithConfirmation', actor);
+    } else {
+      this.$emit('joinEvent', actor);
+    }
   }
 
   joinModal() {
diff --git a/js/src/components/Event/ParticipationTable.vue b/js/src/components/Event/ParticipationTable.vue
new file mode 100644
index 000000000..511b786d7
--- /dev/null
+++ b/js/src/components/Event/ParticipationTable.vue
@@ -0,0 +1,164 @@
+<template>
+    <b-table
+            :data="data"
+            ref="queueTable"
+            detailed
+            detail-key="id"
+            :checked-rows.sync="checkedRows"
+            checkable
+            :is-row-checkable="row => row.role !== ParticipantRole.CREATOR"
+            checkbox-position="left"
+            default-sort="insertedAt"
+            default-sort-direction="asc"
+            :show-detail-icon="false"
+            :loading="this.$apollo.loading"
+            paginated
+            backend-pagination
+            :aria-next-label="$t('Next page')"
+            :aria-previous-label="$t('Previous page')"
+            :aria-page-label="$t('Page')"
+            :aria-current-label="$t('Current page')"
+            :total="total"
+            :per-page="perPage"
+            backend-sorting
+            :default-sort-direction="'desc'"
+            :default-sort="['insertedAt', 'desc']"
+            @page-change="page => $emit('page-change', page)"
+            @sort="(field, order) => $emit('sort', field, order)"
+    >
+        <template slot-scope="props">
+            <b-table-column field="insertedAt" :label="$t('Date')" sortable>
+                <b-tag type="is-success" class="has-text-centered">{{ props.row.insertedAt | formatDateString }}<br>{{ props.row.insertedAt | formatTimeString }}</b-tag>
+            </b-table-column>
+            <b-table-column field="role" :label="$t('Role')" sortable v-if="showRole">
+                                  <span v-if="props.row.role === ParticipantRole.CREATOR">
+                                      {{ $t('Organizer') }}
+                                  </span>
+                <span v-else-if="props.row.role === ParticipantRole.PARTICIPANT">
+                                      {{ $t('Participant') }}
+                                  </span>
+            </b-table-column>
+            <b-table-column field="actor.preferredUsername" :label="$t('Participant')" sortable>
+                <article class="media">
+                    <figure class="media-left" v-if="props.row.actor.avatar">
+                        <p class="image is-48x48">
+                            <img :src="props.row.actor.avatar.url" alt="">
+                        </p>
+                    </figure>
+                    <b-icon class="media-left" v-else-if="props.row.actor.preferredUsername === 'anonymous'" size="is-large" icon="incognito" />
+                    <b-icon class="media-left" v-else size="is-large" icon="account-circle" />
+                    <div class="media-content">
+                        <div class="content">
+                                              <span v-if="props.row.actor.preferredUsername !== 'anonymous'">
+                                                  <span v-if="props.row.actor.name">{{ props.row.actor.name }}</span><br />
+                                                  <span class="is-size-7 has-text-grey">@{{ props.row.actor.preferredUsername }}</span>
+                                              </span>
+                            <span v-else>
+                                                  {{ $t('Anonymous participant') }}
+                                              </span>
+                        </div>
+                    </div>
+                </article>
+            </b-table-column>
+            <b-table-column field="metadata.message" :label="$t('Message')">
+                                  <span @click="toggleQueueDetails(props.row)" :class="{ 'ellipsed-message': props.row.metadata.message.length > MESSAGE_ELLIPSIS_LENGTH }" v-if="props.row.metadata && props.row.metadata.message">
+                                    {{ props.row.metadata.message | ellipsize }}
+                                  </span>
+                <span v-else class="has-text-grey">
+                                      {{ $t('No message') }}
+                                  </span>
+            </b-table-column>
+        </template>
+        <template slot="detail" slot-scope="props">
+            <article v-html="nl2br(props.row.metadata.message)" />
+        </template>
+        <template slot="bottom-left" v-if="checkedRows.length > 0">
+            <div class="buttons">
+                <b-button @click="acceptParticipants(checkedRows)" type="is-success" v-if="canAcceptParticipants">
+                    {{ $tc('No participant to approve|Approve participant|Approve {number} participants', checkedRows.length, { number: checkedRows.length }) }}
+                </b-button>
+                <b-button @click="refuseParticipants(checkedRows)" type="is-danger" v-if="canRefuseParticipants">
+                    {{ $tc('No participant to reject|Reject participant|Reject {number} participants', checkedRows.length, { number: checkedRows.length }) }}
+                </b-button>
+            </div>
+        </template>
+    </b-table>
+</template>
+<script lang="ts">
+import { Component, Prop, Vue } from 'vue-property-decorator';
+import { IParticipant, ParticipantRole } from '@/types/event.model';
+import { Refs } from '@/shims-vue';
+import { nl2br } from '@/utils/html';
+import { asyncForEach } from '@/utils/asyncForEach';
+
+const MESSAGE_ELLIPSIS_LENGTH = 130;
+
+@Component({
+  filters: {
+    ellipsize: (text?: string) => text && text.substr(0, MESSAGE_ELLIPSIS_LENGTH).concat('…'),
+  },
+})
+export default class ParticipationTable extends Vue {
+  @Prop({ required: true, type: Array }) data!: IParticipant[];
+  @Prop({ required: true, type: Number }) total!: number;
+  @Prop({ required: true, type: Function }) acceptParticipant;
+  @Prop({ required: true, type: Function }) refuseParticipant;
+  @Prop({ required: false, type: Boolean, default: false }) showRole;
+  @Prop({ required: false, type: Number, default: 20 }) perPage;
+  checkedRows: IParticipant[] = [];
+
+  MESSAGE_ELLIPSIS_LENGTH = MESSAGE_ELLIPSIS_LENGTH;
+  nl2br = nl2br;
+  ParticipantRole = ParticipantRole;
+
+  $refs!: Refs<{
+    queueTable: any,
+  }>;
+
+  toggleQueueDetails(row: IParticipant) {
+    if (row.metadata.message && row.metadata.message.length < MESSAGE_ELLIPSIS_LENGTH) return;
+    this.$refs.queueTable.toggleDetails(row);
+  }
+
+  async acceptParticipants(participants: IParticipant[]) {
+    await asyncForEach(participants, async (participant: IParticipant) => {
+      await this.acceptParticipant(participant);
+    });
+    this.checkedRows = [];
+  }
+
+  async refuseParticipants(participants: IParticipant[]) {
+    await asyncForEach(participants, async (participant: IParticipant) => {
+      await this.refuseParticipant(participant);
+    });
+    this.checkedRows = [];
+  }
+
+    /**
+     * We can accept participants if at least one of them is not approved
+     */
+  get canAcceptParticipants(): boolean {
+    return this.checkedRows.some(
+        (participant: IParticipant) => [ParticipantRole.NOT_APPROVED, ParticipantRole.REJECTED].includes(participant.role),
+    );
+  }
+
+    /**
+     * We can refuse participants if at least one of them is something different than not approved
+     */
+  get canRefuseParticipants(): boolean {
+    return this.checkedRows.some((participant: IParticipant) => participant.role !== ParticipantRole.REJECTED);
+  }
+}
+</script>
+<style lang="scss" scoped>
+    .ellipsed-message {
+        cursor: pointer;
+    }
+
+    .table {
+        span.tag {
+            height: initial;
+        }
+    }
+</style>
\ No newline at end of file
diff --git a/js/src/components/NavBar.vue b/js/src/components/NavBar.vue
index 922f9d2ca..9af4041ff 100644
--- a/js/src/components/NavBar.vue
+++ b/js/src/components/NavBar.vue
@@ -29,7 +29,7 @@
               <figure class="image is-32x32" v-if="identity.avatar">
                 <img class="is-rounded" :src="identity.avatar.url" alt="" />
               </figure>
-              <b-icon v-else icon="account-circle" />
+              <b-icon v-else size="is-medium" icon="account-circle" />
             </div>
 
             <div class="media-content">
@@ -180,6 +180,10 @@ nav {
       background: $secondary;
     }
 
+    span.icon.is-medium {
+      display: flex;
+    }
+
     img {
       max-height: 2.5em;
     }
diff --git a/js/src/components/Participation/ParticipationWithoutAccount.vue b/js/src/components/Participation/ParticipationWithoutAccount.vue
index 73d333b44..1c4c8602d 100644
--- a/js/src/components/Participation/ParticipationWithoutAccount.vue
+++ b/js/src/components/Participation/ParticipationWithoutAccount.vue
@@ -7,18 +7,24 @@
                     <b-message type="is-info">{{ $t("Your email will only be used to confirm that you're a real person and send you eventual updates for this event. It will NOT be transmitted to other instances or to the event organizer.") }}</b-message>
                     <b-message type="is-danger" v-if="error">{{ error }}</b-message>
                     <b-field :label="$t('Email')">
-                        <b-field>
-                            <b-input
-                                    type="email"
-                                    v-model="anonymousParticipation.email"
-                                    placeholder="Your email"
-                                    required>
-                            </b-input>
-                            <p class="control">
-                                <b-button type="is-primary" native-type="submit">{{ $t('Send email') }}</b-button>
-                            </p>
-                        </b-field>
+                        <b-input
+                                type="email"
+                                v-model="anonymousParticipation.email"
+                                placeholder="Your email"
+                                required>
+                        </b-input>
                     </b-field>
+                    <p v-if="event.joinOptions === EventJoinOptions.RESTRICTED">{{ $t("The event organizer manually approves participations. Since you've chosen to participate without an account, please explain why you want to participate to this event.") }}</p>
+                    <p v-else>{{ $t("If you want, you may send a message to the event organizer here.") }}</p>
+                    <b-field :label="$t('Message')">
+                        <b-input
+                                type="textarea"
+                                v-model="anonymousParticipation.message"
+                                minlength="10"
+                                :required="event.joinOptions === EventJoinOptions.RESTRICTED">
+                        </b-input>
+                    </b-field>
+                    <b-button type="is-primary" native-type="submit">{{ $t('Send email') }}</b-button>
                     <div class="has-text-centered">
                         <b-button native-type="button" tag="a" type="is-text" @click="$router.go(-1)">
                             {{ $t('Back to previous page') }}
@@ -31,7 +37,7 @@
 </template>
 <script lang="ts">
 import { Component, Prop, Vue } from 'vue-property-decorator';
-import { EventModel, IEvent, IParticipant, ParticipantRole } from '@/types/event.model';
+import { EventModel, IEvent, IParticipant, ParticipantRole, EventJoinOptions } from '@/types/event.model';
 import { FETCH_EVENT, JOIN_EVENT } from '@/graphql/event';
 import { IConfig } from '@/types/config.model';
 import { CONFIG } from '@/graphql/config';
@@ -55,10 +61,11 @@ import { RouteName } from '@/router';
 })
 export default class ParticipationWithoutAccount extends Vue {
   @Prop({ type: String, required: true }) uuid!: string;
-  anonymousParticipation: { email: String } = { email: '' };
+  anonymousParticipation: { email: String, message: String } = { email: '', message: '' };
   event!: IEvent;
   config!: IConfig;
   error: String|boolean = false;
+  EventJoinOptions = EventJoinOptions;
 
   async joinEvent() {
     this.error = false;
@@ -69,6 +76,7 @@ export default class ParticipationWithoutAccount extends Vue {
           eventId: this.event.id,
           actorId: this.config.anonymous.actorId,
           email: this.anonymousParticipation.email,
+          message: this.anonymousParticipation.message,
         },
         update: (store, { data }) => {
           if (data == null) return;
diff --git a/js/src/graphql/event.ts b/js/src/graphql/event.ts
index b229ad325..2b81e2213 100644
--- a/js/src/graphql/event.ts
+++ b/js/src/graphql/event.ts
@@ -2,23 +2,28 @@ import gql from 'graphql-tag';
 import { COMMENT_FIELDS_FRAGMENT } from '@/graphql/comment';
 
 const participantQuery = `
-  role,
-  id,
-  actor {
-    preferredUsername,
-    avatar {
-      url
+  total,
+  elements {
+    role,
+    id,
+    actor {
+      preferredUsername,
+      avatar {
+        url
+      },
+      name,
+      id,
+      domain
     },
-    name,
-    id,
-    domain
-  },
-  event {
-    id,
-    uuid
-  },
-  metadata {
-    cancellationToken
+    event {
+      id,
+      uuid
+    },
+    metadata {
+      cancellationToken,
+      message
+    },
+    insertedAt
   }
 `;
 
@@ -371,11 +376,12 @@ export const EDIT_EVENT = gql`
 `;
 
 export const JOIN_EVENT = gql`
-  mutation JoinEvent($eventId: ID!, $actorId: ID!, $email: String) {
+  mutation JoinEvent($eventId: ID!, $actorId: ID!, $email: String, $message: String) {
     joinEvent(
       eventId: $eventId,
       actorId: $actorId,
-      email: $email
+      email: $email,
+      message: $message
     ) {
         ${participantQuery}
     }
diff --git a/js/src/i18n/en_US.json b/js/src/i18n/en_US.json
index 07ce2c0fe..6185fdaff 100644
--- a/js/src/i18n/en_US.json
+++ b/js/src/i18n/en_US.json
@@ -23,7 +23,6 @@
 	"Allow all comments": "Allow all comments",
 	"Allow registrations": "Allow registrations",
 	"An error has occurred.": "An error has occurred.",
-	"And no anonymous participations|And one anonymous participation|And {count} anonymous participations": "And no anonymous participations|And one anonymous participation|And {count} anonymous participations",
 	"Anonymous participants will be asked to confirm their participation through e-mail.": "Anonymous participants will be asked to confirm their participation through e-mail.",
 	"Anonymous participations": "Anonymous participations",
 	"Approve": "Approve",
@@ -467,5 +466,17 @@
 	"{count} requests waiting": "{count} requests waiting",
 	"{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.",
 	"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks",
-	"© The OpenStreetMap Contributors": "© The OpenStreetMap Contributors"
+	"© The OpenStreetMap Contributors": "© The OpenStreetMap Contributors",
+	"The event organizer manually approves participations. Since you've chosen to participate without an account, please explain why you want to participate to this event.": "The event organizer manually approves participations. Since you've chosen to participate without an account, please explain why you want to participate to this event.",
+	"If you want, you may send a message to the event organizer here.": "If you want, you may send a message to the event organizer here.",
+	"Message": "Message",
+	"Anonymous participant": "Anonymous participant",
+	"No message": "No message",
+	"No participant to approve|Approve participant|Approve {number} participants": "No participant to approve|Approve participant|Approve {number} participants",
+	"No participant to reject|Reject participant|Reject {number} participants": "No participant to reject|Reject participant|Reject {number} participants",
+	"Role": "Role",
+	"Participant": "Participant",
+	"Participation confirmation": "Participation confirmation",
+	"The event organiser has chosen to validate manually participations. Do you want to add a little note to explain why you want to participate to this event?": "The event organiser has chosen to validate manually participations. Do you want to add a little note to explain why you want to participate to this event?",
+	"Confirm my participation": "Confirm my participation"
 }
\ No newline at end of file
diff --git a/js/src/i18n/es.json b/js/src/i18n/es.json
index 4d012eaf8..d52fea964 100644
--- a/js/src/i18n/es.json
+++ b/js/src/i18n/es.json
@@ -23,7 +23,6 @@
     "Allow all comments": "Permitir todos los comentarios",
     "Allow registrations": "Permitir registros",
     "An error has occurred.": "Se ha producido un error.",
-    "And no anonymous participations|And one anonymous participation|And {count} anonymous participations": "Y sin participaciones anónimas|Y una participación anónima|Y {count} participaciones anónimas",
     "Anonymous participants will be asked to confirm their participation through e-mail.": "Los participantes anónimos deberán confirmar su participación por correo electrónico.",
     "Anonymous participations": "Participaciones anónimas",
     "Approve": "Aprobar",
diff --git a/js/src/i18n/fi.json b/js/src/i18n/fi.json
index ab6ea63f5..ca5568d9f 100644
--- a/js/src/i18n/fi.json
+++ b/js/src/i18n/fi.json
@@ -22,7 +22,6 @@
     "Allow all comments": "Salli kaikki kommentit",
     "Allow registrations": "Salli rekisteröityminen",
     "An error has occurred.": "Tapahtui virhe.",
-    "And no anonymous participations|And one anonymous participation|And {count} anonymous participations": "Ei anonyymejä osallistujia|Myös yksi anonyymi osallistuja|Myös {count} anonyymiä osallistujaa",
     "Anonymous participants will be asked to confirm their participation through e-mail.": "Anonyymejä osallistujia pyydetään vahvistamaan osallistumisensa sähköpostitse.",
     "Anonymous participations": "Anonyymit osallistujat",
     "Approve": "Hyväksy",
diff --git a/js/src/i18n/fr_FR.json b/js/src/i18n/fr_FR.json
index eb6987bfa..22bb46c53 100644
--- a/js/src/i18n/fr_FR.json
+++ b/js/src/i18n/fr_FR.json
@@ -23,7 +23,6 @@
     "Allow all comments": "Autoriser tous les commentaires",
     "Allow registrations": "Autoriser les inscriptions",
     "An error has occurred.": "Une erreur est survenue.",
-    "And no anonymous participations|And one anonymous participation|And {count} anonymous participations": "Et aucune participation anonyme|Et une participation anonyme|Et {count} participations anonymes",
     "Anonymous participants will be asked to confirm their participation through e-mail.": "Les participants anonymes devront confirmer leur participation par email.",
     "Anonymous participations": "Participations anonymes",
     "Approve": "Approuver",
@@ -252,14 +251,14 @@
     "Or": "Ou",
     "Organized": "Organisés",
     "Organized by {name}": "Organisé par {name}",
-    "Organizer": "Organisateur",
+    "Organizer": "Organisateur⋅ice",
     "Other software may also support this.": "D'autres logiciels peuvent également supporter cette fonctionnalité.",
     "Otherwise this identity will just be removed from the group administrators.": "Sinon cette identité sera juste supprimée des administrateurs du groupe.",
     "Page limited to my group (asks for auth)": "Accès limité à mon groupe (demande authentification)",
     "Page not found": "Page non trouvée",
     "Participant already was rejected.": "Le participant a déjà été refusé.",
     "Participant has already been approved as participant.": "Le participant a déjà été approuvé en tant que participant.",
-    "Participants": "Participants",
+    "Participants": "Participant⋅e⋅s",
     "Participate": "Participer",
     "Participate using your email address": "Participer en utilisant votre adresse email",
     "Participation approval": "Validation des participations",
@@ -473,5 +472,17 @@
     "{count} requests waiting": "Une demande en attente|{count} demandes en attente",
     "{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} garantit {respect} des personnes qui l'utiliseront. Puisque {source}, il est publiquement auditable, ce qui garantit sa transparence.",
     "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© Les contributeurs de Mobilizon {date} - Fait avec Elixir, Phoenix, VueJS & et de l'amour et des semaines",
-    "© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap"
+    "© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap",
+    "The event organizer manually approves participations. Since you've chosen to participate without an account, please explain why you want to participate to this event.": "L'organisateur⋅ice de l'événement valide les participations manuellement. Comme vous avez choisi de participer sans compte, merci d'expliquer pourquoi vous voulez participer à cet événement.",
+    "If you want, you may send a message to the event organizer here.": "Si vous le désirez, vous pouvez laisser un message pour l'organisateur⋅ice de l'événement ci-dessous.",
+    "Message": "Message",
+    "Anonymous participant": "Participant⋅e anonyme",
+    "No message": "Pas de message",
+    "No participant to approve|Approve participant|Approve {number} participants": "Aucun⋅e participant⋅e à valider|Valider le ou la participant⋅e|Valider {number} participant⋅es",
+    "No participant to reject|Reject participant|Reject {number} participants": "Aucun⋅e participant⋅e à refuser|Refuser le ou la participant⋅e|Refuser {number} participant⋅es",
+    "Role": "Rôle",
+    "Participant": "Participant⋅e",
+    "Participation confirmation": "Confirmation de votre participation",
+    "The event organiser has chosen to validate manually participations. Do you want to add a little note to explain why you want to participate to this event?": "L'organisateur⋅ice de l'événement a choisi de valider manuellement les demandes de participation. Voulez-vous ajouter un petit message pour expliquer pourquoi vous souhaitez participer à cet événement ?",
+    "Confirm my participation": "Confirmer ma participation"
 }
diff --git a/js/src/i18n/oc.json b/js/src/i18n/oc.json
index 03d8839f6..eadb25bbb 100644
--- a/js/src/i18n/oc.json
+++ b/js/src/i18n/oc.json
@@ -25,7 +25,6 @@
     "Allow all comments": "Autorizar totes los comentaris",
     "Allow registrations": "Permetre las inscripcions",
     "An error has occurred.": "Una error s’es producha.",
-    "And no anonymous participations|And one anonymous participation|And {count} anonymous participations": "E cap de participacion anonima|E una participacion anonima|E {count} participacions anonima",
     "Anonymous participants will be asked to confirm their participation through e-mail.": "Òm demandarà als participants anonims de confirmar lor venguda via un corrièl.",
     "Anonymous participations": "Participacions anonimas",
     "Approve": "Aprovar",
diff --git a/js/src/i18n/pt_BR.json b/js/src/i18n/pt_BR.json
index de388042a..6fd8a23bd 100644
--- a/js/src/i18n/pt_BR.json
+++ b/js/src/i18n/pt_BR.json
@@ -22,7 +22,6 @@
     "Allow all comments": "Permitir todos comentários",
     "Allow registrations": "Permitir inscrições",
     "An error has occurred.": "Ocorreu um erro.",
-    "And no anonymous participations|And one anonymous participation|And {count} anonymous participations": "E nenhuma participação anônima|E uma participação anônima|E {count} participações anônimas",
     "Anonymous participants will be asked to confirm their participation through e-mail.": "Os participantes anônimos deverão confirmar sua participação por email.",
     "Anonymous participations": "Participações anônimas",
     "Approve": "Aprovar",
diff --git a/js/src/plugins/notifier.ts b/js/src/plugins/notifier.ts
index e0be7146f..9920a1130 100644
--- a/js/src/plugins/notifier.ts
+++ b/js/src/plugins/notifier.ts
@@ -1,5 +1,6 @@
 import Vue from 'vue';
 import { ColorModifiers } from 'buefy/types/helpers';
+import { Route, RawLocation } from 'vue-router';
 
 declare module 'vue/types/vue' {
   interface Vue {
@@ -8,6 +9,23 @@ declare module 'vue/types/vue' {
       error: (message: string) => void;
       info: (message: string) => void;
     };
+    beforeRouteEnter?(
+        to: Route,
+        from: Route,
+        next: (to?: RawLocation | false | ((vm: Vue) => void)) => void,
+    ): void;
+
+    beforeRouteLeave?(
+        to: Route,
+        from: Route,
+        next: (to?: RawLocation | false | ((vm: Vue) => void)) => void,
+    ): void;
+
+    beforeRouteUpdate?(
+        to: Route,
+        from: Route,
+        next: (to?: RawLocation | false | ((vm: Vue) => void)) => void,
+    ): void;
   }
 }
 
diff --git a/js/src/router/event.ts b/js/src/router/event.ts
index 02f711f9c..e0f50987c 100644
--- a/js/src/router/event.ts
+++ b/js/src/router/event.ts
@@ -63,7 +63,7 @@ export const eventRoutes: RouteConfig[] = [
     props: { isUpdate: true },
   },
   {
-    path: '/events/participations/:eventId',
+    path: '/events/:eventId/participations',
     name: EventRouteName.PARTICIPATIONS,
     component: participations,
     meta: { requiredAuth: true },
diff --git a/js/src/types/event.model.ts b/js/src/types/event.model.ts
index c84e7e61b..41055f17a 100644
--- a/js/src/types/event.model.ts
+++ b/js/src/types/event.model.ts
@@ -3,6 +3,7 @@ import { Address, IAddress } from '@/types/address.model';
 import { ITag } from '@/types/tag.model';
 import { IPicture } from '@/types/picture.model';
 import { IComment } from '@/types/comment.model';
+import { Paginate } from '@/types/paginate';
 
 export enum EventStatus {
   TENTATIVE = 'TENTATIVE',
@@ -59,7 +60,8 @@ export interface IParticipant {
   role: ParticipantRole;
   actor: IActor;
   event: IEvent;
-  metadata: { cancellationToken?: string };
+  metadata: { cancellationToken?: string, message?: string };
+  insertedAt?: Date;
 }
 
 export class Participant implements IParticipant {
@@ -68,6 +70,7 @@ export class Participant implements IParticipant {
   actor!: IActor;
   role: ParticipantRole = ParticipantRole.NOT_APPROVED;
   metadata = {};
+  insertedAt?: Date;
 
   constructor(hash?: IParticipant) {
     if (!hash) return;
@@ -77,6 +80,7 @@ export class Participant implements IParticipant {
     this.actor = new Actor(hash.actor);
     this.role = hash.role;
     this.metadata = hash.metadata;
+    this.insertedAt = hash.insertedAt;
   }
 }
 
@@ -132,7 +136,7 @@ export interface IEvent {
   organizerActor?: IActor;
   attributedTo: IActor;
   participantStats: IEventParticipantStats;
-  participants: IParticipant[];
+  participants: Paginate<IParticipant>;
 
   relatedEvents: IEvent[];
   comments: IComment[];
@@ -205,7 +209,7 @@ export class EventModel implements IEvent {
   publishAt = new Date();
 
   participantStats = { notApproved: 0, notConfirmed: 0, rejected: 0, participant: 0, moderator: 0, administrator: 0, creator: 0, going: 0 };
-  participants: IParticipant[] = [];
+  participants!: Paginate<IParticipant>;
 
   relatedEvents: IEvent[] = [];
   comments: IComment[] = [];
diff --git a/js/src/utils/asyncForEach.ts b/js/src/utils/asyncForEach.ts
new file mode 100644
index 000000000..3e68a5b18
--- /dev/null
+++ b/js/src/utils/asyncForEach.ts
@@ -0,0 +1,7 @@
+async function asyncForEach(array, callback) {
+  for (let index = 0; index < array.length; index += 1) {
+    await callback(array[index], index, array);
+  }
+}
+
+export { asyncForEach };
diff --git a/js/src/views/Event/Event.vue b/js/src/views/Event/Event.vue
index fc08dfb5a..128121768 100644
--- a/js/src/views/Event/Event.vue
+++ b/js/src/views/Event/Event.vue
@@ -45,6 +45,7 @@
                         :current-actor="currentActor"
                         @joinEvent="joinEvent"
                         @joinModal="isJoinModalActive = true"
+                        @joinEventWithConfirmation="joinEventWithConfirmation"
                         @confirmLeave="confirmLeave"
                 />
                 <b-button type="is-text" v-if="anonymousParticipation !== null" @click="cancelAnonymousParticipation">{{ $t('Cancel anonymous participation')}}</b-button>
@@ -263,13 +264,44 @@
                     <button
                             class="button is-primary"
                             ref="confirmButton"
-                            @click="joinEvent(identity)">
+                            @click="event.joinOptions === EventJoinOptions.RESTRICTED ? joinEventWithConfirmation(identity) : joinEvent(identity)">
                       {{ $t('Confirm my particpation') }}
                     </button>
                   </footer>
                 </template>
               </identity-picker>
         </b-modal>
+        <b-modal :active.sync="isJoinConfirmationModalActive" has-modal-card ref="joinConfirmationModal">
+          <div class="modal-card">
+            <header class="modal-card-head">
+              <p class="modal-card-title">{{ $t('Participation confirmation')}}</p>
+            </header>
+
+            <section class="modal-card-body">
+              <p>{{ $t('The event organiser has chosen to validate manually participations. Do you want to add a little note to explain why you want to participate to this event?') }}</p>
+              <form @submit.prevent="joinEvent(actorForConfirmation, messageForConfirmation)">
+                <b-field :label="$t('Message')">
+                  <b-input
+                          type="textarea"
+                          size="is-medium"
+                          v-model="messageForConfirmation"
+                          minlength="10">
+                  </b-input>
+                </b-field>
+                <div class="buttons">
+                  <b-button
+                          native-type="button"
+                          class="button"
+                          ref="cancelButton"
+                          @click="isJoinConfirmationModalActive = false">
+                    {{ $t('Cancel') }}
+                  </b-button>
+                  <b-button type="is-primary" native-type="submit">{{ $t('Confirm my participation') }}</b-button>
+                </div>
+              </form>
+            </section>
+          </div>
+        </b-modal>
         </div>
     </transition>
   </div>
@@ -281,11 +313,10 @@ import {
     EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
     FETCH_EVENT,
     JOIN_EVENT,
-    LEAVE_EVENT,
   } from '@/graphql/event';
 import { Component, Prop, Watch } from 'vue-property-decorator';
 import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
-import { EventModel, EventStatus, EventVisibility, IEvent, IParticipant, ParticipantRole } from '@/types/event.model';
+import { EventModel, EventStatus, EventVisibility, IEvent, IParticipant, ParticipantRole, EventJoinOptions } from '@/types/event.model';
 import { IPerson, Person } from '@/types/actor';
 import { GRAPHQL_API_ENDPOINT } from '@/api/_entrypoint';
 import DateCalendarIcon from '@/components/Event/DateCalendarIcon.vue';
@@ -398,12 +429,16 @@ export default class Event extends EventMixin {
   showMap: boolean = false;
   isReportModalActive: boolean = false;
   isJoinModalActive: boolean = false;
+  isJoinConfirmationModalActive: boolean = false;
   EventVisibility = EventVisibility;
   EventStatus = EventStatus;
+  EventJoinOptions = EventJoinOptions;
   RouteName = RouteName;
   observer!: IntersectionObserver;
   loadComments: boolean = false;
   anonymousParticipation: boolean|null = null;
+  actorForConfirmation!: IPerson;
+  messageForConfirmation: string = '';
 
   get eventTitle() {
     if (!this.event) return undefined;
@@ -506,7 +541,13 @@ export default class Event extends EventMixin {
     }
   }
 
-  async joinEvent(identity: IPerson) {
+  joinEventWithConfirmation(actor: IPerson) {
+    this.isJoinConfirmationModalActive = true;
+    this.actorForConfirmation = actor;
+  }
+
+  async joinEvent(identity: IPerson, message: string|null = null) {
+    this.isJoinConfirmationModalActive = false;
     this.isJoinModalActive = false;
     try {
       const { data } = await this.$apollo.mutate<{ joinEvent: IParticipant }>({
@@ -514,6 +555,7 @@ export default class Event extends EventMixin {
         variables: {
           eventId: this.event.id,
           actorId: identity.id,
+          message,
         },
         update: (store, { data }) => {
           if (data == null) return;
diff --git a/js/src/views/Event/Participants.vue b/js/src/views/Event/Participants.vue
index 60cf05482..acd3148a2 100644
--- a/js/src/views/Event/Participants.vue
+++ b/js/src/views/Event/Participants.vue
@@ -1,3 +1,4 @@
+import {ParticipantRole} from "@/types/event.model";
 <template>
     <main class="container">
         <b-tabs type="is-boxed" v-if="event" v-model="activeTab">
@@ -7,22 +8,17 @@
                     <span>{{ $t('Participants')}} <b-tag rounded> {{ participantStats.going }} </b-tag> </span>
                 </template>
                 <template>
-                  <section v-if="participantsAndCreators.length > 0">
+                  <section v-if="participants && participants.total > 0">
                       <h2 class="title">{{ $t('Participants') }}</h2>
-                      <p v-if="confirmedAnonymousParticipantsCountCount > 1">
-                          {{ $tc('And no anonymous participations|And one anonymous participation|And {count} anonymous participations', confirmedAnonymousParticipantsCountCount, { count: confirmedAnonymousParticipantsCountCount}) }}
-                      </p>
-                      <div class="columns is-multiline">
-                          <div class="column is-one-quarter-desktop" v-for="participant in participantsAndCreators" :key="participant.actor.id">
-                              <participant-card
-                                  v-if="participant.actor.id !== config.anonymous.actorId"
-                                  :participant="participant"
-                                  :accept="acceptParticipant"
-                                  :reject="refuseParticipant"
-                                  :exclude="refuseParticipant"
-                              />
-                          </div>
-                      </div>
+                      <ParticipationTable
+                              :data="participants.elements"
+                              :accept-participant="acceptParticipant"
+                              :refuse-participant="refuseParticipant"
+                              :showRole="true"
+                              :total="participants.total"
+                              :perPage="PARTICIPANTS_PER_PAGE"
+                              @page-change="(page) => participantPage = page"
+                      />
                   </section>
                 </template>
             </b-tab-item>
@@ -32,18 +28,16 @@
                     <span>{{ $t('Requests') }} <b-tag rounded> {{ participantStats.notApproved }} </b-tag> </span>
                 </template>
                 <template>
-                  <section v-if="queue.length > 0">
+                  <section v-if="queue && queue.total > 0">
                       <h2 class="title">{{ $t('Waiting list') }}</h2>
-                      <div class="columns">
-                          <div class="column is-one-quarter-desktop" v-for="participant in queue" :key="participant.actor.id">
-                              <participant-card
-                                  :participant="participant"
-                                  :accept="acceptParticipant"
-                                  :reject="refuseParticipant"
-                                  :exclude="refuseParticipant"
-                              />
-                          </div>
-                      </div>
+                      <ParticipationTable
+                              :data="queue.elements"
+                              :accept-participant="acceptParticipant"
+                              :refuse-participant="refuseParticipant"
+                              :total="queue.total"
+                              :perPage="PARTICIPANTS_PER_PAGE"
+                              @page-change="(page) => queuePage = page"
+                      />
                   </section>
                 </template>
             </b-tab-item>
@@ -53,18 +47,16 @@
                     <span>{{ $t('Rejected')}} <b-tag rounded> {{ participantStats.rejected }} </b-tag> </span>
                 </template>
                 <template>
-                  <section v-if="rejected.length > 0">
+                  <section v-if="rejected && rejected.total > 0">
                       <h2 class="title">{{ $t('Rejected participations') }}</h2>
-                      <div class="columns">
-                          <div class="column is-one-quarter-desktop" v-for="participant in rejected" :key="participant.actor.id">
-                              <participant-card
-                                      :participant="participant"
-                                      :accept="acceptParticipant"
-                                      :reject="refuseParticipant"
-                                      :exclude="refuseParticipant"
-                              />
-                          </div>
-                      </div>
+                      <ParticipationTable
+                              :data="rejected.elements"
+                              :accept-participant="acceptParticipant"
+                              :refuse-participant="refuseParticipant"
+                              :total="rejected.total"
+                              :perPage="PARTICIPANTS_PER_PAGE"
+                              @page-change="(page) => rejectedPage = page"
+                      />
                   </section>
                 </template>
             </b-tab-item>
@@ -81,9 +73,15 @@ import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
 import { IPerson } from '@/types/actor';
 import { CONFIG } from '@/graphql/config';
 import { IConfig } from '@/types/config.model';
+import ParticipationTable from '@/components/Event/ParticipationTable.vue';
+import { Paginate } from '@/types/paginate';
+
+const PARTICIPANTS_PER_PAGE = 20;
+const MESSAGE_ELLIPSIS_LENGTH = 130;
 
 @Component({
   components: {
+    ParticipationTable,
     ParticipantCard,
   },
   apollo: {
@@ -97,7 +95,7 @@ import { IConfig } from '@/types/config.model';
         return {
           uuid: this.eventId,
           page: 1,
-          limit: 10,
+          limit: PARTICIPANTS_PER_PAGE,
           roles: [ParticipantRole.PARTICIPANT].join(),
           actorId: this.currentActor.id,
         };
@@ -106,18 +104,18 @@ import { IConfig } from '@/types/config.model';
         return !this.currentActor.id;
       },
     },
-    organizers: {
+    participants: {
       query: PARTICIPANTS,
       variables() {
         return {
           uuid: this.eventId,
-          page: 1,
-          limit: 20,
-          roles: [ParticipantRole.CREATOR].join(),
+          page: this.participantPage,
+          limit: PARTICIPANTS_PER_PAGE,
+          roles: [ParticipantRole.CREATOR, ParticipantRole.PARTICIPANT].join(),
           actorId: this.currentActor.id,
         };
       },
-      update: data => data.event.participants.map(participation => new Participant(participation)),
+      update(data) { return this.dataTransform(data); },
       skip() {
         return !this.currentActor.id;
       },
@@ -127,13 +125,13 @@ import { IConfig } from '@/types/config.model';
       variables() {
         return {
           uuid: this.eventId,
-          page: 1,
-          limit: 20,
+          page: this.queuePage,
+          limit: PARTICIPANTS_PER_PAGE,
           roles: [ParticipantRole.NOT_APPROVED].join(),
           actorId: this.currentActor.id,
         };
       },
-      update: data => data.event.participants.map(participation => new Participant(participation)),
+      update(data) { return this.dataTransform(data); },
       skip() {
         return !this.currentActor.id;
       },
@@ -143,27 +141,36 @@ import { IConfig } from '@/types/config.model';
       variables() {
         return {
           uuid: this.eventId,
-          page: 1,
-          limit: 20,
+          page: this.rejectedPage,
+          limit: PARTICIPANTS_PER_PAGE,
           roles: [ParticipantRole.REJECTED].join(),
           actorId: this.currentActor.id,
         };
       },
-      update: data => data.event.participants.map(participation => new Participant(participation)),
+      update(data) { return this.dataTransform(data); },
       skip() {
         return !this.currentActor.id;
       },
     },
   },
+  filters: {
+    ellipsize: (text?: string) => text && text.substr(0, MESSAGE_ELLIPSIS_LENGTH).concat('…'),
+  },
 })
 export default class Participants extends Vue {
   @Prop({ required: true }) eventId!: string;
   page: number = 1;
   limit: number = 10;
 
-  organizers: IParticipant[] = [];
-  queue: IParticipant[] = [];
-  rejected: IParticipant[] = [];
+  participants!: Paginate<IParticipant>;
+  participantPage: number = 1;
+
+  queue!: Paginate<IParticipant>;
+  queuePage: number = 1;
+
+  rejected!: Paginate<IParticipant>;
+  rejectedPage: number = 1;
+
   event!: IEvent;
   config!: IConfig;
 
@@ -173,23 +180,20 @@ export default class Participants extends Vue {
   hasMoreParticipants: boolean = false;
   activeTab: number = 0;
 
+  PARTICIPANTS_PER_PAGE = PARTICIPANTS_PER_PAGE;
+
+  dataTransform(data): Paginate<Participant> {
+    return {
+      total: data.event.participants.total,
+      elements: data.event.participants.elements.map(participation => new Participant(participation)),
+    };
+  }
+
   get participantStats(): IEventParticipantStats | null {
     if (!this.event) return null;
     return this.event.participantStats;
   }
 
-  get participantsAndCreators(): IParticipant[] {
-    if (this.event) {
-      return [...this.organizers, ...this.event.participants]
-          .filter(participant => [ParticipantRole.PARTICIPANT, ParticipantRole.CREATOR].includes(participant.role));
-    }
-    return [];
-  }
-
-  get confirmedAnonymousParticipantsCountCount(): number {
-    return this.participantsAndCreators.filter(({ actor: { id } }) => id === this.config.anonymous.actorId).length;
-  }
-
   @Watch('participantStats', { deep: true })
   watchParticipantStats(stats: IEventParticipantStats) {
     if (!stats) return;
@@ -232,8 +236,8 @@ export default class Participants extends Vue {
         },
       });
       if (data) {
-        this.queue = this.queue.filter(participant => participant.id !== data.updateParticipation.id);
-        this.rejected = this.rejected.filter(participant => participant.id !== data.updateParticipation.id);
+        this.queue.elements = this.queue.elements.filter(participant => participant.id !== data.updateParticipation.id);
+        this.rejected.elements = this.rejected.elements.filter(participant => participant.id !== data.updateParticipation.id);
         this.event.participantStats.going += 1;
         if (participant.role === ParticipantRole.NOT_APPROVED) {
           this.event.participantStats.notApproved -= 1;
@@ -242,7 +246,7 @@ export default class Participants extends Vue {
           this.event.participantStats.rejected -= 1;
         }
         participant.role = ParticipantRole.PARTICIPANT;
-        this.event.participants.push(participant);
+        this.event.participants.elements.push(participant);
       }
     } catch (e) {
       console.error(e);
@@ -260,8 +264,10 @@ export default class Participants extends Vue {
         },
       });
       if (data) {
-        this.event.participants = this.event.participants.filter(participant => participant.id !== data.updateParticipation.id);
-        this.queue = this.queue.filter(participant => participant.id !== data.updateParticipation.id);
+        this.event.participants.elements = this.event.participants.elements.filter(
+            participant => participant.id !== data.updateParticipation.id,
+        );
+        this.queue.elements = this.queue.elements.filter(participant => participant.id !== data.updateParticipation.id);
         this.event.participantStats.rejected += 1;
         if (participant.role === ParticipantRole.PARTICIPANT) {
           this.event.participantStats.participant -= 1;
@@ -271,8 +277,8 @@ export default class Participants extends Vue {
           this.event.participantStats.notApproved -= 1;
         }
         participant.role = ParticipantRole.REJECTED;
-        this.rejected = this.rejected.filter(participantIn => participantIn.id !== participant.id);
-        this.rejected.push(participant);
+        this.rejected.elements = this.rejected.elements.filter(participantIn => participantIn.id !== participant.id);
+        this.rejected.elements.push(participant);
       }
     } catch (e) {
       console.error(e);
diff --git a/lib/federation/activity_pub/activity_pub.ex b/lib/federation/activity_pub/activity_pub.ex
index 697d278f8..5f5b1cce4 100644
--- a/lib/federation/activity_pub/activity_pub.ex
+++ b/lib/federation/activity_pub/activity_pub.ex
@@ -439,7 +439,10 @@ defmodule Mobilizon.Federation.ActivityPub do
              event_id: event.id,
              actor_id: actor.id,
              url: Map.get(additional, :url),
-             metadata: Map.get(additional, :metadata)
+             metadata:
+               additional
+               |> Map.get(:metadata, %{})
+               |> Map.update(:message, nil, &String.trim(HtmlSanitizeEx.strip_tags(&1)))
            }),
          join_data <- Convertible.model_to_as(participant),
          audience <-
diff --git a/lib/federation/activity_pub/transmogrifier.ex b/lib/federation/activity_pub/transmogrifier.ex
index 0efbd73e2..4ba30cab2 100644
--- a/lib/federation/activity_pub/transmogrifier.ex
+++ b/lib/federation/activity_pub/transmogrifier.ex
@@ -306,13 +306,20 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
   end
 
   def handle_incoming(
-        %{"type" => "Join", "object" => object, "actor" => _actor, "id" => id} = data
+        %{
+          "type" => "Join",
+          "object" => object,
+          "actor" => _actor,
+          "id" => id,
+          "participationMessage" => note
+        } = data
       ) do
     with actor <- Utils.get_actor(data),
          {:ok, %Actor{url: _actor_url} = actor} <- Actors.get_actor_by_url(actor),
          object <- Utils.get_url(object),
          {:ok, object} <- ActivityPub.fetch_object_from_url(object),
-         {:ok, activity, object} <- ActivityPub.join(object, actor, false, %{url: id}) do
+         {:ok, activity, object} <-
+           ActivityPub.join(object, actor, false, %{url: id, metadata: %{message: note}}) do
       {:ok, activity, object}
     else
       e ->
diff --git a/lib/federation/activity_pub/utils.ex b/lib/federation/activity_pub/utils.ex
index 0ae9fc459..8b21626ed 100644
--- a/lib/federation/activity_pub/utils.ex
+++ b/lib/federation/activity_pub/utils.ex
@@ -77,6 +77,10 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
           "anonymousParticipationEnabled" => %{
             "@id" => "mz:anonymousParticipationEnabled",
             "@type" => "sc:Boolean"
+          },
+          "participationMessage" => %{
+            "@id" => "mz:participationMessage",
+            "@type" => "sc:Text"
           }
         }
       ]
diff --git a/lib/federation/activity_stream/converter/participant.ex b/lib/federation/activity_stream/converter/participant.ex
index 1581b6547..a1e21824f 100644
--- a/lib/federation/activity_stream/converter/participant.ex
+++ b/lib/federation/activity_stream/converter/participant.ex
@@ -25,7 +25,8 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Participant do
       "type" => "Join",
       "id" => participant.url,
       "actor" => participant.actor.url,
-      "object" => participant.event.url
+      "object" => participant.event.url,
+      "participationMessage" => Map.get(participant.metadata, :message)
     }
   end
 end
diff --git a/lib/graphql/resolvers/event.ex b/lib/graphql/resolvers/event.ex
index ee519a51f..12b1eb4aa 100644
--- a/lib/graphql/resolvers/event.ex
+++ b/lib/graphql/resolvers/event.ex
@@ -102,7 +102,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
   end
 
   def list_participants_for_event(_, _args, _resolution) do
-    {:ok, []}
+    {:ok, %{total: 0, elements: []}}
   end
 
   def stats_participants_going(%EventParticipantStats{} = stats, _args, _resolution) do
diff --git a/lib/graphql/resolvers/participant.ex b/lib/graphql/resolvers/participant.ex
index f06b74239..16294c7c8 100644
--- a/lib/graphql/resolvers/participant.ex
+++ b/lib/graphql/resolvers/participant.ex
@@ -17,12 +17,12 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
   """
   def actor_join_event(
         _parent,
-        %{actor_id: actor_id, event_id: event_id},
+        %{actor_id: actor_id, event_id: event_id} = args,
         %{context: %{current_user: %User{} = user}}
       ) do
     case User.owns_actor(user, actor_id) do
       {:is_owned, %Actor{} = actor} ->
-        do_actor_join_event(actor, event_id)
+        do_actor_join_event(actor, event_id, args)
 
       _ ->
         {:error, "Actor id is not owned by authenticated user"}
@@ -136,7 +136,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
         _parent,
         %{actor_id: actor_id, event_id: event_id, token: token},
         _resolution
-      ) do
+      )
+      when not is_nil(token) do
     with {:anonymous_participation_enabled, true} <-
            {:anonymous_participation_enabled, Config.anonymous_participation?()},
          {:anonymous_actor_id, true} <-
diff --git a/lib/graphql/schema/event.ex b/lib/graphql/schema/event.ex
index 7f0acfe2c..d285fa9ec 100644
--- a/lib/graphql/schema/event.ex
+++ b/lib/graphql/schema/event.ex
@@ -65,7 +65,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
 
     field(:participant_stats, :participant_stats)
 
-    field(:participants, list_of(:participant), description: "The event's participants") do
+    field(:participants, :paginated_participant_list, description: "The event's participants") do
       arg(:page, :integer, default_value: 1)
       arg(:limit, :integer, default_value: 10)
       arg(:roles, :string, default_value: "")
diff --git a/lib/graphql/schema/events/participant.ex b/lib/graphql/schema/events/participant.ex
index bf7ded106..067c637af 100644
--- a/lib/graphql/schema/events/participant.ex
+++ b/lib/graphql/schema/events/participant.ex
@@ -33,12 +33,21 @@ defmodule Mobilizon.GraphQL.Schema.Events.ParticipantType do
     field(:metadata, :participant_metadata,
       description: "The metadata associated to this participant"
     )
+
+    field(:inserted_at, :datetime, description: "The datetime this participant was created")
   end
 
   object :participant_metadata do
     field(:cancellation_token, :string,
       description: "The eventual token to leave an event when user is anonymous"
     )
+
+    field(:message, :string, description: "The eventual message the participant left")
+  end
+
+  object :paginated_participant_list do
+    field(:elements, list_of(:participant), description: "A list of participants")
+    field(:total, :integer, description: "The total number of participants in the list")
   end
 
   enum :participant_role_enum do
@@ -64,6 +73,7 @@ defmodule Mobilizon.GraphQL.Schema.Events.ParticipantType do
       arg(:event_id, non_null(:id))
       arg(:actor_id, non_null(:id))
       arg(:email, :string)
+      arg(:message, :string)
 
       resolve(&Participant.actor_join_event/3)
     end
diff --git a/lib/mobilizon/events/events.ex b/lib/mobilizon/events/events.ex
index 6b25de1fc..c97a0f00c 100644
--- a/lib/mobilizon/events/events.ex
+++ b/lib/mobilizon/events/events.ex
@@ -759,7 +759,7 @@ defmodule Mobilizon.Events do
   Default behaviour is to not return :not_approved or :not_confirmed participants
   """
   @spec list_participants_for_event(String.t(), list(atom()), integer | nil, integer | nil) ::
-          [Participant.t()]
+          Page.t()
   def list_participants_for_event(
         id,
         roles \\ @default_participant_roles,
@@ -769,8 +769,7 @@ defmodule Mobilizon.Events do
     id
     |> list_participants_for_event_query()
     |> filter_role(roles)
-    |> Page.paginate(page, limit)
-    |> Repo.all()
+    |> Page.build_page(page, limit)
   end
 
   @spec list_actors_participants_for_event(String.t()) :: [Actor.t()]
diff --git a/lib/mobilizon/events/participant.ex b/lib/mobilizon/events/participant.ex
index f246b1db0..5a309757c 100644
--- a/lib/mobilizon/events/participant.ex
+++ b/lib/mobilizon/events/participant.ex
@@ -18,11 +18,15 @@ defmodule Mobilizon.Events.Participant do
           role: ParticipantRole.t(),
           url: String.t(),
           event: Event.t(),
-          actor: Actor.t()
+          actor: Actor.t(),
+          metadata: Map.t()
         }
 
   @required_attrs [:url, :role, :event_id, :actor_id]
   @attrs @required_attrs
+  @metadata_attrs [:email, :confirmation_token, :cancellation_token, :message]
+
+  @timestamps_opts [type: :utc_datetime]
 
   @primary_key {:id, :binary_id, autogenerate: true}
   schema "participants" do
@@ -33,6 +37,7 @@ defmodule Mobilizon.Events.Participant do
       field(:email, :string)
       field(:confirmation_token, :string)
       field(:cancellation_token, :string)
+      field(:message, :string)
     end
 
     belongs_to(:event, Event, primary_key: true)
@@ -70,7 +75,7 @@ defmodule Mobilizon.Events.Participant do
 
   defp metadata_changeset(schema, params) do
     schema
-    |> cast(params, [:email, :confirmation_token, :cancellation_token])
+    |> cast(params, @metadata_attrs)
     |> Checker.validate_changeset()
   end
 
diff --git a/schema.graphql b/schema.graphql
index 33ff3d2e8..ae9f71377 100644
--- a/schema.graphql
+++ b/schema.graphql
@@ -1,5 +1,5 @@
 # source: http://localhost:4000/api
-# timestamp: Thu Feb 13 2020 11:32:20 GMT+0100 (GMT+01:00)
+# timestamp: Wed Mar 04 2020 10:26:53 GMT+0100 (GMT+01:00)
 
 schema {
   query: RootQueryType
@@ -839,6 +839,9 @@ type Participant {
   """The participation ID"""
   id: ID
 
+  """The datetime this participant was created"""
+  insertedAt: DateTime
+
   """The metadata associated to this participant"""
   metadata: ParticipantMetadata
 
@@ -849,6 +852,9 @@ type Participant {
 type ParticipantMetadata {
   """The eventual token to leave an event when user is anonymous"""
   cancellationToken: String
+
+  """The eventual message the participant left"""
+  message: String
 }
 
 enum ParticipantRoleEnum {
@@ -1082,203 +1088,9 @@ enum ReportStatus {
 }
 
 type RootMutationType {
-  saveAdminSettings(instanceDescription: String, instanceName: String, instanceTerms: String, instanceTermsType: InstanceTermsType, instanceTermsUrl: String, registrationsOpen: Boolean): AdminSettings
-  changeEmail(email: String!, password: String!): User
-
-  """Create a comment"""
-  createComment(actorId: ID!, eventId: ID, inReplyToCommentId: ID, text: String!): Comment
-
-  """Create an user"""
-  createUser(email: String!, locale: String, password: String!): User
-
-  """Update an identity"""
-  updatePerson(
-    """
-    The avatar for the profile, either as an object or directly the ID of an existing Picture
-    """
-    avatar: PictureInput
-
-    """
-    The banner for the profile, either as an object or directly the ID of an existing Picture
-    """
-    banner: PictureInput
-    id: ID!
-
-    """The displayed name for this profile"""
-    name: String
-
-    """The summary for this profile"""
-    summary: String
-  ): Person
-
-  """Create an event"""
-  createEvent(
-    beginsOn: DateTime!
-    category: String = "meeting"
-    description: String!
-    draft: Boolean = false
-    endsOn: DateTime
-    joinOptions: EventJoinOptions = FREE
-    onlineAddress: String
-    options: EventOptionsInput
-    organizerActorId: ID!
-    phoneAddress: String
-    physicalAddress: AddressInput
-
-    """
-    The picture for the event, either as an object or directly the ID of an existing Picture
-    """
-    picture: PictureInput
-    publishAt: DateTime
-    status: EventStatus
-
-    """The list of tags associated to the event"""
-    tags: [String] = [""]
-    title: String!
-    visibility: EventVisibility = PUBLIC
-  ): Event
-  validateEmail(token: String!): User
-
-  """Delete an event"""
-  deleteEvent(actorId: ID!, eventId: ID!): DeletedObject
-
-  """Accept a participation"""
-  updateParticipation(id: ID!, moderatorActorId: ID!, role: ParticipantRoleEnum!): Participant
-
-  """Leave an event"""
-  leaveEvent(actorId: ID!, eventId: ID!, token: String): DeletedParticipant
-
-  """Delete an identity"""
-  deletePerson(id: ID!): Person
-
-  """Refresh a token"""
-  refreshToken(refreshToken: String!): RefreshedToken
-
-  """Validate an user after registration"""
-  validateUser(token: String!): Login
-
-  """Upload a picture"""
-  uploadPicture(actorId: ID!, alt: String, file: Upload!, name: String!): Picture
-
-  """Delete a feed token"""
-  deleteFeedToken(token: String!): DeletedFeedToken
-
-  """Create a note on a report"""
-  createReportNote(content: String, moderatorId: ID!, reportId: ID!): ReportNote
-
-  """Leave an event"""
-  leaveGroup(actorId: ID!, groupId: ID!): DeletedMember
-
-  """Create a Feed Token"""
-  createFeedToken(actorId: ID): FeedToken
-
   """Send a link through email to reset user password"""
   sendResetPassword(email: String!, locale: String): String
 
-  """Delete a relay subscription"""
-  removeRelay(address: String!): Follower
-
-  """Change default actor for user"""
-  changeDefaultActor(preferredUsername: String!): User
-  deleteReportNote(moderatorId: ID!, noteId: ID!): DeletedObject
-
-  """Create a report"""
-  createReport(commentsIds: [ID] = [""], content: String, eventId: ID, forward: Boolean = false, reportedId: ID!, reporterId: ID!): Report
-
-  """Register a first profile on registration"""
-  registerPerson(
-    """
-    The avatar for the profile, either as an object or directly the ID of an existing Picture
-    """
-    avatar: PictureInput
-
-    """
-    The banner for the profile, either as an object or directly the ID of an existing Picture
-    """
-    banner: PictureInput
-
-    """The email from the user previously created"""
-    email: String!
-
-    """The displayed name for the new profile"""
-    name: String = ""
-    preferredUsername: String!
-
-    """The summary for the new profile"""
-    summary: String = ""
-  ): Person
-
-  """Delete a group"""
-  deleteGroup(actorId: ID!, groupId: ID!): DeletedObject
-  deleteAccount(password: String!): DeletedObject
-
-  """Add a relay subscription"""
-  addRelay(address: String!): Follower
-
-  """Reset user password"""
-  resetPassword(locale: String = "en", password: String!, token: String!): Login
-
-  """Create a group"""
-  createGroup(
-    """
-    The avatar for the group, either as an object or directly the ID of an existing Picture
-    """
-    avatar: PictureInput
-
-    """
-    The banner for the group, either as an object or directly the ID of an existing Picture
-    """
-    banner: PictureInput
-
-    """The identity that creates the group"""
-    creatorActorId: ID!
-
-    """The displayed name for the group"""
-    name: String
-
-    """The name for the group"""
-    preferredUsername: String!
-
-    """The summary for the group"""
-    summary: String = ""
-  ): Group
-
-  """Confirm a participation"""
-  confirmParticipation(confirmationToken: String!): Participant
-  deleteComment(actorId: ID!, commentId: ID!): Comment
-
-  """Join an event"""
-  joinEvent(actorId: ID!, email: String, eventId: ID!): Participant
-
-  """Accept a relay subscription"""
-  acceptRelay(address: String!): Follower
-
-  """Join a group"""
-  joinGroup(actorId: ID!, groupId: ID!): Member
-
-  """Reject a relay subscription"""
-  rejectRelay(address: String!): Follower
-
-  """Create a new person for user"""
-  createPerson(
-    """
-    The avatar for the profile, either as an object or directly the ID of an existing Picture
-    """
-    avatar: PictureInput
-
-    """
-    The banner for the profile, either as an object or directly the ID of an existing Picture
-    """
-    banner: PictureInput
-
-    """The displayed name for the new profile"""
-    name: String = ""
-    preferredUsername: String!
-
-    """The summary for the new profile"""
-    summary: String = ""
-  ): Person
-
   """Update an event"""
   updateEvent(
     beginsOn: DateTime
@@ -1305,18 +1117,211 @@ type RootMutationType {
     title: String
     visibility: EventVisibility = PUBLIC
   ): Event
+  validateEmail(token: String!): User
+
+  """Change default actor for user"""
+  changeDefaultActor(preferredUsername: String!): User
+
+  """Leave an event"""
+  leaveEvent(actorId: ID!, eventId: ID!, token: String): DeletedParticipant
+  deleteAccount(password: String!): DeletedObject
+
+  """Delete a group"""
+  deleteGroup(actorId: ID!, groupId: ID!): DeletedObject
+
+  """Create an user"""
+  createUser(email: String!, locale: String, password: String!): User
+
+  """Add a relay subscription"""
+  addRelay(address: String!): Follower
+
+  """Delete an identity"""
+  deletePerson(id: ID!): Person
+
+  """Accept a relay subscription"""
+  acceptRelay(address: String!): Follower
+
+  """Refresh a token"""
+  refreshToken(refreshToken: String!): RefreshedToken
+
+  """Update an identity"""
+  updatePerson(
+    """
+    The avatar for the profile, either as an object or directly the ID of an existing Picture
+    """
+    avatar: PictureInput
+
+    """
+    The banner for the profile, either as an object or directly the ID of an existing Picture
+    """
+    banner: PictureInput
+    id: ID!
+
+    """The displayed name for this profile"""
+    name: String
+
+    """The summary for this profile"""
+    summary: String
+  ): Person
+
+  """Create a report"""
+  createReport(commentsIds: [ID] = [""], content: String, eventId: ID, forward: Boolean = false, reportedId: ID!, reporterId: ID!): Report
+
+  """Delete a feed token"""
+  deleteFeedToken(token: String!): DeletedFeedToken
+
+  """Reset user password"""
+  resetPassword(locale: String = "en", password: String!, token: String!): Login
+
+  """Login an user"""
+  login(email: String!, password: String!): Login
+
+  """Leave an event"""
+  leaveGroup(actorId: ID!, groupId: ID!): DeletedMember
+
+  """Register a first profile on registration"""
+  registerPerson(
+    """
+    The avatar for the profile, either as an object or directly the ID of an existing Picture
+    """
+    avatar: PictureInput
+
+    """
+    The banner for the profile, either as an object or directly the ID of an existing Picture
+    """
+    banner: PictureInput
+
+    """The email from the user previously created"""
+    email: String!
+
+    """The displayed name for the new profile"""
+    name: String = ""
+    preferredUsername: String!
+
+    """The summary for the new profile"""
+    summary: String = ""
+  ): Person
+
+  """Delete a relay subscription"""
+  removeRelay(address: String!): Follower
 
   """Change an user password"""
   changePassword(newPassword: String!, oldPassword: String!): User
 
-  """Update a report"""
-  updateReportStatus(moderatorId: ID!, reportId: ID!, status: ReportStatus!): Report
-
   """Resend registration confirmation token"""
   resendConfirmationEmail(email: String!, locale: String): String
 
-  """Login an user"""
-  login(email: String!, password: String!): Login
+  """Confirm a participation"""
+  confirmParticipation(confirmationToken: String!): Participant
+
+  """Delete an event"""
+  deleteEvent(actorId: ID!, eventId: ID!): DeletedObject
+
+  """Update a report"""
+  updateReportStatus(moderatorId: ID!, reportId: ID!, status: ReportStatus!): Report
+
+  """Create a group"""
+  createGroup(
+    """
+    The avatar for the group, either as an object or directly the ID of an existing Picture
+    """
+    avatar: PictureInput
+
+    """
+    The banner for the group, either as an object or directly the ID of an existing Picture
+    """
+    banner: PictureInput
+
+    """The identity that creates the group"""
+    creatorActorId: ID!
+
+    """The displayed name for the group"""
+    name: String
+
+    """The name for the group"""
+    preferredUsername: String!
+
+    """The summary for the group"""
+    summary: String = ""
+  ): Group
+
+  """Validate an user after registration"""
+  validateUser(token: String!): Login
+
+  """Join an event"""
+  joinEvent(actorId: ID!, email: String, eventId: ID!, message: String): Participant
+  deleteReportNote(moderatorId: ID!, noteId: ID!): DeletedObject
+  deleteComment(actorId: ID!, commentId: ID!): Comment
+
+  """Reject a relay subscription"""
+  rejectRelay(address: String!): Follower
+
+  """Create a comment"""
+  createComment(actorId: ID!, eventId: ID, inReplyToCommentId: ID, text: String!): Comment
+
+  """Create a note on a report"""
+  createReportNote(content: String, moderatorId: ID!, reportId: ID!): ReportNote
+
+  """Accept a participation"""
+  updateParticipation(id: ID!, moderatorActorId: ID!, role: ParticipantRoleEnum!): Participant
+
+  """Create a Feed Token"""
+  createFeedToken(actorId: ID): FeedToken
+
+  """Join a group"""
+  joinGroup(actorId: ID!, groupId: ID!): Member
+
+  """Create a new person for user"""
+  createPerson(
+    """
+    The avatar for the profile, either as an object or directly the ID of an existing Picture
+    """
+    avatar: PictureInput
+
+    """
+    The banner for the profile, either as an object or directly the ID of an existing Picture
+    """
+    banner: PictureInput
+
+    """The displayed name for the new profile"""
+    name: String = ""
+    preferredUsername: String!
+
+    """The summary for the new profile"""
+    summary: String = ""
+  ): Person
+
+  """Upload a picture"""
+  uploadPicture(actorId: ID!, alt: String, file: Upload!, name: String!): Picture
+
+  """Create an event"""
+  createEvent(
+    beginsOn: DateTime!
+    category: String = "meeting"
+    description: String!
+    draft: Boolean = false
+    endsOn: DateTime
+    joinOptions: EventJoinOptions = FREE
+    onlineAddress: String
+    options: EventOptionsInput
+    organizerActorId: ID!
+    phoneAddress: String
+    physicalAddress: AddressInput
+
+    """
+    The picture for the event, either as an object or directly the ID of an existing Picture
+    """
+    picture: PictureInput
+    publishAt: DateTime
+    status: EventStatus
+
+    """The list of tags associated to the event"""
+    tags: [String] = [""]
+    title: String!
+    visibility: EventVisibility = PUBLIC
+  ): Event
+  saveAdminSettings(instanceDescription: String, instanceName: String, instanceTerms: String, instanceTermsType: InstanceTermsType, instanceTermsUrl: String, registrationsOpen: Boolean): AdminSettings
+  changeEmail(email: String!, password: String!): User
 }
 
 """
diff --git a/test/federation/activity_pub/transmogrifier_test.exs b/test/federation/activity_pub/transmogrifier_test.exs
index 08c1fae40..9d5127dd2 100644
--- a/test/federation/activity_pub/transmogrifier_test.exs
+++ b/test/federation/activity_pub/transmogrifier_test.exs
@@ -818,6 +818,7 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do
       assert activity.data["cc"] == []
     end
 
+    @join_message "I want to get in!"
     test "it accepts Join activities" do
       %Actor{url: organizer_url} = organizer = insert(:actor)
       %Actor{url: participant_url} = _participant = insert(:actor)
@@ -829,13 +830,19 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do
         |> Jason.decode!()
         |> Map.put("actor", participant_url)
         |> Map.put("object", event_url)
+        |> Map.put("participationMessage", @join_message)
 
-      assert {:ok, activity, _} = Transmogrifier.handle_incoming(join_data)
+      assert {:ok, activity, %Participant{} = participant} =
+               Transmogrifier.handle_incoming(join_data)
+
+      assert participant.metadata.message == @join_message
+      assert participant.role == :participant
 
       assert activity.data["type"] == "Accept"
       assert activity.data["object"]["object"] == event_url
       assert activity.data["object"]["id"] =~ "/join/event/"
       assert activity.data["object"]["type"] =~ "Join"
+      assert activity.data["object"]["participationMessage"] == @join_message
       assert activity.data["actor"] == organizer_url
       assert activity.data["id"] =~ "/accept/join/"
     end
@@ -894,6 +901,7 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do
       # Organiser is not present since we use factories directly
       assert event.id
              |> Events.list_participants_for_event()
+             |> Map.get(:elements)
              |> Enum.map(& &1.id) ==
                []
     end
@@ -924,6 +932,7 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do
       # The only participant left is the organizer
       assert event.id
              |> Events.list_participants_for_event()
+             |> Map.get(:elements)
              |> Enum.map(& &1.id) ==
                [organizer_participation.id]
     end
diff --git a/test/graphql/resolvers/participant_test.exs b/test/graphql/resolvers/participant_test.exs
index 4a4e35310..27607c818 100644
--- a/test/graphql/resolvers/participant_test.exs
+++ b/test/graphql/resolvers/participant_test.exs
@@ -7,6 +7,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
   alias Mobilizon.Events
   alias Mobilizon.Events.{Event, EventParticipantStats, Participant}
   alias Mobilizon.GraphQL.AbsintheHelpers
+  alias Mobilizon.Storage.Page
   alias Mobilizon.Web.Email
 
   import Mobilizon.Factory
@@ -446,9 +447,11 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
           participants(roles: "participant,moderator,administrator,creator", actor_id: "#{
         actor.id
       }") {
-            role,
-            actor {
-                preferredUsername
+            elements {
+              role,
+              actor {
+                  preferredUsername
+              }
             }
           }
         }
@@ -462,7 +465,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
 
       assert json_response(res, 200)["errors"] == nil
 
-      assert json_response(res, 200)["data"]["event"]["participants"] == [
+      assert json_response(res, 200)["data"]["event"]["participants"]["elements"] == [
                %{
                  "actor" => %{
                    "preferredUsername" => actor.preferred_username
@@ -485,9 +488,11 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
           participants(page: 1, limit: 1, roles: "participant,moderator,administrator,creator", actorId: "#{
         actor.id
       }") {
-            role,
-            actor {
-                preferredUsername
+            elements {
+              role,
+              actor {
+                  preferredUsername
+              }
             }
           }
         }
@@ -500,7 +505,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
         |> get("/api", AbsintheHelpers.query_skeleton(query, "participants"))
 
       sorted_participants =
-        json_response(res, 200)["data"]["event"]["participants"]
+        json_response(res, 200)["data"]["event"]["participants"]["elements"]
         |> Enum.filter(&(&1["role"] == "PARTICIPANT"))
 
       assert sorted_participants == [
@@ -518,9 +523,11 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
           participants(page: 2, limit: 1, roles: "participant,moderator,administrator,creator", actorId: "#{
         actor.id
       }") {
-            role,
-            actor {
-                preferredUsername
+            elements {
+              role,
+              actor {
+                  preferredUsername
+              }
             }
           }
         }
@@ -533,7 +540,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
         |> get("/api", AbsintheHelpers.query_skeleton(query, "participants"))
 
       sorted_participants =
-        json_response(res, 200)["data"]["event"]["participants"]
+        json_response(res, 200)["data"]["event"]["participants"]["elements"]
         |> Enum.sort_by(
           &(&1
             |> Map.get("actor")
@@ -1053,7 +1060,8 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
       assert res["data"]["joinEvent"]["event"]["id"] == to_string(event.id)
       assert res["data"]["joinEvent"]["actor"]["id"] == to_string(actor_id)
 
-      %Participant{} = participant = event.id |> Events.list_participants_for_event() |> hd
+      %Participant{} =
+        participant = event.id |> Events.list_participants_for_event() |> Map.get(:elements) |> hd
 
       assert participant.metadata.email == @email
 
@@ -1093,7 +1101,9 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
       assert %Participant{
                metadata: %{confirmation_token: confirmation_token},
                role: :not_confirmed
-             } = participant = event.id |> Events.list_participants_for_event([]) |> hd()
+             } =
+               participant =
+               event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd()
 
       # hack to avoid preloading event in participant
       participant = Map.put(participant, :event, event)
@@ -1118,7 +1128,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
       )
 
       assert %Participant{role: :participant} =
-               event.id |> Events.list_participants_for_event() |> hd()
+               event.id |> Events.list_participants_for_event() |> Map.get(:elements) |> hd()
     end
 
     test "I can participate anonymously and and confirm my participation with bad token",
@@ -1140,7 +1150,9 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
       assert res["data"]["joinEvent"]["event"]["id"] == to_string(event.id)
       assert res["data"]["joinEvent"]["actor"]["id"] == to_string(actor_id)
 
-      %Participant{} = participant = event.id |> Events.list_participants_for_event([]) |> hd
+      %Participant{} =
+        participant =
+        event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd
 
       assert participant.metadata.email == @email
 
@@ -1157,7 +1169,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
       assert hd(res["errors"])["message"] == "This token is invalid"
 
       assert %Participant{role: :not_confirmed} =
-               event.id |> Events.list_participants_for_event([]) |> hd()
+               event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd()
     end
 
     test "I can participate anonymously but change my mind and cancel my participation",
@@ -1181,7 +1193,9 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
 
       {:ok, %Event{participant_stats: %{not_confirmed: 1}}} = Events.get_event(event.id)
 
-      %Participant{} = participant = event.id |> Events.list_participants_for_event([]) |> hd
+      %Participant{} =
+        participant =
+        event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd
 
       assert participant.metadata.email == @email
 
@@ -1205,7 +1219,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
                id: participant_id,
                role: :not_confirmed,
                metadata: %{cancellation_token: cancellation_token}
-             } = event.id |> Events.list_participants_for_event([]) |> hd()
+             } = event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd()
 
       res =
         conn
@@ -1221,7 +1235,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
       assert res["data"]["leaveEvent"]["id"] == participant_id
 
       {:ok, %Event{participant_stats: %{not_confirmed: 0}}} = Events.get_event(event.id)
-      assert Events.list_participants_for_event(event.id, []) == []
+      assert Events.list_participants_for_event(event.id, []) == %Page{elements: [], total: 0}
     end
 
     test "I can participate anonymously, confirm my participation and then be confirmed by the organizer",
@@ -1274,7 +1288,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
       assert %Participant{
                role: :not_confirmed,
                metadata: %{confirmation_token: confirmation_token, email: @email}
-             } = event.id |> Events.list_participants_for_event([]) |> hd
+             } = event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd
 
       conn
       |> AbsintheHelpers.graphql_query(
@@ -1293,7 +1307,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
               }} = Events.get_event(event.id)
 
       assert %Participant{role: :not_approved, id: participant_id} =
-               event.id |> Events.list_participants_for_event([]) |> hd
+               event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd
 
       update_participation_mutation = """
           mutation UpdateParticipation($participantId: ID!, $role: String!, $moderatorActorId: ID!) {
@@ -1325,7 +1339,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do
       assert res["errors"] == nil
 
       assert %Participant{role: :participant} =
-               event.id |> Events.list_participants_for_event([]) |> hd
+               event.id |> Events.list_participants_for_event([]) |> Map.get(:elements) |> hd
 
       assert {:ok,
               %Event{
diff --git a/test/mobilizon/events/events_test.exs b/test/mobilizon/events/events_test.exs
index 6a34b4720..7dda47589 100644
--- a/test/mobilizon/events/events_test.exs
+++ b/test/mobilizon/events/events_test.exs
@@ -110,8 +110,11 @@ defmodule Mobilizon.EventsTest do
       assert event.ends_on == DateTime.from_naive!(~N[2010-04-17 14:00:00Z], "Etc/UTC")
       assert event.title == "some title"
 
-      assert hd(Events.list_participants_for_event(event.id)).actor.id == actor.id
-      assert hd(Events.list_participants_for_event(event.id)).role == :creator
+      assert %Participant{} =
+               participant = hd(Events.list_participants_for_event(event.id).elements)
+
+      assert participant.actor.id == actor.id
+      assert participant.role == :creator
     end
 
     test "create_event/1 with invalid data returns error changeset" do