diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..0bdf9349542f84135829e676c0a0df1d475210f5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+.eggs/
+.idea/
+.tox/
+*.egg-info/
+*/__pycache__/
+dist/
+junit-*.xml
+.mypy_cache/
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..48cd4ae8a9ae7f5c8ad9c4adeb931c8a9a2262d9
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,26 @@
+image: python
+
+stages:
+  - test
+
+before_script:
+  - pip install -r requirements.txt
+  - pip install -r requirements-test.txt
+
+python36:
+  image: python:3.6
+  stage: test
+  script:
+    - tox -e py36
+
+python37:
+  image: python:3.7
+  stage: test
+  script:
+    - tox -e py37
+
+python38:
+  image: python:3.8
+  stage: test
+  script:
+    - tox -e py38
diff --git a/Jenkinsfile b/Jenkinsfile
new file mode 100644
index 0000000000000000000000000000000000000000..ea1def2e7996ac4535296d16bf43550cc8a67284
--- /dev/null
+++ b/Jenkinsfile
@@ -0,0 +1,29 @@
+#!/usr/bin/env groovy
+
+pipeline {
+    agent { label 'python3' }
+    stages {
+        stage('Run unit tests') {
+            steps {
+                sh 'tox'
+                sh 'python3.6 setup.py test -a "--junitxml=junit.xml"'
+            }
+        }
+        stage('Build source distribution') {
+            steps {
+                // source dist -> dist/setra-client-<version>.tar.gz
+                sh 'python3.6 setup.py sdist'
+                archiveArtifacts artifacts: 'dist/setra-client-*.tar.gz'
+            }
+        }
+    }
+    post {
+        always {
+            junit '**/junit*.xml'
+        }
+        cleanup {
+            sh 'rm -vf junit.xml'
+            sh 'rm -vrf build dist'
+        }
+    }
+}
diff --git a/README.md b/README.md
index 4cb47aa7940eb583bb544a5f879b464482bff2c3..eb004224d14539667bb1a51b9103be7ba6a0e040 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,53 @@
 # Setra Client
 
