diff --git a/geomagio/api/db/create.py b/geomagio/api/db/create.py
index b2dc79313aa132b8bc510a87cc5900e1e60d6d36..80fc17e878bde68070f327f8e79278fa06a7dfdc 100644
--- a/geomagio/api/db/create.py
+++ b/geomagio/api/db/create.py
@@ -3,8 +3,8 @@ import sqlalchemy
 from .common import database, sqlalchemy_metadata
 
 # register models with sqlalchemy_metadata by importing
-from .metadata import metadata
-from .session import session
+from .metadata_table import metadata
+from .session_table import session
 
 
 def create_db():
diff --git a/geomagio/api/db/metadata.py b/geomagio/api/db/metadata_table.py
similarity index 65%
rename from geomagio/api/db/metadata.py
rename to geomagio/api/db/metadata_table.py
index 79d4b9a35e2e15b421300c102401b8b8611478ad..9b1571a21c1abf54a7aed5e644e0974d7c001c99 100644
--- a/geomagio/api/db/metadata.py
+++ b/geomagio/api/db/metadata_table.py
@@ -1,8 +1,8 @@
-import datetime
+from datetime import datetime
 import enum
 
 from obspy import UTCDateTime
-from sqlalchemy import or_, Boolean, Column, Index, Integer, String, Table, Text
+from sqlalchemy import or_, Boolean, Column, Index, Integer, JSON, String, Table, Text
 import sqlalchemy_utc
 
 from ...metadata import Metadata, MetadataCategory
