diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/controller/SubmitAppController.java b/core/src/main/java/nl/tudelft/ewi/auta/core/controller/SubmitAppController.java
new file mode 100644
index 0000000000000000000000000000000000000000..147f999704f5646226b2ce7fbc46ee0b7896935b
--- /dev/null
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/controller/SubmitAppController.java
@@ -0,0 +1,329 @@
+package nl.tudelft.ewi.auta.core.controller;
+
+import com.google.gson.Gson;
+import freemarker.template.TemplateException;
+import nl.tudelft.ewi.auta.common.model.metric.Verdict;
+import nl.tudelft.ewi.auta.core.database.EntityContainer;
+import nl.tudelft.ewi.auta.core.database.Repositories;
+import nl.tudelft.ewi.auta.core.dto.SubmitAppSubmissionDto;
+import nl.tudelft.ewi.auta.core.dto.SubmitAppFeedbackDto;
+import nl.tudelft.ewi.auta.core.jobs.JobQueue;
+import nl.tudelft.ewi.auta.core.model.FileStore;
+import nl.tudelft.ewi.auta.core.model.Job;
+import nl.tudelft.ewi.auta.core.model.Submission;
+import nl.tudelft.ewi.auta.core.model.SubmitAppMetadata;
+import nl.tudelft.ewi.auta.core.report.ReportCreatedEvent;
+import nl.tudelft.ewi.auta.core.report.SubmitAppReportGenerator;
+import nl.tudelft.ewi.auta.core.response.Response;
+import nl.tudelft.ewi.auta.core.response.exception.InvalidFileNameException;
+import nl.tudelft.ewi.auta.core.response.exception.NoSuchSubmissionException;
+import nl.tudelft.ewi.auta.srf.model.AccessLevel;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.ApplicationListener;
+import org.springframework.data.util.Pair;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.time.Instant;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * A controller interfacing with Submit.
+ */
+@RestController
+public class SubmitAppController extends ControllerBase
+        implements ApplicationListener<ReportCreatedEvent> {
+    private static final Logger logger = LoggerFactory.getLogger(SubmitAppController.class);
+
+    /**
+     * The repositories.
+     */
+    private final Repositories repositories;
+
+    /**
+     * The file repository for uploaded files.
+     */
+    private final FileStore files;
+
+    /**
+     * The queue of job messages to be sent to the workers.
+     */
+    private final JobQueue queue;
+
+    /**
+     * The report generator.
+     */
+    private final SubmitAppReportGenerator reportGenerator;
+
+    /**
+     * The JSON serialization service.
+     */
+    private final Gson gson;
+
+    /**
+     * The access levels to use when generating feedback for the student.
+     */
+    private static final Map<AccessLevel, String> SUBMIT_ACCESS_LEVEL_MAP = Map.of(
+            AccessLevel.PUBLIC, "STUDENT",
+            AccessLevel.SUBMITTER, "STUDENT",
+            AccessLevel.EDUCATIONAL, "TA",
+            AccessLevel.INSTRUCTOR, "TEACHER"
+    );
+
+    /**
+     * Submit grade scheme ranges.
+     */
+    private static final Map<String, Pair<Integer, Integer>> SUBMIT_GRADE_SCHEME_RANGE = Map.of(
+            "PASS_FAIL", Pair.of(0, 1),
+            "DUTCH_GRADE", Pair.of(10, 100),
+            "DUTCH_GRADE_1000", Pair.of(100, 1000),
+            "SCORE_OUT_OF_10", Pair.of(0, 10),
+            "SCORE_OUT_OF_100", Pair.of(0, 100),
+            "SCORE_OUT_OF_1000", Pair.of(0, 1000),
+            "SCORE_OUT_OF_10000", Pair.of(0, 10000),
+            "UNLIMITED", Pair.of(0, Integer.MAX_VALUE)
+    );
+
+    /**
+     * Creates a new Submit integration controller.
+     *
+     * @param repositories the repositories
+     * @param files the file store
+     * @param queue the job queue
+     * @param reportGenerator the report generator
+     * @param gson the JSON serializer
+     */
+    public SubmitAppController(
+            final Repositories repositories,
+            final FileStore files,
+            final JobQueue queue,
+            final SubmitAppReportGenerator reportGenerator,
+            final Gson gson
+    ) {
+        this.repositories = repositories;
+        this.files = files;
+        this.queue = queue;
+        this.reportGenerator = reportGenerator;
+        this.gson = gson;
+    }
+
+    /**
+     * Receives a submission from Submit.
+     *
+     * This follows the
+     * <a href="https://gitlab.ewi.tudelft.nl/eip/labrador/submit/-/blob/7a0dd3e996fff2866a0e9d03807b356ce26c6002/docs/ScriptApi.md">
+     * Submit Script API</a>.
+     *
+     * @param aid the assignment's ID
+     * @param submissionDto the submission from Submit
+     *
+     * @return a 201 Created response containing an id field with the submission's ID, or an error
+     *
+     * @throws IOException if the uploaded file could not be processed
+     * @throws URISyntaxException if no valid URL could be generated for the submission
+     */
+    @PostMapping(
+            path = "/api/v1/assignment/{aid}/submission/from-submit"
+    )
+    public ResponseEntity<Response> receiveSubmission(
+            final @PathVariable String aid,
+            final @ModelAttribute SubmitAppSubmissionDto submissionDto
+    ) throws IOException, URISyntaxException {
+        final var res = new Response();
+
+        logger.debug("Receiving submission from Submit");
+
+        @Nullable
+        final var originalFilename = submissionDto.file.getOriginalFilename();
+        if (originalFilename == null || originalFilename.isEmpty()) {
+            throw new InvalidFileNameException("The file has no name");
+        }
+
+        final var submissionRepository = this.repositories.getSubmissionRepository();
+        final var assignmentRepository = this.repositories.getAssignmentRepository();
+
+        final var assignment = assignmentRepository.findExisting(aid);
+
+        final var extension = this.getExtension(originalFilename);
+        final String filename = submissionDto.submissionId + extension;
+
+        final var temp = Files.createTempFile("auta-upload-", extension);
+        submissionDto.file.transferTo(temp.toFile());
+        this.files.add(filename, temp);
+
+        var submission = new Submission();
+        submission.setName("submit-submission-" + submissionDto.submissionId);
+        submission.setAssignmentId(aid);
+        submission.setContents("/" + filename);
+        submission.getPipelineLog().setSubmitted(Instant.now());
+        submission.setSubmitAppMetadata(new SubmitAppMetadata(
+                submissionDto.submissionId,
+                submissionDto.scriptId,
+                submissionDto.key,
+                submissionDto.gradeScheme,
+                submissionDto.url
+        ));
+
+        submission = submissionRepository.save(submission);
+        @Nullable
+        final var sid = submission.getId();
+        Objects.requireNonNull(sid);
+        res.put("id", sid);
+
+        this.queue.add(new Job(assignment, submission));
+
+        return ResponseEntity.created(this.generateSubmissionUri(aid, sid)).body(res);
+    }
+
+    @Override
+    public void onApplicationEvent(final ReportCreatedEvent event) {
+        final var sid = event.getSubmissionId();
+        final var submissionRepo = this.repositories.getSubmissionRepository();
+        final var submission = submissionRepo.findById(sid)
+                .orElseThrow(() -> new NoSuchSubmissionException("No submission with id " + sid));
+
+        @Nullable
+        final var submitMetadata = submission.getSubmitAppMetadata();
+        if (submitMetadata == null) {
+            return;
+        }
+
+        final var entityRepository = this.repositories.getEntityRepository();
+        @Nullable
+        final var entityContainer = entityRepository.findByParentIds(
+                submission.getId(), submission.getAssignmentId()
+        ).orElse(null);
+        if (entityContainer == null) {
+            return;
+        }
+
+        this.generateAndSendFeedback(entityContainer, submitMetadata);
+    }
+
+    private void generateAndSendFeedback(
+            final EntityContainer entityContainer, final SubmitAppMetadata submitMetadata
+    ) {
+        try {
+            final var url = new URL(submitMetadata.getUrl());
+
+            SUBMIT_ACCESS_LEVEL_MAP.entrySet().stream().forEach(e -> {
+                try {
+                    final var report = this.reportGenerator.generateReport(
+                            entityContainer, Set.of(e.getKey())
+                    );
+
+                    this.sendFeedback(url, report, e.getValue(), submitMetadata, null);
+                } catch (final IOException | TemplateException ex) {
+                    logger.error("Could not template report", ex);
+                }
+            });
+
+            this.sendScore(url, submitMetadata, entityContainer.getVerdict());
+        } catch (final MalformedURLException ex) {
+            logger.error("Bad Submit callback URL {}", submitMetadata.getUrl(), ex);
+        }
+    }
+
+    private void sendFeedback(
+            final URL url,
+            final String report,
+            final String visibleFor,
+            final SubmitAppMetadata submitMetadata,
+            final @Nullable Integer score
+    ) {
+        try {
+            final var conn = (HttpURLConnection) url.openConnection();
+            conn.setDoOutput(true);
+            conn.setRequestMethod("POST");
+            conn.setRequestProperty("Content-Type", "application/json");
+            try (
+                    var out = conn.getOutputStream();
+                    var writer = new OutputStreamWriter(out, StandardCharsets.UTF_8);
+                    var in = conn.getInputStream()
+            ) {
+                final var body = new SubmitAppFeedbackDto();
+                body.submissionId = submitMetadata.getSubmissionId();
+                body.scriptId = submitMetadata.getScriptId();
+                body.key = submitMetadata.getKey();
+                body.visibleFor = visibleFor;
+                body.textualFeedback = report;
+                body.score = score;
+                this.gson.toJson(body, writer);
+
+                try {
+                    in.close();
+                } catch (final IOException ex) {
+                    // Ignore
+                }
+            }
+        } catch (final IOException ex) {
+            logger.error("Could not send report to Submit", ex);
+        }
+    }
+
+    private void sendScore(
+            final URL url,
+            final SubmitAppMetadata submitMetadata,
+            final Verdict verdict
+    ) {
+        final int score;
+        final var range = SUBMIT_GRADE_SCHEME_RANGE.get(submitMetadata.getGradeScheme());
+        if (verdict == Verdict.PASS || verdict == Verdict.WARN) {
+            score = range.getSecond();
+        } else if (verdict == Verdict.FAIL) {
+            score = range.getFirst();
+        } else {
+            score = -1;
+        }
+
+        this.sendFeedback(url, "", "STUDENT", submitMetadata, score);
+    }
+
+    /**
+     * Generates a URI to a submission.
+     *
+     * @param aid the assignment ID
+     * @param sid the submission ID
+     *
+     * @return the URI
+     *
+     * @throws URISyntaxException if the final URI violates the URI specification
+     */
+    @Nonnull
+    private URI generateSubmissionUri(
+            final String aid, final String sid
+    ) throws URISyntaxException {
+        return new URI("/api/v1/assignment/").resolve(aid).resolve("submission").resolve(sid);
+    }
+
+    /**
+     * Gets the extension of a filename.
+     *
+     * @param filename the filename
+     * @return the extension of the file
+     */
+    private String getExtension(final String filename) {
+        if (filename.lastIndexOf(".") != -1 && filename.lastIndexOf(".") != 0) {
+            return filename.substring(filename.lastIndexOf("."));
+        } else {
+            return "";
+        }
+    }
+}