-Client for doing HTTP requests to the SETRA api
\ No newline at end of file
+Client for doing HTTP requests to the SETRA api
+
+```python
+from setra_client import SetraClient
+from setra_client.models import Batch, Multi, Transaction, Voucher
+
+c = SetraClient(url='https://example.com',
+                headers={'X-Gravitee-API-Key': 'c-d-a-b'})
+
+batch = Batch.from_dict({
+    "client": 1,
+    "batchid": 2,
+    "period": 3,
+    "interface": 4,
+    "vouchertype": 5,
+    "batchid_interface": 6
+})
+vouchers = [Voucher.from_dict({
+    "batchid": 1,
+    "voucherno_interface": 2,
+    "exref": 3,
+    "voucherno": 4
+})]
+transactions = [Transaction.from_dict({
+    "voucherid": 1,
+    "account": 1,
+    "amount": 1,
+    "transdate": 1,
+    "curamount": 1,
+    "currency": 1,
+    "description": 1,
+    "dim1": 1,
+    "dim2": 1,
+    "dim3": 1,
+    "dim4": 1,
+    "dim5": 1,
+    "dim6": 1,
+    "dim7": 1,
+    "sequenceno": 1,
+    "taxcode": 1,
+    "transtype": 1,
+    "arrivaldate": 1,
+    "compldelay": 1,
+    "discdate": 1,
+    "duedate": 1,
+    "extinvref": 1
+})]
+
+multi = Multi(batch=batch, vouchers=vouchers, transactions=transactions)
+response = c.post_multi(multi)
+```
diff --git a/example-config.json b/example-config.json
new file mode 100644
index 0000000000000000000000000000000000000000..f9ce49691a1ec4788794cbfcfc0c44000ab14451
--- /dev/null
+++ b/example-config.json
@@ -0,0 +1,33 @@
+{
+  "_": "Config example for the setra_client command-line utility",
+  "client": {
+    "url": "https:/example.com/",
+    "batch_url": "/batch/",
+    "transaction_url": "/transaction/",
+    "voucher_url": "/voucher/",
+    "multi_url": "/addtrans/",
+    "headers": {
+      "X-Gravitee-Api-Key": "..."
+    },
+    "return_objects": true,
+    "use_sessions": true
+  },
+  "logging": {
+    "version": 1,
+    "replace_existing_loggers": false,
+    "root": {
+      "level": "INFO"
+    },
+    "loggers": {
+      "__main__": {
+        "level": "INFO"
+      },
+      "setra_client": {
+        "level": "WARNING"
+      },
+      "urllib3": {
+        "level": "DEBUG"
+      }
+    }
+  }
+}
diff --git a/requirements-test.txt b/requirements-test.txt
new file mode 100644
index 0000000000000000000000000000000000000000..b74d7b0763e9b7aed45201e3d92efaeac408cd2c
--- /dev/null
+++ b/requirements-test.txt
@@ -0,0 +1,3 @@
+pytest
+requests_mock
+tox
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..3ebed4dc42c006aebe7f8593613fafa1566d6458
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,3 @@
+pydantic
+requests
+setuptools
diff --git a/setra_client/__init__.py b/setra_client/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..a0552908729bd7e2a3727ec5a38ee053422fe178
--- /dev/null
+++ b/setra_client/__init__.py
@@ -0,0 +1,6 @@
+from .client import SetraClient
+from .version import get_distribution
+
+
+__all__ = ['SetraClient']
+__version__ = get_distribution().version
diff --git a/setra_client/client.py b/setra_client/client.py
new file mode 100644
index 0000000000000000000000000000000000000000..a0d1ae87904845cd650b10e71663b76c3666e3f7
--- /dev/null
+++ b/setra_client/client.py
@@ -0,0 +1,217 @@
+"""Client for connecting to SETRA API"""
+import json
+import logging
+import os
+import urllib.parse
+from typing import Tuple, Union, List
+
+import requests
+
+from setra_client.models import Multi
+
+logger = logging.getLogger(__name__)
+
+
+def load_json_file(name):
+    """Load json file from the fixtures directory"""
+    here = os.path.realpath(
+        os.path.join(os.getcwd(),
+                     os.path.dirname(__file__).rsplit('/', 1)[0]))
+    with open(os.path.join(here, 'tests/fixtures', name)) as f:
+        data = json.load(f)
+    return data
+
+
+def merge_dicts(*dicts):
+    """
+    Combine a series of dicts without mutating any of them.
+
+    >>> merge_dicts({'a': 1}, {'b': 2})
+    {'a': 1, 'b': 2}
+    >>> merge_dicts({'a': 1}, {'a': 2})
+    {'a': 2}
+    >>> merge_dicts(None, None, None)
+    {}
+    """
+    combined = dict()
+    for d in dicts:
+        if not d:
+            continue
+        for k in d:
+            combined[k] = d[k]
+    return combined
+
+
+class SetraEndpoints:
+    def __init__(self,
+                 url,
+                 batch_url='batch/',
+                 transaction_url='transaction/',
+                 voucher_url='voucher/',
+                 multi_url='addtrans/'
+                 ):
+        self.baseurl = url
+        self.batch_url = batch_url
+        self.transaction_url = transaction_url
+        self.voucher_url = voucher_url
+        self.multi_url = multi_url
+
+    """ Get endpoints relative to the SETRA API URL. """
+
+    def __repr__(self):
+        return '{cls.__name__}({url!r})'.format(
+            cls=type(self),
+            url=self.baseurl)
+
+    def batch(self, batch_id: str = None):
+        """
+        URL for Batch endpoint
+        """
+        if batch_id is None:
+            return urllib.parse.urljoin(self.baseurl, self.batch_url)
+        else:
+            return urllib.parse.urljoin(self.baseurl,
+                                        '/'.join((self.batch_url, batch_id)))
+
+    def transaction(self, trans_id: str = None):
+        """
+        Url for Transaction endpoint
+        """
+        if trans_id is None:
+            return urllib.parse.urljoin(self.baseurl, self.transaction_url)
+        else:
+            return urllib.parse.urljoin(self.baseurl,
+                                        '/'.join((self.transaction_url,
+                                                  trans_id)))
+
+    def voucher(self, vouch_id: str = None):
+        """
+        Url for Voucher endpoint
+        """
+        if vouch_id is None:
+            return urllib.parse.urljoin(self.baseurl, self.voucher_url)
+        else:
+            return urllib.parse.urljoin(self.baseurl,
+                                        '/'.join((self.voucher_url, vouch_id)))
+
+    def post_multi(self):
+        return urllib.parse.urljoin(self.baseurl, self.multi_url)
+
+
+class SetraClient(object):
+    default_headers = {
+        'Accept': 'application/json',
+    }
+
+    def __init__(self,
+                 url: str,
+                 headers: Union[None, dict] = None,
+                 return_objects: bool = True,
+                 use_sessions: bool = True,
+                 ):
+        """
+        SETRA API client.
+
+        :param str url: Base API URL
+        :param dict headers: Append extra headers to all requests
+        :param bool return_objects: Return objects instead of raw JSON
+        :param bool use_sessions: Keep HTTP connections alive (default True)
+        """
+
+        self.urls = SetraEndpoints(url)
+        self.headers = merge_dicts(self.default_headers, headers)
+        self.return_objects = return_objects
+        if use_sessions:
+            self.session = requests.Session()
+        else:
+            self.session = requests
+
+    def _build_request_headers(self, headers):
+        request_headers = {}
+        for h in self.headers:
+            request_headers[h] = self.headers[h]
+        for h in (headers or ()):
+            request_headers[h] = headers[h]
+        return request_headers
+
+    def call(self,
+             method_name,
+             url,
+             headers=None,
+             params=None,
+             return_response=True,
+             **kwargs):
+        headers = self._build_request_headers(headers)
+        if params is None:
+            params = {}
+        logger.debug('Calling %s %s with params=%r',
+                     method_name,
+                     urllib.parse.urlparse(url).path,
+                     params)
+        r = self.session.request(method_name,
+                                 url,
+                                 headers=headers,
+                                 params=params,
+                                 **kwargs)
+        if r.status_code in (500, 400, 401):
+            logger.warning('Got HTTP %d: %r', r.status_code, r.content)
+        if return_response:
+            return r
+        r.raise_for_status()
+        return r.json()
+
+    def get(self, url, **kwargs):
+        return self.call('GET', url, **kwargs)
+
+    def put(self, url, **kwargs):
+        return self.call('PUT', url, **kwargs)
+
+    def post(self, url, **kwargs):
+        return self.call('POST', url, **kwargs)
+
+    def object_or_data(self, cls, data) -> Union[object, dict]:
+        if not self.return_objects:
+            return data
+        return cls.from_dict(data)
+
+    def get_batch(self, batch_id: int = None):
+        """
+        GETs one or all batches from SETRA
+        """
+        url = self.urls.batch(str(batch_id))
+        response = self.get(url)
+        return response.json()
+
+    def get_voucher(self, vouch_id: int):
+        """
+        GETs one or all batches from SETRA
+        """
+        url = self.urls.voucher(str(vouch_id))
+        response = self.get(url)
+        return response.json()
+
+    def get_transaction(self, trans_id: int):
+        """
+        GETs one or all batches from SETRA
+        """
+        url = self.urls.transaction(str(trans_id))
+        response = self.get(url)
+        return response.json()
+
+    def post_multi(self, multidata: Multi):
+        """
+        POST combination of batch, vouchers and transactions
+        """
+        url = self.urls.post_multi()
+        headers = {'Content-Type': 'application/json'}
+        response = self.post(url,
+                             data=multidata.json(),
+                             headers=headers)
+        return response
+
+
+def get_client(config_dict):
+    """
+    Get a SetraClient from configuration.
+    """
+    return SetraClient(**config_dict)
diff --git a/setra_client/models.py b/setra_client/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..569587d0080a5240b32275de5fe50080dd87d4e8
--- /dev/null
+++ b/setra_client/models.py
@@ -0,0 +1,79 @@
+"""Models used by the client"""
+import datetime
+import json
+import typing
+from typing import Optional, TypeVar
+
+import pydantic
+from pydantic import validator
+
+NameType = TypeVar('NameType')
+
+
+def to_lower_camel(s: str) -> str:
+    """Alias generator to avoid breaking PEP8"""
+    first, *others = s.split('_')
+    return ''.join([first.lower(), *map(str.capitalize, others)])
+
+
+class BaseModel(pydantic.BaseModel):
+    """Expanded BaseModel for convenience"""
+
+    @classmethod
+    def from_dict(cls, data: dict):
+        """Initialize class from dict"""
+        return cls(**data)
+
+    @classmethod
+    def from_json(cls, json_data: str):
+        """Initialize class from json file"""
+        data = json.loads(json_data)
+        return cls.from_dict(data)
+
+
+class Batch(BaseModel):
+    """Model for making json formatted list of a Batch"""
+    client: str
+    batchid: str
+    period: str
+    interface: str
+    vouchertype: str
+    batchid_interface: str
+
+
+class Voucher(BaseModel):
+    voucherdate: Optional[datetime.date]
+    exref: str
+    voucherno: int
+
+
+class Transaction(BaseModel):
+    account: str
+    amount: float
+    transdate: datetime.date
+    curamount: float
+    currency: str
+    description: str
+    dim1: str
+    dim2: str
+    dim3: str
+    dim4: str
+    dim5: str
+    dim6: str
+    dim7: str
+    sequenceno: int
+    taxcode: str
+    transtype: str
+    arrivaldate: datetime.date
+    compldelay: datetime.date
+    discdate: datetime.date
+    duedate: datetime.date
+    extinvref: str
+    voucherno_interface: str
+
+
+class Multi(BaseModel):
+    batch: Batch
+    vouchers: typing.List[Voucher]
+    transactions: typing.List[Transaction]
+
diff --git a/setra_client/version.py b/setra_client/version.py
new file mode 100644
index 0000000000000000000000000000000000000000..b81165e678ffcd287f1acf5cc8d3822cb69fc50c
--- /dev/null
+++ b/setra_client/version.py
@@ -0,0 +1,18 @@
+"""Version for distribution purposes"""
+import os
+
+import pkg_resources
+
+
+DISTRIBUTION_NAME = 'setra-client'
+
+
+def get_distribution():
+    """ Get the distribution object for this single module dist. """
+    try:
+        return pkg_resources.get_distribution(DISTRIBUTION_NAME)
+    except pkg_resources.DistributionNotFound:
+        return pkg_resources.Distribution(
+            project_name=DISTRIBUTION_NAME,
+            version='0.0.0',
+            location=os.path.dirname(__file__))
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000000000000000000000000000000000000..a72c1fa24efb53105f8a3213bf25b34cb36763ab
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+import sys
+
+import setuptools
+import setuptools.command.test
+
+
+def get_requirements(filename):
+    """ Read requirements from file. """
+    with open(filename, mode='rt', encoding='utf-8') as f:
+        for line in f:
+            # TODO: Will not work with #egg-info
+            requirement = line.partition('#')[0].strip()
+            if not requirement:
+                continue
+            yield requirement
+
+
+def get_textfile(filename):
+    """ Get contents from a text file. """
+    with open(filename, mode='rt', encoding='utf-8') as f:
+        return f.read().lstrip()
+
+
+def get_packages():
+    """ List of (sub)packages to install. """
+    return setuptools.find_packages('.', include=('setra_client',
+                                                  'setra_client.*'))
+
+
+class PyTest(setuptools.command.test.test):
+    """ Run tests using pytest.
+
+    From `http://doc.pytest.org/en/latest/goodpractices.html`.
+
+    """
+
+    user_options = [('pytest-args=', 'a', "Arguments to pass to pytest")]
+
+    def initialize_options(self):
+        super().initialize_options()
+        self.pytest_args = []
+
+    def run_tests(self):
+        import shlex
+        import pytest
+        args = self.pytest_args
+        if args:
+            args = shlex.split(args)
+        errno = pytest.main(args)
+        raise SystemExit(errno)
+
+
+def run_setup():
+    setup_requirements = ['setuptools_scm']
+    test_requirements = list(get_requirements('requirements-test.txt'))
+    install_requirements = list(get_requirements('requirements.txt'))
+
+    if {'build_sphinx', 'upload_docs'}.intersection(sys.argv):
+        setup_requirements.extend(get_requirements('docs/requirements.txt'))
+        setup_requirements.extend(install_requirements)
+
+    setuptools.setup(
+        name='setra-client',
+        description='Client for the SETRA API',
+        long_description=get_textfile('README.md'),
+        long_description_content_type='text/markdown',
+
+        url='https://git.app.uib.no/it-bott-integrasjoner/setra-client',
+        author='BOTT-INT',
+        author_email='bnt-int@usit.uio.no',
+
+        use_scm_version=True,
+        packages=get_packages(),
+        setup_requires=setup_requirements,
+        install_requires=install_requirements,
+        tests_require=test_requirements,
+        cmdclass={
+            'test': PyTest,
+        },
+        classifiers=[
+            'Development Status :: 3 - Alpha',
+            'Intended Audience :: Developers',
+            'Topic :: Software Development :: Libraries',
+            'Programming Language :: Python :: 3 :: Only',
+            'Programming Language :: Python :: 3.6',
+            'Programming Language :: Python :: 3.7',
+            'Programming Language :: Python :: 3.8',
+        ],
+        keywords='SETRA API client',
+    )
+
+
+if __name__ == '__main__':
+    run_setup()
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000000000000000000000000000000000000..94ea78eb653b27697a059be636d07887c3306571
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,61 @@
+import json
+
+import pytest
+import requests_mock
+
+from setra_client.client import SetraClient, SetraEndpoints, load_json_file
+
+
+@pytest.fixture
+def baseurl():
+    return 'https://localhost'
+
+
+@pytest.fixture
+def endpoints(baseurl):
+    return SetraEndpoints(baseurl)
+
+
+@pytest.fixture
+def custom_endpoints(baseurl):
+    return SetraEndpoints(baseurl,
+                          '/custom/batch/',
+                          '/custom/transaction/',
+                          '/custom/voucher/',
+                          '/custom/addtrans/'
+                          )
+
+
+@pytest.fixture
+def client(baseurl):
+    return SetraClient(baseurl)
+
+
+@pytest.fixture
+def batch_fixture():
+    return load_json_file('batch_fixture.json')
+
+
+@pytest.fixture
+def voucher_fixture():
+    return load_json_file('voucher_fixture.json')
+
+
+@pytest.fixture
+def trans_fixture():
+    return load_json_file('trans_fixture.json')
+
+
+@pytest.fixture
+def trans_fail_fixture():
+    return load_json_file('trans_fail_fixture.json')
+
+
+@pytest.fixture
+def multi_fixture():
+    return load_json_file('multi_fixture.json')
+
+
+@pytest.fixture
+def multi_fail_fixture():
+    return load_json_file('multi_fail_fixture.json')
diff --git a/tests/fixtures/batch_fixture.json b/tests/fixtures/batch_fixture.json
new file mode 100644
index 0000000000000000000000000000000000000000..c5fefce8118a31b0b307e1de525ebf410cb14157
--- /dev/null
+++ b/tests/fixtures/batch_fixture.json
@@ -0,0 +1,8 @@
+{
+  "client": 1,
+  "batchid": 2,
+  "period": 3,
+  "interface": 4,
+  "vouchertype": 5,
+  "batchid_interface": 6
+}
diff --git a/tests/fixtures/multi_fail_fixture.json b/tests/fixtures/multi_fail_fixture.json
new file mode 100644
index 0000000000000000000000000000000000000000..dca0847e589189fdafd1803ece23549659ba7e7e
--- /dev/null
+++ b/tests/fixtures/multi_fail_fixture.json
@@ -0,0 +1,68 @@
+{
+  "batch": {
+    "client": 1,
+    "batchid": 54,
+    "period": 1,
+    "interface": 1,
+    "vouchertype": 1,
+    "batchid_interface": 1
+  },
+  "vouchers": [
+    {
+      "batchid": 55,
+      "voucherno_interface": 1,
+      "exref": 1,
+      "voucherno": 65
+    }
+  ],
+  "transactions": [
+    {
+      "voucherid": 66,
+      "account": 1,
+      "amount": 1,
+      "transdate": 1,
+      "curamount": 1,
+      "currency": 1,
+      "description": 1,
+      "dim1": 1,
+      "dim2": 1,
+      "dim3": 1,
+      "dim4": 1,
+      "dim5": 1,
+      "dim6": 1,
+      "dim7": 1,
+      "sequenceno": 1,
+      "taxcode": 1,
+      "transtype": 1,
+      "arrivaldate": 1,
+      "compldelay": 1,
+      "discdate": 1,
+      "duedate": 1,
+      "extinvref": 1
+    },
+    {
+      "voucherid": 66,
+      "account": 2,
+      "amount": 2,
+      "transdate": 2,
+      "curamount": 2,
+      "currency": 2,
+      "description": 2,
+      "dim1": 2,
+      "dim2": 2,
+      "dim3": 2,
+      "dim4": 2,
+      "dim5": 2,
+      "dim6": 2,
+      "dim7": 2,
+      "sequenceno": 2,
+      "taxcode": 2,
+      "transtype": 2,
+      "arrivaldate": 2,
+      "compldelay": 2,
+      "discdate": 2,
+      "duedate": 2,
+      "extinvref": 2
+    }
+  ]
+}
diff --git a/tests/fixtures/multi_fixture.json b/tests/fixtures/multi_fixture.json
new file mode 100644
index 0000000000000000000000000000000000000000..8749f4224593fd0320f2836a5f088e11bdedd34f
--- /dev/null
+++ b/tests/fixtures/multi_fixture.json
@@ -0,0 +1,68 @@
+{
+  "batch": {
+    "client": 1,
+    "batchid": 55,
+    "period": 1,
+    "interface": 1,
+    "vouchertype": 1,
+    "batchid_interface": 1
+  },
+  "vouchers": [
+    {
+      "batchid": 55,
+      "voucherno_interface": 1,
+      "exref": 1,
+      "voucherno": 66
+    }
+  ],
+  "transactions": [
+    {
+      "voucherid": 66,
+      "account": 1,
+      "amount": 1,
+      "transdate": 1,
+      "curamount": 1,
+      "currency": 1,
+      "description": 1,
+      "dim1": 1,
+      "dim2": 1,
+      "dim3": 1,
+      "dim4": 1,
+      "dim5": 1,
+      "dim6": 1,
+      "dim7": 1,
+      "sequenceno": 1,
+      "taxcode": 1,
+      "transtype": 1,
+      "arrivaldate": 1,
+      "compldelay": 1,
+      "discdate": 1,
+      "duedate": 1,
+      "extinvref": 1
+    },
+    {
+      "voucherid": 66,
+      "account": 2,
+      "amount": 2,
+      "transdate": 2,
+      "curamount": 2,
+      "currency": 2,
+      "description": 2,
+      "dim1": 2,
+      "dim2": 2,
+      "dim3": 2,
+      "dim4": 2,
+      "dim5": 2,
+      "dim6": 2,
+      "dim7": 2,
+      "sequenceno": 2,
+      "taxcode": 2,
+      "transtype": 2,
+      "arrivaldate": 2,
+      "compldelay": 2,
+      "discdate": 2,
+      "duedate": 2,
+      "extinvref": 2
+    }
+  ]
+}
diff --git a/tests/fixtures/trans_fail_fixture.json b/tests/fixtures/trans_fail_fixture.json
new file mode 100644
index 0000000000000000000000000000000000000000..d867364ba00cd4e223ea4006d91397466ff50310
--- /dev/null
+++ b/tests/fixtures/trans_fail_fixture.json
@@ -0,0 +1,23 @@
+{
+  "voucherid": 1,
+  "account": 1,
+  "amount": 1,
+  "transdate": 1,
+  "curamount": 1,
+  "currency": 1,
+  "description": 1,
+  "dim1": 1,
+  "dim2": 1,
+  "dim3": 1,
+  "dim4": 1,
+  "dim5": 1,
+  "dim6": 1,
+  "dim7": 1,
+  "sequenceno": 1,
+  "taxcode": 1,
+  "transtype": 1,
+  "arrivaldate": 1,
+  "compldelay": 1,
+  "discdate": 1,
+  "duedate": 1
+}
diff --git a/tests/fixtures/trans_fixture.json b/tests/fixtures/trans_fixture.json
new file mode 100644
index 0000000000000000000000000000000000000000..ddbaaf0abe48dc7ba0fc365eee66cfe42e6febe3
--- /dev/null
+++ b/tests/fixtures/trans_fixture.json
@@ -0,0 +1,24 @@
+{
+  "voucherid": 1,
+  "account": 1,
+  "amount": 1,
+  "transdate": 1,
+  "curamount": 1,
+  "currency": 1,
+  "description": 1,
+  "dim1": 1,
+  "dim2": 1,
+  "dim3": 1,
+  "dim4": 1,
+  "dim5": 1,
+  "dim6": 1,
+  "dim7": 1,
+  "sequenceno": 1,
+  "taxcode": 1,
+  "transtype": 1,
+  "arrivaldate": 1,
+  "compldelay": 1,
+  "discdate": 1,
+  "duedate": 1,
+  "extinvref": 1
+}
diff --git a/tests/fixtures/voucher_fixture.json b/tests/fixtures/voucher_fixture.json
new file mode 100644
index 0000000000000000000000000000000000000000..c63fb58b5736ff119133a6eae39401f06b6786bb
--- /dev/null
+++ b/tests/fixtures/voucher_fixture.json
@@ -0,0 +1,6 @@
+{
+  "batchid": 1,
+  "voucherno_interface": 2,
+  "exref": 3,
+  "voucherno": 4
+}
diff --git a/tests/test_client.py b/tests/test_client.py
new file mode 100644
index 0000000000000000000000000000000000000000..cd667a913fd155a5eb9d1212d35af5a189b578ea
--- /dev/null
+++ b/tests/test_client.py
@@ -0,0 +1,50 @@
+import pytest
+from requests import HTTPError
+
+from setra_client.client import SetraClient
+
+
+@pytest.fixture
+def header_name():
+    return 'X-Test'
+
+
+@pytest.fixture
+def client_cls(header_name):
+    class TestClient(SetraClient):
+        default_headers = {
+            header_name: '6a9a32f0-7322-4ef3-bbce-6685a3388e67',
+        }
+
+    return TestClient
+
+
+def test_init_does_not_mutate_arg(client_cls, baseurl):
+    headers = {}
+    client = client_cls(baseurl, headers=headers)
+    assert headers is not client.headers
+    assert not headers
+
+
+def test_init_applies_default_headers(client_cls, baseurl, header_name):
+    headers = {}
+    client = client_cls(baseurl, headers=headers)
+    assert header_name in client.headers
+    assert client.headers[header_name] == client.default_headers[header_name]
+
+
+def test_init_modify_defaults(client_cls, baseurl, header_name):
+    headers = {header_name: 'ede37fdd-a2ae-4a96-9d80-110528425ea6'}
+    client = client_cls(baseurl, headers=headers)
+    # Check that we respect the headers arg, and don't use default_headers
+    assert client.headers[header_name] == headers[header_name]
+    # Check that we don't do this by mutating default_headers
+    assert client.default_headers[header_name] != headers[header_name]
+
+#
+# def test_get_update_schema(client, requests_mock):
+#     """Ensure getting update schema works"""
+#     requests_mock.get('https://localhost/_webservices/?ws=contacts/upsert/1.0',
+#                       json={'foo': 'bar'})
+#     response = client.get_update_schema()
+#     assert response == {'foo': 'bar'}
diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py
new file mode 100644
index 0000000000000000000000000000000000000000..fcf3743ba559c9d4aed94ab1232829247e5817a9
--- /dev/null
+++ b/tests/test_endpoints.py
@@ -0,0 +1,8 @@
+from setra_client.client import SetraEndpoints
+
+
+def test_init(baseurl):
+    endpoints = SetraEndpoints(baseurl)
+    assert endpoints.baseurl == baseurl
+
+
diff --git a/tests/test_models.py b/tests/test_models.py
new file mode 100644
index 0000000000000000000000000000000000000000..fb1049bbf4c11e293f08751667c97780326b749c
--- /dev/null
+++ b/tests/test_models.py
@@ -0,0 +1,34 @@
+import pytest
+from pydantic import ValidationError
+
+from setra_client.models import Batch, Multi, Voucher, Transaction
+
+
+def test_batch(batch_fixture):
+    # Check correct example work
+    assert Batch(**batch_fixture)
+
+
+def test_voucher(voucher_fixture):
+    # Check correct example work
+    assert Voucher(**voucher_fixture)
+
+
+def test_transaction(trans_fixture):
+    # Check correct example work
+    assert Transaction(**trans_fixture)
+
+
+def test_transaction_fail(trans_fail_fixture):
+    # Check missing required field fails
+    with pytest.raises(ValidationError):
+        Transaction(**trans_fail_fixture)
+
+
+def test_multi(multi_fixture):
+    assert Multi(**multi_fixture)
+
+
+def test_multi_fail(multi_fail_fixture):
+    with pytest.raises(ValidationError):
+        Multi(**multi_fail_fixture)
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000000000000000000000000000000000000..657b6b712e968077478a1f1118b27223a6524f93
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,14 @@
+[tox]
+envlist = py36,py37,py38
+
+[testenv]
+description = Run tests with {basepython}
+deps =
+    -rrequirements-test.txt
+    -rrequirements.txt
+commands =
+    {envpython} -m pytest --junitxml=junit-{envname}.xml {posargs}
+
+[pytest]
+xfail_strict = true
+addopts = -rxs -v