@@ -23,7 +23,7 @@ metadata = Table(
     Column(
         "created_time",
         sqlalchemy_utc.UtcDateTime,
-        default=sqlalchemy_utc.now,
+        default=sqlalchemy_utc.utcnow(),
         index=True,
     ),
     # reviewer
@@ -46,7 +46,7 @@ metadata = Table(
     # whether metadata is valid (based on review)
     Column("metadata_valid", Boolean, default=True, index=True),
     # metadata json blob
-    Column("metadata", Text, nullable=True),
+    Column("metadata", JSON, nullable=True),
     # general comment
     Column("comment", Text, nullable=True),
     # review specific comment
@@ -79,34 +79,62 @@ metadata = Table(
 )
 
 
+async def create_metadata(meta: Metadata) -> Metadata:
+    query = metadata.insert()
+    values = meta.datetime_dict(exclude={"id"}, exclude_none=True)
+    query = query.values(**values)
+    metadata.id = await database.execute(query)
+    return metadata
+
+
+async def delete_metadata(id: int) -> None:
+    query = metadata.delete().where(metadata.c.id == id)
+    await database.execute(query)
+
+
 async def get_metadata(
     *,  # make all params keyword
-    network: str,
-    station: str,
+    id: int = None,
+    network: str = None,
+    station: str = None,
     channel: str = None,
     location: str = None,
     category: MetadataCategory = None,
-    starttime: UTCDateTime = None,
-    endtime: UTCDateTime = None,
+    starttime: datetime = None,
+    endtime: datetime = None,
     data_valid: bool = None,
     metadata_valid: bool = None,
 ):
-    query = (
-        metadata.select()
-        .where(metadata.c.network.like(network or "%"))
-        .where(metadata.c.station.like(station or "%"))
-        .where(metadata.c.channel.like(channel or "%"))
-        .where(metadata.c.location.like(location or "%"))
-    )
+    query = metadata.select()
+    if id is not None:
+        query = query.where(metadata.c.id == id)
+    if category:
+        query = query.where(metadata.c.category == category)
+    if network or station or channel or location:
+        query = (
+            query.where(metadata.c.network.like(network or "%"))
+            .where(metadata.c.station.like(station or "%"))
+            .where(metadata.c.channel.like(channel or "%"))
+            .where(metadata.c.location.like(location or "%"))
+        )
     if starttime:
         query = query.where(
-            or_(metadata.c.endtime == None, metadata.c.endtime > starttime.datetime)
+            or_(metadata.c.endtime == None, metadata.c.endtime > starttime)
         )
     if endtime:
         query = query.where(
-            or_(metadata.c.starttime == None, metadata.c.starttime < endtime.datetime)
+            or_(metadata.c.starttime == None, metadata.c.starttime < endtime)
         )
     if data_valid is not None:
         query = query.where(metadata.c.data_valid == data_valid)
     if metadata_valid is not None:
         query = query.where(metadata.c.metadata_valid == metadata_valid)
+    rows = await database.fetch_all(query)
+    return [Metadata(**row) for row in rows]
+
+
+async def update_metadata(meta: Metadata) -> None:
+    query = metadata.update().where(metadata.c.id == meta.id)
+    values = meta.datetime_dict(exclude={"id"})
+    query = query.values(**values)
+    await database.execute(query)
diff --git a/geomagio/api/db/session.py b/geomagio/api/db/session_table.py
similarity index 100%
rename from geomagio/api/db/session.py
rename to geomagio/api/db/session_table.py
diff --git a/geomagio/api/secure/MetadataQuery.py b/geomagio/api/secure/MetadataQuery.py
new file mode 100644
index 0000000000000000000000000000000000000000..629ad014907dbf7d35c76acd4781caad98ea184e
--- /dev/null
+++ b/geomagio/api/secure/MetadataQuery.py
@@ -0,0 +1,31 @@
+from datetime import timezone
+
+from obspy import UTCDateTime
+from pydantic import BaseModel, validator
+
+from ...metadata import MetadataCategory
+from ... import pydantic_utcdatetime
+
+
+class MetadataQuery(BaseModel):
+    id: int = None
+    category: MetadataCategory = None
+    starttime: UTCDateTime = None
+    endtime: UTCDateTime = None
+    network: str = None
+    station: str = None
+    channel: str = None
+    location: str = None
+    data_valid: bool = None
+    metadata_valid: bool = True
+
+    def datetime_dict(self, **kwargs):
+        values = self.dict(**kwargs)
+        for key in ["starttime", "endtime"]:
+            if key in values and values[key] is not None:
+                values[key] = values[key].datetime.replace(tzinfo=timezone.utc)
+        return values
+
+    @validator("starttime")
+    def set_default_starttime(cls, starttime: UTCDateTime = None) -> UTCDateTime:
+        return starttime or UTCDateTime() - 30 * 86400
diff --git a/geomagio/api/secure/SessionMiddleware.py b/geomagio/api/secure/SessionMiddleware.py
index ed978a4a2ffab76d1d465a69f71766c6331ad594..1138e0797e0635e00c5ec2ac17ea28208594b178 100644
--- a/geomagio/api/secure/SessionMiddleware.py
+++ b/geomagio/api/secure/SessionMiddleware.py
@@ -113,6 +113,7 @@ class SessionMiddleware:
         self, message: Message, value: str, max_age: int = None,
     ):
         headers = MutableHeaders(scope=message)
+        headers.append("Cache-Control", "no-cache")
         headers.append(
             "Set-Cookie",
             f"{self.session_cookie}={value};"
diff --git a/geomagio/api/secure/app.py b/geomagio/api/secure/app.py
index e6508cef732e3d445f2540593fb82a257d233828..0edaec59c82254689011d97c8ec43701d67d2321 100644
--- a/geomagio/api/secure/app.py
+++ b/geomagio/api/secure/app.py
@@ -4,9 +4,10 @@ import uuid
 
 from fastapi import Depends, FastAPI, Request, Response
 
-from ..db.session import delete_session, get_session, save_session
+from ..db.session_table import delete_session, get_session, save_session
 from .encryption import get_fernet
 from .login import current_user, router as login_router, User
+from .metadata import router as metadata_router
 from .SessionMiddleware import SessionMiddleware
 
 
@@ -29,6 +30,7 @@ app.add_middleware(
 
 # include login routes to manage user
 app.include_router(login_router)
+app.include_router(metadata_router)
 
 
 @app.get("/")
diff --git a/geomagio/api/secure/metadata.py b/geomagio/api/secure/metadata.py
new file mode 100644
index 0000000000000000000000000000000000000000..5e4f084622ff72c54eadfdb95e82838bf788e3cc
--- /dev/null
+++ b/geomagio/api/secure/metadata.py
@@ -0,0 +1,89 @@
+"""Module for metadata service.
+
+Uses login.py for user management.
+
+Anyone can access metadata.
+Logged in users can create new metadata.
+Update and delete are restricted based on group membership.
+
+
+Configuration:
+    uses environment variables:
+
+    ADMIN_GROUP           - delete is restricted the admin group.
+    REVIEWER_GROUP        - update is restricted the reviewer group.
+"""
+import os
+from typing import List
+
+from fastapi import APIRouter, Body, Depends, Request, Response
+from obspy import UTCDateTime
+
+from ...metadata import Metadata, MetadataCategory
+from ..db import metadata_table
+from .login import require_user, User
+from .MetadataQuery import MetadataQuery
+from ... import pydantic_utcdatetime
+
+# routes for login/logout
+router = APIRouter()
+
+
+@router.post("/metadata", response_model=Metadata)
+async def create_metadata(
+    request: Request, metadata: Metadata, user: User = Depends(require_user()),
+):
+    metadata = await metadata_table.create_metadata(metadata)
+    return Response(metadata, status_code=201, media_type="application/json")
+
+
+@router.delete("/metadata/{id}")
+async def delete_metadata(
+    id: int, user: User = Depends(require_user(os.getenv("ADMIN_GROUP", "admin")))
+):
+    await metadata_table.delete_metadata(id)
+
+
+@router.get("/metadata", response_model=List[Metadata])
+async def get_metadata(
+    category: MetadataCategory = None,
+    starttime: UTCDateTime = None,
+    endtime: UTCDateTime = None,
+    network: str = None,
+    station: str = None,
+    channel: str = None,
+    location: str = None,
+    data_valid: bool = None,
+    metadata_valid: bool = True,
+):
+    query = MetadataQuery(
+        category=category,
+        starttime=starttime,
+        endtime=endtime,
+        network=network,
+        station=station,
+        channel=channel,
+        location=location,
+        data_valid=data_valid,
+        metadata_valid=metadata_valid,
+    )
+    metas = await metadata_table.get_metadata(**query.datetime_dict(exclude={"id"}))
+    return metas
+
+
+@router.get("/metadata/{id}", response_model=Metadata)
+async def get_metadata_by_id(id: int):
+    meta = await metadata_table.get_metadata(id=id)
+    if len(meta) != 1:
+        return Response(status_code=404)
+    else:
+        return meta[0]
+
+
+@router.put("/metadata/{id}")
+async def update_metadata(
+    id: int,
+    metadata: Metadata = Body(...),
+    user: User = Depends(require_user([os.getenv("REVIEWER_GROUP", "reviewer")])),
+):
+    await metadata_table.update_metadata(metadata)
diff --git a/geomagio/api/ws/app.py b/geomagio/api/ws/app.py
index 279e21229ff313854e46b38701d15ac48f8d5613..63819226e35e573593034497435a58ef6ba0ce89 100644
--- a/geomagio/api/ws/app.py
+++ b/geomagio/api/ws/app.py
@@ -2,6 +2,7 @@ from typing import Dict, Union
 
 from fastapi import FastAPI, Request, Response
 from fastapi.exceptions import RequestValidationError
+from fastapi.middleware.cors import CORSMiddleware
 from fastapi.responses import JSONResponse, PlainTextResponse, RedirectResponse
 from obspy import UTCDateTime
 
@@ -22,6 +23,9 @@ VERSION = "version"
 
 
 app = FastAPI(docs_url="/docs", openapi_prefix="/ws")
+
+app.add_middleware(CORSMiddleware, allow_origins=["*"], max_age=86400)
+
 app.include_router(data.router)
 app.include_router(elements.router)
 app.include_router(observatories.router)
diff --git a/geomagio/metadata/Metadata.py b/geomagio/metadata/Metadata.py
index b1962fdd243562debc2d844d340d6375799aeb59..ce2ad5211ac07c5023c4821396a8abe1ed1fd4f5 100644
--- a/geomagio/metadata/Metadata.py
+++ b/geomagio/metadata/Metadata.py
@@ -1,7 +1,8 @@
+from datetime import timezone
 from typing import Dict
 
 from obspy import UTCDateTime
-from pydantic import BaseModel
+from pydantic import BaseModel, validator
 
 from .. import pydantic_utcdatetime
 from .MetadataCategory import MetadataCategory
@@ -75,3 +76,14 @@ class Metadata(BaseModel):
     comment: str = None
     # review specific comment
     review_comment: str = None
+
+    def datetime_dict(self, **kwargs):
+        values = self.dict(**kwargs)
+        for key in ["created_time", "reviewed_time", "starttime", "endtime"]:
+            if key in values and values[key] is not None:
+                values[key] = values[key].datetime.replace(tzinfo=timezone.utc)
+        return values
+
+    @validator("created_time")
+    def set_default_created_time(cls, created_time: UTCDateTime = None) -> UTCDateTime:
+        return created_time or UTCDateTime()
diff --git a/geomagio/metadata/MetadataCategory.py b/geomagio/metadata/MetadataCategory.py
index d70092f842e83753b5121c7ea093cabcc2cfd36b..44c5608b6febabc9a4c42b7fe28c798dd2fe6192 100644
--- a/geomagio/metadata/MetadataCategory.py
+++ b/geomagio/metadata/MetadataCategory.py
@@ -5,4 +5,5 @@ class MetadataCategory(str, Enum):
     ADJUSTED_MATRIX = "adjusted-matrix"
     FLAG = "flag"
     INSTRUMENT = "instrument"
+    OBSERVATORY = "observatory"
     READING = "reading"
diff --git a/geomagio/metadata/__init__.py b/geomagio/metadata/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..7502db0243cac6af13d38c8386c3dd292ae386c2
--- /dev/null
+++ b/geomagio/metadata/__init__.py
@@ -0,0 +1,5 @@
+from .Metadata import Metadata
+from .MetadataCategory import MetadataCategory
+
+
+__all__ = ["Metadata", "MetadataCategory"]
diff --git a/test_metadata.py b/test_metadata.py
new file mode 100644
index 0000000000000000000000000000000000000000..5d597ed8b74ba94e713c50989cbb7b5cded6fe62
--- /dev/null
+++ b/test_metadata.py
@@ -0,0 +1,123 @@
+from obspy import UTCDateTime
+
+from geomagio.api.db import database, metadata_table
+from geomagio.api.ws.Observatory import OBSERVATORIES
+from geomagio.metadata import Metadata, MetadataCategory
+
+
+test_metadata = [
+    Metadata(
+        category=MetadataCategory.INSTRUMENT,
+        created_by="test_metadata.py",
+        network="NT",
+        station="BDT",
+        metadata={
+            "type": "FGE",
+            "channels": {
+                # each channel maps to a list of components to calculate nT
+                # TODO: calculate these lists based on "FGE" type
+                "U": [{"channel": "U_Volt", "offset": 0, "scale": 313.2}],
+                "V": [{"channel": "V_Volt", "offset": 0, "scale": 312.3}],
+                "W": [{"channel": "W_Volt", "offset": 0, "scale": 312.0}],
+            },
+            "electronics": {
+                "serial": "E0542",
+                # these scale values are used to convert voltage
+                "x-scale": 313.2,  # V/nT
+                "y-scale": 312.3,  # V/nT
+                "z-scale": 312.0,  # V/nT
+                "temperature-scale": 0.01,  # V/K
+            },
+            "sensor": {
+                "serial": "S0419",
+                # these constants combine with instrument setting for offset
+                "x-constant": 36958,  # nT/mA
+                "y-constant": 36849,  # nT/mA
+                "z-constant": 36811,  # nT/mA
+            },
+        },
+    ),
+    Metadata(
+        category=MetadataCategory.INSTRUMENT,
+        created_by="test_metadata.py",
+        network="NT",
+        station="NEW",
+        metadata={
+            "type": "Narod",
+            "channels": {
+                "U": [
+                    {"channel": "U_Volt", "offset": 0, "scale": 100},
+                    {"channel": "U_Bin", "offset": 0, "scale": 500},
+                ],
+                "V": [
+                    {"channel": "V_Volt", "offset": 0, "scale": 100},
+                    {"channel": "V_Bin", "offset": 0, "scale": 500},
+                ],
+                "W": [
+                    {"channel": "W_Volt", "offset": 0, "scale": 100},
+                    {"channel": "W_Bin", "offset": 0, "scale": 500},
+                ],
+            },
+        },
+    ),
+    Metadata(
+        category=MetadataCategory.INSTRUMENT,
+        created_by="test_metadata.py",
+        network="NT",
+        station="LLO",
+        metadata={
+            "type": "Narod",
+            "channels": {
+                "U": [
+                    {"channel": "U_Volt", "offset": 0, "scale": 100},
+                    {"channel": "U_Bin", "offset": 0, "scale": 500},
+                ],
+                "V": [
+                    {"channel": "V_Volt", "offset": 0, "scale": 100},
+                    {"channel": "V_Bin", "offset": 0, "scale": 500},
+                ],
+                "W": [
+                    {"channel": "W_Volt", "offset": 0, "scale": 100},
+                    {"channel": "W_Bin", "offset": 0, "scale": 500},
+                ],
+            },
+        },
+    ),
+]
+
+# add observatories
+for observatory in OBSERVATORIES:
+    network = "NT"
+    if observatory.agency == "USGS":
+        network = "NT"
+    # rest alphabetical by agency
+    elif observatory.agency == "BGS":
+        network = "GB"
+    elif observatory.agency == "GSC":
+        network = "C2"
+    elif observatory.agency == "JMA":
+        network = "JP"
+    elif observatory.agency == "SANSA":
+        network = "AF"
+    test_metadata.append(
+        Metadata(
+            category=MetadataCategory.OBSERVATORY,
+            created_by="test_metadata.py",
+            network=network,
+            station=observatory.id,
+            metadata=observatory.dict(),
+        )
+    )
+
+
+async def load_test_metadata():
+    await database.connect()
+    for meta in test_metadata:
+        await metadata_table.create_metadata(meta)
+    await database.disconnect()
+
+
+if __name__ == "__main__":
+    import asyncio
+
+    asyncio.run(load_test_metadata())