diff --git a/web-services-app/pom.xml b/web-services-app/pom.xml
index 35e44b0794..ac8b7f2410 100644
--- a/web-services-app/pom.xml
+++ b/web-services-app/pom.xml
@@ -166,6 +166,12 @@
wiremock-jre8
test
+
+ info.freelibrary
+ jiiify-presentation-v3
+
+ 0.12.3
+
jakarta.servlet
diff --git a/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/processing/IiifV3ManifestService.java b/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/processing/IiifV3ManifestService.java
new file mode 100644
index 0000000000..fc14a5f213
--- /dev/null
+++ b/web-services-app/src/main/java/edu/unc/lib/boxc/web/services/processing/IiifV3ManifestService.java
@@ -0,0 +1,241 @@
+package edu.unc.lib.boxc.web.services.processing;
+
+import edu.unc.lib.boxc.auth.api.Permission;
+import edu.unc.lib.boxc.auth.api.models.AgentPrincipals;
+import edu.unc.lib.boxc.auth.api.services.AccessControlService;
+import edu.unc.lib.boxc.auth.api.services.DatastreamPermissionUtil;
+import edu.unc.lib.boxc.common.util.URIUtil;
+import edu.unc.lib.boxc.model.api.DatastreamType;
+import edu.unc.lib.boxc.model.api.ResourceType;
+import edu.unc.lib.boxc.model.api.exceptions.NotFoundException;
+import edu.unc.lib.boxc.model.api.ids.PID;
+import edu.unc.lib.boxc.search.api.models.ContentObjectRecord;
+import edu.unc.lib.boxc.search.api.models.Datastream;
+import edu.unc.lib.boxc.web.common.services.AccessCopiesService;
+import info.freelibrary.iiif.presentation.v3.AnnotationPage;
+import info.freelibrary.iiif.presentation.v3.Canvas;
+import info.freelibrary.iiif.presentation.v3.ImageContent;
+import info.freelibrary.iiif.presentation.v3.Manifest;
+import info.freelibrary.iiif.presentation.v3.PaintingAnnotation;
+import info.freelibrary.iiif.presentation.v3.properties.Label;
+import info.freelibrary.iiif.presentation.v3.properties.Metadata;
+import info.freelibrary.iiif.presentation.v3.properties.RequiredStatement;
+import info.freelibrary.iiif.presentation.v3.services.ImageService3;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.util.CollectionUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static edu.unc.lib.boxc.model.api.DatastreamType.JP2_ACCESS_COPY;
+
+/**
+ * Service for generating iiif v3 manifests for repository object
+ * @author bbpennel
+ */
+public class IiifV3ManifestService {
+ private static final Logger log = LoggerFactory.getLogger(IiifV3ManifestService.class);
+ private AccessCopiesService accessCopiesService;
+ private AccessControlService accessControlService;
+ private String baseIiifv3Path;
+ private String baseAccessPath;
+ private String baseServicesApiPath;
+
+ /**
+ * Constructs a manifest record for the object identified by the provided PID
+ * @param pid
+ * @param agent
+ * @return
+ */
+ public Manifest buildManifest(PID pid, AgentPrincipals agent) {
+ assertHasAccess(pid, agent);
+ var contentObjs = accessCopiesService.listViewableFiles(pid, agent.getPrincipals());
+ if (contentObjs.size() == 0) {
+ throw new NotFoundException("No objects were found for inclusion in manifest for object " + pid.getId());
+ }
+ log.debug("Constructing manifest for {} containing {} items", pid.getId(), contentObjs.size());
+ ContentObjectRecord rootObj = contentObjs.get(0);
+ var manifest = new Manifest(getManifestPath(rootObj), new Label(getTitle(rootObj)));
+ manifest.setMetadata(constructMetadataSection(rootObj));
+ addAttribution(manifest, rootObj);
+
+ addCanvasItems(manifest, contentObjs);
+
+ return manifest;
+ }
+
+ private List constructMetadataSection(ContentObjectRecord rootObj) {
+ var metadataList = new ArrayList();
+ String abstractText = rootObj.getAbstractText();
+ if (abstractText != null) {
+ metadataList.add(new Metadata("description", abstractText));
+ }
+ addMultiValuedMetadataField(metadataList, "Creators", rootObj.getCreator());
+ addMultiValuedMetadataField(metadataList, "Subjects", rootObj.getSubject());
+ addMultiValuedMetadataField(metadataList, "Languages", rootObj.getLanguage());
+ metadataList.add(new Metadata("", "View full record"));
+ return metadataList;
+ }
+
+ private void addMultiValuedMetadataField(List metadataList, String fieldName, List values) {
+ if (!CollectionUtils.isEmpty(values)) {
+ metadataList.add(new Metadata(fieldName, String.join(", ", values)));
+ }
+ }
+
+ private String getTitle(ContentObjectRecord contentObj) {
+ String title = contentObj.getTitle();
+ return (title != null) ? title : "";
+ }
+
+ private void addAttribution(Manifest manifest, ContentObjectRecord rootObj) {
+ String attribution = "University of North Carolina Libraries, Digital Collections Repository";
+ String collection = rootObj.getParentCollectionName();
+ if (collection != null) {
+ attribution += " - Part of " + collection;
+ }
+ manifest.setRequiredStatement(new RequiredStatement("Attribution", attribution));
+ }
+
+ /**
+ * Add canvas items for each record in the set being processed
+ * @param manifest
+ * @param contentObjs
+ */
+ private void addCanvasItems(Manifest manifest, List contentObjs) {
+ var canvases = new ArrayList