diff --git a/geomagio/api/secure/login.py b/geomagio/api/secure/login.py
index c3407d9317c5f6d4fad6f6a0f62c0b2b23d1df40..b687dec3bd562b92b6b30ac4657db62936e5d279 100644
--- a/geomagio/api/secure/login.py
+++ b/geomagio/api/secure/login.py
@@ -35,6 +35,7 @@ Usage:
 """
 import logging
 import os
+import requests
 from typing import Callable, List, Optional
 
 from authlib.integrations.starlette_client import OAuth
@@ -56,7 +57,8 @@ class User(BaseModel):
 
 
 async def current_user(request: Request) -> Optional[User]:
-    """Get logged in user, or None if not logged in.
+    """Get user information from gitlab access token or session(if currently logged in).
+    Returns none if access token is not vald or user is not logged in.
 
     Usage example:
         user: Optional[User] = Depends(current_user)
@@ -64,9 +66,45 @@ async def current_user(request: Request) -> Optional[User]:
     """
     if "user" in request.session:
         return User(**request.session["user"])
+    if "apiuser" in request.session:
+        return User(**request.session["apiuser"])
+    if "Authorization" in request.headers:
+        user = get_api_user(token=request.headers["Authorization"])
+        if user is not None:
+            request.session["apiuser"] = user.dict()
+            return user
     return None
 
 
+def get_api_user(token: str) -> Optional[User]:
+    url = os.getenv("GITLAB_API_URL")
+    header = {"PRIVATE-TOKEN": token}
+    # request user information from gitlab api with access token
+    userinfo_response = requests.get(
+        f"{url}/user",
+        headers=header,
+    )
+    userinfo = userinfo_response.json()
+    try:
+        user = User(
+            email=userinfo["email"],
+            sub=userinfo["id"],
+            name=userinfo["name"],
+            nickname=userinfo["username"],
+            picture=userinfo["avatar_url"],
+        )
+    except KeyError:
+        logging.info(f"Invalid token: {userinfo_response.status_code}")
+        return None
+    # use valid token to retrieve user's groups
+    groups_response = requests.get(
+        f"{url}/groups",
+        headers=header,
+    )
+    user.groups = [g["full_path"] for g in groups_response.json()]
+    return user
+
+
 def require_user(
     allowed_groups: List[str] = None,
 ) -> Callable[[Request, User], User]:
diff --git a/geomagio/api/secure/metadata.py b/geomagio/api/secure/metadata.py
index 710128d1af33bd856d08c0b2d48b9b1cabff5029..e619c66362b8b4e960dd92cba5b5d88ff493b6b2 100644
--- a/geomagio/api/secure/metadata.py
+++ b/geomagio/api/secure/metadata.py
@@ -19,11 +19,10 @@ from typing import List
 from fastapi import APIRouter, Body, Depends, Request, Response
 from obspy import UTCDateTime
 
-from ...metadata import Metadata, MetadataCategory
+from ...metadata import Metadata, MetadataCategory, MetadataQuery
+from ... import pydantic_utcdatetime
 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()
