diff --git a/keyserver/src/creators/invite-link-creator.js b/keyserver/src/creators/invite-link-creator.js
new file mode 100644
--- /dev/null
+++ b/keyserver/src/creators/invite-link-creator.js
@@ -0,0 +1,121 @@
+// @flow
+
+import type {
+  CreateOrUpdatePublicLinkRequest,
+  InviteLink,
+} from 'lib/types/link-types.js';
+import { threadPermissions } from 'lib/types/thread-permission-types.js';
+import { ServerError } from 'lib/utils/errors.js';
+
+import createIDs from './id-creator.js';
+import { dbQuery, SQL } from '../database/database.js';
+import { fetchPrimaryInviteLinks } from '../fetchers/link-fetchers.js';
+import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js';
+import { checkThreadPermission } from '../fetchers/thread-permission-fetchers.js';
+import { Viewer } from '../session/viewer.js';
+
+const secretRegex = /^[a-zA-Z0-9]+$/;
+
+async function createOrUpdatePublicLink(
+  viewer: Viewer,
+  request: CreateOrUpdatePublicLinkRequest,
+): Promise<InviteLink> {
+  if (!secretRegex.test(request.name)) {
+    throw new ServerError('invalid_parameters');
+  }
+
+  const permissionPromise = checkThreadPermission(
+    viewer,
+    request.communityID,
+    threadPermissions.MANAGE_INVITE_LINKS,
+  );
+  const existingPrimaryLinksPromise = fetchPrimaryInviteLinks(viewer);
+  const fetchThreadInfoPromise = fetchServerThreadInfos(
+    SQL`t.id = ${request.communityID}`,
+  );
+  const [hasPermission, existingPrimaryLinks, { threadInfos }] =
+    await Promise.all([
+      permissionPromise,
+      existingPrimaryLinksPromise,
+      fetchThreadInfoPromise,
+    ]);
+  if (!hasPermission) {
+    throw new ServerError('invalid_credentials');
+  }
+  const threadInfo = threadInfos[request.communityID];
+  if (!threadInfo) {
+    throw new ServerError('invalid_parameters');
+  }
+  const defaultRoleID = Object.keys(threadInfo.roles).find(
+    roleID => threadInfo.roles[roleID].isDefault,
+  );
+  if (!defaultRoleID) {
+    throw new ServerError('invalid_parameters');
+  }
+
+  const existingPrimaryLink = existingPrimaryLinks.find(
+    link => link.communityID === request.communityID && link.primary,
+  );
+  if (existingPrimaryLink) {
+    const query = SQL`
+      UPDATE invite_links
+      SET name = ${request.name}
+      WHERE \`primary\` = 1 AND community = ${request.communityID}
+    `;
+    try {
+      await dbQuery(query);
+    } catch {
+      throw new ServerError('invalid_parameters');
+    }
+    return {
+      name: request.name,
+      primary: true,
+      role: defaultRoleID,
+      communityID: request.communityID,
+      expirationTime: null,
+      limitOfUses: null,
+      numberOfUses: 0,
+    };
+  }
+
+  const [id] = await createIDs('invite_links', 1);
+
+  const row = [id, request.name, true, request.communityID, defaultRoleID];
+
+  const createLinkQuery = SQL`
+    INSERT INTO invite_links(id, name, \`primary\`, community, role)
+    SELECT ${row}
+    WHERE NOT EXISTS (
+      SELECT i.id
+      FROM invite_links i
+      WHERE i.\`primary\` = 1 AND i.community = ${request.communityID}
+    )
+  `;
+  let result = null;
+  try {
+    result = (await dbQuery(createLinkQuery))[0];
+  } catch {
+    throw new ServerError('invalid_parameters');
+  }
+
+  if (result.affectedRows === 0) {
+    const deleteIDs = SQL`
+      DELETE FROM ids
+      WHERE id = ${id}
+    `;
+    await dbQuery(deleteIDs);
+    throw new ServerError('invalid_parameters');
+  }
+
+  return {
+    name: request.name,
+    primary: true,
+    role: defaultRoleID,
+    communityID: request.communityID,
+    expirationTime: null,
+    limitOfUses: null,
+    numberOfUses: 0,
+  };
+}
+
+export { createOrUpdatePublicLink };
diff --git a/keyserver/src/endpoints.js b/keyserver/src/endpoints.js
--- a/keyserver/src/endpoints.js
+++ b/keyserver/src/endpoints.js
@@ -20,6 +20,7 @@
 import type { JSONResponder } from './responders/handlers.js';
 import { getSessionPublicKeysResponder } from './responders/keys-responders.js';
 import {
+  createOrUpdatePublicLinkResponder,
   fetchPrimaryInviteLinksResponder,
   inviteLinkVerificationResponder,
 } from './responders/link-responders.js';
