diff --git a/Dockerfile b/Dockerfile
index 70a507a3596ae16bdfdc22b8ec5e91be70bae9a2..19ddbde3c8583c3966965d50399cb0d8d7a8b667 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,8 +1,6 @@
 ####
 # Build locally:
-#   docker build
-#       --build-arg gitlab_token=<token>
-#       -t nshmp-ws-static .
+#   docker build -t nshmp-ws-static .
 ####
 
 ARG BUILD_IMAGE=usgs/amazoncorretto:11
@@ -10,9 +8,7 @@ ARG FROM_IMAGE=usgs/amazoncorretto:11
 
 FROM ${BUILD_IMAGE} as builder
 
-# TODO: Token needed until nshmp-lib is public
-ARG GITLAB_TOKEN=null
-ARG CI_JOB_TOKEN=null
+# For GitLab CI/CD
 ARG CI_PROJECT_URL=null
 ARG CI_COMMIT_BRANCH=null
 
diff --git a/README.md b/README.md
index 87adcf8a2ce6383fe6336f0af7461a15274f3d97..2ac8c7129425af4c7a1f6f46f1f854a0ce358520 100644
--- a/README.md
+++ b/README.md
@@ -74,8 +74,5 @@ docker run -e SERVICE=aashto -p 8080:8080 usgs/nshmp-ws-static:production-latest
 To build the Docker image locally run:
 
 ```bash
-docker build --build-arg GITLAB_TOKEN="{gitlab_token}" -t nshmp-ws-static .
+docker build -t nshmp-ws-static .
 ```
-
-> Replace `{gitlab_token}` with the GitLab API
-> [access token](https://code.usgs.gov/-/profile/personal_access_tokens)
diff --git a/gradle.properties b/gradle.properties
index 33e58e7cd9cd9e17fb4a1aab904938409ee454b9..54580ed9db2de6f8bf341bec3ac945bb1b63a349 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -8,7 +8,7 @@ netcdfVersion = 5.5.2
 nodePluginVersion = 3.0.1
 nodeVersion = 16.3.0
 nshmpLibVersion = 1.0.6
-nshmpWsUtilsVersion = 0.3.9
+nshmpWsUtilsVersion = 0.3.10
 openApiVersion = 4.0.0
 shadowVersion = 7.1.1
 slfVersion = 1.7.30
diff --git a/gradle/repositories.gradle b/gradle/repositories.gradle
index 09519817c26f8f1bd4eb5bd436c8862ca8659094..27f42af58505e5d8e31738e3bfd8c0f485b94961 100644
--- a/gradle/repositories.gradle
+++ b/gradle/repositories.gradle
@@ -7,23 +7,7 @@ repositories {
   }
 
   maven {
-    url "https://code.usgs.gov/api/v4/groups/160/-/packages/maven"
-    name "GitLab"
-    if (System.getenv("CI_JOB_TOKEN") && System.getenv("CI_JOB_TOKEN") != "null") {
-      credentials(HttpHeaderCredentials) {
-        name = "Job-Token"
-        value = System.getenv("CI_JOB_TOKEN")
-      }
-    } else if (System.getenv("GITLAB_TOKEN") && System.getenv("GITLAB_TOKEN") != "null") {
-      credentials(HttpHeaderCredentials) {
-        name = "Private-Token"
-        value = System.getenv("GITLAB_TOKEN")
-      }
-    } else {
-      throw new GradleException("CI_JOB_TOKEN or GITLAB_TOKEN environmental variable must be set")
-    }
-    authentication {
-      header(HttpHeaderAuthentication)
-    }
+    url "https://code.usgs.gov/api/v4/groups/1352/-/packages/maven"
+    name "NSHMP GitLab Group"
   }
 }
diff --git a/src/aashto/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfController.java b/src/aashto/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfController.java
index 69f25638e1f10a0db3dda70d2e0b9d8ebb6af596..bb5bc19870dadd7c7fb864fcc397ef05489106fd 100644
--- a/src/aashto/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfController.java
+++ b/src/aashto/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfController.java
@@ -4,9 +4,8 @@ import java.nio.file.Path;
 
 import gov.usgs.earthquake.nshmp.gmm.NehrpSiteClass;
 import gov.usgs.earthquake.nshmp.netcdf.NetcdfGroundMotions;
-import gov.usgs.earthquake.nshmp.netcdf.www.NetcdfService.RequestDataSiteClass;
-import gov.usgs.earthquake.nshmp.netcdf.www.NetcdfService.ResponseData;
-import gov.usgs.earthquake.nshmp.netcdf.www.NetcdfService.ServiceResponseMetadata;
+import gov.usgs.earthquake.nshmp.netcdf.www.Metadata.ServiceResponseMetadata;
+import gov.usgs.earthquake.nshmp.netcdf.www.Request.RequestDataSiteClass;
 import gov.usgs.earthquake.nshmp.www.NshmpMicronautServlet;
 import gov.usgs.earthquake.nshmp.www.ResponseBody;
 