@@ -41,7 +40,7 @@ async def create_metadata(
 
 @router.delete("/metadata/{id}")
 async def delete_metadata(
-    id: int, user: User = Depends(require_user(os.getenv("ADMIN_GROUP", "admin")))
+    id: int, user: User = Depends(require_user([os.getenv("ADMIN_GROUP", "admin")]))
 ):
     await metadata_table.delete_metadata(id)
 
diff --git a/geomagio/api/ws/metadata.py b/geomagio/api/ws/metadata.py
index 9f4febeb93bc0a3b89541697df2bc405a5f9881d..655c33e59f0ec84b4329e5e3c6642c6dbe24882c 100644
--- a/geomagio/api/ws/metadata.py
+++ b/geomagio/api/ws/metadata.py
@@ -3,8 +3,7 @@ from typing import List
 from fastapi import APIRouter
 from obspy import UTCDateTime
 
-from ...metadata import Metadata, MetadataCategory
-from ..secure.MetadataQuery import MetadataQuery
+from ...metadata import Metadata, MetadataCategory, MetadataQuery
 from ..db import metadata_table
 
 router = APIRouter()
diff --git a/geomagio/apiclient/MetadataFactory.py b/geomagio/apiclient/MetadataFactory.py
new file mode 100644
index 0000000000000000000000000000000000000000..288901c6856ce2a2a90ec8054555283b301d4d2e
--- /dev/null
+++ b/geomagio/apiclient/MetadataFactory.py
@@ -0,0 +1,71 @@
+import os
+import requests
+from typing import List, Union
+
+from obspy import UTCDateTime
+from pydantic import parse_obj_as
+
+from ..metadata import Metadata, MetadataQuery
+
+
+class MetadataFactory(object):
+    def __init__(
+        self,
+        url: str = "http://{}/ws/secure/metadata".format(
+            os.getenv("GEOMAG_API_HOST", "127.0.0.1:8000")
+        ),
+        token: str = os.getenv("GITLAB_API_TOKEN"),
+    ):
+        self.url = url
+        self.token = token
+        self.header = self._get_headers()
+
+    def _get_headers(self):
+        return {"Authorization": self.token} if self.token else None
+
+    def delete_metadata(self, metadata: Metadata) -> bool:
+        response = requests.delete(url=f"{self.url}/{metadata.id}", headers=self.header)
+        if response.status_code == 200:
+            return True
+        return False
+
+    def get_metadata(self, query: MetadataQuery) -> List[Metadata]:
+        if query.id:
+            response = self.get_metadata_by_id(id=query.id)
+        else:
+            args = parse_params(query=query)
+            response = requests.get(url=self.url, params=args, headers=self.header)
+        metadata = parse_obj_as(Union[List[Metadata], Metadata], response.json())
+        if isinstance(metadata, Metadata):
+            metadata = [metadata]
+        return metadata
+
+    def get_metadata_by_id(self, id: int) -> requests.Response:
+        return requests.get(f"{self.url}/{id}", headers=self.header)
+
+    def create_metadata(self, metadata: Metadata) -> Metadata:
+        response = requests.post(
+            url=self.url, data=metadata.json(), headers=self.header
+        )
+        return Metadata(**response.json())
+
+    def update_metadata(self, metadata: Metadata) -> Metadata:
+        response = requests.put(
+            url=f"{self.url}/{metadata.id}", data=metadata.json(), headers=self.header
+        )
+        return Metadata(**response.json())
+
+
+def parse_params(query: MetadataQuery) -> str:
+    query = query.dict(exclude_none=True)
+    args = {}
+    for key in query.keys():
+        element = query[key]
+        # convert times to strings
+        if isinstance(element, UTCDateTime):
+            element = element.isoformat()
+        # get string value of metadata category
+        if key == "category":
+            element = element.value
+        args[key] = element
+    return args
diff --git a/geomagio/apiclient/__init__.py b/geomagio/apiclient/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..53bb45e4c2ebd258886d2bcf0d41522b7a519bdb
--- /dev/null
+++ b/geomagio/apiclient/__init__.py
@@ -0,0 +1,4 @@
+from .metadata import app
+from .MetadataFactory import MetadataFactory
+
+__all__ = ["app", "MetadataFactory"]
diff --git a/geomagio/apiclient/metadata.py b/geomagio/apiclient/metadata.py
new file mode 100644
index 0000000000000000000000000000000000000000..afb3ea63e3c64354553c11c0b8b8cc4773bdb7f3
--- /dev/null
+++ b/geomagio/apiclient/metadata.py
@@ -0,0 +1,144 @@
+import sys
+import json
+import os
+from typing import Dict, Optional
+
+from obspy import UTCDateTime
+import typer
+
+from ..metadata import Metadata, MetadataCategory, MetadataQuery
+from .MetadataFactory import MetadataFactory
+
+
+def load_metadata(input_file: str) -> Optional[Dict]:
+    if input_file is None:
+        return None
+    if input_file == "-":
+        data = json.loads(sys.stdin.read())
+        return data
+    with open(input_file, "r") as file:
+        data = json.load(file)
+    return data
+
+
+app = typer.Typer()
+
+
+@app.command()
+def create(
+    url: str = "http://{}/ws/secure/metadata".format(
+        os.getenv("GEOMAG_API_HOST", "127.0.0.1:8000")
+    ),
+    category: MetadataCategory = None,
+    channel: str = None,
+    created_after: str = None,
+    created_before: str = None,
+    data_valid: bool = True,
+    endtime: str = None,
+    id: int = None,
+    input_file: str = None,
+    location: str = None,
+    metadata_valid: bool = True,
+    network: str = None,
+    starttime: str = None,
+    station: str = None,
+    wrap: bool = True,
+):
+    input_metadata = load_metadata(input_file=input_file)
+    if not wrap:
+        metadata = Metadata(**input_metadata)
+    else:
+        metadata = Metadata(
+            category=category,
+            channel=channel,
+            created_after=UTCDateTime(created_after) if created_after else None,
+            created_before=UTCDateTime(created_before) if created_before else None,
+            data_valid=data_valid,
+            endtime=UTCDateTime(endtime) if endtime else None,
+            id=id,
+            location=location,
+            metadata=input_metadata,
+            metadata_valid=metadata_valid,
+            network=network,
+            starttime=UTCDateTime(starttime) if starttime else None,
+            station=station,
+        )
+    metadata = MetadataFactory(url=url).create_metadata(metadata=metadata)
+    print(metadata.json())
+
+
+@app.command()
+def delete(
+    input_file: str,
+    url: str = "http://{}/ws/secure/metadata".format(
+        os.getenv("GEOMAG_API_HOST", "127.0.0.1:8000")
+    ),
+):
+    metadata_dict = load_metadata(input_file=input_file)
+    metadata = Metadata(**metadata_dict)
+    deleted = MetadataFactory(url=url).delete_metadata(metadata=metadata)
+    if not deleted:
+        sys.exit(1)
+
+
+@app.command()
+def get(
+    url: str = "http://{}/ws/secure/metadata".format(
+        os.getenv("GEOMAG_API_HOST", "127.0.0.1:8000")
+    ),
+    category: Optional[MetadataCategory] = None,
+    channel: Optional[str] = None,
+    created_after: Optional[str] = None,
+    created_before: Optional[str] = None,
+    data_valid: Optional[bool] = True,
+    endtime: Optional[str] = None,
+    id: Optional[int] = None,
+    location: Optional[str] = None,
+    metadata_valid: Optional[bool] = True,
+    network: Optional[str] = None,
+    starttime: Optional[str] = None,
+    station: Optional[str] = None,
+    getone: bool = False,
+):
+    query = MetadataQuery(
+        category=category,
+        channel=channel,
+        created_after=UTCDateTime(created_after) if created_after else None,
+        created_before=UTCDateTime(created_before) if created_before else None,
+        data_valid=data_valid,
+        endtime=UTCDateTime(endtime) if endtime else None,
+        id=id,
+        location=location,
+        metadata_valid=metadata_valid,
+        network=network,
+        starttime=UTCDateTime(starttime) if starttime else None,
+        station=station,
+    )
+    metadata = MetadataFactory(url=url).get_metadata(query=query)
+    if not metadata:
+        print([])
+        return
+
+    if getone:
+        if len(metadata) > 1:
+            raise ValueError("More than one matching record")
+        print(metadata[0].json())
+        return
+    print("[" + ",".join([m.json() for m in metadata]) + "]")
+
+
+@app.command()
+def update(
+    input_file: str,
+    url: str = "http://{}/ws/secure/metadata".format(
+        os.getenv("GEOMAG_API_HOST", "127.0.0.1:8000")
+    ),
+):
+    metadata_dict = load_metadata(input_file=input_file)
+    metadata = Metadata(**metadata_dict)
+    response = MetadataFactory(url=url).update_metadata(metadata=metadata)
+    print(response.json())
+
+
+def main():
+    app()
diff --git a/geomagio/api/secure/MetadataQuery.py b/geomagio/metadata/MetadataQuery.py
similarity index 79%
rename from geomagio/api/secure/MetadataQuery.py
rename to geomagio/metadata/MetadataQuery.py
index 6b30699d9ef1371019a6b89aaa0244b5b0c30111..36e6c3ebe3cd20d880c25ff0d683757c84a2d833 100644
--- a/geomagio/api/secure/MetadataQuery.py
+++ b/geomagio/metadata/MetadataQuery.py
@@ -2,9 +2,10 @@ from datetime import timezone
 
 from obspy import UTCDateTime
 from pydantic import BaseModel
+from typing import Optional
 
-from ...metadata import MetadataCategory
-from ... import pydantic_utcdatetime
+from .. import pydantic_utcdatetime
+from .MetadataCategory import MetadataCategory
 
 
 class MetadataQuery(BaseModel):
@@ -18,8 +19,8 @@ class MetadataQuery(BaseModel):
     station: str = None
     channel: str = None
     location: str = None
-    data_valid: bool = None
-    metadata_valid: bool = True
+    data_valid: Optional[bool] = None
+    metadata_valid: Optional[bool] = None
 
     def datetime_dict(self, **kwargs):
         values = self.dict(**kwargs)
diff --git a/geomagio/metadata/__init__.py b/geomagio/metadata/__init__.py
index 7502db0243cac6af13d38c8386c3dd292ae386c2..37b2adc332b5f6a90f02c75ac9166cf19de48089 100644
--- a/geomagio/metadata/__init__.py
+++ b/geomagio/metadata/__init__.py
@@ -1,5 +1,6 @@
 from .Metadata import Metadata
 from .MetadataCategory import MetadataCategory
+from .MetadataQuery import MetadataQuery
 
 
-__all__ = ["Metadata", "MetadataCategory"]
+__all__ = ["Metadata", "MetadataCategory", "MetadataQuery"]
diff --git a/geomagio/processing/__init__.py b/geomagio/processing/__init__.py
index b17686a9ee5a9b5cda1a2960f69c27c9d8305d95..be97cfbd5e32485df54fc814fd646c595685fed7 100644
--- a/geomagio/processing/__init__.py
+++ b/geomagio/processing/__init__.py
@@ -17,7 +17,7 @@ __all__ = [
     "obsrio_minute",
     "obsrio_second",
     "obsrio_temperatures",
-    "obsrid_tenhertz",
+    "obsrio_tenhertz",
     "rotate",
     "sqdist_minute",
 ]
diff --git a/setup.py b/setup.py
index 6216d647e18bb973c54c54a4011d9db32115d710..a73fd11e7b6ce8880a573af13857d342c6348f12 100644
--- a/setup.py
+++ b/setup.py
@@ -25,8 +25,9 @@ setuptools.setup(
     use_pipfile=True,
     entry_points={
         "console_scripts": [
-            "magproc-prepfiles=geomagio.processing.magproc:main",
             "generate-matrix=geomagio.processing.adjusted:main",
+            "geomag-apiclient=geomagio.apiclient.metadata:main",
+            "magproc-prepfiles=geomagio.processing.magproc:main",
             "obsrio-filter=geomagio.processing.obsrio:main",
         ],
     },