diff --git a/api/__init__.py b/api/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/api/apps.py b/api/apps.py
new file mode 100644
index 0000000000000000000000000000000000000000..6d524a7ff40db97c096b761f978a4bbaee89784d
--- /dev/null
+++ b/api/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class HeliosConfig(AppConfig):
+    name = "api"
+    verbose_name = "API"
diff --git a/api/templates/api/index.html b/api/templates/api/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..08263747170fce6900ba5b8ec31fc2ab0e6617f6
--- /dev/null
+++ b/api/templates/api/index.html
@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html>
+<body>
+
+<h1>Helios API</h1>
+<h2>Examples</h2>
+<hr>
+<pre>
+GET /api/user/&lt;username&gt;/elections/
+GET /api/user/&lt;username&gt;/elections/?limit=10
+
+GET /api/elections/
+GET /api/elections/?limit=10
+
+GET /api/elections/&lt;uuid&gt;/voters/
+</pre>
+<hr>
+<p>* default limits are 100 objects</p>
+
+</body>
+</html>
diff --git a/api/urls.py b/api/urls.py
new file mode 100644
index 0000000000000000000000000000000000000000..531a45acf3541965a5e9ff45bdc0e1025c4285f2
--- /dev/null
+++ b/api/urls.py
@@ -0,0 +1,11 @@
+from django.conf.urls import url
+
+from . import views
+
+
+urlpatterns = [
+    url(r"^$", views.IndexView.as_view()),
+    url(r"^user/(?P<username>[^/]+)/elections/$", views.UserElectionsView.as_view()),
+    url(r"^elections/$", views.ElectionsView.as_view()),
+    url(r"^elections/(?P<uuid>[^/]+)/voters/$", views.ElectionVotersView.as_view()),
+]
diff --git a/api/views.py b/api/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..e6fc9f7727f55a71a9efb0a7b091a712d688bff1
--- /dev/null
+++ b/api/views.py
@@ -0,0 +1,103 @@
+import pytz
+from django.http import JsonResponse
+from django.views.generic import TemplateView, View
+
+from helios.models import Election
+from helios_auth.models import User
+
+
+DEFAULT_LIMIT = 100
+
+
+class JsonView(View):
+    def get_payload(self, request, *args, **kwargs):
+        raise NotImplementedError
+
+    def get(self, request, *args, **kwargs):
+        return JsonResponse(self.get_payload(request, *args, **kwargs))
+
+
+class IndexView(TemplateView):
+    template_name = "api/index.html"
+
+
+def election_as_dict(election):
+    voting_start_at = (
+        election.voting_start_at.replace(tzinfo=pytz.UTC)
+        if election.voting_start_at
+        else None
+    )
+    voting_end_at = (
+        election.voting_end_at.replace(tzinfo=pytz.UTC)
+        if election.voting_end_at
+        else None
+    )
+    return {
+        "name": election.name,
+        "uuid": election.uuid,
+        "short_name": election.short_name,
+        "url": election.url,
+        "created_at": election.created_at,
+        "voting_has_started": election.voting_has_started(),
+        "voting_has_stopped": election.voting_has_stopped(),
+        "voting_start_at": voting_start_at,
+        "voting_end_at": voting_end_at,
+    }
+
+
+class ElectionsView(JsonView):
+    def get_payload(self, request, *args, **kwargs):
+        limit = int(request.GET.get("limit", DEFAULT_LIMIT))
+        qs = Election.objects.exclude(frozen_at=None).order_by("-created_at")[:limit]
+
+        elections = []
+        for election in qs:
+            elections.append(election_as_dict(election))
+
+        return {"elections": elections}
+
+
+class UserElectionsView(JsonView):
+    def get_payload(self, request, username, *args, **kwargs):
+        try:
+            user = User.objects.get(user_id__iexact=username)
+        except User.DoesNotExist:
+            return {}
+
+        limit = int(request.GET.get("limit", DEFAULT_LIMIT))
+        qs = (
+            user.voter_set.all()
+            .order_by("-election__created_at")
+            .select_related("election")[:limit]
+        )
+
+        elections = []
+        for voter in qs:
+            election = election_as_dict(voter.election)
+            election["user_has_voted"] = voter.vote_hash is not None
+            elections.append(election)
+
+        return {"username": username, "elections": elections}
+
+
+class ElectionVotersView(JsonView):
+    def get_payload(self, request, uuid, *args, **kwargs):
+        try:
+            election = Election.objects.get(uuid=uuid)
+        except Election.DoesNotExist:
+            return {}
+
+        result = election_as_dict(election)
+        result["voters"] = []
+
+        voters = (
+            election.voter_set.all()
+            .values_list("user__user_id", "vote_hash")
+            .order_by("user__user_id")
+        )
+        for user_id, vote_hash in voters:
+            result["voters"].append(
+                {"username": user_id, "has_voted": vote_hash is not None}
+            )
+
+        return result
diff --git a/manage.py b/manage.py
old mode 100644
new mode 100755
diff --git a/settings.py b/settings.py
index a158572929f1ffd11b7e3b7080c92d33aa8e2894..8b25fc25a73d6601022edd633721475869dc8366 100644
--- a/settings.py
+++ b/settings.py
@@ -166,6 +166,7 @@ INSTALLED_APPS = (
     'helios_auth',
     'helios',
     'server_ui',
+    'api',
 )
 
 ANYMAIL = {
diff --git a/urls.py b/urls.py
index 8c29e83ea1a7b309247f81caf3dc6a423d6e6cd7..05ea89adb3e7acc1d5ba05b36d306ff9af5c89bb 100644
--- a/urls.py
+++ b/urls.py
@@ -15,5 +15,7 @@ urlpatterns = [
     url(r'static/helios/(?P<path>.*)$', serve, {'document_root' : settings.ROOT_PATH + '/helios/media'}),
     url(r'static/(?P<path>.*)$', serve, {'document_root' : settings.ROOT_PATH + '/server_ui/media'}),
 
+    url(r'^api/', include('api.urls')),
+
     url(r'^', include('server_ui.urls')),
 ]