@@ -24,6 +23,7 @@ import io.micronaut.runtime.event.annotation.EventListener;
 import io.swagger.v3.oas.annotations.Hidden;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.ExampleObject;
 import io.swagger.v3.oas.annotations.media.Schema;
 import io.swagger.v3.oas.annotations.responses.ApiResponse;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -64,36 +64,83 @@ public class NetcdfController {
    * @param longitude Longitude of site, in decimal degrees
    * @param latitude Latitude of site, in decimal degrees
    * @param siteClass Site class
+   * @param format Optional - Response return type: CSV or JSON. Default: JSON
    */
   @Operation(
       summary = "Returns risk-targeted design response spectra from a slash based call",
       description = "### Returns a risk-targeted design response spectrum for a " +
           "user-specified latitude, longitude, and site class.\n" +
 
-          "Enter the latitude and longitude select the site class, and press `Execute`.\n" +
+          "Enter the latitude and longitude, select the site class " +
+          "and format (optional), and press `Execute`.\n" +
 
-          "### Service call pattern\n" +
+          "### Service Response Types\n" +
+          "The web service can respond in JSON or CSV format.\n" +
+          "<br><br>" +
+          "Choose the format type by adding a `format` query to the service call:" +
+          "`?format=JSON` or `?format=CSV`\n" +
+          "<br><br>" +
+          "Default: `JSON`\n\n" +
+
+          "### Service Call Pattern\n" +
           "This service call is slashed delimited with pattern: " +
           "`/spectra/{longitude}/{latitude}/{siteClass}`\n" +
           "<br><br>" +
-          "Example: `/spectra/-118/34/A`",
+          "Example: `/spectra/-118/34/A`\n" +
+          "<br><br>" +
+          "CSV Example: `/spectra/-118/34/A?format=CSV`\n",
       operationId = "aashto-slash")
   @ApiResponse(
       description = "Spatially interpolates data from https://doi.org/10.5066/P9Z206HY",
       responseCode = "200",
-      content = @Content(
-          mediaType = MediaType.APPLICATION_JSON,
-          schema = @Schema(implementation = Response.class)))
-  @Get(uri = "/{longitude}/{latitude}/{siteClass}", produces = MediaType.APPLICATION_JSON)
-  public HttpResponse<String> doGetSlashBySite(
+      content = {
+          @Content(
+              mediaType = MediaType.APPLICATION_JSON,
+              schema = @Schema(implementation = Response.class)),
+          @Content(
+              mediaType = MediaType.TEXT_CSV,
+              examples = @ExampleObject(
+                  name = "CSV",
+                  value = "AASHTO-2023 Web Service\n" +
+                      "\n" +
+                      "Longitude,-105.0,Latitude,40.0,SiteClass,BC\n" +
+                      "Period (s),0.0,0.01,0.02,0.03,0.05,0.075, ...\n" +
+                      "Spectral Acceleration (g),0.073,0.08,0.12,0.14,0.18, ...\n"))
+      })
+  @Get(uri = "/{longitude}/{latitude}/{siteClass}{?format}",
+      produces = { MediaType.APPLICATION_JSON, MediaType.TEXT_CSV })
+  public HttpResponse<String> doGetSlashBySiteFormat(
       HttpRequest<?> request,
-      @Schema(required = true) @PathVariable @Nullable Double latitude,
-      @Schema(required = true) @PathVariable @Nullable Double longitude,
-      @Schema(required = true) @PathVariable @Nullable NehrpSiteClass siteClass) {
-    var query = new Query(longitude, latitude, siteClass);
+      @Schema(required = true) @PathVariable Double latitude,
+      @Schema(required = true) @PathVariable Double longitude,
+      @Schema(required = true,
+          implementation = NehrpSiteClass.class) @PathVariable NehrpSiteClass siteClass,
+      @Schema(required = false,
+          defaultValue = "JSON") @QueryValue @Nullable ResponseFormat format) {
+    var query = new Query(longitude, latitude, siteClass, format);
     return service.handleServiceCall(request, query);
   }
 
+  /**
+   * GET method to return hazard curves using slash delimited.
+   *
+   * @param request The HTTP request
+   * @param longitude The longitude of the site
+   * @param latitude Latitude of the site
+   * @param format Optional - Response return type: CSV or JSON. Default: JSON
+   */
+  @Hidden
+  @Get(uri = "/{longitude}/{latitude}{?format}",
+      produces = { MediaType.APPLICATION_JSON, MediaType.TEXT_CSV })
+  public HttpResponse<String> doGetSlashFormat(
+      HttpRequest<?> request,
+      @Schema(required = true) @PathVariable Double longitude,
+      @Schema(required = true) @PathVariable Double latitude,
+      @Schema(required = false,
+          defaultValue = "JSON") @QueryValue @Nullable ResponseFormat format) {
+    return doGetSlashBySiteFormat(request, latitude, longitude, null, format);
+  }
+
   /**
    * GET method to return a static curve using URL query.
    *
@@ -101,6 +148,7 @@ public class NetcdfController {
    * @param longitude The longitude of the site
    * @param latitude Latitude of the site
    * @param siteClass The site class (optional)
+   * @param format Optional - Response return type: CSV or JSON. Default: JSON
    */
   @Hidden
   @Operation(
@@ -122,30 +170,15 @@ public class NetcdfController {
       content = @Content(
           mediaType = MediaType.APPLICATION_JSON,
           schema = @Schema(implementation = Response.class)))
-  @Get(uri = "{?longitude,latitude,siteClass}", produces = MediaType.APPLICATION_JSON)
+  @Get(uri = "{?longitude,latitude,siteClass,format}",
+      produces = { MediaType.APPLICATION_JSON, MediaType.TEXT_CSV })
   public HttpResponse<String> doGet(
       HttpRequest<?> request,
       @Schema(required = true) @QueryValue @Nullable Double latitude,
       @Schema(required = true) @QueryValue @Nullable Double longitude,
-      @QueryValue @Nullable NehrpSiteClass siteClass) {
-    var query = new Query(longitude, latitude, siteClass);
-    return service.handleServiceCall(request, query);
-  }
-
-  /**
-   * GET method to return hazard curves using slash delimited.
-   *
-   * @param request The HTTP request
-   * @param longitude The longitude of the site
-   * @param latitude Latitude of the site
-   */
-  @Hidden
-  @Get(uri = "/{longitude}/{latitude}", produces = MediaType.APPLICATION_JSON)
-  public HttpResponse<String> doGetSlash(
-      HttpRequest<?> request,
-      @Schema(required = true) @PathVariable @Nullable Double longitude,
-      @Schema(required = true) @PathVariable @Nullable Double latitude) {
-    var query = new Query(longitude, latitude, null);
+      @QueryValue @Nullable NehrpSiteClass siteClass,
+      @Schema(defaultValue = "JSON") @QueryValue @Nullable ResponseFormat format) {
+    var query = new Query(longitude, latitude, siteClass, format);
     return service.handleServiceCall(request, query);
   }
 
diff --git a/src/aashto/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfServiceGroundMotions.java b/src/aashto/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfServiceGroundMotions.java
index 5b1d333ca294a59f5e102c2fcd05c36afa2c50c0..e5ddcae46b8b3fa7c96fc3f7fb12ce30ecb06804 100644
--- a/src/aashto/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfServiceGroundMotions.java
+++ b/src/aashto/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfServiceGroundMotions.java
@@ -8,8 +8,11 @@ import gov.usgs.earthquake.nshmp.geo.Location;
 import gov.usgs.earthquake.nshmp.netcdf.NetcdfGroundMotions;
 import gov.usgs.earthquake.nshmp.netcdf.NetcdfVersion;
 import gov.usgs.earthquake.nshmp.netcdf.data.StaticData;
+import gov.usgs.earthquake.nshmp.netcdf.www.Metadata.ServiceResponseMetadata;
 import gov.usgs.earthquake.nshmp.netcdf.www.NetcdfWsUtils.Key;
 import gov.usgs.earthquake.nshmp.netcdf.www.Query.Service;
+import gov.usgs.earthquake.nshmp.netcdf.www.Request.RequestData;
+import gov.usgs.earthquake.nshmp.netcdf.www.Request.RequestDataSiteClass;
 import gov.usgs.earthquake.nshmp.www.ResponseBody;
 import gov.usgs.earthquake.nshmp.www.ResponseMetadata;
 import gov.usgs.earthquake.nshmp.www.WsUtils;
@@ -26,7 +29,6 @@ import io.micronaut.http.HttpRequest;
 public class NetcdfServiceGroundMotions extends NetcdfService<Query> {
 
   static final String SERVICE_DESCRIPTION = "Get static ground motions from a NetCDF file";
-  static final String SERVICE_NAME = "AASHTO-2023 Web Service";
   static final String X_LABEL = "Period (s)";
   static final String Y_LABEL = "Spectral Acceleration (g)";
 
@@ -35,12 +37,12 @@ public class NetcdfServiceGroundMotions extends NetcdfService<Query> {
   }
 
   @Override
-  ResponseBody<String, Metadata> getMetadataResponse(HttpRequest<?> request) {
-    var metadata = new Metadata(request, SERVICE_DESCRIPTION);
+  ResponseBody<String, Metadata<Query>> getMetadataResponse(HttpRequest<?> request) {
+    var metadata = new Metadata<Query>(request, this, SERVICE_DESCRIPTION);
 
-    return ResponseBody.<String, Metadata> usage()
+    return ResponseBody.<String, Metadata<Query>> usage()
         .metadata(new ResponseMetadata(NetcdfVersion.appVersions()))
-        .name(SERVICE_NAME)
+        .name(getServiceName())
         .request(NetcdfWsUtils.getRequestUrl(request))
         .response(metadata)
         .url(NetcdfWsUtils.getRequestUrl(request))
@@ -49,12 +51,12 @@ public class NetcdfServiceGroundMotions extends NetcdfService<Query> {
 
   @Override
   String getServiceName() {
-    return SERVICE_NAME;
+    return getSourceModel().name;
   }
 
   @Override
   SourceModel getSourceModel() {
-    return new SourceModel();
+    return new SourceModel(netcdf());
   }
 
   @Override
@@ -63,18 +65,18 @@ public class NetcdfServiceGroundMotions extends NetcdfService<Query> {
   }
 
   @Override
-  ResponseBody<?, ?> processRequest(HttpRequest<?> httpRequest, Query query, Service service) {
+  String processRequest(HttpRequest<?> httpRequest, Query query, Service service) {
     var site = Location.create(query.longitude, query.latitude);
-    var requestData = new RequestData(site);
+    var requestData = new RequestData(site, query.format);
     var url = NetcdfWsUtils.getRequestUrl(httpRequest);
 
     switch (service) {
       case CURVES:
-        return processCurves(requestData, url);
+        return toResponseFromList(processCurves(requestData, url));
       case CURVES_BY_SITE_CLASS:
-        requestData = new RequestDataSiteClass(site, query.siteClass);
-        return processCurvesSiteClass(
-            (RequestDataSiteClass) requestData, url);
+        requestData = new RequestDataSiteClass(site, query.siteClass, query.format);
+        return toResponse(processCurvesSiteClass(
+            (RequestDataSiteClass) requestData, url));
       default:
         throw new RuntimeException("Netcdf service [" + service + "] not found");
     }
@@ -92,7 +94,7 @@ public class NetcdfServiceGroundMotions extends NetcdfService<Query> {
 
     return ResponseBody.<RequestDataSiteClass, ResponseData<ServiceResponseMetadata>> success()
         .metadata(new ResponseMetadata(NetcdfVersion.appVersions()))
-        .name(SERVICE_NAME)
+        .name(getServiceName())
         .request(request)
         .response(responseData)
         .url(url)
@@ -106,11 +108,11 @@ public class NetcdfServiceGroundMotions extends NetcdfService<Query> {
     WsUtils.checkValue(Key.LATITUDE, request.latitude);
     WsUtils.checkValue(Key.LONGITUDE, request.longitude);
     var curves = netcdf().staticData(request.site);
-    var responseData = toList(request.site, curves);
+    var responseData = toList(request, curves);
 
     return ResponseBody.<RequestData, List<ResponseData<ServiceResponseMetadata>>> success()
         .metadata(new ResponseMetadata(NetcdfVersion.appVersions()))
-        .name(SERVICE_NAME)
+        .name(getServiceName())
         .request(request)
         .response(responseData)
         .url(url)
@@ -118,11 +120,12 @@ public class NetcdfServiceGroundMotions extends NetcdfService<Query> {
   }
 
   List<ResponseData<ServiceResponseMetadata>> toList(
-      Location site,
+      RequestData requestData,
       StaticData<XySequence> curves) {
     return curves.entrySet().stream()
         .map(entry -> {
-          var request = new RequestDataSiteClass(site, entry.getKey());
+          var request =
+              new RequestDataSiteClass(requestData.site, entry.getKey(), requestData.format);
           return toResponseData(request, entry.getValue());
         })
         .collect(Collectors.toList());
diff --git a/src/lib/src/main/resources/swagger/index.js b/src/aashto/src/main/resources/swagger/index.js
similarity index 100%
rename from src/lib/src/main/resources/swagger/index.js
rename to src/aashto/src/main/resources/swagger/index.js
diff --git a/src/hazard/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/HazardQuery.java b/src/hazard/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/HazardQuery.java
index 3b385e560c7d3ef7516e8124ff51436465d54d3e..9d474a7b22d20bfb95a7c70dc80f40f06d931f08 100644
--- a/src/hazard/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/HazardQuery.java
+++ b/src/hazard/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/HazardQuery.java
@@ -6,8 +6,13 @@ import gov.usgs.earthquake.nshmp.gmm.NehrpSiteClass;
 public class HazardQuery extends Query {
   public final Imt imt;
 
-  public HazardQuery(Double longitude, Double latitude, NehrpSiteClass siteClass, Imt imt) {
-    super(longitude, latitude, siteClass);
+  public HazardQuery(
+      Double longitude,
+      Double latitude,
+      NehrpSiteClass siteClass,
+      Imt imt,
+      ResponseFormat format) {
+    super(longitude, latitude, siteClass, format);
     this.imt = imt;
   }
 }
diff --git a/src/hazard/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfController.java b/src/hazard/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfController.java
index ea2eea3699c4ae06360ca804487ce0628ab0d26f..f14d8449a2aa35055936926c95369321a10154d7 100644
--- a/src/hazard/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfController.java
+++ b/src/hazard/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfController.java
@@ -6,10 +6,9 @@ import java.util.List;
 import gov.usgs.earthquake.nshmp.gmm.Imt;
 import gov.usgs.earthquake.nshmp.gmm.NehrpSiteClass;
 import gov.usgs.earthquake.nshmp.netcdf.NetcdfHazardCurves;
-import gov.usgs.earthquake.nshmp.netcdf.www.NetcdfService.RequestDataImt;
-import gov.usgs.earthquake.nshmp.netcdf.www.NetcdfService.RequestDataSiteClass;
-import gov.usgs.earthquake.nshmp.netcdf.www.NetcdfService.ResponseData;
-import gov.usgs.earthquake.nshmp.netcdf.www.NetcdfServiceHazardCurves.HazardResponseMetadata;
+import gov.usgs.earthquake.nshmp.netcdf.www.Metadata.HazardResponseMetadata;
+import gov.usgs.earthquake.nshmp.netcdf.www.Request.RequestDataImt;
+import gov.usgs.earthquake.nshmp.netcdf.www.Request.RequestDataSiteClass;
 import gov.usgs.earthquake.nshmp.www.NshmpMicronautServlet;
 import gov.usgs.earthquake.nshmp.www.ResponseBody;
 
@@ -26,6 +25,7 @@ import io.micronaut.http.annotation.QueryValue;
 import io.micronaut.runtime.event.annotation.EventListener;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.ExampleObject;
 import io.swagger.v3.oas.annotations.media.Schema;
 import io.swagger.v3.oas.annotations.responses.ApiResponse;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -73,27 +73,53 @@ public class NetcdfController {
       description = "### Returns hazard curves for a " +
           "user-specified latitude, longitude, site class, and IMT.\n" +
 
-          "Enter the latitude and longitude select the site class and IMT, and press `Execute`.\n" +
+          "Enter the latitude and longitude, select the site class, " +
+          "IMT, and format, and press `Execute`.\n" +
+
+          "### Service Response Types\n" +
+          "The web service can respond in JSON or CSV format.\n" +
+          "<br><br>" +
+          "Choose the format type by adding a `format` query to the service call:" +
+          "`?format=JSON` or `?format=CSV`\n" +
+          "<br><br>" +
+          "Default: `JSON`\n\n" +
 
           "### Service call pattern\n" +
           "This service call is slashed delimited with pattern: " +
           "`/hazard/{longitude}/{latitude}/{siteClass}/{imt}`\n" +
           "<br><br>" +
-          "Example: `/hazard/-118/34/BC/PGA`",
+          "Example: `/hazard/-118/34/BC/PGA`" +
+          "<br><br>" +
+          "CSV Example: `/hazard/-118/34/BC/PGA?format=CSV`",
       operationId = "hazard-by-imt")
   @ApiResponse(
       description = "Spatially interpolated hazard curves",
       responseCode = "200",
-      content = @Content(
-          schema = @Schema(implementation = ResponseByImt.class)))
-  @Get(uri = "/{longitude}/{latitude}/{siteClass}/{imt}", produces = MediaType.APPLICATION_JSON)
+      content = {
+          @Content(
+              mediaType = MediaType.APPLICATION_JSON,
+              schema = @Schema(implementation = ResponseByImt.class)),
+          @Content(
+              mediaType = MediaType.TEXT_CSV,
+              examples = @ExampleObject(
+                  name = "CSV",
+                  value = "Static Hazard Curves\n" +
+                      "\n" +
+                      "Longitude,-104.99,Latitude,39.34,SiteClass,BC,Imt,PGA\n" +
+                      "Ground Motion (g),0.00233,0.0035,0.00524,0.00786,0.0118, ...\n" +
+                      "Annual Frequency of Exceedence,0.036386,0.026034,0.018125,0.012197, ...\n"))
+      })
+  @Get(uri = "/{longitude}/{latitude}/{siteClass}/{imt}{?format}",
+      produces = MediaType.APPLICATION_JSON)
   public HttpResponse<String> doGetSlashByImt(
       HttpRequest<?> request,
-      @Schema(required = true) @PathVariable @Nullable Double latitude,
-      @Schema(required = true) @PathVariable @Nullable Double longitude,
-      @Schema(required = true) @PathVariable @Nullable NehrpSiteClass siteClass,
-      @Schema(required = true) @PathVariable @Nullable Imt imt) {
-    return doGet(request, longitude, latitude, siteClass, imt);
+      @Schema(required = true) @PathVariable Double latitude,
+      @Schema(required = true) @PathVariable Double longitude,
+      @Schema(required = true) @PathVariable NehrpSiteClass siteClass,
+      @Schema(required = true) @PathVariable Imt imt,
+      @Schema(required = false, defaultValue = "JSON",
+          implementation = ResponseFormat.class) @QueryValue @Nullable ResponseFormat format) {
+    return doGet(request, longitude, latitude, siteClass, imt, format);
   }
 
   /**
@@ -109,26 +135,54 @@ public class NetcdfController {
       description = "### Returns hazard curves for a " +
           "user-specified latitude, longitude, and site class.\n" +
 
-          "Enter the latitude and longitude select the site class, and press `Execute`.\n" +
+          "Enter the latitude and longitude, select the site class and format (optional), " +
+          " and press `Execute`.\n" +
+
+          "### Service Response Types\n" +
+          "The web service can respond in JSON or CSV format.\n" +
+          "<br><br>" +
+          "Choose the format type by adding a `format` query to the service call:" +
+          "`?format=JSON` or `?format=CSV`\n" +
+          "<br><br>" +
+          "Default: `JSON`\n\n" +
 
           "### Service call pattern\n" +
           "This service call is slashed delimited with pattern: " +
           "`/hazard/{longitude}/{latitude}/{siteClass}`\n" +
           "<br><br>" +
-          "Example: `/hazard/-118/34/BC`",
+          "Example: `/hazard/-118/34/BC`" +
+          "<br><br>" +
+          "Example: `/hazard/-118/34/BC?format=CSV`",
       operationId = "hazard-by-siteclass")
   @ApiResponse(
       description = "Spatially interpolated hazard curves",
       responseCode = "200",
-      content = @Content(
-          schema = @Schema(implementation = ResponseBySiteClass.class)))
-  @Get(uri = "/{longitude}/{latitude}/{siteClass}", produces = MediaType.APPLICATION_JSON)
+      content = {
+          @Content(
+              mediaType = MediaType.APPLICATION_JSON,
+              schema = @Schema(implementation = ResponseBySiteClass.class)),
+          @Content(
+              mediaType = MediaType.TEXT_CSV,
+              examples = @ExampleObject(
+                  name = "CSV",
+                  value = "Static Hazard Curves\n" +
+                      "\n" +
+                      "Longitude,-104.99,Latitude,39.34,SiteClass,BC,Imt,PGA\n" +
+                      "Ground Motion (g),0.00233,0.0035,0.00524,0.00786,0.0118, ...\n" +
+                      "Annual Frequency of Exceedence,0.036386,0.026034,0.018125,0.012197, ...\n" +
+                      "\n" +
+                      "..."))
+
+      })
+  @Get(uri = "/{longitude}/{latitude}/{siteClass}{?format}", produces = MediaType.APPLICATION_JSON)
   public HttpResponse<String> doGetSlashBySite(
       HttpRequest<?> request,
-      @Schema(required = true) @PathVariable @Nullable Double latitude,
-      @Schema(required = true) @PathVariable @Nullable Double longitude,
-      @Schema(required = true) @PathVariable @Nullable NehrpSiteClass siteClass) {
-    return doGet(request, longitude, latitude, siteClass, null);
+      @Schema(required = true) @PathVariable Double latitude,
+      @Schema(required = true) @PathVariable Double longitude,
+      @Schema(required = true) @PathVariable NehrpSiteClass siteClass,
+      @Schema(required = false, defaultValue = "JSON",
+          implementation = ResponseFormat.class) @QueryValue @Nullable ResponseFormat format) {
+    return doGet(request, longitude, latitude, siteClass, null, format);
   }
 
   /**
@@ -143,25 +197,52 @@ public class NetcdfController {
       description = "### Returns hazard curves for all site class for a " +
           "user-specified latitude and longitude.\n" +
 
-          "Enter the latitude and longitude and press `Execute`.\n" +
+          "Enter the latitude and longitude, select the format (optional), and press `Execute`.\n" +
+
+          "### Service Response Types\n" +
+          "The web service can respond in JSON or CSV format.\n" +
+          "<br><br>" +
+          "Choose the format type by adding a `format` query to the service call:" +
+          "`?format=JSON` or `?format=CSV`\n" +
+          "<br><br>" +
+          "Default: `JSON`\n\n" +
 
           "### Service call pattern\n" +
           "This service call is slashed delimited with pattern: " +
           "`/hazard/{longitude}/{latitude}`\n" +
           "<br><br>" +
-          "Example: `/hazard/-118/34`",
+          "Example: `/hazard/-118/34`" +
+          "<br><br>" +
+          "Example: `/hazard/-118/34?format=CSV`",
       operationId = "hazard")
   @ApiResponse(
       description = "Returns static curves from the NSHM NetCDF file",
       responseCode = "200",
-      content = @Content(
-          schema = @Schema(implementation = Response.class)))
-  @Get(uri = "/{longitude}/{latitude}", produces = MediaType.APPLICATION_JSON)
+      content = {
+          @Content(
+              mediaType = MediaType.APPLICATION_JSON,
+              schema = @Schema(implementation = Response.class)),
+          @Content(
+              mediaType = MediaType.TEXT_CSV,
+              examples = @ExampleObject(
+                  name = "CSV",
+                  value = "Static Hazard Curves\n" +
+                      "\n" +
+                      "Longitude,-104.99,Latitude,39.34,SiteClass,BC,Imt,PGA\n" +
+                      "Ground Motion (g),0.00233,0.0035,0.00524,0.00786,0.0118, ...\n" +
+                      "Annual Frequency of Exceedence,0.036386,0.026034,0.018125,0.012197, ...\n" +
+                      "\n" +
+                      "..."))
+
+      })
+  @Get(uri = "/{longitude}/{latitude}{?format}", produces = MediaType.APPLICATION_JSON)
   public HttpResponse<String> doGetSlash(
       HttpRequest<?> request,
-      @Schema(required = true) @PathVariable @Nullable Double longitude,
-      @Schema(required = true) @PathVariable @Nullable Double latitude) {
-    return doGet(request, longitude, latitude, null, null);
+      @Schema(required = true) @PathVariable Double longitude,
+      @Schema(required = true) @PathVariable Double latitude,
+      @Schema(required = false, defaultValue = "JSON",
+          implementation = ResponseFormat.class) @QueryValue @Nullable ResponseFormat format) {
+    return doGet(request, longitude, latitude, null, null, format);
   }
 
   /**
@@ -177,28 +258,56 @@ public class NetcdfController {
       description = "### Returns hazard curves for a " +
           "user-specified latitude, longitude, site class, and IMT.\n" +
 
-          "Enter the latitude and longitude select the site class (optional) and " +
-          "IMT (optional), and press `Execute`.\n" +
+          "Enter the latitude and longitude select the site class (optional) " +
+          "IMT (optional), and format (optional), and press `Execute`.\n" +
+
+          "### Service Response Types\n" +
+          "The web service can respond in JSON or CSV format.\n" +
+          "<br><br>" +
+          "Choose the format type by adding a `format` query to the service call:" +
+          "`?format=JSON` or `?format=CSV`\n" +
+          "<br><br>" +
+          "Default: `JSON`\n\n" +
 
           "### Service call pattern\n" +
           "This service call is query based with pattern: " +
-          "`/hazard?longitude={number}&latitude={number}&siteClass={string}&imt={string}`\n" +
+          "`/hazard?longitude={number}&latitude={number}" +
+          "&siteClass={string}&imt={string}&format={CSV|JSON}`\n" +
           "<br><br>" +
-          "Example: `/hazard?longitude=-118&latitude=34&siteClass=A&imt=PGA`",
+          "Example: `/hazard?longitude=-118&latitude=34&siteClass=A&imt=PGA`" +
+          "<br><br>" +
+          "CSV Example: `/hazard?longitude=-118&latitude=34&siteClass=A&imt=PGA?format=CSV`",
       operationId = "hazard-by-imt")
   @ApiResponse(
       description = "Spatially interpolated hazard curves",
       responseCode = "200",
-      content = @Content(
-          schema = @Schema(implementation = ResponseByImt.class)))
-  @Get(uri = "{?longitude,latitude,siteClass,imt}", produces = MediaType.APPLICATION_JSON)
+      content = {
+          @Content(
+              mediaType = MediaType.APPLICATION_JSON,
+              schema = @Schema(implementation = ResponseByImt.class)),
+          @Content(
+              mediaType = MediaType.TEXT_CSV,
+              examples = @ExampleObject(
+                  name = "CSV",
+                  value = "Static Hazard Curves\n" +
+                      "\n" +
+                      "Longitude,-104.99,Latitude,39.34,SiteClass,BC,Imt,PGA\n" +
+                      "Ground Motion (g),0.00233,0.0035,0.00524,0.00786,0.0118, ...\n" +
+                      "Annual Frequency of Exceedence,0.036386,0.026034,0.018125,0.012197, ...\n" +
+                      "\n" +
+                      "..."))
+
+      })
+  @Get(uri = "{?longitude,latitude,siteClass,imt,format}", produces = MediaType.APPLICATION_JSON)
   public HttpResponse<String> doGet(
       HttpRequest<?> request,
       @Schema(required = true) @QueryValue @Nullable Double longitude,
       @Schema(required = true) @QueryValue @Nullable Double latitude,
       @QueryValue @Nullable NehrpSiteClass siteClass,
-      @QueryValue @Nullable Imt imt) {
-    var query = new HazardQuery(longitude, latitude, siteClass, imt);
+      @QueryValue @Nullable Imt imt,
+      @Schema(required = false, defaultValue = "JSON",
+          implementation = ResponseFormat.class) @QueryValue @Nullable ResponseFormat format) {
+    var query = new HazardQuery(longitude, latitude, siteClass, imt, format);
     return service.handleServiceCall(request, query);
   }
 
diff --git a/src/hazard/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfServiceHazardCurves.java b/src/hazard/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfServiceHazardCurves.java
index 94a1d74c36e8bf2e72ffd0c828a36d61ce719fb6..831ccfc27c2c2ef6ee691fd0fe6891191bf463fe 100644
--- a/src/hazard/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfServiceHazardCurves.java
+++ b/src/hazard/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfServiceHazardCurves.java
@@ -6,13 +6,16 @@ import java.util.stream.Collectors;
 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.gmm.NehrpSiteClass;
 import gov.usgs.earthquake.nshmp.netcdf.NetcdfHazardCurves;
 import gov.usgs.earthquake.nshmp.netcdf.NetcdfVersion;
 import gov.usgs.earthquake.nshmp.netcdf.data.StaticData;
 import gov.usgs.earthquake.nshmp.netcdf.data.StaticDataHazardCurves;
+import gov.usgs.earthquake.nshmp.netcdf.www.Metadata.HazardResponseMetadata;
 import gov.usgs.earthquake.nshmp.netcdf.www.NetcdfWsUtils.Key;
 import gov.usgs.earthquake.nshmp.netcdf.www.Query.Service;
+import gov.usgs.earthquake.nshmp.netcdf.www.Request.RequestData;
+import gov.usgs.earthquake.nshmp.netcdf.www.Request.RequestDataImt;
+import gov.usgs.earthquake.nshmp.netcdf.www.Request.RequestDataSiteClass;
 import gov.usgs.earthquake.nshmp.www.ResponseBody;
 import gov.usgs.earthquake.nshmp.www.ResponseMetadata;
 import gov.usgs.earthquake.nshmp.www.WsUtils;
@@ -29,7 +32,6 @@ import io.micronaut.http.HttpRequest;
 public class NetcdfServiceHazardCurves extends NetcdfService<HazardQuery> {
 
   static final String SERVICE_DESCRIPTION = "Get static hazard curves from a NetCDF file";
-  static final String SERVICE_NAME = "Static Hazard Curves";
   static final String X_LABEL = "Ground Motion (g)";
   static final String Y_LABEL = "Annual Frequency of Exceedence";
 
@@ -38,11 +40,11 @@ public class NetcdfServiceHazardCurves extends NetcdfService<HazardQuery> {
   }
 
   @Override
-  ResponseBody<String, Metadata> getMetadataResponse(HttpRequest<?> request) {
-    var metadata = new Metadata(request, SERVICE_DESCRIPTION);
-    return ResponseBody.<String, Metadata> usage()
+  ResponseBody<String, Metadata<HazardQuery>> getMetadataResponse(HttpRequest<?> request) {
+    var metadata = new Metadata<HazardQuery>(request, this, SERVICE_DESCRIPTION);
+    return ResponseBody.<String, Metadata<HazardQuery>> usage()
         .metadata(new ResponseMetadata(NetcdfVersion.appVersions()))
-        .name(SERVICE_NAME)
+        .name(getServiceName())
         .request(NetcdfWsUtils.getRequestUrl(request))
         .response(metadata)
         .url(NetcdfWsUtils.getRequestUrl(request))
@@ -62,7 +64,7 @@ public class NetcdfServiceHazardCurves extends NetcdfService<HazardQuery> {
 
   @Override
   String getServiceName() {
-    return SERVICE_NAME;
+    return getSourceModel().name;
   }
 
   @Override
@@ -82,11 +84,11 @@ public class NetcdfServiceHazardCurves extends NetcdfService<HazardQuery> {
     WsUtils.checkValue(Key.LATITUDE, request.latitude);
     WsUtils.checkValue(Key.LONGITUDE, request.longitude);
     var curves = netcdf().staticData(request.site);
-    var curvesAsList = toList(request.site, curves);
+    var curvesAsList = toList(request, curves);
 
     return ResponseBody.<RequestData, List<List<ResponseData<HazardResponseMetadata>>>> success()
         .metadata(new ResponseMetadata(NetcdfVersion.appVersions()))
-        .name(SERVICE_NAME)
+        .name(getServiceName())
         .request(request)
         .response(curvesAsList)
         .url(url)
@@ -105,7 +107,7 @@ public class NetcdfServiceHazardCurves extends NetcdfService<HazardQuery> {
 
     return ResponseBody.<RequestDataSiteClass, List<ResponseData<HazardResponseMetadata>>> success()
         .metadata(new ResponseMetadata(NetcdfVersion.appVersions()))
-        .name(SERVICE_NAME)
+        .name(getServiceName())
         .request(request)
         .response(curvesAsList)
         .url(url)
@@ -123,7 +125,7 @@ public class NetcdfServiceHazardCurves extends NetcdfService<HazardQuery> {
 
     return ResponseBody.<RequestDataSiteClass, ResponseData<HazardResponseMetadata>> success()
         .metadata(new ResponseMetadata(NetcdfVersion.appVersions()))
-        .name(SERVICE_NAME)
+        .name(getServiceName())
         .request(request)
         .response(toResponseData(request, request.imt, curves.get(request.imt)))
         .url(url)
@@ -131,26 +133,43 @@ public class NetcdfServiceHazardCurves extends NetcdfService<HazardQuery> {
   }
 
   @Override
-  ResponseBody<?, ?> processRequest(HttpRequest<?> httpRequest, HazardQuery query,
+  String processRequest(HttpRequest<?> httpRequest, HazardQuery query,
       Service service) {
     var site = Location.create(query.longitude, query.latitude);
     var url = NetcdfWsUtils.getRequestUrl(httpRequest);
 
     switch (service) {
       case CURVES:
-        var requestData = new RequestData(site);
-        return processCurves(requestData, url);
+        var requestData = new RequestData(site, query.format);
+        return toResponseFromListOfList(processCurves(requestData, url));
       case CURVES_BY_SITE_CLASS:
-        var requestDataSiteClass = new RequestDataSiteClass(site, query.siteClass);
-        return processCurvesSiteClass(requestDataSiteClass, url);
+        var requestDataSiteClass = new RequestDataSiteClass(site, query.siteClass, query.format);
+        return toResponseFromList(processCurvesSiteClass(requestDataSiteClass, url));
       case CURVES_BY_IMT:
-        var requestDataImt = new RequestDataImt(site, query.siteClass, query.imt);
-        return processCurvesImt(requestDataImt, url);
+        var requestDataImt = new RequestDataImt(site, query.siteClass, query.imt, query.format);
+        return toResponse(processCurvesImt(requestDataImt, url));
       default:
         throw new RuntimeException("Netcdf service [" + service + "] not found");
     }
   }
 
+  private String toResponseFromListOfList(
+      ResponseBody<RequestData, List<List<ResponseData<HazardResponseMetadata>>>> serviceResponse) {
+    if (serviceResponse.getRequest().format == ResponseFormat.CSV) {
+      var csvResponse =
+          toCsvFromListOfList(serviceResponse.getRequest(), serviceResponse.getResponse());
+      return String.format("%s\n\n%s", getServiceName(), csvResponse);
+    } else {
+      return NetcdfWsUtils.GSON.toJson(serviceResponse);
+    }
+  }
+
+  private String toCsvFromListOfList(RequestData requestData,
+      List<List<ResponseData<HazardResponseMetadata>>> responseData) {
+    return responseData.stream().map(responses -> toCsvResponseFromList(requestData, responses))
+        .collect(Collectors.joining("\n\n"));
+  }
+
   List<ResponseData<HazardResponseMetadata>> toList(
       RequestDataSiteClass request,
       StaticDataHazardCurves curves) {
@@ -160,11 +179,12 @@ public class NetcdfServiceHazardCurves extends NetcdfService<HazardQuery> {
   }
 
   List<List<ResponseData<HazardResponseMetadata>>> toList(
-      Location site,
+      RequestData requestData,
       StaticData<StaticDataHazardCurves> curves) {
     return curves.entrySet().stream()
         .map(entry -> {
-          var request = new RequestDataSiteClass(site, entry.getKey());
+          var request =
+              new RequestDataSiteClass(requestData.site, entry.getKey(), requestData.format);
           return toList(request, entry.getValue());
         })
         .collect(Collectors.toList());
@@ -183,17 +203,8 @@ public class NetcdfServiceHazardCurves extends NetcdfService<HazardQuery> {
     public final List<Imt> imts;
 
     HazardSourceModel() {
-      super();
+      super(netcdf());
       imts = netcdf().netcdfData().imts().stream().sorted().collect(Collectors.toList());
     }
   }
-
-  static class HazardResponseMetadata extends ServiceResponseMetadata {
-    public final Imt imt;
-
-    HazardResponseMetadata(NehrpSiteClass siteClass, Imt imt, String xLabel, String yLabel) {
-      super(siteClass, xLabel, yLabel);
-      this.imt = imt;
-    }
-  }
 }
diff --git a/src/hazard/src/main/resources/swagger/index.js b/src/hazard/src/main/resources/swagger/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..2a6fbfd60d9634ea5dff2ddbd01738b4e600fc60
--- /dev/null
+++ b/src/hazard/src/main/resources/swagger/index.js
@@ -0,0 +1,41 @@
+window.onload = function() {
+  let contextPath = window.location.pathname;
+  contextPath = contextPath.endsWith('/') ? contextPath.slice(0, -1) : contextPath;
+
+  const ui = SwaggerUIBundle({
+    defaultModelsExpandDepth: 0,
+    deepLinking: true,
+    dom_id: '#swagger-ui',
+    layout: 'BaseLayout',
+    plugins: [SwaggerUIBundle.plugins.DownloadUrl, updateContextPath(contextPath)],
+    presets: [SwaggerUIBundle.presets.apis],
+    tagsSorter: 'alpha',
+    tryItOutEnabled: true,
+    validatorUrl: null,
+    url: `./swagger`,
+  });
+
+  window.ui = ui;
+};
+
+function updateContextPath(contextPath) {
+  return {
+    statePlugins: {
+      spec: {
+        wrapActions: {
+          updateJsonSpec: (oriAction) => (...args) => {
+            const [spec] = args;
+            if (spec && spec.paths) {
+              const newPaths = {};
+              Object.entries(spec.paths).forEach(
+                ([path, value]) => (newPaths[contextPath + path] = value)
+              );
+              spec.paths = newPaths;
+            }
+            oriAction(...args);
+          },
+        },
+      },
+    },
+  };
+}
diff --git a/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/Metadata.java b/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/Metadata.java
new file mode 100644
index 0000000000000000000000000000000000000000..c228a0b1223ea92d2b75d1ed60b817ab0d4e7e53
--- /dev/null
+++ b/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/Metadata.java
@@ -0,0 +1,111 @@
+package gov.usgs.earthquake.nshmp.netcdf.www;
+
+import gov.usgs.earthquake.nshmp.gmm.Imt;
+import gov.usgs.earthquake.nshmp.gmm.NehrpSiteClass;
+import gov.usgs.earthquake.nshmp.netcdf.Netcdf;
+import gov.usgs.earthquake.nshmp.netcdf.data.ScienceBaseMetadata;
+import gov.usgs.earthquake.nshmp.netcdf.www.NetcdfService.SourceModel;
+import gov.usgs.earthquake.nshmp.netcdf.www.Request.RequestData;
+import gov.usgs.earthquake.nshmp.netcdf.www.Request.RequestDataImt;
+import gov.usgs.earthquake.nshmp.netcdf.www.Request.RequestDataSiteClass;
+import gov.usgs.earthquake.nshmp.netcdf.www.meta.DoubleParameter;
+
+import io.micronaut.http.HttpRequest;
+
+/**
+ * Web service metadata.
+ *
+ * @author U.S. Geological Survey
+ */
+public class Metadata<T extends Query> {
+  public final String description;
+  public final String[] syntax;
+  public final SourceModel model;
+  public final DoubleParameter longitude;
+  public final DoubleParameter latitude;
+  public final DoubleParameter vs30;
+  public final NetcdfMetadata netcdfMetadata;
+
+  Metadata(HttpRequest<?> request, NetcdfService<T> netcdfService, String description) {
+    var netcdf = netcdfService.netcdf();
+    var url = request.getUri().toString();
+    url = url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
+    this.description = description;
+    syntax = new String[] {
+        url + "/{longitude:number}/{latitude:number}",
+        url + "?longitude={number}&latitude={number}",
+        url + "/{longitude:number}/{latitude:number}/{siteClass:NehrpSiteClass}",
+        url + "?longitude={number}&latitude={number}&siteClass={NehrpSiteClass}",
+    };
+
+    var min = netcdf.netcdfData().minimumBounds();
+    var max = netcdf.netcdfData().maximumBounds();
+
+    longitude = new DoubleParameter(
+        "Longitude",
+        "°",
+        min.longitude,
+        max.longitude);
+    latitude = new DoubleParameter(
+        "Latitude",
+        "°",
+        min.latitude,
+        max.latitude);
+    model = netcdfService.getSourceModel();
+    vs30 = new DoubleParameter(
+        "Vs30",
+        "m/s",
+        150,
+        1500);
+    netcdfMetadata = new NetcdfMetadata(netcdf);
+  }
+
+  class NetcdfMetadata {
+    public final String netcdfFile;
+    public final ScienceBaseMetadata scienceBaseMetadata;
+
+    NetcdfMetadata(Netcdf<?> netcdf) {
+      var fileName = netcdf.netcdfPath().getFileName();
+      netcdfFile = fileName == null ? netcdf.netcdfPath().toString() : fileName.toString();
+      scienceBaseMetadata = netcdf.netcdfData().scienceBaseMetadata();
+    }
+  }
+
+  interface ServiceMetadata {
+    RequestData toRequestMetadata(RequestData requestData);
+  }
+
+  static class ServiceResponseMetadata implements ServiceMetadata {
+    public NehrpSiteClass siteClass;
+    public String xLabel;
+    public String yLabel;
+
+    ServiceResponseMetadata(
+        NehrpSiteClass siteClass,
+        String xLabel,
+        String yLabel) {
+      this.siteClass = siteClass;
+      this.xLabel = xLabel;
+      this.yLabel = yLabel;
+    }
+
+    @Override
+    public RequestDataSiteClass toRequestMetadata(RequestData requestData) {
+      return new RequestDataSiteClass(requestData.site, siteClass, requestData.format);
+    }
+  }
+
+  static class HazardResponseMetadata extends ServiceResponseMetadata {
+    public final Imt imt;
+
+    HazardResponseMetadata(NehrpSiteClass siteClass, Imt imt, String xLabel, String yLabel) {
+      super(siteClass, xLabel, yLabel);
+      this.imt = imt;
+    }
+
+    @Override
+    public RequestDataImt toRequestMetadata(RequestData requestData) {
+      return new RequestDataImt(requestData.site, siteClass, imt, requestData.format);
+    }
+  }
+}
diff --git a/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfService.java b/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfService.java
index c6f12ebcb46b5442b27c176e4fca3979fc84bacc..243ab9418da29a3112e98a87576af188613d6c18 100644
--- a/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfService.java
+++ b/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfService.java
@@ -2,22 +2,24 @@ package gov.usgs.earthquake.nshmp.netcdf.www;
 
 import static gov.usgs.earthquake.nshmp.netcdf.www.NetcdfWsUtils.GSON;
 
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Map;
 import java.util.logging.Logger;
+import java.util.stream.Collectors;
 
-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.Text;
+import gov.usgs.earthquake.nshmp.Text.Delimiter;
 import gov.usgs.earthquake.nshmp.gmm.NehrpSiteClass;
 import gov.usgs.earthquake.nshmp.netcdf.Netcdf;
-import gov.usgs.earthquake.nshmp.netcdf.data.ScienceBaseMetadata;
+import gov.usgs.earthquake.nshmp.netcdf.www.Metadata.ServiceResponseMetadata;
 import gov.usgs.earthquake.nshmp.netcdf.www.Query.Service;
-import gov.usgs.earthquake.nshmp.netcdf.www.meta.DoubleParameter;
+import gov.usgs.earthquake.nshmp.netcdf.www.Request.RequestData;
+import gov.usgs.earthquake.nshmp.netcdf.www.Request.RequestDataSiteClass;
 import gov.usgs.earthquake.nshmp.www.ResponseBody;
 
 import io.micronaut.http.HttpRequest;
 import io.micronaut.http.HttpResponse;
-import io.swagger.v3.oas.annotations.media.Schema;
 
 /**
  * Abstract service handler for {@code NetcdfController}.
@@ -41,7 +43,7 @@ public abstract class NetcdfService<T extends Query> {
    *
    * @param httpRequest The HTTP request
    */
-  abstract ResponseBody<String, Metadata> getMetadataResponse(HttpRequest<?> httpRequest);
+  abstract ResponseBody<String, Metadata<T>> getMetadataResponse(HttpRequest<?> httpRequest);
 
   /**
    * Returns the service name
@@ -79,13 +81,13 @@ public abstract class NetcdfService<T extends Query> {
       String url);
 
   /**
-   * Process the service request and returns the reponse.
+   * Process the service request and returns the string response.
    *
    * @param httpRequest The HTTP request
    * @param query The HTTP query
    * @param service The NetCDF service
    */
-  abstract ResponseBody<?, ?> processRequest(
+  abstract String processRequest(
       HttpRequest<?> httpRequest,
       T query,
       Service service);
@@ -108,8 +110,7 @@ public abstract class NetcdfService<T extends Query> {
         return metadata(httpRequest);
       }
       var service = getService(query);
-      var svcResponse = processRequest(httpRequest, query, service);
-      var response = GSON.toJson(svcResponse);
+      var response = processRequest(httpRequest, query, service);
       LOGGER.fine("Result:\n" + response);
       return HttpResponse.ok(response);
     } catch (Exception e) {
@@ -126,143 +127,78 @@ public abstract class NetcdfService<T extends Query> {
     }
   }
 
-  private HttpResponse<String> metadata(HttpRequest<?> httpRequest) {
-    var svcResponse = getMetadataResponse(httpRequest);
-    var response = GSON.toJson(svcResponse);
-    LOGGER.fine("Result:\n" + response);
-    return HttpResponse.ok(response);
+  <U extends ServiceResponseMetadata> String toCsvResponse(
+      RequestData requestData,
+      ResponseData<U> responseData) {
+    return toCsv(requestData, responseData);
   }
 
-  class Metadata {
-    public final String description;
-    public final String[] syntax;
-    public final SourceModel model;
-    public final DoubleParameter longitude;
-    public final DoubleParameter latitude;
-    public final DoubleParameter vs30;
-    public final NetcdfMetadata netcdfMetadata;
+  <U extends ServiceResponseMetadata> String toCsvResponseFromList(
+      RequestData requestData,
+      List<ResponseData<U>> responseData) {
+    var response = responseData.stream().map(data -> {
+      return toCsv(requestData, data);
+    }).collect(Collectors.joining("\n\n"));
 
-    Metadata(HttpRequest<?> request, String description) {
-      var url = request.getUri().toString();
-      url = url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
-      this.description = description;
-      syntax = new String[] {
-          url + "/{longitude:number}/{latitude:number}",
-          url + "?longitude={number}&latitude={number}",
-          url + "/{longitude:number}/{latitude:number}/{siteClass:NehrpSiteClass}",
-          url + "?longitude={number}&latitude={number}&siteClass={NehrpSiteClass}",
-      };
-
-      var min = netcdf().netcdfData().minimumBounds();
-      var max = netcdf.netcdfData().maximumBounds();
-
-      longitude = new DoubleParameter(
-          "Longitude",
-          "°",
-          min.longitude,
-          max.longitude);
-      latitude = new DoubleParameter(
-          "Latitude",
-          "°",
-          min.latitude,
-          max.latitude);
-      model = getSourceModel();
-      vs30 = new DoubleParameter(
-          "Vs30",
-          "m/s",
-          150,
-          1500);
-      netcdfMetadata = new NetcdfMetadata();
-    }
+    return response;
   }
 
-  class NetcdfMetadata {
-    public final String netcdfFile;
-    public final ScienceBaseMetadata scienceBaseMetadata;
-
-    NetcdfMetadata() {
-      var fileName = netcdf.netcdfPath().getFileName();
-      netcdfFile = fileName == null ? netcdf().netcdfPath().toString() : fileName.toString();
-      scienceBaseMetadata = netcdf().netcdfData().scienceBaseMetadata();
+  <U extends RequestData, V extends ServiceResponseMetadata> String toResponse(
+      ResponseBody<U, ResponseData<V>> serviceResponse) {
+    if (serviceResponse.getRequest().format == ResponseFormat.CSV) {
+      var csvResponse = toCsvResponse(serviceResponse.getRequest(), serviceResponse.getResponse());
+      return String.format("%s\n\n%s", getServiceName(), csvResponse);
+    } else {
+      return NetcdfWsUtils.GSON.toJson(serviceResponse);
     }
   }
 
-  class SourceModel {
-    public final String name;
-    public final Map<NehrpSiteClass, Double> siteClasses;
-
-    SourceModel() {
-      name = netcdf().netcdfData().scienceBaseMetadata().label;
-      siteClasses = netcdf().netcdfData().vs30Map();
+  <U extends RequestData, V extends ServiceResponseMetadata> String toResponseFromList(
+      ResponseBody<U, List<ResponseData<V>>> serviceResponse) {
+    if (serviceResponse.getRequest().format == ResponseFormat.CSV) {
+      var csvResponse =
+          toCsvResponseFromList(serviceResponse.getRequest(), serviceResponse.getResponse());
+      return String.format("%s\n\n%s", getServiceName(), csvResponse);
+    } else {
+      return NetcdfWsUtils.GSON.toJson(serviceResponse);
     }
   }
 
-  static class ServiceResponseMetadata {
-    public NehrpSiteClass siteClass;
-    public String xLabel;
-    public String yLabel;
-
-    ServiceResponseMetadata(
-        NehrpSiteClass siteClass,
-        String xLabel,
-        String yLabel) {
-      this.siteClass = siteClass;
-      this.xLabel = xLabel;
-      this.yLabel = yLabel;
-    }
+  private HttpResponse<String> metadata(HttpRequest<?> httpRequest) {
+    var svcResponse = getMetadataResponse(httpRequest);
+    var response = GSON.toJson(svcResponse);
+    LOGGER.fine("Result:\n" + response);
+    return HttpResponse.ok(response);
   }
 
-  static class ResponseData<T extends ServiceResponseMetadata> {
-    final T metadata;
-    final XySequence data;
+  private <U extends RequestData, V extends ServiceResponseMetadata> String toCsv(
+      U requestData,
+      ResponseData<V> responseData) {
+    var request = responseData.metadata.toRequestMetadata(requestData);
 
-    ResponseData(T metadata, XySequence data) {
-      this.metadata = metadata;
-      this.data = data;
-    }
+    List<Object> xs = new ArrayList<>();
+    xs.add(responseData.metadata.xLabel);
+    xs.addAll(responseData.data.xValues().boxed().collect(Collectors.toList()));
 
-    public T getMetadata() {
-      return metadata;
-    }
+    List<Object> ys = new ArrayList<>();
+    ys.add(responseData.metadata.yLabel);
+    ys.addAll(responseData.data.yValues().boxed().collect(Collectors.toList()));
 
-    @Schema(implementation = XySequenceSchema.class)
-    public XySequence getData() {
-      return data;
-    }
-  }
+    var lines = new ArrayList<String>();
+    lines.add(request.toCsv());
+    lines.add(Text.join(xs, Delimiter.COMMA));
+    lines.add(Text.join(ys, Delimiter.COMMA));
 
-  static class RequestData {
-    public Double latitude;
-    public Double longitude;
-    public transient Location site;
-
-    RequestData(Location site) {
-      latitude = site.latitude;
-      longitude = site.longitude;
-      this.site = site;
-    }
+    return lines.stream().collect(Collectors.joining(Text.NEWLINE));
   }
 
-  static class RequestDataImt extends RequestDataSiteClass {
-    public Imt imt;
-
-    RequestDataImt(Location site, NehrpSiteClass siteClass, Imt imt) {
-      super(site, siteClass);
-      this.imt = imt;
-    }
-  }
-
-  static class RequestDataSiteClass extends RequestData {
-    public NehrpSiteClass siteClass;
+  static class SourceModel {
+    public final String name;
+    public final Map<NehrpSiteClass, Double> siteClasses;
 
-    RequestDataSiteClass(Location site, NehrpSiteClass siteClass) {
-      super(site);
-      this.siteClass = siteClass;
+    SourceModel(Netcdf<?> netcdf) {
+      name = netcdf.netcdfData().scienceBaseMetadata().label;
+      siteClasses = netcdf.netcdfData().vs30Map();
     }
   }
-
-  private static class XySequenceSchema {
-    public double[] xs;
-    public double[] ys;
-  }
 }
diff --git a/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfWsUtils.java b/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfWsUtils.java
index 70216205cdd87b608250c53141bdd8bb479d4429..c4233ad557e292cf3b4a1fd20eb1a9790c8ac8fb 100644
--- a/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfWsUtils.java
+++ b/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/NetcdfWsUtils.java
@@ -86,6 +86,7 @@ public class NetcdfWsUtils {
     // Update description
     var description = new StringBuilder()
         .append(scienceBaseMetadata.description + "\n")
+        .append(swaggerResponseFormatSection())
         .append(serviceSection)
         .append(swaggerParameterSection(netcdfData))
         .append(swaggerScienceBaseSection(scienceBaseMetadata))
@@ -95,7 +96,7 @@ public class NetcdfWsUtils {
     return openApi;
   }
 
-  static enum Key {
+  public static enum Key {
     LATITUDE,
     LONGITUDE,
     SITE_CLASS,
@@ -120,6 +121,22 @@ public class NetcdfWsUtils {
         .toString();
   }
 
+  private static String swaggerResponseFormatSection() {
+    return new StringBuilder()
+        .append(
+            "<details>\n" +
+                "<summary>Response Format: CSV or JSON</summary>\n")
+        .append(
+            "The web service can respond in JSON or CSV format.\n" +
+                "<br><br>" +
+                "Choose the format type by adding a `format` query to the service call:" +
+                "`?format=JSON` or `?format=CSV`\n\n" +
+                "<br><br>" +
+                "Default: `JSON`")
+        .append("</details>")
+        .toString();
+  }
+
   private static String swaggerScienceBaseSection(ScienceBaseMetadata scienceBaseMetadata) {
     return new StringBuilder()
         .append(
diff --git a/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/Query.java b/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/Query.java
index 0df439675424a85db25094b81722a6f75e57091f..de913518cdf6931006f97598209f3d2b4d359926 100644
--- a/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/Query.java
+++ b/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/Query.java
@@ -6,11 +6,13 @@ public class Query {
   public final Double longitude;
   public final Double latitude;
   public final NehrpSiteClass siteClass;
+  public final ResponseFormat format;
 
-  public Query(Double longitude, Double latitude, NehrpSiteClass siteClass) {
+  public Query(Double longitude, Double latitude, NehrpSiteClass siteClass, ResponseFormat format) {
     this.longitude = longitude;
     this.latitude = latitude;
     this.siteClass = siteClass;
+    this.format = format == null ? ResponseFormat.JSON : format;
   }
 
   public static enum Service {
diff --git a/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/Request.java b/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/Request.java
new file mode 100644
index 0000000000000000000000000000000000000000..eb193cbb2c40d17be105336c9a1f8610b185b5cc
--- /dev/null
+++ b/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/Request.java
@@ -0,0 +1,85 @@
+package gov.usgs.earthquake.nshmp.netcdf.www;
+
+import java.util.List;
+
+import gov.usgs.earthquake.nshmp.Text;
+import gov.usgs.earthquake.nshmp.Text.Delimiter;
+import gov.usgs.earthquake.nshmp.geo.Location;
+import gov.usgs.earthquake.nshmp.gmm.Imt;
+import gov.usgs.earthquake.nshmp.gmm.NehrpSiteClass;
+import gov.usgs.earthquake.nshmp.netcdf.www.NetcdfWsUtils.Key;
+
+/**
+ * Web service request data.
+ *
+ * @author U.S. Geological Survey
+ */
+public class Request {
+
+  interface RequestDataToCsv {
+    /**
+     * Convert the request data into a CSV line.
+     */
+    String toCsv();
+  }
+
+  /**
+   * Basic request data with latitude, longitude, and format.
+   */
+  static class RequestData implements RequestDataToCsv {
+    public Double latitude;
+    public Double longitude;
+    public ResponseFormat format;
+    public transient Location site;
+
+    RequestData(Location site, ResponseFormat format) {
+      latitude = site.latitude;
+      longitude = site.longitude;
+      this.format = format;
+      this.site = site;
+    }
+
+    public String toCsv() {
+      return Text.join(
+          List.of(Key.LONGITUDE.toString(), longitude, Key.LATITUDE.toString(), latitude),
+          Delimiter.COMMA);
+    }
+  }
+
+  /**
+   * Request data with site class and imt
+   */
+  static class RequestDataImt extends RequestDataSiteClass {
+    public Imt imt;
+
+    RequestDataImt(Location site, NehrpSiteClass siteClass, Imt imt, ResponseFormat format) {
+      super(site, siteClass, format);
+      this.imt = imt;
+    }
+
+    @Override
+    public String toCsv() {
+      return String.format("%s,%s",
+          super.toCsv(), Text.join(List.of(Key.IMT.toString(), imt.name()), Delimiter.COMMA));
+    }
+  }
+
+  /**
+   * Request data with site class
+   */
+  static class RequestDataSiteClass extends RequestData {
+    public NehrpSiteClass siteClass;
+
+    RequestDataSiteClass(Location site, NehrpSiteClass siteClass, ResponseFormat format) {
+      super(site, format);
+      this.siteClass = siteClass;
+    }
+
+    @Override
+    public String toCsv() {
+      return String.format("%s,%s",
+          super.toCsv(), Text.join(List.of(Key.SITE_CLASS.toString(), siteClass), Delimiter.COMMA));
+    }
+  }
+
+}
diff --git a/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/ResponseData.java b/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/ResponseData.java
new file mode 100644
index 0000000000000000000000000000000000000000..431e0f630ebc531fa5180dbfdf0d2d0a1464c6a4
--- /dev/null
+++ b/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/ResponseData.java
@@ -0,0 +1,35 @@
+package gov.usgs.earthquake.nshmp.netcdf.www;
+
+import gov.usgs.earthquake.nshmp.data.XySequence;
+import gov.usgs.earthquake.nshmp.netcdf.www.Metadata.ServiceResponseMetadata;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+/**
+ * Web service response data.
+ *
+ * @author U.S. Geological Survey
+ */
+public class ResponseData<T extends ServiceResponseMetadata> {
+  final T metadata;
+  final XySequence data;
+
+  ResponseData(T metadata, XySequence data) {
+    this.metadata = metadata;
+    this.data = data;
+  }
+
+  public T getMetadata() {
+    return metadata;
+  }
+
+  @Schema(implementation = XySequenceSchema.class)
+  public XySequence getData() {
+    return data;
+  }
+
+  private static class XySequenceSchema {
+    public double[] xs;
+    public double[] ys;
+  }
+}
diff --git a/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/ResponseFormat.java b/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/ResponseFormat.java
new file mode 100644
index 0000000000000000000000000000000000000000..f1ca6ea50ab4380a905d5675006afab64ce08422
--- /dev/null
+++ b/src/lib/src/main/java/gov/usgs/earthquake/nshmp/netcdf/www/ResponseFormat.java
@@ -0,0 +1,11 @@
+package gov.usgs.earthquake.nshmp.netcdf.www;
+
+/**
+ * Web service return format.
+ *
+ * @author U.S. Geological Survey
+ */
+public enum ResponseFormat {
+  JSON,
+  CSV;
+}
diff --git a/src/lib/src/main/resources/application.yml b/src/lib/src/main/resources/application.yml
index 448a7d535a1b583240dcdbd0dacac534c08ad192..0c5312ed4339fd496c60809e06df9d15483ed83a 100644
--- a/src/lib/src/main/resources/application.yml
+++ b/src/lib/src/main/resources/application.yml
@@ -9,9 +9,3 @@ micronaut:
         enabled: true
         paths: classpath:swagger
         mapping: /**
-
-nshmp-ws-static:
-  # Hazard example
-  netcdf-file: ${netcdf:src/main/resources/hazard-example.nc}
-  # Ground motions example
-  # netcdf-file: ${netcdf:src/main/resources/rtsa-example.nc}
diff --git a/src/lib/src/main/resources/swagger/index.css b/src/lib/src/main/resources/swagger/index.css
index b514b935545bc9401d16f678674db2c91e44f4bb..cef94f5156369b9dac439a652b9c52bd0ce673b5 100644
--- a/src/lib/src/main/resources/swagger/index.css
+++ b/src/lib/src/main/resources/swagger/index.css
@@ -117,6 +117,10 @@ details {
   box-shadow: 0 3px 1px -2px #0003, 0 2px 2px #00000024, 0 1px 5px #0000001f;
 }
 
+details:first-of-type {
+  margin-top: 1.5em;
+}
+
 details:hover {
   background-color: #f3f3f3;
 }