diff --git a/geomagio/WebService.py b/geomagio/WebService.py
new file mode 100644
index 0000000000000000000000000000000000000000..411ab6b80f9c2eac0f9188b453a94f9b1d66821c
--- /dev/null
+++ b/geomagio/WebService.py
@@ -0,0 +1,353 @@
+"""WSGI implementation of Intermagnet Web Service
+"""
+
+from __future__ import print_function
+from cgi import parse_qs, escape
+from datetime import datetime
+from json import load
+import os.path
+import sys
+
+from geomagio.edge import EdgeFactory
+from geomagio.iaga2002 import IAGA2002Writer
+from geomagio.ObservatoryMetadata import ObservatoryMetadata
+from obspy.core import UTCDateTime
+
+
+DEFAULT_DATA_TYPE = 'variation'
+DEFAULT_ELEMENTS = ('X', 'Y', 'Z', 'F')
+DEFAULT_OUTPUT_FORMAT = 'iaga2002'
+DEFAULT_SAMPLING_PERIOD = '60'
+ERROR_CODE_MESSAGES = {
+        204: 'No Data',
+        400: 'Bad Request',
+        404: 'Not Found',
+        409: 'Conflict',
+        500: 'Internal Server Error',
+        501: 'Not Implemented',
+        503: 'Service Unavailable'
+}
+VALID_DATA_TYPES = [
+        'variation',
+        'adjusted',
+        'quasi-definitive',
+        'definitive'
+]
+VALID_OUTPUT_FORMATS = ['iaga2002']
+VALID_SAMPLING_PERIODS = ['1', '60']
+
+
+def _get_param(params, key, required=False):
+    """Get parameter from dictionary.
+
+    Parameters
+    ----------
+    params : dict
+        parameters dictionary.
+    key : str
+        parameter name.
+    required : bool
+        if required parameter.
+
+    Returns
+    -------
+    value : str
+        value from dictionary.
+
+    Raises
+    ------
+    WebServiceException
+        if the parameter is specified more than once
+        or if required paramenter is not specified.
+    """
+    value = params.get(key)
+    if isinstance(value, (list, tuple)):
+        if len(value) > 1:
+            raise WebServiceException('"' + key +
+                    '" may only be specified once.')
+        value = escape(value[0])
+    if value is None:
+        if required:
+            raise WebServiceException('"' + key +
+                    '" is a required parameter.')
+    return value
+
+
+class WebService(object):
+    def __init__(self, factory, metadata=None):
+        self.factory = factory
+        self.metadata = metadata or ObservatoryMetadata().metadata
+        base = os.path.dirname(__file__)
+        filepath = os.path.abspath(os.path.join(base, '..', 'package.json'))
+        with open(filepath) as package:
+            specifications = load(package)
+        self.version = specifications['version']
+
+    def __call__(self, environ, start_response):
+        """Implement WSGI interface"""
+        try:
+            # parse params
+            query = self.parse(parse_qs(environ['QUERY_STRING']))
+            query._verify_parameters()
+        except Exception:
+            exception = sys.exc_info()[1]
+            message = exception.args[0]
+            error_body = self.error(400, message, environ, start_response)
+            return [error_body]
+        try:
+            # fetch timeseries
+            timeseries = self.fetch(query)
+            # format timeseries
+            timeseries_string = self.format_data(
+                    query, timeseries, start_response)
+            if isinstance(timeseries_string, str):
+                timeseries_string = timeseries_string.encode('utf8')
+        except Exception:
+            exception = sys.exc_info()[1]
+            message = exception.args[0]
+            error_body = self.error(500, message, environ, start_response)
+            return [error_body]
+        return [timeseries_string]
+
+    def error(self, code, message, environ, start_response):
+        """Assign error_body value based on error format."""
+        # TODO: Add option for json formatted error
+        error_body = self.http_error(code, message, environ)
+        status = str(code) + ' ' + ERROR_CODE_MESSAGES[code]
+        start_response(status,
+                [
+                    ("Content-Type", "text/plain")
+                ])
+        if isinstance(error_body, str):
+            error_body = error_body.encode('utf8')
+        return error_body
+
+    def fetch(self, query):
+        """Get requested timeseries.
+
+        Parameters
+        ----------
+        query : dictionary of parsed query parameters
+
+        Returns
+        -------
+        obspy.core.Stream
+            timeseries object with requested data.
+        """
+        if query.sampling_period == '1':
+            sampling_period = 'second'
+        if query.sampling_period == '60':
+            sampling_period = 'minute'
+        timeseries = self.factory.get_timeseries(
+                observatory=query.observatory_id,
+                channels=query.elements,
+                starttime=query.starttime,
+                endtime=query.endtime,
+                type=query.data_type,
+                interval=sampling_period)
+        return timeseries
+
+    def format_data(self, query, timeseries, start_response):
+        """Format requested timeseries.
+
+        Parameters
+        ----------
+        query : dictionary of parsed query parameters
+        timeseries : obspy.core.Stream
+            timeseries object with data to be written
+
+        Returns
+        -------
+        unicode
+          IAGA2002 formatted string.
+        """
+        # TODO: Add option for json format
+        timeseries_string = IAGA2002Writer.format(timeseries, query.elements)
+        start_response('200 OK',
+                [
+                    ("Content-Type", "text/plain")
+                ])
+        return timeseries_string
+
+    def http_error(self, code, message, environ):
+        """Format http error message.
+
+        Returns
+        -------
+        error_body : str
+            body of http error message.
+        """
+        status_message = ERROR_CODE_MESSAGES[code]
+        http_error_body = 'Error ' + str(code) + ': ' + status_message + \
+                '\n\n' + message + '\n\n' + \
+                'Usage details are available from ' + \
+                'http://geomag.usgs.gov/ws/edge/ \n\n' + \
+                'Request:\n' + \
+                environ['PATH_INFO'] + '?' + environ['QUERY_STRING'] + \
+                '\n\n' + 'Request Submitted:\n' + \
+                datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + '\n\n' + \
+                'Service version:\n' + \
+                str(self.version)
+        return http_error_body
+
+    def parse(self, params):
+        """Parse query string parameters and set defaults.
+
+        Parameters
+        ----------
+        params : dictionary
+            parameters dictionary.
+
+        Returns
+        -------
+        WebServiceQuery
+            parsed query object.
+
+        Raises
+        ------
+        WebServiceException
+            if any parameters are not supported.
+        """
+        # Get values
+        observatory_id = _get_param(params, 'id', required=True)
+        starttime = _get_param(params, 'starttime')
+        endtime = _get_param(params, 'endtime')
+        elements = _get_param(params, 'elements')
+        sampling_period = _get_param(params, 'sampling_period')
+        data_type = _get_param(params, 'type')
+        output_format = _get_param(params, 'format')
+        # Assign values or defaults
+        if not output_format:
+            output_format = DEFAULT_OUTPUT_FORMAT
+        else:
+            output_format = output_format.lower()
+        observatory_id = observatory_id.upper()
+        if observatory_id not in self.metadata.keys():
+            raise WebServiceException(
+                   'Bad id value "%s".'
+                   ' Valid values are: %s'
+                   % (observatory_id, self.metadata.keys()))
+        if not starttime:
+            now = datetime.now()
+            today = UTCDateTime(
+                    year=now.year,
+                    month=now.month,
+                    day=now.day,
+                    hour=0)
+            starttime = today
+        else:
+            try:
+                starttime = UTCDateTime(starttime)
+            except:
+                raise WebServiceException(
+                        'Bad starttime value "%s".'
+                        ' Valid values are ISO-8601 timestamps.' % starttime)
+        if not endtime:
+            endtime = starttime + (24 * 60 * 60 - 1)
+        else:
+            try:
+                endtime = UTCDateTime(endtime)
+            except:
+                raise WebServiceException(
+                        'Bad endtime value "%s".'
+                        ' Valid values are ISO-8601 timestamps.' % endtime)
+        if not elements:
+            elements = DEFAULT_ELEMENTS
+        else:
+            elements = [e.strip().upper() for e in elements.replace(',', '')]
+        if not sampling_period:
+            sampling_period = DEFAULT_SAMPLING_PERIOD
+        else:
+            sampling_period = sampling_period
+        if not data_type:
+            data_type = DEFAULT_DATA_TYPE
+        else:
+            data_type = data_type.lower()
+        # Create WebServiceQuery object and set properties
+        query = WebServiceQuery()
+        query.observatory_id = observatory_id
+        query.starttime = starttime
+        query.endtime = endtime
+        query.elements = elements
+        query.sampling_period = sampling_period
+        query.data_type = data_type
+        query.output_format = output_format
+        return query
+
+
+class WebServiceQuery(object):
+    """Query parameters for a web service request.
+
+    Parameters
+    ----------
+    observatory_id : str
+        observatory
+    starttime : obspy.core.UTCDateTime
+        time of first requested sample
+    endtime : obspy.core.UTCDateTime
+        time of last requested sample
+    elements : array_like
+        list of requested elements
+    sampling_period : int
+        period between samples in seconds
+        default 60.
+    data_type : {'variation', 'adjusted', 'quasi-definitive', 'definitive'}
+        data type
+        default 'variation'.
+    output_format : {'iaga2002', 'json'}
+        output format.
+        default 'iaga2002'.
+    """
+    def __init__(self, observatory_id=None, starttime=None, endtime=None,
+            elements=None, sampling_period=60, data_type='variation',
+            output_format='iaga2002'):
+        self.observatory_id = observatory_id
+        self.starttime = starttime
+        self.endtime = endtime
+        self.elements = elements
+        self.sampling_period = sampling_period
+        self.data_type = data_type
+        self.output_format = output_format
+
+    def _verify_parameters(self):
+        """Verify that parameters are valid.
+
+        Raises
+        ------
+        WebServiceException
+            if any parameters are not supported.
+        """
+        if len(self.elements) > 4 and self.output_format == 'iaga2002':
+            raise WebServiceException(
+                    'No more than four elements allowed for iaga2002 format.')
+        if self.starttime > self.endtime:
+            raise WebServiceException(
+                    'Starttime must be before endtime.')
+        if self.data_type not in VALID_DATA_TYPES:
+            raise WebServiceException(
+                    'Bad type value "%s".'
+                    ' Valid values are: %s'
+                    % (self.data_type, VALID_DATA_TYPES))
+        if self.sampling_period not in VALID_SAMPLING_PERIODS:
+            raise WebServiceException(
+                    'Bad sampling_period value "%s".'
+                    ' Valid values are: %s'
+                    % (self.sampling_period, VALID_SAMPLING_PERIODS))
+        if self.output_format not in VALID_OUTPUT_FORMATS:
+            raise WebServiceException(
+                    'Bad format value "%s".'
+                    ' Valid values are: %s'
+                    % (self.output_format, VALID_OUTPUT_FORMATS))
+
+
+class WebServiceException(Exception):
+    """Base class for exceptions thrown by web services."""
+    pass
+
+
+if __name__ == '__main__':
+    from wsgiref.simple_server import make_server
+
+    app = WebService(EdgeFactory())
+    httpd = make_server('', 7981, app)
+    httpd.serve_forever()
diff --git a/geomagio/edge/EdgeFactory.py b/geomagio/edge/EdgeFactory.py
index 675a27b8dda4c3ab06b68e1abf77d03af48a7b14..be4b765ca3ace4d070f7df31a141901b2b1c5e1f 100644
--- a/geomagio/edge/EdgeFactory.py
+++ b/geomagio/edge/EdgeFactory.py
@@ -154,7 +154,7 @@ class EdgeFactory(TimeseriesFactory):
         finally:
             output = temp_stdout.getvalue()
             if output != '':
