diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/HazVersion.java b/src/main/java/gov/usgs/earthquake/nshmp/www/HazVersion.java
index 538964aa5be6630bb36ebd924d8f5930ba95b470..b3c072cb07703efaeff8e8dfb17a9c8ace4d6ddd 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/HazVersion.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/HazVersion.java
@@ -5,6 +5,7 @@ import java.util.ArrayList;
 
 import org.eclipse.jgit.api.Git;
 
+import com.google.common.collect.Lists;
 import com.google.common.io.Resources;
 
 import gov.usgs.earthquake.nshmp.internal.AppVersion;
@@ -15,11 +16,7 @@ public class HazVersion implements AppVersion {
   private static final String MODEL_FILE = "model-version.json";
 
   public static VersionInfo[] appVersions(Path modelPath) {
-    var versions = new ArrayList<VersionInfo>();
-    versions.add(new HazVersion().getVersionInfo());
-    versions.add(new LibVersion().getVersionInfo());
-    versions.add(new WsUtilsVersion().getVersionInfo());
-
+    var versions = Lists.newArrayList(appVersions());
     var nshmVersion = getNshmVersion(modelPath);
 
     if (nshmVersion != null) {
@@ -29,6 +26,15 @@ public class HazVersion implements AppVersion {
     return versions.toArray(new VersionInfo[0]);
   }
 
+  public static VersionInfo[] appVersions() {
+    var versions = new ArrayList<VersionInfo>();
+    versions.add(new HazVersion().getVersionInfo());
+    versions.add(new LibVersion().getVersionInfo());
+    versions.add(new WsUtilsVersion().getVersionInfo());
+
+    return versions.toArray(new VersionInfo[0]);
+  }
+
   /**
    * Returns the version info from resources file.
    */
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/PrimingResource.java b/src/main/java/gov/usgs/earthquake/nshmp/www/PrimingResource.java
index 6e1d36196a24f395757ead0f029e74b546771a1c..5a5c3bbd8c4573ad6d93453b96198e1d9994615f 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/PrimingResource.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/PrimingResource.java
@@ -57,36 +57,40 @@ public class PrimingResource implements OrderedResource {
   @Value("${nshmp-haz.model-path}")
   private Path modelPath;
 
+  @Value("${nshmp-haz.gmm}")
+  private boolean gmmDeploy;
+
   @Override
   public void beforeCheckpoint(Context<? extends Resource> context) throws Exception {
     var model = ServletUtil.loadModel(modelPath);
     ServletUtil.model(model);
     updateParameter();
+    if (!gmmDeploy) {
+      var region = Regions.createRectangular("Bounds", model.bounds().min, model.bounds().max);
 
-    var region = Regions.createRectangular("Bounds", model.bounds().min, model.bounds().max);
-
-    try (MicronautLambdaHandler handler = new MicronautLambdaHandler()) {
-      var paths = List.of("/hazard", "/disagg");
+      try (MicronautLambdaHandler handler = new MicronautLambdaHandler()) {
+        var paths = List.of("/hazard", "/disagg");
 
-      paths.forEach(path -> {
-        handler.handleRequest(
-            getAwsProxyRequest(path, Optional.empty()),
-            new MockLambdaContext());
-      });
-
-      paths.forEach(path -> {
-        LOCATIONS.forEach(namedLocation -> {
-          var location = namedLocation.location();
+        paths.forEach(path -> {
+          handler.handleRequest(
+              getAwsProxyRequest(path, Optional.empty()),
+              new MockLambdaContext());
+        });
 
-          if (region.contains(location)) {
-            handler.handleRequest(
-                getAwsProxyRequest(
-                    String.format("%s/%f/%f/760", path, location.longitude, location.latitude),
-                    Optional.of(Imt.PGA)),
-                new MockLambdaContext());
-          }
+        paths.forEach(path -> {
+          LOCATIONS.forEach(namedLocation -> {
+            var location = namedLocation.location();
+
+            if (region.contains(location)) {
+              handler.handleRequest(
+                  getAwsProxyRequest(
+                      String.format("%s/%f/%f/760", path, location.longitude, location.latitude),
+                      Optional.of(Imt.PGA)),
+                  new MockLambdaContext());
+            }
+          });
         });
-      });
+      }
     }
   }
 
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/ServletUtil.java b/src/main/java/gov/usgs/earthquake/nshmp/www/ServletUtil.java
index b44aaaa6feef89af9af27fb38c2bb75854d1c6cb..448a30b0584101b944b09e33898506c1da5092bf 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/ServletUtil.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/ServletUtil.java
@@ -62,7 +62,7 @@ public class ServletUtil {
 
   private static HazardModel HAZARD_MODEL;
 
-  private static Optional<String> awsRuntime =
+  static Optional<String> awsRuntime =
       Optional.ofNullable(System.getenv("AWS_LAMBDA_RUNTIME_API"));
 
   static {
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/SwaggerController.java b/src/main/java/gov/usgs/earthquake/nshmp/www/SwaggerController.java
index 2068cc2e0521d02c2443870daf33a2e4f16f52ea..ee6affd7fd63c5bb18f80e8809222059603dd644 100644
--- a/src/main/java/gov/usgs/earthquake/nshmp/www/SwaggerController.java
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/SwaggerController.java
@@ -2,12 +2,14 @@ package gov.usgs.earthquake.nshmp.www;
 
 import java.util.List;
 import java.util.Optional;
+import java.util.stream.Collectors;
 
 import org.slf4j.LoggerFactory;
 
 import gov.usgs.earthquake.nshmp.model.HazardModel;
 import gov.usgs.earthquake.nshmp.www.source.FeaturesService;
 
+import io.micronaut.context.annotation.Value;
 import io.micronaut.http.HttpRequest;
 import io.micronaut.http.HttpResponse;
 import io.micronaut.http.MediaType;
@@ -17,6 +19,7 @@ import io.swagger.v3.core.util.Yaml;
 import io.swagger.v3.oas.annotations.Hidden;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.Paths;
 import io.swagger.v3.parser.OpenAPIV3Parser;
 import jakarta.inject.Inject;
 
@@ -35,6 +38,9 @@ public class SwaggerController {
   @Inject
   private NshmpMicronautServlet servlet;
 
+  @Value("${nshmp-haz.gmm}")
+  private boolean gmmDeploy;
+
   @Get(produces = MediaType.TEXT_EVENT_STREAM)
   public HttpResponse<String> doGet(HttpRequest<?> request) {
     try {
@@ -52,6 +58,75 @@ public class SwaggerController {
       HttpRequest<?> request,
       HazardModel model) {
     var openApi = new OpenAPIV3Parser().read("META-INF/swagger/nshmp-haz.yml");
+
+    if (ServletUtil.awsRuntime.isPresent()) {
+      if (gmmDeploy) {
+        openApi = gmmSwagger(openApi);
+      } else {
+        openApi = hazardSwagger(filterGmms(openApi), model);
+      }
+    } else {
+      openApi = hazardSwagger(openApi, model);
+    }
+
+    openApi.servers(null);
+
+    return openApi;
+  }
+
+  /**
+   * Filter GMM services.
+   *
+   * @param openApi OpenAPI docs.
+   */
+  private OpenAPI filterGmms(OpenAPI openApi) {
+    Paths paths = new Paths();
+    openApi.getPaths().forEach((path, pathItem) -> {
+      if (!path.contains("/gmm")) {
+        paths.put(path, pathItem);
+      }
+    });
+    openApi.paths(paths);
+
+    openApi.setTags(openApi.getTags().stream()
+        .filter(tag -> !tag.getName().contains("Ground Motion Models"))
+        .collect(Collectors.toList()));
+
+    return openApi;
+  }
+
+  /**
+   * Filter all services except GMM.
+   *
+   * @param openApi The OpenAPI docs.
+   */
+  private OpenAPI gmmSwagger(OpenAPI openApi) {
+    Paths paths = new Paths();
+    openApi.getPaths().forEach((path, pathItem) -> {
+      if (path.contains("/gmm")) {
+        // Remove "/gmm" for AWS deployments
+        paths.put(path.substring(4), pathItem);
+      }
+    });
+    openApi.paths(paths);
+
+    openApi.setTags(openApi.getTags().stream()
+        .filter(tag -> tag.getName().contains("Ground Motion Models"))
+        .collect(Collectors.toList()));
+
+    openApi.getInfo().setTitle("NSHMP Ground Motion Models");
+    openApi.getInfo().setDescription("Ground motion model web services.");
+
+    return openApi;
+  }
+
+  /**
+   * Fillter GMM services.
+   *
+   * @param openApi OpenAPI docs.
+   * @param model The current model.
+   */
+  private OpenAPI hazardSwagger(OpenAPI openApi, HazardModel model) {
     var bounds = model.bounds();
     var components = openApi.getComponents();
     var schemas = components.getSchemas();
@@ -59,7 +134,6 @@ public class SwaggerController {
     SwaggerUtils.imtSchema(schemas, List.copyOf(model.config().hazard.imts));
     var boundsInfo = SwaggerUtils.locationBoundsInfo(bounds.min, bounds.max, Optional.empty());
     FeaturesService.featureTypeSchema(schemas);
-    openApi.servers(null);
 
     openApi.getInfo().setTitle(model.name() + " Web Services");
     openApi.getInfo().setDescription(
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/gmm/GmmCalc.java b/src/main/java/gov/usgs/earthquake/nshmp/www/gmm/GmmCalc.java
new file mode 100644
index 0000000000000000000000000000000000000000..31c51dbef9c5c2c6ff666055be865bc89ba6c113
--- /dev/null
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/gmm/GmmCalc.java
@@ -0,0 +1,384 @@
+package gov.usgs.earthquake.nshmp.www.gmm;
+
+import static java.lang.Math.cos;
+import static java.lang.Math.hypot;
+import static java.lang.Math.sin;
+import static java.lang.Math.tan;
+import static java.lang.Math.toRadians;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import com.google.common.primitives.Doubles;
+
+import gov.usgs.earthquake.nshmp.Maths;
+import gov.usgs.earthquake.nshmp.gmm.Gmm;
+import gov.usgs.earthquake.nshmp.gmm.GmmInput;
+import gov.usgs.earthquake.nshmp.gmm.GroundMotion;
+import gov.usgs.earthquake.nshmp.gmm.GroundMotionModel;
+import gov.usgs.earthquake.nshmp.gmm.GroundMotions;
+import gov.usgs.earthquake.nshmp.gmm.Imt;
+import gov.usgs.earthquake.nshmp.tree.Branch;
+import gov.usgs.earthquake.nshmp.tree.LogicTree;
+import gov.usgs.earthquake.nshmp.www.gmm.GmmService.Distance;
+import gov.usgs.earthquake.nshmp.www.gmm.GmmService.Magnitude;
+import gov.usgs.earthquake.nshmp.www.gmm.GmmService.Request;
+import gov.usgs.earthquake.nshmp.www.gmm.XyDataGroup.EpiSeries;
+
+import jakarta.inject.Singleton;
+
+/*
+ * GMM service calculators.
+ *
+ * @author U.S. Geological Survey
+ */
+@Singleton
+class GmmCalc {
+
+  /* Compute ground motion response spectra. */
+  static Map<Gmm, GmmSpectraData> spectra(Request request) {
+
+    Map<Gmm, GmmSpectraData> gmmSpectra = new EnumMap<>(Gmm.class);
+
+    for (Gmm gmm : request.gmms) {
+      Set<Imt> saImts = gmm.responseSpectrumImts();
+
+      List<LogicTree<GroundMotion>> saImtTrees = saImts.stream()
+          .map(imt -> gmm.instance(imt).calc(request.input))
+          .collect(Collectors.toList());
+
+      List<Double> saPeriods = saImts.stream()
+          .map(imt -> imt.period())
+          .collect(Collectors.toList());
+
+      gmmSpectra.put(
+          gmm,
+          new GmmSpectraData(
+              treeToDataGroup(gmm, Imt.PGA, request.input),
+              treeToDataGroup(gmm, Imt.PGV, request.input),
+              treesToDataGroup(saPeriods, saImtTrees)));
+    }
+
+    return gmmSpectra;
+  }
+
+  private static Optional<GmmData<Double>> treeToDataGroup(Gmm gmm, Imt imt, GmmInput input) {
+    Optional<GmmData<Double>> dataGroup = Optional.empty();
+
+    if (gmm.supportedImts().contains(imt)) {
+      LogicTree<GroundMotion> imtTrees = gmm.instance(imt).calc(input);
+      dataGroup = Optional.of(treeToDataGroup(imtTrees));
+    }
+
+    return dataGroup;
+  }
+
+  private static GmmData<Double> treeToDataGroup(LogicTree<GroundMotion> tree) {
+    List<String> branchIds = tree.stream()
+        .map(Branch::id)
+        .collect(Collectors.toList());
+
+    GroundMotion combined = GroundMotions.combine(tree);
+    double μs = combined.mean();
+    double σs = combined.sigma();
+
+    List<EpiBranch<Double>> epiBranches = new ArrayList<>();
+
+    // short circuit if tree is single branch
+    if (tree.size() > 1) {
+      // branch index, imt index
+      double[] μBranches = tree.stream().mapToDouble(branch -> branch.value().mean()).toArray();
+      double[] σBranches = tree.stream().mapToDouble(branch -> branch.value().sigma()).toArray();
+      double[] weights = tree.stream().mapToDouble(branch -> branch.weight()).toArray();
+
+      for (int i = 0; i < tree.size(); i++) {
+        EpiBranch<Double> epiBranch = new EpiBranch<>(
+            branchIds.get(i),
+            μBranches[i],
+            σBranches[i],
+            weights[i]);
+
+        epiBranches.add(epiBranch);
+      }
+    }
+
+    return new GmmData<>(μs, σs, epiBranches);
+  }
+
+  private static GmmDataXs<double[]> treesToDataGroup(
+      List<Double> xValues,
+      List<LogicTree<GroundMotion>> trees) {
+
+    // Can't use Trees.transpose() because some
+    // GMMs have period dependent weights
+
+    List<GmmData<Double>> imtData = trees.stream()
+        .map(tree -> treeToDataGroup(tree))
+        .collect(Collectors.toList());
+
+    LogicTree<GroundMotion> modelTree = trees.get(0);
+    List<EpiBranch<double[]>> epiBranches = new ArrayList<>();
+
+    List<String> branchIds = modelTree.stream()
+        .map(Branch::id)
+        .collect(Collectors.toList());
+
+    if (modelTree.size() > 1) {
+      List<double[]> μBranches = new ArrayList<>();
+      List<double[]> σBranches = new ArrayList<>();
+      List<double[]> weights = new ArrayList<>();
+      for (int i = 0; i < modelTree.size(); i++) {
+        μBranches.add(new double[xValues.size()]);
+        σBranches.add(new double[xValues.size()]);
+        weights.add(new double[xValues.size()]);
+      }
+
+      // imt indexing
+      for (int i = 0; i < imtData.size(); i++) {
+        GmmData<Double> data = imtData.get(i);
+
+        // branch indexing
+        for (int j = 0; j < data.tree.size(); j++) {
+          EpiBranch<Double> branch = data.tree.get(j);
+          μBranches.get(j)[i] = branch.μs;
+          σBranches.get(j)[i] = branch.σs;
+          weights.get(j)[i] = branch.weights;
+        }
+      }
+
+      for (int i = 0; i < modelTree.size(); i++) {
+        EpiBranch<double[]> epiBranch = new EpiBranch<>(
+            branchIds.get(i),
+            μBranches.get(i),
+            σBranches.get(i),
+            weights.get(i));
+        epiBranches.add(epiBranch);
+      }
+    }
+
+    double[] xs = xValues.stream().mapToDouble(Double::doubleValue).toArray();
+    double[] μs = imtData.stream().mapToDouble(data -> data.μs).toArray();
+    double[] σs = imtData.stream().mapToDouble(data -> data.σs).toArray();
+
+    return new GmmDataXs<>(xs, μs, σs, epiBranches);
+  }
+
+  /* Compute ground motions over a range of distances. */
+  static Map<Gmm, GmmDataXs<double[]>> distance(Distance.Request request, double[] distances) {
+    var inputList = hangingWallDistances(request.input, distances);
+    return calculateGroundMotions(request.gmms, inputList, request.imt, distances);
+  }
+
+  /* Compute ground motions over a range of magnitudes. */
+  static Map<Gmm, GmmDataXs<double[]>> magnitude(
+      Magnitude.Request request,
+      double[] magnitudes,
+      double distance) {
+
+    var gmmInputs = Arrays.stream(magnitudes)
+        .mapToObj(Mw -> GmmInput.builder().fromCopy(request.input).mag(Mw).build())
+        .map(gmmInput -> hangingWallDistance(gmmInput, distance))
+        .collect(Collectors.toList());
+    return calculateGroundMotions(request.gmms, gmmInputs, request.imt, magnitudes);
+  }
+
+  private static List<GmmInput> hangingWallDistances(
+      GmmInput inputModel,
+      double[] rValues) {
+
+    return Arrays.stream(rValues)
+        .mapToObj(r -> hangingWallDistance(inputModel, r))
+        .collect(Collectors.toList());
+  }
+
+  /* Compute distance metrics for a fault. */
+  private static GmmInput hangingWallDistance(GmmInput in, double r) {
+    /* Dip in radians */
+    double δ = toRadians(in.dip);
+
+    /* Horizontal and vertical widths of fault */
+    double h = cos(δ) * in.width;
+    double v = sin(δ) * in.width;
+
+    /* Depth to bottom of rupture */
+    double zBot = in.zTor + v;
+
+    /* Distance range over which site is normal to fault plane */
+    double rCutLo = tan(δ) * in.zTor;
+    double rCutHi = tan(δ) * zBot + h;
+
+    /* rRup values corresponding to cutoffs above */
+    double rRupLo = Maths.hypot(in.zTor, rCutLo);
+    double rRupHi = Maths.hypot(zBot, rCutHi - h);
+
+    double rJB = (r < 0) ? -r : (r < h) ? 0.0 : r - h;
+    double rRup = (r < rCutLo)
+        ? hypot(r, in.zTor)
+        : (r > rCutHi)
+            ? hypot(r - h, zBot)
+            : rRupScaled(
+                r, rCutLo, rCutHi, rRupLo, rRupHi);
+
+    return GmmInput.builder()
+        .fromCopy(in)
+        .distances(rJB, rRup, r)
+        .build();
+  }
+
+  private static Map<Gmm, GmmDataXs<double[]>> calculateGroundMotions(
+      Set<Gmm> gmms,
+      List<GmmInput> gmmInputs,
+      Imt imt,
+      double[] distances) {
+
+    Map<Gmm, GmmDataXs<double[]>> gmValues = new EnumMap<>(Gmm.class);
+
+    for (Gmm gmm : gmms) {
+      List<LogicTree<GroundMotion>> trees = new ArrayList<>();
+      GroundMotionModel model = gmm.instance(imt);
+      for (GmmInput gmmInput : gmmInputs) {
+        trees.add(model.calc(gmmInput));
+      }
+      GmmDataXs<double[]> dataGroup = GmmCalc.treesToDataGroup(Doubles.asList(distances), trees);
+      gmValues.put(gmm, dataGroup);
+    }
+    return gmValues;
+  }
+
+  /*
+   * Computes rRup for a surface distance r. The range [rCutLo, rCutHi] must
+   * contain r; rRupLo and rRupHi are rRup at rCutLo and rCutHi, respectively.
+   */
+  private static double rRupScaled(
+      double r,
+      double rCutLo,
+      double rCutHi,
+      double rRupLo,
+      double rRupHi) {
+
+    double rRupΔ = rRupHi - rRupLo;
+    double rCutΔ = rCutHi - rCutLo;
+    return rRupLo + (r - rCutLo) / rCutΔ * rRupΔ;
+  }
+
+  static class GmmSpectraData {
+    final Optional<GmmData<Double>> pga;
+    final Optional<GmmData<Double>> pgv;
+    final GmmDataXs<double[]> sa;
+
+    GmmSpectraData(
+        Optional<GmmData<Double>> pga,
+        Optional<GmmData<Double>> pgv,
+        GmmDataXs<double[]> sa) {
+      this.pga = pga;
+      this.pgv = pgv;
+      this.sa = sa;
+    }
+  }
+
+  static class GmmData<T> {
+    final T μs;
+    final T σs;
+    final List<EpiBranch<T>> tree;
+
+    GmmData(
+        T μs,
+        T σs,
+        List<EpiBranch<T>> tree) {
+
+      this.μs = μs;
+      this.σs = σs;
+      this.tree = tree;
+    }
+  }
+
+  static class GmmDataXs<T> extends GmmData<T> {
+
+    final T xs;
+
+    GmmDataXs(
+        T xs,
+        T μs,
+        T σs,
+        List<EpiBranch<T>> tree) {
+      super(μs, σs, tree);
+      this.xs = xs;
+    }
+  }
+
+  static class EpiBranch<T> {
+
+    final String id;
+    final T μs;
+    final T σs;
+    final T weights;
+
+    EpiBranch(String id, T μs, T σs, T weights) {
+      this.id = id;
+      this.μs = μs;
+      this.σs = σs;
+      this.weights = weights;
+    }
+  }
+
+  static class SpectraTree {
+    final String id;
+    final TreeValues values;
+    final TreeValues weights;
+
+    SpectraTree(String id, TreeValues values, TreeValues weights) {
+      this.id = id;
+      this.values = values;
+      this.weights = weights;
+    }
+
+    static List<SpectraTree> toList(
+        List<EpiSeries<Double>> pgaBranches,
+        List<EpiSeries<Double>> pgvBranches,
+        List<EpiSeries<double[]>> saBranches) {
+      List<SpectraTree> trees = new ArrayList<>();
+
+      for (int i = 0; i < saBranches.size(); i++) {
+        Optional<EpiSeries<Double>> pga =
+            pgaBranches.isEmpty() ? Optional.empty() : Optional.of(pgaBranches.get(i));
+        Optional<EpiSeries<Double>> pgv =
+            pgvBranches.isEmpty() ? Optional.empty() : Optional.of(pgvBranches.get(i));
+
+        EpiSeries<double[]> sa = saBranches.get(i);
+
+        TreeValues values = new TreeValues(
+            pga.isPresent() ? pga.get().values : null,
+            pgv.isPresent() ? pgv.get().values : null,
+            sa.values);
+
+        TreeValues weights = new TreeValues(
+            pga.isPresent() ? pga.get().weights : null,
+            pgv.isPresent() ? pgv.get().weights : null,
+            sa.weights);
+
+        trees.add(new SpectraTree(sa.id, values, weights));
+      }
+
+      return trees;
+    }
+  }
+
+  static class TreeValues {
+    final Double pga;
+    final Double pgv;
+    final double[] sa;
+
+    TreeValues(Double pga, Double pgv, double[] sa) {
+      this.pga = pga;
+      this.pgv = pgv;
+      this.sa = sa;
+    }
+  }
+
+}
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/gmm/GmmController.java b/src/main/java/gov/usgs/earthquake/nshmp/www/gmm/GmmController.java
new file mode 100644
index 0000000000000000000000000000000000000000..c9ff40c624ddf08a7763cbe84586f07ef67ea35c
--- /dev/null
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/gmm/GmmController.java
@@ -0,0 +1,547 @@
+package gov.usgs.earthquake.nshmp.www.gmm;
+
+import java.util.Optional;
+import java.util.Set;
+
+import gov.usgs.earthquake.nshmp.gmm.Gmm;
+import gov.usgs.earthquake.nshmp.gmm.GmmInput;
+import gov.usgs.earthquake.nshmp.gmm.Imt;
+import gov.usgs.earthquake.nshmp.www.NshmpMicronautServlet;
+import gov.usgs.earthquake.nshmp.www.gmm.GmmService.Distance;
+import gov.usgs.earthquake.nshmp.www.gmm.GmmService.Id;
+import gov.usgs.earthquake.nshmp.www.gmm.GmmService.Magnitude;
+import gov.usgs.earthquake.nshmp.www.gmm.GmmService.Request;
+import gov.usgs.earthquake.nshmp.www.gmm.GmmService.Spectra;
+
+import io.micronaut.core.annotation.Nullable;
+import io.micronaut.http.HttpRequest;
+import io.micronaut.http.MediaType;
+import io.micronaut.http.annotation.Controller;
+import io.micronaut.http.annotation.Get;
+import io.micronaut.http.annotation.QueryValue;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.inject.Inject;
+
+@Tag(name = "Ground Motion Models")
+@Controller("${nshmp-haz.gmm-path}")
+class GmmController {
+
+  private static final String JAVADOC_URL =
+      "https://earthquake.usgs.gov/nshmp/docs/nshmp-lib/gov/usgs/earthquake/nshmp";
+
+  private static final String GMM_URL = JAVADOC_URL + "/gmm/Gmm.html";
+  private static final String GMM_INPUT_URL = JAVADOC_URL + "/gmm/GmmInput.html";
+  private static final String DEFAULTS_URL =
+      JAVADOC_URL + "/gmm/GmmInput.Builder.html#withDefaults()";
+
+  private static final String GMM_INPUT_PARAMS =
+      "<pre>[Mw, rJB, rRup, rX, dip, width, zTor, rake, vs30, z1p0, z2p5, zSed]</pre>";
+
+  private static final String SPECTRA_QUERY_1 = "/gmm/spectra?gmm=ASK_14";
+  private static final String SPECTRA_QUERY_2 =
+      "/gmm/spectra?gmm=ASK_14,BSSA_14,CB_14,CY_14&Mw=7.2&vs30=530";
+  private static final String SPECTRA_EX_1 =
+      "<a href=\"" + SPECTRA_QUERY_1 + "\">" + SPECTRA_QUERY_1 + "</a>";
+  private static final String SPECTRA_EX_2 =
+      "<a href=\"" + SPECTRA_QUERY_2 + "\">" + SPECTRA_QUERY_2 + "</a>";
+
+  private static final String SPECTRA_DESCRIPTION =
+      "Returns the response spectra of one or more GMMs for a given scenario defined " +
+          "by the following source and site parameters: " + GMM_INPUT_PARAMS + "<br><br>" +
+          "At a minimum, one GMM argument is required (default source and site parameters are used):" +
+          "<br><br>" + SPECTRA_EX_1 + "<br><br>" +
+          "Alternatively, multiple GMMs may be requested with custom source and site parameters:" +
+          "<br><br>" + SPECTRA_EX_2 + "<br><br>" +
+          "See the <i>nshmp-haz</i> documentation for the list of supported " +
+          "<a href=\"" + GMM_URL + "\">GMM IDs</a> and <a href=\"" + GMM_INPUT_URL + "\"> " +
+          "GMM input parameters</a> and built in <a href=\"" + DEFAULTS_URL +
+          "\"> default values</a>." +
+          "<br><br>For supported GMMs and *GmmInput* parameters with default values " +
+          "see the usage information." +
+          "<br><br>Given no query parameters the usage information is returned.";
+
+  private static final String SPECTRA_SUMMARY =
+      "Ground motion model (GMM) deterministic response spectra";
+
+  @Inject
+  private NshmpMicronautServlet servlet;
+
+  /**
+   * GET method for response spectra.
+   *
+   * <p> Web service path: /nshmp/data/spectra
+   *
+   * <p> A {@code GmmInput} is constructed off the remaining HTTP parameters
+   * that are not defined in the @Get
+   *
+   * @param request The HTTP request
+   * @param gmm The ground motion models
+   * @param Mw The moment magnitude of an earthquake ([-2, 9.7])
+   * @param rJB The shortest distance from a site to the surface projection of a
+   *        rupture, in kilometers ([0, 1000])
+   * @param rRup The shortest distance from a site to a rupture, in kilometers
+   *        ([0, 1000])
+   * @param rX The shortest distance from a site to the extended trace a fault,
+   *        in kilometers ([0, 1000])
+   * @param dip The dip of a rupture surface, in degrees ([0, 90])
+   * @param width The width of a rupture surface, in kilometers ([0, 60])
+   * @param zTop The depth to the top of a rupture surface, in kilometers and
+   *        positive-down ([0, 700])
+   * @param zHyp The depth to the hypocenter on a rupture surface, in kilometers
+   *        and positive-down ([0, 700])
+   * @param rake The rake (or sense of slip) of a rupture surface, in degrees
+   *        ([-180, 1080])
+   * @param vs30 The average shear-wave velocity down to 30 meters, in
+   *        kilometers per second ([150, 3000])
+   * @param z1p0 Depth to a shear-wave velocity of 1.0 kilometers per second, in
+   *        kilometers ([0, 5])
+   * @param z2p5 Depth to a shear-wave velocity of 2.5 kilometers per second, in
+   *        kilometers ([0, 10])
+   * @param zSed Sediment thickness, in kilometers ([0, 20])
+   */
+  @Operation(
+      summary = SPECTRA_SUMMARY,
+      description = SPECTRA_DESCRIPTION,
+      operationId = "gmm_spectra_doGetSpectra")
+  @ApiResponse(
+      description = "Response Spectra Service",
+      responseCode = "200",
+      content = @Content(schema = @Schema(type = "string")))
+  @Get(uri = "/spectra", produces = MediaType.APPLICATION_JSON)
+  public String doGetSpectra(
+      HttpRequest<?> http,
+      @Schema(required = true) @QueryValue @Nullable Set<Gmm> gmm,
+      @Schema(
+          defaultValue = "7.5",
+          minimum = "-2",
+          maximum = "9.7") @QueryValue @Nullable Double Mw,
+      @Schema(
+          defaultValue = "10",
+          minimum = "0",
+          maximum = "1000") @QueryValue @Nullable Double rJB,
+      @Schema(
+          defaultValue = "10.3",
+          minimum = "0",
+          maximum = "1000") @QueryValue @Nullable Double rRup,
+      @Schema(
+          defaultValue = "10",
+          minimum = "0",
+          maximum = "1000") @QueryValue @Nullable Double rX,
+      @Schema(
+          defaultValue = "90",
+          minimum = "0",
+          maximum = "90") @QueryValue @Nullable Double dip,
+      @Schema(
+          defaultValue = "14",
+          minimum = "0",
+          maximum = "60") @QueryValue @Nullable Double width,
+      @Schema(
+          defaultValue = "0.5",
+          minimum = "0",
+          maximum = "700") @QueryValue @Nullable Double zTop,
+      @Schema(
+          defaultValue = "7.5",
+          minimum = "0",
+          maximum = "700") @QueryValue @Nullable Double zHyp,
+      @Schema(
+          defaultValue = "0",
+          minimum = "-180",
+          maximum = "180") @QueryValue @Nullable Double rake,
+      @Schema(
+          defaultValue = "760",
+          minimum = "150",
+          maximum = "3000") @QueryValue @Nullable Double vs30,
+      @Schema(
+          defaultValue = "",
+          minimum = "0",
+          maximum = "5") @QueryValue @Nullable Double z1p0,
+      @Schema(
+          defaultValue = "",
+          minimum = "0",
+          maximum = "10") @QueryValue @Nullable Double z2p5,
+      @Schema(
+          defaultValue = "",
+          minimum = "0",
+          maximum = "20") @QueryValue @Nullable Double zSed) {
+    Id id = Id.SPECTRA;
+    try {
+      Set<Gmm> gmms = ServiceUtil.readGmms(http);
+      if (gmms.isEmpty()) {
+        return ServiceUtil.metadata(http, id);
+      }
+      GmmInput in = ServiceUtil.readGmmInput(http);
+      Request gmmRequest = new Request(http, id, gmms, in);
+      return Spectra.process(gmmRequest);
+    } catch (Exception e) {
+      return Utils.handleError(e, id.name, http.getUri().getPath());
+    }
+  }
+
+  /**
+   * GET method for ground motion vs. distance, with distance in log space.
+   *
+   * <p> Web service path: /nshmp/data/gmm/distance
+   *
+   * <p> A {@code GmmInput} is constructed off the remaining HTTP parameters
+   * that are not defined in the @Get
+   *
+   * @param request The HTTP request
+   * @param gmm The ground motion models
+   * @param imt The IMT
+   * @param rMin The minimum distance to compute ((0, 1000])
+   * @param rMax The maximum distance to compute ((0, 1000])
+   * @param Mw The moment magnitude of an earthquake ([-2, 9.7])
+   * @param rJB The shortest distance from a site to the surface projection of a
+   *        rupture, in kilometers ([0, 1000])
+   * @param rRup The shortest distance from a site to a rupture, in kilometers
+   *        ([0, 1000])
+   * @param rX The shortest distance from a site to the extended trace a fault,
+   *        in kilometers ([0, 1000])
+   * @param dip The dip of a rupture surface, in degrees ([0, 90])
+   * @param width The width of a rupture surface, in kilometers ([0, 60])
+   * @param zTop The depth to the top of a rupture surface, in kilometers and
+   *        positive-down ([0, 700])
+   * @param zHyp The depth to the hypocenter on a rupture surface, in kilometers
+   *        and positive-down ([0, 700])
+   * @param rake The rake (or sense of slip) of a rupture surface, in degrees
+   *        ([-180, 1080])
+   * @param vs30 The average shear-wave velocity down to 30 meters, in
+   *        kilometers per second ([150, 3000])
+   * @param z1p0 Depth to a shear-wave velocity of 1.0 kilometers per second, in
+   *        kilometers ([0, 5])
+   * @param z2p5 Depth to a shear-wave velocity of 2.5 kilometers per second, in
+   *        kilometers ([0, 10])
+   * @param zSed Sediment thickness, in kilometers ([0, 20])
+   */
+  @Operation(
+      summary = "Return ground motion Vs. distance in log space given a ground motion model",
+      description = "Returns ground motion Vs. distance in log space " +
+          "given a GMM and a *GmmInput*.\n\n" +
+          "For supported GMMs and *GmmInput* parameters with default values " +
+          "see the usage information.\n\n" +
+          "Given no query parameters the usage information is returned.",
+      operationId = "gmm_distance_doGetDistance")
+  @ApiResponse(
+      description = "Ground motion Vs. distance in log space",
+      responseCode = "200",
+      content = @Content(schema = @Schema(type = "string")))
+  @Get(uri = "/distance{?gmm,imt,rMin,rMax}", produces = MediaType.APPLICATION_JSON)
+  public String doGetDistance(
+      HttpRequest<?> http,
+      @Schema(required = true) @QueryValue @Nullable Set<Gmm> gmm,
+      @Schema(required = true) @QueryValue Optional<Imt> imt,
+      @Schema(
+          defaultValue = "0.001",
+          minimum = "0",
+          maximum = "1000",
+          exclusiveMinimum = true) @QueryValue @Nullable Double rMin,
+      @Schema(
+          defaultValue = "100",
+          minimum = "0",
+          maximum = "1000",
+          exclusiveMinimum = true) @QueryValue @Nullable Double rMax,
+      @Schema(
+          defaultValue = "6.5",
+          minimum = "-2",
+          maximum = "9.7") @QueryValue @Nullable Double Mw,
+      @Schema(
+          defaultValue = "10",
+          minimum = "0",
+          maximum = "1000") @QueryValue @Nullable Double rJB,
+      @Schema(
+          defaultValue = "10.3",
+          minimum = "0",
+          maximum = "1000") @QueryValue @Nullable Double rRup,
+      @Schema(
+          defaultValue = "10",
+          minimum = "0",
+          maximum = "1000") @QueryValue @Nullable Double rX,
+      @Schema(
+          defaultValue = "90",
+          minimum = "0",
+          maximum = "90") @QueryValue @Nullable Double dip,
+      @Schema(
+          defaultValue = "14",
+          minimum = "0",
+          maximum = "60") @QueryValue @Nullable Double width,
+      @Schema(
+          defaultValue = "0.5",
+          minimum = "0",
+          maximum = "700") @QueryValue @Nullable Double zTop,
+      @Schema(
+          defaultValue = "7.5",
+          minimum = "0",
+          maximum = "700") @QueryValue @Nullable Double zHyp,
+      @Schema(
+          defaultValue = "0",
+          minimum = "-180",
+          maximum = "180") @QueryValue @Nullable Double rake,
+      @Schema(
+          defaultValue = "760",
+          minimum = "150",
+          maximum = "3000") @QueryValue @Nullable Double vs30,
+      @Schema(
+          defaultValue = "",
+          minimum = "0",
+          maximum = "5") @QueryValue @Nullable Double z1p0,
+      @Schema(
+          defaultValue = "",
+          minimum = "0",
+          maximum = "10") @QueryValue @Nullable Double z2p5,
+      @Schema(
+          defaultValue = "",
+          minimum = "0",
+          maximum = "20") @QueryValue @Nullable Double zSed) {
+    Id id = Id.DISTANCE;
+    try {
+      Set<Gmm> gmms = ServiceUtil.readGmms(http);
+      if (gmms.isEmpty()) {
+        return ServiceUtil.metadata(http, id);
+      }
+      GmmInput in = ServiceUtil.readGmmInput(http);
+      Distance.Request request = new Distance.Request(
+          http, id, gmms, in, imt, Optional.ofNullable(rMin), Optional.ofNullable(rMax));
+      return Distance.process(request);
+    } catch (Exception e) {
+      return Utils.handleError(e, id.name, http.getUri().getPath());
+    }
+  }
+
+  /**
+   * GET method for ground motion vs. distance.
+   *
+   * <p> Web service path: /nshmp/data/gmm/distance
+   *
+   * <p> A {@code GmmInput} is constructed off the remaining HTTP parameters
+   * that are not defined in the @Get
+   *
+   * @param request The HTTP request
+   * @param gmm The ground motion models
+   * @param imt The IMT
+   * @param rMin The minimum distance to compute ([-1000, 1000])
+   * @param rMax The maximum distance to compute ([-1000, 1000])
+   * @param Mw The moment magnitude of an earthquake ([-2, 9.7])
+   * @param dip The dip of a rupture surface, in degrees ([0, 90])
+   * @param width The width of a rupture surface, in kilometers ([0, 60])
+   * @param zTop The depth to the top of a rupture surface, in kilometers and
+   *        positive-down ([0, 700])
+   * @param zHyp The depth to the hypocenter on a rupture surface, in kilometers
+   *        and positive-down ([0, 700])
+   * @param rake The rake (or sense of slip) of a rupture surface, in degrees
+   *        ([-180, 1080])
+   * @param vs30 The average shear-wave velocity down to 30 meters, in
+   *        kilometers per second ([150, 3000])
+   * @param z1p0 Depth to a shear-wave velocity of 1.0 kilometers per second, in
+   *        kilometers ([0, 5])
+   * @param z2p5 Depth to a shear-wave velocity of 2.5 kilometers per second, in
+   *        kilometers ([0, 10])
+   * @param zSed Sediment thickness, in kilometers ([0, 20])
+   */
+  @Operation(
+      summary = "Return ground motion Vs. distance given a ground motion model",
+      description = "Returns ground motion Vs. distance given a GMM and a *GmmInput*.\n\n" +
+          "For supported GMMs and *GmmInput* parameters with default values " +
+          "see the usage information.\n\n" +
+          "Given no query parameters the usage information is returned.",
+      operationId = "gmm_hwfw_doGetHwFw")
+  @ApiResponse(
+      description = "Ground motion Vs. distance",
+      responseCode = "200",
+      content = @Content(schema = @Schema(type = "string")))
+  @Get(uri = "/hw-fw{?gmm,imt,rMin,rMax}", produces = MediaType.APPLICATION_JSON)
+  public String doGetHwFw(
+      HttpRequest<?> http,
+      @Schema(required = true) @QueryValue @Nullable Set<Gmm> gmm,
+      @Schema(required = true) @QueryValue Optional<Imt> imt,
+      @Schema(
+          defaultValue = "-100",
+          minimum = "-1000",
+          maximum = "1000",
+          exclusiveMinimum = true) @QueryValue @Nullable Double rMin,
+      @Schema(
+          defaultValue = "100",
+          minimum = "-1000",
+          maximum = "1000",
+          exclusiveMinimum = true) @QueryValue @Nullable Double rMax,
+      @Schema(
+          defaultValue = "6.5",
+          minimum = "-2",
+          maximum = "9.7") @QueryValue @Nullable Double Mw,
+      @Schema(
+          defaultValue = "90",
+          minimum = "0",
+          maximum = "90") @QueryValue @Nullable Double dip,
+      @Schema(
+          defaultValue = "14",
+          minimum = "0",
+          maximum = "60") @QueryValue @Nullable Double width,
+      @Schema(
+          defaultValue = "0.5",
+          minimum = "0",
+          maximum = "700") @QueryValue @Nullable Double zTop,
+      @Schema(
+          defaultValue = "7.5",
+          minimum = "0",
+          maximum = "700") @QueryValue @Nullable Double zHyp,
+      @Schema(
+          defaultValue = "0",
+          minimum = "-180",
+          maximum = "180") @QueryValue @Nullable Double rake,
+      @Schema(
+          defaultValue = "760",
+          minimum = "150",
+          maximum = "3000") @QueryValue @Nullable Double vs30,
+      @Schema(
+          defaultValue = "",
+          minimum = "0",
+          maximum = "5") @QueryValue @Nullable Double z1p0,
+      @Schema(
+          defaultValue = "",
+          minimum = "0",
+          maximum = "10") @QueryValue @Nullable Double z2p5,
+      @Schema(
+          defaultValue = "",
+          minimum = "0",
+          maximum = "20") @QueryValue @Nullable Double zSed) {
+
+    Id id = Id.HW_FW;
+    try {
+      Set<Gmm> gmms = ServiceUtil.readGmms(http);
+      if (gmms.isEmpty()) {
+        return ServiceUtil.metadata(http, id);
+      }
+      GmmInput in = ServiceUtil.readGmmInput(http);
+      Distance.Request request = new Distance.Request(
+          http, id, gmms, in, imt, Optional.ofNullable(rMin), Optional.ofNullable(rMax));
+      return Distance.process(request);
+    } catch (Exception e) {
+      return Utils.handleError(e, id.name, http.getUri().getPath());
+    }
+  }
+
+  /**
+   * GET method for ground motion vs. distance.
+   *
+   * <p> Web service path: /nshmp/data/gmm/distance
+   *
+   * <p> A {@code GmmInput} is constructed off the remaining HTTP parameters
+   * that are not defined in the @Get
+   *
+   * @param request The HTTP request
+   * @param gmm The ground motion models
+   * @param imt The IMT
+   * @param rMin The minimum distance to compute ([-1000, 1000])
+   * @param rMax The maximum distance to compute ([-1000, 1000])
+   * @param Mw The moment magnitude of an earthquake ([-2, 9.7])
+   * @param dip The dip of a rupture surface, in degrees ([0, 90])
+   * @param width The width of a rupture surface, in kilometers ([0, 60])
+   * @param zTop The depth to the top of a rupture surface, in kilometers and
+   *        positive-down ([0, 700])
+   * @param zHyp The depth to the hypocenter on a rupture surface, in kilometers
+   *        and positive-down ([0, 700])
+   * @param rake The rake (or sense of slip) of a rupture surface, in degrees
+   *        ([-180, 1080])
+   * @param vs30 The average shear-wave velocity down to 30 meters, in
+   *        kilometers per second ([150, 3000])
+   * @param z1p0 Depth to a shear-wave velocity of 1.0 kilometers per second, in
+   *        kilometers ([0, 5])
+   * @param z2p5 Depth to a shear-wave velocity of 2.5 kilometers per second, in
+   *        kilometers ([0, 10])
+   * @param zSed Sediment thickness, in kilometers ([0, 20])
+   */
+  @Operation(
+      summary = "Return ground motion Vs. magnitude given a ground motion model",
+      description = "Returns ground motion Vs. magnitude given a GMM and a *GmmInput*.\n\n" +
+          "For supported GMMs and *GmmInput* parameters with default values " +
+          "see the usage information.\n\n" +
+          "Given no query parameters the usage information is returned.",
+      operationId = "gmm_magnitude_doGetMagnitude")
+  @ApiResponse(
+      description = "Ground motion Vs. magnitude",
+      responseCode = "200",
+      content = @Content(schema = @Schema(type = "string")))
+  @Get(uri = "/magnitude{?gmm,imt,mMin,mMax,step}", produces = MediaType.APPLICATION_JSON)
+  public String doGetMagnitude(
+      HttpRequest<?> http,
+      @Schema(required = true) @QueryValue @Nullable Set<Gmm> gmm,
+      @Schema(required = true) @QueryValue Optional<Imt> imt,
+      @Schema(
+          defaultValue = "5",
+          minimum = "-2",
+          maximum = "9.7",
+          exclusiveMinimum = true) @QueryValue @Nullable Double mMin,
+      @Schema(
+          defaultValue = "8",
+          minimum = "-2",
+          maximum = "9.7",
+          exclusiveMinimum = true) @QueryValue @Nullable Double mMax,
+      @Schema(
+          defaultValue = "0.1",
+          minimum = "0",
+          maximum = "1",
+          exclusiveMinimum = true) @QueryValue @Nullable Double step,
+      @Schema(
+          defaultValue = "10",
+          minimum = "0",
+          maximum = "1000",
+          exclusiveMinimum = true) @QueryValue @Nullable Double distance,
+      @Schema(
+          defaultValue = "6.5",
+          minimum = "-2",
+          maximum = "9.7") @QueryValue @Nullable Double Mw,
+      @Schema(
+          defaultValue = "90",
+          minimum = "0",
+          maximum = "90") @QueryValue @Nullable Double dip,
+      @Schema(
+          defaultValue = "14",
+          minimum = "0",
+          maximum = "60") @QueryValue @Nullable Double width,
+      @Schema(
+          defaultValue = "0.5",
+          minimum = "0",
+          maximum = "700") @QueryValue @Nullable Double zTop,
+      @Schema(
+          defaultValue = "7.5",
+          minimum = "0",
+          maximum = "700") @QueryValue @Nullable Double zHyp,
+      @Schema(
+          defaultValue = "0",
+          minimum = "-180",
+          maximum = "180") @QueryValue @Nullable Double rake,
+      @Schema(
+          defaultValue = "760",
+          minimum = "150",
+          maximum = "3000") @QueryValue @Nullable Double vs30,
+      @Schema(
+          defaultValue = "",
+          minimum = "0",
+          maximum = "5") @QueryValue @Nullable Double z1p0,
+      @Schema(
+          defaultValue = "",
+          minimum = "0",
+          maximum = "10") @QueryValue @Nullable Double z2p5,
+      @Schema(
+          defaultValue = "",
+          minimum = "0",
+          maximum = "20") @QueryValue @Nullable Double zSed) {
+    Id id = Id.MAGNITUDE;
+    try {
+      Set<Gmm> gmms = ServiceUtil.readGmms(http);
+      if (gmms.isEmpty()) {
+        return ServiceUtil.metadata(http, id);
+      }
+      GmmInput in = ServiceUtil.readGmmInput(http);
+      Magnitude.Request gmmRequest = new Magnitude.Request(
+          http, id, gmms, in, imt, Optional.ofNullable(mMin), Optional.ofNullable(mMax),
+          Optional.ofNullable(step), Optional.ofNullable(distance));
+      return Magnitude.process(gmmRequest);
+    } catch (Exception e) {
+      return Utils.handleError(e, id.name, http.getUri().getPath());
+    }
+  }
+}
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/gmm/GmmService.java b/src/main/java/gov/usgs/earthquake/nshmp/www/gmm/GmmService.java
new file mode 100644
index 0000000000000000000000000000000000000000..b4b5587fec5bb67848770678343613f4b2b91d60
--- /dev/null
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/gmm/GmmService.java
@@ -0,0 +1,499 @@
+package gov.usgs.earthquake.nshmp.www.gmm;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import gov.usgs.earthquake.nshmp.Maths;
+import gov.usgs.earthquake.nshmp.data.DoubleData;
+import gov.usgs.earthquake.nshmp.data.XySequence;
+import gov.usgs.earthquake.nshmp.gmm.Gmm;
+import gov.usgs.earthquake.nshmp.gmm.GmmInput;
+import gov.usgs.earthquake.nshmp.gmm.Imt;
+import gov.usgs.earthquake.nshmp.www.HazVersion;
+import gov.usgs.earthquake.nshmp.www.ResponseBody;
+import gov.usgs.earthquake.nshmp.www.ResponseMetadata;
+import gov.usgs.earthquake.nshmp.www.gmm.GmmCalc.EpiBranch;
+import gov.usgs.earthquake.nshmp.www.gmm.GmmCalc.GmmDataXs;
+import gov.usgs.earthquake.nshmp.www.gmm.GmmCalc.GmmSpectraData;
+import gov.usgs.earthquake.nshmp.www.gmm.GmmCalc.SpectraTree;
+import gov.usgs.earthquake.nshmp.www.gmm.XyDataGroup.EpiSeries;
+
+import io.micronaut.http.HttpRequest;
+import jakarta.inject.Singleton;
+
+/*
+ * GMM service implementations.
+ *
+ * @author U.S. Geological Survey
+ */
+@Singleton
+class GmmService {
+
+  /* Base request object for all GMM services. */
+  static class Request {
+
+    transient final HttpRequest<?> http;
+    transient final Id serviceId;
+
+    final Set<Gmm> gmms;
+    final GmmInput input;
+
+    public Request(
+        HttpRequest<?> http, Id id,
+        Set<Gmm> gmms, GmmInput input) {
+      this.http = http;
+      this.serviceId = id;
+      this.input = input;
+      this.gmms = gmms;
+    }
+  }
+
+  /* Response object for all GMM services. */
+  static class Response<T, U> {
+
+    XyDataGroup<T, U> means;
+    XyDataGroup<T, U> sigmas;
+
+    private Response(Id service, Optional<Imt> imt) {
+      String yLabelMedian = imt.isPresent()
+          ? String.format("%s (%s)", service.yLabelMedian, imt.get().units())
+          : service.yLabelMedian;
+
+      means = XyDataGroup.create(
+          service.groupNameMean,
+          service.xLabel,
+          yLabelMedian);
+
+      sigmas = XyDataGroup.create(
+          service.groupNameSigma,
+          service.xLabel,
+          service.yLabelSigma);
+    }
+
+    static Response<XySequence, EpiSeries<double[]>> create(
+        Id service,
+        Map<Gmm, GmmDataXs<double[]>> result,
+        Optional<Imt> imt) {
+
+      Response<XySequence, EpiSeries<double[]>> response = new Response<>(service, imt);
+
+      for (Entry<Gmm, GmmDataXs<double[]>> entry : result.entrySet()) {
+        Gmm gmm = entry.getKey();
+        GmmDataXs<double[]> data = entry.getValue();
+
+        XySequence μTotal = XySequence.create(data.xs, formatExp(data.μs));
+        XySequence σTotal = XySequence.create(data.xs, format(data.σs));
+
+        EpiGroup<double[]> group = treeToEpiGroup(data.tree);
+        response.means.add(gmm.name(), gmm.toString(), μTotal, group.μ);
+        response.sigmas.add(gmm.name(), gmm.toString(), σTotal, group.sigma);
+      }
+
+      return response;
+    }
+
+    private static EpiGroup<Double> treeToEpiGroupSingle(List<EpiBranch<Double>> tree) {
+      List<EpiSeries<Double>> μEpi = List.of();
+      List<EpiSeries<Double>> sigmaEpi = List.of();
+
+      // If we have a GMM with a single GroundMotion branch,
+      // then the DataGroup branch list is empty
+      if (!tree.isEmpty()) {
+        boolean hasSigmaBranches = tree.get(0).id.contains(" : ");
+
+        μEpi = tree.stream()
+            .map(b -> new EpiSeries<>(b.id, formatExp(b.μs), b.weights))
+            .collect(Collectors.toList());
+
+        if (hasSigmaBranches) {
+          μEpi = collapseGmTreeSingle(μEpi, 0);
+
+          sigmaEpi = tree.stream()
+              .map(b -> new EpiSeries<>(b.id, format(b.σs), b.weights))
+              .collect(Collectors.toList());
+          sigmaEpi = collapseGmTreeSingle(sigmaEpi, 1);
+        }
+      }
+
+      return new EpiGroup<>(μEpi, sigmaEpi);
+    }
+
+    private static EpiGroup<double[]> treeToEpiGroup(List<EpiBranch<double[]>> tree) {
+      List<EpiSeries<double[]>> μEpi = List.of();
+      List<EpiSeries<double[]>> sigmaEpi = List.of();
+
+      // If we have a GMM with a single GroundMotion branch,
+      // then the DataGroup branch list is empty
+      if (!tree.isEmpty()) {
+        boolean hasSigmaBranches = tree.get(0).id.contains(" : ");
+
+        μEpi = tree.stream()
+            .map(b -> new EpiSeries<>(b.id, formatExp(b.μs), b.weights))
+            .collect(Collectors.toList());
+
+        if (hasSigmaBranches) {
+          μEpi = collapseGmTree(μEpi, 0);
+
+          sigmaEpi = tree.stream()
+              .map(b -> new EpiSeries<>(b.id, format(b.σs), b.weights))
+              .collect(Collectors.toList());
+          sigmaEpi = collapseGmTree(sigmaEpi, 1);
+        }
+      }
+
+      return new EpiGroup<>(μEpi, sigmaEpi);
+    }
+
+    static Response<SpectraData, SpectraTree> spectraCreate(
+        Id service,
+        Map<Gmm, GmmSpectraData> result,
+        Optional<Imt> imt) {
+
+      Response<SpectraData, SpectraTree> response = new Response<>(service, imt);
+
+      for (Entry<Gmm, GmmSpectraData> entry : result.entrySet()) {
+        Gmm gmm = entry.getKey();
+        GmmSpectraData data = entry.getValue();
+
+        XySequence saμTotal = XySequence.create(data.sa.xs, formatExp(data.sa.μs));
+        XySequence saσTotal = XySequence.create(data.sa.xs, format(data.sa.σs));
+
+        SpectraData μTotal = new SpectraData(
+            data.pga.isPresent() ? formatExp(data.pga.get().μs) : null,
+            data.pgv.isPresent() ? formatExp(data.pgv.get().μs) : null,
+            saμTotal);
+
+        SpectraData σTotal = new SpectraData(
+            data.pga.isPresent() ? format(data.pga.get().σs) : null,
+            data.pgv.isPresent() ? format(data.pgv.get().σs) : null,
+            saσTotal);
+
+        EpiGroup<double[]> saGroup = treeToEpiGroup(data.sa.tree);
+        Optional<EpiGroup<Double>> pgaGroup = Optional.empty();
+        Optional<EpiGroup<Double>> pgvGroup = Optional.empty();
+
+        if (data.pga.isPresent()) {
+          pgaGroup = Optional.of(treeToEpiGroupSingle(data.pga.get().tree));
+        }
+
+        if (data.pgv.isPresent()) {
+          pgvGroup = Optional.of(treeToEpiGroupSingle(data.pgv.get().tree));
+        }
+
+        List<SpectraTree> μTrees = SpectraTree.toList(
+            pgaGroup.isPresent() ? pgaGroup.get().μ : List.of(),
+            pgvGroup.isPresent() ? pgvGroup.get().μ : List.of(),
+            saGroup.μ);
+
+        List<SpectraTree> sigmaTrees = SpectraTree.toList(
+            pgaGroup.isPresent() ? pgaGroup.get().sigma : List.of(),
+            pgvGroup.isPresent() ? pgvGroup.get().sigma : List.of(),
+            saGroup.sigma);
+
+        response.means.add(gmm.name(), gmm.toString(), μTotal, μTrees);
+        response.sigmas.add(gmm.name(), gmm.toString(), σTotal, sigmaTrees);
+      }
+
+      return response;
+    }
+  }
+
+  static class Spectra {
+
+    static String process(Request request) {
+      Map<Gmm, GmmSpectraData> spectra = GmmCalc.spectra(request);
+      Response<SpectraData, SpectraTree> response =
+          Response.spectraCreate(request.serviceId, spectra, Optional.empty());
+      var body = ResponseBody.success()
+          .name(request.serviceId.name)
+          .url(request.http.getUri().getPath())
+          .metadata(new ResponseMetadata(HazVersion.appVersions()))
+          .request(request)
+          .response(response)
+          .build();
+      return ServiceUtil.GSON.toJson(body);
+    }
+
+  }
+
+  static class Distance {
+
+    private static final double R_MIN = -100.0;
+    private static final double R_MAX = 100.0;
+    private final static int R_POINTS = 100;
+
+    static String process(Distance.Request request) {
+
+      double[] rArray = distanceArray(request);
+
+      Map<Gmm, GmmDataXs<double[]>> gmvr = GmmCalc.distance(request, rArray);
+      Response<XySequence, EpiSeries<double[]>> response =
+          Response.create(request.serviceId, gmvr, Optional.of(request.imt));
+      var body = ResponseBody.success()
+          .name(request.serviceId.name)
+          .url(request.http.getUri().getPath())
+          .metadata(new ResponseMetadata(HazVersion.appVersions()))
+          .request(request)
+          .response(response)
+          .build();
+      return ServiceUtil.GSON.toJson(body);
+    }
+
+    private static double[] distanceArray(
+        GmmService.Distance.Request request) {
+      boolean isLog = request.serviceId.equals(Id.DISTANCE) ? true : false;
+      double rStep = isLog
+          ? (Math.log10(request.rMax / request.rMin)) / (R_POINTS - 1)
+          : 1.0;
+      return isLog
+          ? ServiceUtil.sequenceLog(request.rMin, request.rMax, rStep)
+          : ServiceUtil.sequenceLinear(request.rMin, request.rMax, rStep);
+    }
+
+    static class Request extends GmmService.Request {
+
+      Imt imt;
+      double rMin;
+      double rMax;
+
+      Request(
+          HttpRequest<?> http, Id serviceId,
+          Set<Gmm> gmms, GmmInput in,
+          Optional<Imt> imt,
+          Optional<Double> rMin,
+          Optional<Double> rMax) {
+
+        super(http, serviceId, gmms, in);
+        this.imt = imt.orElse(Imt.PGA);
+        this.rMin = rMin.orElse(R_MIN);
+        this.rMax = rMax.orElse(R_MAX);
+      }
+    }
+  }
+
+  static class Magnitude {
+
+    public static final double M_MIN = 5.0;
+    public static final double M_MAX = 8.0;
+    public static final double M_STEP = 0.1;
+    public static final double M_DISTANCE = 10.0;
+
+    static String process(Magnitude.Request request) {
+
+      double[] mArray = ServiceUtil.sequenceLinear(
+          request.mMin, request.mMax, request.step);
+
+      Map<Gmm, GmmDataXs<double[]>> gmvm = GmmCalc.magnitude(request, mArray, request.distance);
+      Response<XySequence, EpiSeries<double[]>> response =
+          Response.create(request.serviceId, gmvm, Optional.of(request.imt));
+      var body = ResponseBody.success()
+          .name(request.serviceId.name)
+          .url(request.http.getUri().getPath())
+          .metadata(new ResponseMetadata(HazVersion.appVersions()))
+          .request(request)
+          .response(response)
+          .build();
+      return ServiceUtil.GSON.toJson(body);
+    }
+
+    static class Request extends GmmService.Request {
+
+      Imt imt;
+      double mMin;
+      double mMax;
+      double step;
+      double distance;
+
+      Request(
+          HttpRequest<?> http, Id serviceId,
+          Set<Gmm> gmms, GmmInput in,
+          Optional<Imt> imt,
+          Optional<Double> mMin,
+          Optional<Double> mMax,
+          Optional<Double> step,
+          Optional<Double> distance) {
+
+        super(http, serviceId, gmms, in);
+        this.imt = imt.orElse(Imt.PGA);
+        this.mMin = mMin.orElse(M_MIN);
+        this.mMax = mMax.orElse(M_MAX);
+        this.step = step.orElse(M_STEP);
+        this.distance = distance.orElse(M_DISTANCE);
+      }
+    }
+  }
+
+  /*
+   * Consolidators of epi branches. GMMs return logic trees that represent all
+   * combinations of means and sigmas. For the response spectra service we want
+   * those branches with either common means or common sigmas recombined.
+   *
+   * We do know a bit about the branch IDs in ground motion logic trees and we
+   * can leverage that when combining branches. They always take the form
+   * "μ_id : σ_id" so and index of 0 will consolidate using the IDs of the mean
+   * branches and an index of 1 will consolidate using the IDs of the sigma
+   * branches.
+   */
+
+  static List<EpiSeries<Double>> collapseGmTreeSingle(List<EpiSeries<Double>> epiList, int index) {
+    List<String> ids = new ArrayList<>();
+    Map<String, Double> valueMap = new HashMap<>();
+    Map<String, Double> wtMap = new HashMap<>();
+
+    for (EpiSeries<Double> epi : epiList) {
+      String id = epi.id.split(" : ")[index];
+
+      if (!ids.contains(id)) {
+        ids.add(id);
+        valueMap.put(id, epi.values);
+        wtMap.put(id, epi.weights);
+      } else {
+        wtMap.merge(id, epi.weights, (a, b) -> a + b);
+      }
+    }
+
+    return ids.stream()
+        .map(id -> new EpiSeries<>(id, valueMap.get(id), format(wtMap.get(id))))
+        .collect(Collectors.toList());
+  }
+
+  static List<EpiSeries<double[]>> collapseGmTree(List<EpiSeries<double[]>> epiList, int index) {
+
+    List<String> ids = new ArrayList<>();
+    Map<String, double[]> valueMap = new HashMap<>();
+    Map<String, double[]> wtMap = new HashMap<>();
+
+    for (EpiSeries<double[]> epi : epiList) {
+      String id = epi.id.split(" : ")[index];
+      if (!ids.contains(id)) {
+        ids.add(id);
+        valueMap.put(id, epi.values);
+        wtMap.put(id, Arrays.copyOf(epi.weights, epi.weights.length));
+      } else {
+        wtMap.merge(id, epi.weights, DoubleData::add);
+      }
+    }
+    return ids.stream()
+        .map(id -> new EpiSeries<>(id, valueMap.get(id), format(wtMap.get(id))))
+        .collect(Collectors.toList());
+  }
+
+  private static double formatExp(double value) {
+    return Maths.roundToDigits(Math.exp(value), ServiceUtil.ROUND);
+  }
+
+  private static double[] formatExp(double[] values) {
+    return Arrays.stream(values)
+        .map(d -> formatExp(d))
+        .toArray();
+  }
+
+  private static double format(double value) {
+    return Maths.roundToDigits(value, ServiceUtil.ROUND);
+  }
+
+  private static double[] format(double[] values) {
+    return Arrays.stream(values)
+        .map(d -> format(d))
+        .toArray();
+  }
+
+  static class SpectraData {
+    final Double pga;
+    final Double pgv;
+    final XySequence sa;
+
+    SpectraData(Double pga, Double pgv, XySequence sa) {
+      this.pga = pga;
+      this.pgv = pgv;
+      this.sa = sa;
+    }
+  }
+
+  private static class EpiGroup<T> {
+    final List<EpiSeries<T>> μ;
+    final List<EpiSeries<T>> sigma;
+
+    EpiGroup(List<EpiSeries<T>> μ, List<EpiSeries<T>> sigma) {
+      this.μ = μ;
+      this.sigma = sigma;
+    }
+  }
+
+  public static enum Id {
+
+    DISTANCE(
+        "Ground Motion Vs. Distance",
+        "Compute ground motion Vs. distance",
+        "/distance",
+        "Means",
+        "Sigmas",
+        "Distance (km)",
+        "Median ground motion",
+        "Standard deviation"),
+
+    HW_FW(
+        "Hanging Wall Effect",
+        "Compute hanging wall effect on ground motion Vs. distance",
+        "/hw-fw",
+        "Means",
+        "Sigmas",
+        "Distance (km)",
+        "Median ground motion",
+        "Standard deviation"),
+
+    MAGNITUDE(
+        "Ground Motion Vs. Magnitude",
+        "Compute ground motion Vs. magnitude",
+        "/magnitude",
+        "Means",
+        "Sigmas",
+        "Magnitude",
+        "Median ground motion",
+        "Standard deviation"),
+
+    SPECTRA(
+        "Deterministic Response Spectra",
+        "Compute deterministic response spectra",
+        "/spectra",
+        "Means",
+        "Sigmas",
+        "Period (s)",
+        "Median ground motion (g)",
+        "Standard deviation");
+
+    public final String name;
+    public final String description;
+    public final String pathInfo;
+    public final String resultName;
+    public final String groupNameMean;
+    public final String groupNameSigma;
+    public final String xLabel;
+    public final String yLabelMedian;
+    public final String yLabelSigma;
+
+    private Id(
+        String name, String description,
+        String pathInfo, String groupNameMean,
+        String groupNameSigma, String xLabel,
+        String yLabelMedian, String yLabelSigma) {
+      this.name = name;
+      this.description = description;
+      this.resultName = name + " Results";
+      this.pathInfo = pathInfo;
+      this.groupNameMean = groupNameMean;
+      this.groupNameSigma = groupNameSigma;
+      this.xLabel = xLabel;
+      this.yLabelMedian = yLabelMedian;
+      this.yLabelSigma = yLabelSigma;
+    }
+  }
+
+}
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/gmm/ServiceUtil.java b/src/main/java/gov/usgs/earthquake/nshmp/www/gmm/ServiceUtil.java
new file mode 100644
index 0000000000000000000000000000000000000000..d4ad5af48931ddb287659f3ac652e00f2524675d
--- /dev/null
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/gmm/ServiceUtil.java
@@ -0,0 +1,459 @@
+package gov.usgs.earthquake.nshmp.www.gmm;
+
+import static gov.usgs.earthquake.nshmp.gmm.GmmInput.Field.DIP;
+import static gov.usgs.earthquake.nshmp.gmm.GmmInput.Field.MW;
+import static gov.usgs.earthquake.nshmp.gmm.GmmInput.Field.RAKE;
+import static gov.usgs.earthquake.nshmp.gmm.GmmInput.Field.RJB;
+import static gov.usgs.earthquake.nshmp.gmm.GmmInput.Field.RRUP;
+import static gov.usgs.earthquake.nshmp.gmm.GmmInput.Field.RX;
+import static gov.usgs.earthquake.nshmp.gmm.GmmInput.Field.VS30;
+import static gov.usgs.earthquake.nshmp.gmm.GmmInput.Field.WIDTH;
+import static gov.usgs.earthquake.nshmp.gmm.GmmInput.Field.Z1P0;
+import static gov.usgs.earthquake.nshmp.gmm.GmmInput.Field.Z2P5;
+import static gov.usgs.earthquake.nshmp.gmm.GmmInput.Field.ZHYP;
+import static gov.usgs.earthquake.nshmp.gmm.GmmInput.Field.ZSED;
+import static gov.usgs.earthquake.nshmp.gmm.GmmInput.Field.ZTOR;
+import static gov.usgs.earthquake.nshmp.gmm.Imt.AI;
+import static gov.usgs.earthquake.nshmp.gmm.Imt.PGD;
+import static io.micronaut.core.type.Argument.DOUBLE;
+import static java.util.stream.Collectors.toCollection;
+
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import com.google.common.collect.Range;
+import com.google.common.primitives.Doubles;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonSerializationContext;
+import com.google.gson.JsonSerializer;
+
+import gov.usgs.earthquake.nshmp.data.DoubleData;
+import gov.usgs.earthquake.nshmp.data.Sequences;
+import gov.usgs.earthquake.nshmp.gmm.Gmm;
+import gov.usgs.earthquake.nshmp.gmm.GmmInput;
+import gov.usgs.earthquake.nshmp.gmm.GmmInput.Constraints;
+import gov.usgs.earthquake.nshmp.gmm.GmmInput.Field;
+import gov.usgs.earthquake.nshmp.gmm.Imt;
+import gov.usgs.earthquake.nshmp.model.TectonicSetting;
+import gov.usgs.earthquake.nshmp.www.HazVersion;
+import gov.usgs.earthquake.nshmp.www.ResponseBody;
+import gov.usgs.earthquake.nshmp.www.ResponseMetadata;
+import gov.usgs.earthquake.nshmp.www.WsUtils;
+import gov.usgs.earthquake.nshmp.www.gmm.GmmService.Id;
+import gov.usgs.earthquake.nshmp.www.meta.EnumParameter;
+
+import io.micronaut.http.HttpParameters;
+import io.micronaut.http.HttpRequest;
+
+class ServiceUtil {
+
+  final static int ROUND = 5;
+  static final Range<Double> MAGNITUDE_DEFAULT_VALUES = Range.closed(5.0, 8.0);
+  static final double MAGNITUDE_DEFAULT_STEP = 0.1;
+  static final double MAGNITUDE_DEFAULT_DISTANCE = 10.0;
+  static final Range<Double> DISTANCE_DEFAULT_VALUES = Range.closed(-100.0, 100.0);
+
+  static final Gson GSON;
+
+  static {
+    GSON = new GsonBuilder()
+        .setPrettyPrinting()
+        .serializeNulls()
+        .disableHtmlEscaping()
+        .registerTypeAdapter(Double.class, new WsUtils.NaNSerializer())
+        .registerTypeAdapter(ServiceUtil.Parameters.class,
+            new ServiceUtil.Parameters.Serializer())
+        .registerTypeAdapter(Imt.class, new WsUtils.EnumSerializer<Imt>())
+        .registerTypeAdapter(Constraints.class, new WsUtils.ConstraintsSerializer())
+        .create();
+  }
+
+  public static String metadata(HttpRequest<?> request, Id service) {
+    return GSON.toJson(ServiceUtil.getMetadata(request, service));
+  }
+
+  /** Query and JSON reqest/response keys. */
+  static final class Key {
+    public static final String DISTANCE = "distance";
+    public static final String IMT = "imt";
+    public static final String GMM = "gmm";
+    public static final String M_MIN = "mMin";
+    public static final String M_MAX = "mMax";
+    public static final String R_MAX = "rMax";
+    public static final String R_MIN = "rMin";
+    public static final String STEP = "step";
+  }
+
+  /* Read the GMM input query values. */
+  static GmmInput readGmmInput(HttpRequest<?> request) {
+    HttpParameters params = request.getParameters();
+    GmmInput.Builder b = GmmInput.builder().withDefaults();
+    params.getFirst(MW.id, DOUBLE).ifPresent(b::mag);
+    params.getFirst(RJB.id, DOUBLE).ifPresent(b::rJB);
+    params.getFirst(RRUP.id, DOUBLE).ifPresent(b::rRup);
+    params.getFirst(RX.id, DOUBLE).ifPresent(b::rX);
+    params.getFirst(DIP.id, DOUBLE).ifPresent(b::dip);
+    params.getFirst(WIDTH.id, DOUBLE).ifPresent(b::width);
+    params.getFirst(ZTOR.id, DOUBLE).ifPresent(b::zTor);
+    params.getFirst(ZHYP.id, DOUBLE).ifPresent(b::zHyp);
+    params.getFirst(RAKE.id, DOUBLE).ifPresent(b::rake);
+    params.getFirst(VS30.id, DOUBLE).ifPresent(b::vs30);
+    params.getFirst(Z1P0.id, DOUBLE).ifPresent(b::z1p0);
+    params.getFirst(Z2P5.id, DOUBLE).ifPresent(b::z2p5);
+    params.getFirst(ZSED.id, DOUBLE).ifPresent(b::zSed);
+    return b.build();
+  }
+
+  /* Read the 'gmm' query values. */
+  static Set<Gmm> readGmms(HttpRequest<?> request) {
+    return request.getParameters()
+        .getAll(Key.GMM)
+        .stream()
+        .map(s -> s.split(","))
+        .flatMap(Arrays::stream)
+        .map(Gmm::valueOf)
+        .collect(toCollection(() -> EnumSet.noneOf(Gmm.class)));
+  }
+
+  static ResponseBody<String, MetadataResponse> getMetadata(
+      HttpRequest<?> request,
+      Id service) {
+
+    String url = request.getUri().getPath();
+    return ResponseBody.<String, MetadataResponse> usage()
+        .name(service.name)
+        .url(url)
+        .metadata(new ResponseMetadata(HazVersion.appVersions()))
+        .request(url)
+        .response(new MetadataResponse(request, service))
+        .build();
+  }
+
+  static double[] sequenceLog(double min, double max, double step) {
+    return DoubleData.round(
+        ROUND,
+        DoubleData.pow10(
+            Sequences.arrayBuilder(Math.log10(min), Math.log10(max), step)
+                .centered()
+                .build()));
+  }
+
+  static double[] sequenceLinear(double min, double max, double step) {
+    return Sequences.arrayBuilder(min, max, step)
+        .scale(ROUND)
+        .centered()
+        .build();
+  }
+
+  static final class MetadataResponse {
+    String description;
+    String syntax;
+    Parameters parameters;
+
+    public MetadataResponse(HttpRequest<?> request, Id service) {
+      this.syntax = request.getUri().getPath();
+      this.description = service.description;
+      this.parameters = new Parameters(service);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  private static Param createGmmInputParam(
+      Field field,
+      Optional<?> constraint) {
+
+    // if (field == VSINF) return new BooleanParam(field);
+    return new NumberParam(field, (Range<Double>) constraint.orElseThrow());
+  }
+
+  /*
+   * Placeholder class; all parameter serialization is done via the custom
+   * Serializer. Service reference needed serialize().
+   */
+  static final class Parameters {
+
+    private final Id service;
+
+    Parameters(Id service) {
+      this.service = service;
+    }
+
+    static final class Serializer implements JsonSerializer<Parameters> {
+
+      @Override
+      public JsonElement serialize(
+          Parameters meta,
+          Type type,
+          JsonSerializationContext context) {
+
+        JsonObject root = new JsonObject();
+
+        if (!meta.service.equals(Id.SPECTRA)) {
+          Set<Imt> imtSet = EnumSet.complementOf(EnumSet.range(PGD, AI));
+          final EnumParameter<Imt> imts = new EnumParameter<>(
+              "Intensity measure type",
+              imtSet);
+          root.add(Key.IMT, context.serialize(imts));
+        }
+
+        /* Serialize input fields. */
+        Constraints defaults = Constraints.defaults();
+        for (Field field : Field.values()) {
+          if (!meta.service.equals(Id.SPECTRA) &&
+              (field.equals(Field.RX) || field.equals(Field.RRUP) || field.equals(Field.RJB))) {
+            continue;
+          }
+
+          Param param = createGmmInputParam(field, defaults.get(field));
+          JsonElement fieldElem = context.serialize(param);
+          root.add(field.id, fieldElem);
+        }
+
+        if (meta.service.equals(Id.DISTANCE) || meta.service.equals(Id.HW_FW)) {
+          double min = meta.service.equals(Id.HW_FW)
+              ? Range.openClosed(0.0, 1.0).lowerEndpoint()
+              : DISTANCE_DEFAULT_VALUES.lowerEndpoint();
+          NumberParam rMin = new NumberParam(
+              "Minimum distance",
+              "The minimum distance to use for calculation",
+              Field.RX.units.orElse(null),
+              Range.openClosed(0.0, 1000.0),
+              min);
+          root.add(Key.R_MIN, context.serialize(rMin));
+
+          NumberParam rMax = new NumberParam(
+              "Maximum distance",
+              "The maximum distance to use for calculation",
+              Field.RX.units.orElse(null),
+              Range.closed(-1000.0, 1000.0),
+              DISTANCE_DEFAULT_VALUES.upperEndpoint());
+          root.add(Key.R_MAX, context.serialize(rMax));
+        }
+
+        if (meta.service.equals(Id.MAGNITUDE)) {
+          @SuppressWarnings("unchecked")
+          NumberParam mMin = new NumberParam(
+              "Minimum distance",
+              "The minimum distance to use for calculation",
+              Field.MW.units.orElse(null),
+              (Range<Double>) defaults.get(Field.MW).orElseThrow(),
+              MAGNITUDE_DEFAULT_VALUES.lowerEndpoint());
+          root.add(Key.M_MIN, context.serialize(mMin));
+
+          @SuppressWarnings("unchecked")
+          NumberParam mMax = new NumberParam(
+              "Maximum magnitude",
+              "The maximum magnitude to use for calculation",
+              Field.MW.units.orElse(null),
+              (Range<Double>) defaults.get(Field.MW).orElseThrow(),
+              MAGNITUDE_DEFAULT_VALUES.upperEndpoint());
+          root.add(Key.M_MAX, context.serialize(mMax));
+
+          @SuppressWarnings("unchecked")
+          NumberParam distance = new NumberParam(
+              "Distance",
+              "The distance between site and trace ",
+              Field.RX.units.orElse(null),
+              (Range<Double>) defaults.get(Field.RX).orElseThrow(),
+              MAGNITUDE_DEFAULT_DISTANCE);
+          root.add(Key.DISTANCE, context.serialize(distance));
+
+          NumberParam step = new NumberParam(
+              "Magnitude step",
+              "The step between each magnitude ",
+              null,
+              Range.closed(0.0001, 1.0),
+              MAGNITUDE_DEFAULT_STEP);
+          root.add(Key.STEP, context.serialize(step));
+        }
+
+        /* Add only add those Gmms that belong to a Group. */
+        List<Gmm> gmms = Arrays.stream(Gmm.Group.values())
+            .flatMap(group -> group.gmms().stream())
+            .sorted(Comparator.comparing(Object::toString))
+            .distinct()
+            .collect(Collectors.toList());
+
+        GmmParam gmmParam = new GmmParam(
+            GMM_NAME,
+            GMM_INFO,
+            gmms);
+        root.add(Key.GMM, context.serialize(gmmParam));
+
+        /* Add gmm groups. */
+        GroupParam groups = new GroupParam(
+            GROUP_NAME,
+            GROUP_INFO,
+            EnumSet.allOf(Gmm.Group.class));
+        root.add(GROUP_KEY, context.serialize(groups));
+
+        return root;
+      }
+    }
+  }
+
+  /*
+   * Marker interface for spectra parameters. This was previously implemented as
+   * an abstract class for label, info, and units, but Gson serialized subclass
+   * fields before parent fields. To maintain a preferred order, one can write
+   * custom serializers or repeat these four fields in each implementation.
+   */
+  private static interface Param {}
+
+  @SuppressWarnings("unused")
+  private static final class NumberParam implements Param {
+
+    final String label;
+    final String info;
+    final String units;
+    final Double min;
+    final Double max;
+    final Double value;
+
+    NumberParam(GmmInput.Field field, Range<Double> constraint) {
+      this(field, constraint, field.defaultValue);
+    }
+
+    NumberParam(GmmInput.Field field, Range<Double> constraint, Double value) {
+      this.label = field.label;
+      this.info = field.info;
+      this.units = field.units.orElse(null);
+      this.min = constraint.lowerEndpoint();
+      this.max = constraint.upperEndpoint();
+      this.value = Doubles.isFinite(value) ? value : null;
+    }
+
+    NumberParam(String label, String info, String units, Range<Double> constraint, Double value) {
+      this.label = label;
+      this.info = info;
+      this.units = units;
+      this.min = constraint.lowerEndpoint();
+      this.max = constraint.upperEndpoint();
+      this.value = Doubles.isFinite(value) ? value : null;
+    }
+  }
+
+  @SuppressWarnings("unused")
+  @Deprecated
+  private static final class BooleanParam implements Param {
+
+    // used to support vsInf
+    // will likely be used for other flags in future (e.g. vertical GM)
+
+    final String label;
+    final String info;
+    final boolean value;
+
+    BooleanParam(GmmInput.Field field) {
+      this(field, field.defaultValue == 1.0);
+    }
+
+    BooleanParam(GmmInput.Field field, boolean value) {
+      this.label = field.label;
+      this.info = field.info;
+      this.value = value;
+    }
+  }
+
+  private static final String GMM_NAME = "Ground Motion Models";
+  private static final String GMM_INFO = "Empirical models of ground motion";
+
+  @SuppressWarnings("unused")
+  private static class GmmParam implements Param {
+
+    final String label;
+    final String info;
+    final List<Value> values;
+
+    GmmParam(String label, String info, List<Gmm> gmms) {
+      this.label = label;
+      this.info = info;
+      this.values = gmms.stream()
+          .map(gmm -> new Value(gmm))
+          .collect(Collectors.toList());
+    }
+
+    private static class Value {
+
+      final String id;
+      final String label;
+      final Gmm.Type type;
+      final ArrayList<String> supportedImts;
+      final Constraints constraints;
+
+      Value(Gmm gmm) {
+        this.id = gmm.name();
+        this.label = gmm.toString();
+        this.type = gmm.type();
+        this.supportedImts = supportedImts(gmm.supportedImts());
+        this.constraints = gmm.constraints();
+      }
+    }
+
+    private static ArrayList<String> supportedImts(Set<Imt> imts) {
+      ArrayList<String> supportedImts = new ArrayList<>();
+
+      for (Imt imt : imts) {
+        supportedImts.add(imt.name());
+      }
+
+      return supportedImts;
+    }
+
+  }
+
+  private static final String GROUP_KEY = "group";
+  private static final String GROUP_NAME = "Ground Motion Model Groups";
+  private static final String GROUP_INFO = "Groups of related ground motion models ";
+
+  @SuppressWarnings("unused")
+  private static final class GroupParam implements Param {
+
+    final String label;
+    final String info;
+    final List<Value> values;
+
+    GroupParam(String label, String info, Set<Gmm.Group> groups) {
+      this.label = label;
+      this.info = info;
+      this.values = new ArrayList<>();
+      for (Gmm.Group group : groups) {
+        this.values.add(new Value(group));
+      }
+    }
+
+    private static class Value {
+
+      final String id;
+      final String label;
+      final List<Gmm> data;
+      final String type;
+
+      Value(Gmm.Group group) {
+        this.id = group.name();
+        this.label = group.toString();
+        this.data = group.gmms();
+
+        /* TODO get group gmm type directly from group */
+        if (group.toString().contains("Active Volcanic (HI)")) {
+          this.type = TectonicSetting.VOLCANIC.name();
+        } else {
+          this.type = group.gmms().stream()
+              .map(gmm -> gmm.type().name())
+              .findFirst()
+              .orElseThrow();
+        }
+      }
+    }
+  }
+}
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/gmm/Utils.java b/src/main/java/gov/usgs/earthquake/nshmp/www/gmm/Utils.java
new file mode 100644
index 0000000000000000000000000000000000000000..66668f228229a11c4f7ac2e24aee0e28c8becea7
--- /dev/null
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/gmm/Utils.java
@@ -0,0 +1,65 @@
+package gov.usgs.earthquake.nshmp.www.gmm;
+
+import static com.google.common.base.CaseFormat.LOWER_CAMEL;
+import static com.google.common.base.CaseFormat.UPPER_UNDERSCORE;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+
+import gov.usgs.earthquake.nshmp.www.HazVersion;
+import gov.usgs.earthquake.nshmp.www.ResponseBody;
+import gov.usgs.earthquake.nshmp.www.ResponseMetadata;
+
+public class Utils {
+  public static final Gson GSON;
+
+  static {
+    GSON = new GsonBuilder()
+        .disableHtmlEscaping()
+        .serializeNulls()
+        .setPrettyPrinting()
+        .create();
+  }
+
+  public enum Key {
+    DISTANCE,
+    ID,
+    IMT,
+    GMM,
+    GPSDATASET,
+    GROUP,
+    LABEL,
+    LATITUDE,
+    LONGITUDE,
+    M_MIN,
+    M_MAX,
+    MODEL,
+    NAME,
+    R_MAX,
+    R_MIN,
+    STEP,
+    Z1P0,
+    Z2P5;
+
+    @Override
+    public String toString() {
+      return UPPER_UNDERSCORE.to(LOWER_CAMEL, name());
+    }
+  }
+
+  public static String handleError(
+      Throwable e,
+      String name,
+      String url) {
+    var msg = e.getMessage() + " (see logs)";
+    var svcResponse = ResponseBody.error()
+        .name(name)
+        .url(url)
+        .metadata(new ResponseMetadata(HazVersion.appVersions()))
+        .request(msg)
+        .response(url)
+        .build();
+    e.printStackTrace();
+    return GSON.toJson(svcResponse);
+  }
+}
diff --git a/src/main/java/gov/usgs/earthquake/nshmp/www/gmm/XyDataGroup.java b/src/main/java/gov/usgs/earthquake/nshmp/www/gmm/XyDataGroup.java
new file mode 100644
index 0000000000000000000000000000000000000000..c7a233cef7d4dcf868046591f5d8f881936de514
--- /dev/null
+++ b/src/main/java/gov/usgs/earthquake/nshmp/www/gmm/XyDataGroup.java
@@ -0,0 +1,65 @@
+package gov.usgs.earthquake.nshmp.www.gmm;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/*
+ * XY data sequences serialization object.
+ *
+ * @author U.S. Geological Survey
+ */
+@SuppressWarnings("unused")
+class XyDataGroup<T, U> {
+
+  private final String label;
+  private final String xLabel;
+  private final String yLabel;
+  private final List<Series<T, U>> data;
+
+  private XyDataGroup(String label, String xLabel, String yLabel) {
+    this.label = label;
+    this.xLabel = xLabel;
+    this.yLabel = yLabel;
+    this.data = new ArrayList<>();
+  }
+
+  /* Create a data group. */
+  static <T, U> XyDataGroup<T, U> create(String name, String xLabel, String yLabel) {
+    return new XyDataGroup<T, U>(name, xLabel, yLabel);
+  }
+
+  /* Add a data sequence */
+  XyDataGroup<T, U> add(String id, String name, T data, List<U> tree) {
+    this.data.add(new Series<>(id, name, data, tree));
+    return this;
+  }
+
+  static class Series<T, U> {
+
+    private final String id;
+    private final String label;
+    private final T data;
+    private final List<U> tree;
+
+    Series(String id, String label, T data, List<U> tree) {
+      this.id = id;
+      this.label = label;
+      this.data = data;
+      this.tree = tree;
+    }
+  }
+
+  static class EpiSeries<T> {
+
+    final String id;
+    final T values;
+    final T weights;
+
+    EpiSeries(String id, T values, T weights) {
+      this.id = id;
+      this.values = values;
+      this.weights = weights;
+    }
+  }
+
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 37590421097c60101a0d4a1605d81860d1859c07..ecab27f174462fa9862f1f8363935d9652e5167f 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -21,3 +21,11 @@ nshmp-haz:
   #     java -jar build/libs/nshmp-haz.jar --model=<path/to/model>
   #
   model-path: ${MODEL:nshms/nshm-conus-2018}
+
+  ##
+  # Whether this is a GMM service deploy
+  gmm: ${GMM_DEPLOY:false}
+
+  ##
+  # The base path for GMM services
+  gmm-path: ${GMM_PATH:/gmm}