@@ -97,6 +98,10 @@
     responder: multimediaMessageCreationResponder,
     requiredPolicies: baseLegalPolicies,
   },
+  create_or_update_public_link: {
+    responder: createOrUpdatePublicLinkResponder,
+    requiredPolicies: baseLegalPolicies,
+  },
   create_reaction_message: {
     responder: reactionMessageCreationResponder,
     requiredPolicies: baseLegalPolicies,
diff --git a/keyserver/src/responders/link-responders.js b/keyserver/src/responders/link-responders.js
--- a/keyserver/src/responders/link-responders.js
+++ b/keyserver/src/responders/link-responders.js
@@ -6,10 +6,13 @@
   type InviteLinkVerificationRequest,
   type InviteLinkVerificationResponse,
   type FetchInviteLinksResponse,
+  type InviteLink,
   inviteLinkValidator,
+  type CreateOrUpdatePublicLinkRequest,
 } from 'lib/types/link-types.js';
 import { tShape, tID } from 'lib/utils/validation-utils.js';
 
+import { createOrUpdatePublicLink } from '../creators/invite-link-creator.js';
 import {
   fetchPrimaryInviteLinks,
   verifyInviteLink,
@@ -71,4 +74,27 @@
   );
 }
 
-export { inviteLinkVerificationResponder, fetchPrimaryInviteLinksResponder };
+const createOrUpdatePublicLinkInputValidator: TInterface<CreateOrUpdatePublicLinkRequest> =
+  tShape({
+    name: t.String,
+    communityID: tID,
+  });
+
+async function createOrUpdatePublicLinkResponder(
+  viewer: Viewer,
+  input: mixed,
+): Promise<InviteLink> {
+  const request = await validateInput(
+    viewer,
+    createOrUpdatePublicLinkInputValidator,
+    input,
+  );
+  const response = await createOrUpdatePublicLink(viewer, request);
+  return validateOutput(viewer.platformDetails, inviteLinkValidator, response);
+}
+
+export {
+  inviteLinkVerificationResponder,
+  fetchPrimaryInviteLinksResponder,
+  createOrUpdatePublicLinkResponder,
+};
diff --git a/lib/types/endpoints.js b/lib/types/endpoints.js
--- a/lib/types/endpoints.js
+++ b/lib/types/endpoints.js
@@ -51,6 +51,7 @@
   CREATE_ERROR_REPORT: 'create_error_report',
   CREATE_MESSAGE_REPORT: 'create_message_report',
   CREATE_MULTIMEDIA_MESSAGE: 'create_multimedia_message',
+  CREATE_OR_UPDATE_PUBLIC_LINK: 'create_or_update_public_link',
   CREATE_REACTION_MESSAGE: 'create_reaction_message',
   EDIT_MESSAGE: 'edit_message',
   CREATE_TEXT_MESSAGE: 'create_text_message',
diff --git a/lib/types/link-types.js b/lib/types/link-types.js
--- a/lib/types/link-types.js
+++ b/lib/types/link-types.js
@@ -55,3 +55,8 @@
 export type InviteLinksStore = {
   +links: InviteLinks,
 };
+
+export type CreateOrUpdatePublicLinkRequest = {
+  +name: string,
+  +communityID: string,
+};