-                sys.stderr.write(output)
+                sys.stderr.write(str(output))
             temp_stdout.close()
             sys.stdout = original_stdout
         self._post_process(timeseries, starttime, endtime, channels)
diff --git a/geomagio/iaga2002/IAGA2002Writer.py b/geomagio/iaga2002/IAGA2002Writer.py
index e73611baaaa843180fa6fc514b4b14952133c4a2..1c80b43236b2412d269ff59d60fcc56a784dc801 100644
--- a/geomagio/iaga2002/IAGA2002Writer.py
+++ b/geomagio/iaga2002/IAGA2002Writer.py
@@ -40,10 +40,11 @@ class IAGA2002Writer(object):
         stats = timeseries[0].stats
         if len(channels) != 4:
             channels = self._pad_to_four_channels(timeseries, channels)
-        out.write(self._format_headers(stats, channels))
-        out.write(self._format_comments(stats))
-        out.write(self._format_channels(channels, stats.station))
-        out.write(self._format_data(timeseries, channels))
+        out.write(self._format_headers(stats, channels).encode('utf8'))
+        out.write(self._format_comments(stats).encode('utf8'))
+        out.write(self._format_channels(channels, stats.station).encode(
+                'utf8'))
+        out.write(self._format_data(timeseries, channels).encode('utf8'))
 
     def _format_headers(self, stats, channels):
         """format headers for IAGA2002 file