diff --git a/gradle/nshm.gradle b/gradle/nshm.gradle
index c125f642a9a432ab3c473d8eaae3be7899c7a20a..2bcfe0831e6ac942d904f3b7f9d62b2609253667 100644
--- a/gradle/nshm.gradle
+++ b/gradle/nshm.gradle
@@ -100,7 +100,6 @@ task generateAlaska2023(type: JavaExec) {
   main = "gov.usgs.earthquake.nshmp.model.GenerateActual"
 }
 
-
 // Generate CONUS 2018 for CI
 task generateConus2018(type: JavaExec) {
   description = "Generate conus-2018 acutal for CI/CD"
diff --git a/src/test/java/gov/usgs/earthquake/nshmp/model/GenerateActual.java b/src/test/java/gov/usgs/earthquake/nshmp/model/GenerateActual.java
index 4a9c54d1114efbee5f249eafb6a582df206ca2f9..8996fb07eab4d24e9aa3d09ff4f3f2248ab08e11 100644
--- a/src/test/java/gov/usgs/earthquake/nshmp/model/GenerateActual.java
+++ b/src/test/java/gov/usgs/earthquake/nshmp/model/GenerateActual.java
@@ -2,9 +2,14 @@ package gov.usgs.earthquake.nshmp.model;
 
 import java.io.IOException;
 import java.util.Optional;
+import java.util.concurrent.ExecutionException;
 
 import gov.usgs.earthquake.nshmp.model.NshmTestUtils.Nshm;
 import gov.usgs.earthquake.nshmp.model.NshmTestUtils.NshmModel;
+import gov.usgs.earthquake.nshmp.www.Application;
+
+import io.micronaut.context.ApplicationContext;
+import io.micronaut.runtime.Micronaut;
 
 /**
  * Generate actual results to compare to expected results.
@@ -15,10 +20,21 @@ import gov.usgs.earthquake.nshmp.model.NshmTestUtils.NshmModel;
  */
 class GenerateActual {
 
-  public static void main(String[] args) throws IOException {
+  public static void main(String[] args)
+      throws IOException, InterruptedException, ExecutionException {
     Nshm nshm = NshmTests.NSHMS.get(System.getProperty("NSHM"));
+
+    // Generate command line
     NshmModel nshmModel = NshmTestUtils.loadModel(nshm);
     NshmTestUtils.writeExpecteds(nshmModel, Optional.of(NshmTests.DATA_PATH));
     nshmModel.exec.shutdown();
+
+    // Generate web
+    ApplicationContext context = Micronaut
+        .build("--model=" + nshm.modelPath())
+        .mainClass(Application.class)
+        .start();
+    NshmTestUtils.writeWebExpecteds(nshm, Optional.of(NshmTests.WEB_DATA_PATH));
+    context.close();
   }
 }
diff --git a/src/test/java/gov/usgs/earthquake/nshmp/model/NshmTestUtils.java b/src/test/java/gov/usgs/earthquake/nshmp/model/NshmTestUtils.java
index 8b32caf6a58c1398beffbd37ac579701efd0d803..89852afa2adca428b55a8c4d794d7ad655cd53c1 100644
--- a/src/test/java/gov/usgs/earthquake/nshmp/model/NshmTestUtils.java
+++ b/src/test/java/gov/usgs/earthquake/nshmp/model/NshmTestUtils.java
@@ -16,6 +16,7 @@ import java.util.Map;
 import java.util.Map.Entry;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.logging.Logger;
@@ -37,7 +38,11 @@ import gov.usgs.earthquake.nshmp.calc.Site;
 import gov.usgs.earthquake.nshmp.data.XySequence;
 import gov.usgs.earthquake.nshmp.geo.Location;
 import gov.usgs.earthquake.nshmp.gmm.Imt;
+import gov.usgs.earthquake.nshmp.www.Application;
+import gov.usgs.earthquake.nshmp.www.hazard.HazardServiceUtils;
 
+import io.micronaut.context.ApplicationContext;
+import io.micronaut.runtime.Micronaut;
 import io.swagger.v3.core.util.Yaml;
 
 /**
@@ -83,16 +88,50 @@ class NshmTestUtils {
    * Test a NSHM.
    *
    * @param nshm The NSHM to test
+   * @throws ExecutionException
+   * @throws InterruptedException
    */
-  static void testNshm(Nshm nshm, Optional<Path> dataPath) {
-    NshmModel nshmModel = loadModel(nshm);
+  static void testNshm(Nshm nshm, Optional<Path> dataPath)
+      throws InterruptedException, ExecutionException {
+    Optional<NshmModel> nshmModel = Optional.empty();
+
+    if (dataPath.isEmpty()) {
+      nshmModel = Optional.of(loadModel(nshm));
+    }
+
+    for (NamedLocation location : nshm.locations()) {
+      LOGGER.info("Location: " + location.toString());
+      compareCurves(nshm, location, nshmModel, dataPath);
+    }
+
+    nshmModel.ifPresent(model -> model.exec.shutdown());
+  }
+
+  /**
+   * Test a NSHM.
+   *
+   * @param nshm The NSHM to test
+   * @throws ExecutionException
+   * @throws InterruptedException
+   */
+  static void testWebNshm(Nshm nshm, Optional<Path> dataPath)
+      throws InterruptedException, ExecutionException {
+
+    Optional<ApplicationContext> context = Optional.empty();
+
+    if (dataPath.isEmpty()) {
+      context = Optional.of(Micronaut
+          .build("--model=" + nshm.modelPath())
+          .mainClass(Application.class)
+          .start());
+    }
 
     for (NamedLocation location : nshm.locations()) {
       LOGGER.info("Location: " + location.toString());
-      compareCurves(nshmModel, location, dataPath);
+      compareWebCurves(nshm, location, dataPath);
     }
 
-    nshmModel.exec.shutdown();
+    context.ifPresent(ApplicationContext::close);
   }
 
   /**
@@ -110,6 +149,18 @@ class NshmTestUtils {
     }
   }
 
+  static void writeWebExpecteds(
+      Nshm nshm,
+      Optional<Path> dataPath) throws InterruptedException, ExecutionException, IOException {
+    for (NamedLocation location : nshm.locations()) {
+      Map<String, XySequence> xyMap = HazardServiceUtils.generateActual(location, nshm.imts());
+      String json = new StringBuilder(GSON.toJson(xyMap))
+          .append(Text.NEWLINE)
+          .toString();
+      writeExpected(nshm, location, json, dataPath);
+    }
+  }
+
   private static void assertCurveEquals(XySequence expected, XySequence actual, double tol) {
     // IMLs close but not exact due to exp() transform
     assertArrayEquals(
@@ -137,11 +188,28 @@ class NshmTestUtils {
         Double.valueOf(expected).equals(Double.valueOf(actual));
   }
 
-  private static void compareCurves(NshmModel nshmModel, NamedLocation location,
-      Optional<Path> dataPath) {
+  private static void compareCurves(
+      Nshm nshm,
+      NamedLocation location,
+      Optional<NshmModel> nshmModel,
+      Optional<Path> dataPath) throws ExecutionException {
+    Map<String, XySequence> actual = dataPath.isPresent() && nshmModel.isEmpty()
+        ? readExpected(nshm, location, dataPath) : generateActual(nshmModel.get(), location);
+    Map<String, XySequence> expected = readExpected(nshm, location, Optional.empty());
+
+    for (String key : actual.keySet()) {
+      assertCurveEquals(expected.get(key), actual.get(key), TOLERANCE);
+    }
+  }
+
+  private static void compareWebCurves(
+      Nshm nshm,
+      NamedLocation location,
+      Optional<Path> dataPath) throws InterruptedException, ExecutionException {
     Map<String, XySequence> actual = dataPath.isPresent()
-        ? readExpected(nshmModel, location, dataPath) : generateActual(nshmModel, location);
-    Map<String, XySequence> expected = readExpected(nshmModel, location, Optional.empty());
+        ? readExpected(nshm, location, dataPath)
+        : HazardServiceUtils.generateActual(location, nshm.imts());
+    Map<String, XySequence> expected = readExpected(nshm, location, Optional.empty());
 
     for (String key : actual.keySet()) {
       assertCurveEquals(expected.get(key), actual.get(key), TOLERANCE);
@@ -176,11 +244,11 @@ class NshmTestUtils {
     return xyMap;
   }
 
-  private static Map<String, XySequence> readExpected(NshmModel nshmModel, NamedLocation loc,
+  private static Map<String, XySequence> readExpected(Nshm nshm, NamedLocation loc,
       Optional<Path> dataPath) {
     Path resultPath = dataPath.orElse(DATA_PATH)
-        .resolve(nshmModel.nshm.modelName())
-        .resolve(nshmModel.nshm.resultFilename(loc));
+        .resolve(nshm.modelName())
+        .resolve(nshm.resultFilename(loc));
 
     JsonObject obj = null;
     try (BufferedReader br = Files.newBufferedReader(resultPath)) {
diff --git a/src/test/java/gov/usgs/earthquake/nshmp/model/NshmTests.java b/src/test/java/gov/usgs/earthquake/nshmp/model/NshmTests.java
index 0c24c00cfaf3e0f54f8245c552655d4b5c900b82..38f8f37da8cf448fc88ad4829913ff5adb675e3e 100644
--- a/src/test/java/gov/usgs/earthquake/nshmp/model/NshmTests.java
+++ b/src/test/java/gov/usgs/earthquake/nshmp/model/NshmTests.java
@@ -11,6 +11,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.ExecutionException;
 
 import org.junit.jupiter.api.Test;
 
@@ -26,6 +27,7 @@ import gov.usgs.earthquake.nshmp.site.NshmpSite;
  */
 class NshmTests {
   static Path DATA_PATH = Paths.get("src/test/resources/e2e/actual");
+  static Path WEB_DATA_PATH = Paths.get("src/test/resources/e2e/actual/web");
 
   /* Alaska test sites */
   private static final List<NamedLocation> ALASKA_LOCATIONS = List.of(
@@ -126,55 +128,70 @@ class NshmTests {
    * Test Alaska 2007 NSHM
    *
    * To run test: ./gradlew testAlaska2007
+   * @throws ExecutionException
+   * @throws InterruptedException
    */
   @Test
-  final void testAlaska2007() throws IOException {
+  final void testAlaska2007() throws IOException, InterruptedException, ExecutionException {
     Nshm nshm = NSHMS.get("nshm-alaska-2007");
     NshmTestUtils.testNshm(nshm, getDataPath(nshm));
+    NshmTestUtils.testWebNshm(nshm, getWebDataPath(nshm));
   }
 
   /**
    * Test Alaska 2023 NSHM
    *
    * To run test: ./gradlew testAlaska2023
+   * @throws ExecutionException
+   * @throws InterruptedException
    */
   @Test
-  final void testAlaska2023() throws IOException {
+  final void testAlaska2023() throws IOException, InterruptedException, ExecutionException {
     Nshm nshm = NSHMS.get("nshm-alaska-2023");
     NshmTestUtils.testNshm(nshm, getDataPath(nshm));
+    NshmTestUtils.testWebNshm(nshm, getWebDataPath(nshm));
   }
 
   /**
    * Test CONUS 2018 NSHM
    *
    * To run test: ./gradlew testConus2018
+   * @throws ExecutionException
+   * @throws InterruptedException
    */
   @Test
-  final void testConus2018() throws IOException {
+  final void testConus2018() throws IOException, InterruptedException, ExecutionException {
     Nshm nshm = NSHMS.get("nshm-conus-2018");
     NshmTestUtils.testNshm(nshm, getDataPath(nshm));
+    NshmTestUtils.testWebNshm(nshm, getWebDataPath(nshm));
   }
 
   /**
    * Test CONUS 2023 NSHM
    *
    * To run test: ./gradlew testConus2023
+   * @throws ExecutionException
+   * @throws InterruptedException
    */
   @Test
-  final void testConus2023() throws IOException {
+  final void testConus2023() throws IOException, InterruptedException, ExecutionException {
     Nshm nshm = NSHMS.get("nshm-conus-2023");
     NshmTestUtils.testNshm(nshm, getDataPath(nshm));
+    NshmTestUtils.testWebNshm(nshm, getWebDataPath(nshm));
   }
 
   /**
    * Test Hawaii 2021 NSHM
    *
    * To run test: ./gradlew testHawaii2021
+   * @throws ExecutionException
+   * @throws InterruptedException
    */
   @Test
-  final void testHawaii2021() throws IOException {
+  final void testHawaii2021() throws IOException, InterruptedException, ExecutionException {
     Nshm nshm = NSHMS.get("nshm-hawaii-2021");
     NshmTestUtils.testNshm(nshm, getDataPath(nshm));
+    NshmTestUtils.testWebNshm(nshm, getWebDataPath(nshm));
   }
 
   /**
@@ -192,4 +209,9 @@ class NshmTests {
     return Files.exists(DATA_PATH.resolve(nshm.modelName())) ? Optional.of(DATA_PATH)
         : Optional.empty();
   }
+
+  private static Optional<Path> getWebDataPath(Nshm nshm) {
+    return Files.exists(DATA_PATH.resolve(nshm.modelName())) ? Optional.of(DATA_PATH)
+        : Optional.empty();
+  }
 }
diff --git a/src/test/java/gov/usgs/earthquake/nshmp/www/hazard/HazardServiceUtils.java b/src/test/java/gov/usgs/earthquake/nshmp/www/hazard/HazardServiceUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..d7649d099cd72867b6208e7193700c5791f90d36
--- /dev/null
+++ b/src/test/java/gov/usgs/earthquake/nshmp/www/hazard/HazardServiceUtils.java
@@ -0,0 +1,44 @@
+package gov.usgs.earthquake.nshmp.www.hazard;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.stream.Collectors;
+
+import gov.usgs.earthquake.nshmp.NamedLocation;
+import gov.usgs.earthquake.nshmp.calc.Hazard;
+import gov.usgs.earthquake.nshmp.data.XySequence;
+import gov.usgs.earthquake.nshmp.gmm.Imt;
+import gov.usgs.earthquake.nshmp.www.hazard.HazardService.Request;
+
+import io.micronaut.http.HttpRequest;
+
+public class HazardServiceUtils {
+
+  public static Map<String, XySequence> generateActual(
+      NamedLocation loc,
+      Set<Imt> imts)
+      throws InterruptedException, ExecutionException {
+    Request request = new Request(
+        HttpRequest.GET(""),
+        loc.location().longitude,
+        loc.location().latitude,
+        760,
+        imts,
+        false,
+        false);
+
+    Hazard hazard = HazardService.calcHazard(request);
+
+    Map<String, XySequence> xyMap = hazard.curves().entrySet().stream()
+        .collect(Collectors.toMap(
+            e -> e.getKey().name(),
+            Entry::getValue,
+            (o1, o2) -> o1,
+            LinkedHashMap::new)); // preserve IMT enum order
+
+    return xyMap;
+  }
+}