diff --git a/invenio/modules/webhooks/__init__.py b/invenio/modules/webhooks/__init__.py
new file mode 100644
index 000000000..0eab0f888
--- /dev/null
+++ b/invenio/modules/webhooks/__init__.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+##
+## This file is part of Invenio.
+## Copyright (C) 2014 CERN.
+##
+## Invenio is free software; you can redistribute it and/or
+## modify it under the terms of the GNU General Public License as
+## published by the Free Software Foundation; either version 2 of the
+## License, or (at your option) any later version.
+##
+## Invenio is distributed in the hope that it will be useful, but
+## WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+## General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with Invenio; if not, write to the Free Software Foundation, Inc.,
+## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
diff --git a/invenio/modules/webhooks/config.py b/invenio/modules/webhooks/config.py
new file mode 100644
index 000000000..7d474f3b8
--- /dev/null
+++ b/invenio/modules/webhooks/config.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+##
+## This file is part of Invenio.
+## Copyright (C) 2014 CERN.
+##
+## Invenio is free software; you can redistribute it and/or
+## modify it under the terms of the GNU General Public License as
+## published by the Free Software Foundation; either version 2 of the
+## License, or (at your option) any later version.
+##
+## Invenio is distributed in the hope that it will be useful, but
+## WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+## General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with Invenio; if not, write to the Free Software Foundation, Inc.,
+## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
+
+WEBHOOKS_DEBUG_RECEIVER_URLS = {}
+"""
+Mapping of receiver id to URL pattern. This allows generating URLs to an
+intermediate webhook proxy service like Ultrahook for testing on development
+machines::
+
+    WEBHOOKS_DEBUG_RECEIVER_URLS = {
+        'github': 'https://hook.user.ultrahook.com/?access_token=%%(token)s'
+    }
+"""
diff --git a/invenio/modules/webhooks/models.py b/invenio/modules/webhooks/models.py
new file mode 100644
index 000000000..6351a4275
--- /dev/null
+++ b/invenio/modules/webhooks/models.py
@@ -0,0 +1,150 @@
+# -*- coding: utf-8 -*-
+##
+## This file is part of Invenio.
+## Copyright (C) 2014 CERN.
+##
+## Invenio is free software; you can redistribute it and/or
+## modify it under the terms of the GNU General Public License as
+## published by the Free Software Foundation; either version 2 of the
+## License, or (at your option) any later version.
+##
+## Invenio is distributed in the hope that it will be useful, but
+## WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+## General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with Invenio; if not, write to the Free Software Foundation, Inc.,
+## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
+
+from __future__ import absolute_import
+
+from flask import request, url_for, current_app
+
+from .registry import receivers_registry
+
+
+#
+# Errors
+#
+class WebhookError(Exception):
+    """ General WebHookError """
+    pass
+
+
+class ReceiverDoesNotExists(WebhookError):
+    pass
+
+
+class InvalidPayload(WebhookError):
+    pass
+
+
+#
+# Models
+#
+class Receiver(object):
+    """
+    Base class for all receivers. A receiver is responsible for receiving and
+    extracting a payload from a request, and passing it on to a method which
+    can handle the event notification.
+    """
+    def __init__(self, fn):
+        self._callable = fn
+
+    @classmethod
+    def get(cls, receiver_id):
+        try:
+            return receivers_registry[receiver_id]
+        except KeyError:
+            raise ReceiverDoesNotExists(receiver_id)
+
+    @classmethod
+    def all(cls):
+        return receivers_registry
+
+    @classmethod
+    def register(cls, receiver_id, receiver):
+        receivers_registry[receiver_id] = receiver
+
+    @classmethod
+    def unregister(cls, receiver_id):
+        del receivers_registry[receiver_id]
+
+    @classmethod
+    def get_hook_url(cls, receiver_id, access_token):
+        cls.get(receiver_id)
+        # Allow overwriting hook URL in debug mode.
+        if current_app.debug and \
+           current_app.config.get('WEBHOOKS_DEBUG_RECEIVER_URLS', None):
+            url_pattern = current_app.config[
+                'WEBHOOKS_DEBUG_RECEIVER_URLS'].get(receiver_id, None)
+            if url_pattern:
+                return url_pattern % dict(token=access_token)
+        return url_for(
+            'receivereventlistresource',
+            receiver_id='github',
+            access_token=access_token,
+            _external=True
+        )
+
+    #
+    # Instance methods (override if needed)
+    #
+    def consume_event(self, user_id):
+        """
+        Consume a webhook event by calling the associated callable
+        """
+        event = self._create_event(user_id)
+        self._callable(event)
+
+    def _create_event(self, user_id):
+        return Event(
+            user_id,
+            payload=self.extract_payload()
+        )
+
+    def extract_payload(self):
+        """
+        Method to extract payload from request.
+        """
+        if request.content_type == 'application/json':
+            return request.get_json()
+        elif request.content_type == 'application/x-www-form-urlencoded':
+            return dict(request.form)
+        raise InvalidPayload(request.content_type)
+
+
+class CeleryReceiver(Receiver):
+    """
+    Receiver which will fire a celery task to handle payload instead of running
+    it synchronously during the request.
+    """
+    def __init__(self, task_callable, **options):
+        self._task = task_callable
+        self._options = options
+        from celery import Task
+        assert isinstance(self._task, Task)
+
+    def consume_event(self, user_id):
+        event = self._create_event(user_id)
+        self._task.apply_async(args=[event.__getstate__()], **self._options)
+
+
+class Event(object):
+    """
+    Represents webhook event data which consists of a payload and a user id.
+    """
+    def __init__(self, user_id=None, payload=None):
+        self.user_id = user_id
+        self.payload = payload
+
+    def __getstate__(self):
+        return dict(
+            user_id=self.user_id,
+            payload=self.payload,
+        )
+
+    def __setstate__(self, state):
+        self.user_id = state['user_id']
+        self.payload = state['payload']
diff --git a/invenio/modules/webhooks/registry.py b/invenio/modules/webhooks/registry.py
new file mode 100644
index 000000000..208f05602
--- /dev/null
+++ b/invenio/modules/webhooks/registry.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+##
+## This file is part of Invenio.
+## Copyright (C) 2014 CERN.
+##
+## Invenio is free software; you can redistribute it and/or
+## modify it under the terms of the GNU General Public License as
+## published by the Free Software Foundation; either version 2 of the
+## License, or (at your option) any later version.
+##
+## Invenio is distributed in the hope that it will be useful, but
+## WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+## General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with Invenio; if not, write to the Free Software Foundation, Inc.,
+## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
+
+from flask.ext.registry import DictRegistry, RegistryProxy
+
+receivers_registry = RegistryProxy('webhookext', DictRegistry)
diff --git a/invenio/modules/webhooks/restful.py b/invenio/modules/webhooks/restful.py
new file mode 100644
index 000000000..bf1991491
--- /dev/null
+++ b/invenio/modules/webhooks/restful.py
@@ -0,0 +1,104 @@
+# -*- coding: utf-8 -*-
+##
+## This file is part of Invenio.
+## Copyright (C) 2014 CERN.
+##
+## Invenio is free software; you can redistribute it and/or
+## modify it under the terms of the GNU General Public License as
+## published by the Free Software Foundation; either version 2 of the
+## License, or (at your option) any later version.
+##
+## Invenio is distributed in the hope that it will be useful, but
+## WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+## General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with Invenio; if not, write to the Free Software Foundation, Inc.,
+## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
+
+from __future__ import absolute_import
+
+from functools import wraps
+
+from flask.ext.restful import Resource, abort
+from invenio.ext.restful import require_api_auth, require_oauth_scopes
+from .models import Receiver, ReceiverDoesNotExists, InvalidPayload, \
+    WebhookError
+
+
+def error_handler(f):
+    """
+    Decorator to handle exceptions
+    """
+    @wraps(f)
+    def inner(*args, **kwargs):
+        try:
+            return f(*args, **kwargs)
+        except ReceiverDoesNotExists:
+            abort(404, message="Receiver does not exists.", status=404)
+        except InvalidPayload as e:
+            abort(
+                415,
+                message="Receiver does not support the"
+                        " content-type '%s'." % e.args[0],
+                status=415)
+        except WebhookError as e:
+            abort(
+                500,
+                message="Internal server error",
+                status=500
+            )
+    return inner
+
+#
+# Default decorators
+#
+api_decorators = [
+    require_api_auth(),
+    error_handler,
+]
+
+
+#
+# REST Resources
+#
+class ReceiverEventListResource(Resource):
+    """
+    Receiver event hook
+    """
+    method_decorators = api_decorators
+
+    def get(self, oauth, receiver_id=None):
+        abort(405)
+
+    @require_oauth_scopes('webhooks:event')
+    def post(self, oauth, receiver_id=None):
+        receiver = Receiver.get(receiver_id)
+        receiver.consume_event(oauth.access_token.user_id)
+        return {'status': 202, 'message': 'Accepted'}, 202
+
+    def put(self, oauth, receiver_id=None):
+        abort(405)
+
+    def delete(self, oauth, receiver_id=None):
+        abort(405)
+
+    def head(self, oauth, receiver_id=None):
+        abort(405)
+
+    def options(self, oauth, receiver_id=None):
+        abort(405)
+
+    def patch(self, oauth, receiver_id=None):
+        abort(405)
+
+
+#
+# Register API resources
+#
+def setup_app(app, api):
+    api.add_resource(
+        ReceiverEventListResource,
+        '/api/hooks/receivers/<string:receiver_id>/events/',
+    )
diff --git a/invenio/modules/webhooks/testsuite/__init__.py b/invenio/modules/webhooks/testsuite/__init__.py
new file mode 100644
index 000000000..0eab0f888
--- /dev/null
+++ b/invenio/modules/webhooks/testsuite/__init__.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+##
+## This file is part of Invenio.
+## Copyright (C) 2014 CERN.
+##
+## Invenio is free software; you can redistribute it and/or
+## modify it under the terms of the GNU General Public License as
+## published by the Free Software Foundation; either version 2 of the
+## License, or (at your option) any later version.
+##
+## Invenio is distributed in the hope that it will be useful, but
+## WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+## General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with Invenio; if not, write to the Free Software Foundation, Inc.,
+## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
diff --git a/invenio/modules/webhooks/testsuite/test_api.py b/invenio/modules/webhooks/testsuite/test_api.py
new file mode 100644
index 000000000..f4495e7b1
--- /dev/null
+++ b/invenio/modules/webhooks/testsuite/test_api.py
@@ -0,0 +1,166 @@
+# -*- coding: utf-8 -*-
+##
+## This file is part of Invenio.
+## Copyright (C) 2014 CERN.
+##
+## Invenio is free software; you can redistribute it and/or
+## modify it under the terms of the GNU General Public License as
+## published by the Free Software Foundation; either version 2 of the
+## License, or (at your option) any later version.
+##
+## Invenio is distributed in the hope that it will be useful, but
+## WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+## General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with Invenio; if not, write to the Free Software Foundation, Inc.,
+## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
+
+from __future__ import absolute_import
+
+from invenio.ext.sqlalchemy import db
+from invenio.ext.restful.utils import APITestCase
+
+from ..models import Receiver
+
+
+class WebHooksTestCase(APITestCase):
+    def setUp(self):
+        from invenio.modules.accounts.models import User
+        self.user = User(
+            email='info@invenio-software.org', nickname='tester'
+        )
+        self.user.password = "tester"
+        db.session.add(self.user)
+        db.session.commit()
+        self.create_oauth_token(self.user.id, scopes=["webhooks:event"])
+
+        self.called = 0
+        self.payload = None
+        self.user_id = None
+
+    def tearDown(self):
+        self.remove_oauth_token()
+        if self.user:
+            db.session.delete(self.user)
+            db.session.commit()
+
+        self.called = None
+        self.payload = None
+        self.user_id = None
+
+    def callable(self, event):
+        self.called += 1
+        self.payload = event.payload
+        self.user_id = event.user_id
+
+    def callable_wrong_signature(self):
+        self.called += 1
+
+    def test_405_methods(self):
+        methods = [
+            self.get, self.put, self.delete, self.head, self.options,
+            self.patch
+        ]
+
+        for m in methods:
+            m(
+                'receivereventlistresource',
+                urlargs=dict(receiver_id='test-receiver'),
+                code=405,
+            )
+
+    def test_webhook_post(self):
+        self.post(
+            'receivereventlistresource',
+            urlargs=dict(receiver_id='test-receiver'),
+            code=404,
+        )
+
+        r = Receiver(self.callable)
+        r_invalid = Receiver(self.callable_wrong_signature)
+
+        Receiver.register('test-receiver', r)
+        Receiver.register('test-broken-receiver', r_invalid)
+
+        payload = dict(somekey='somevalue')
+        self.post(
+            'receivereventlistresource',
+            urlargs=dict(receiver_id='test-receiver'),
+            data=payload,
+            code=202,
+        )
+
+        assert self.called == 1
+        assert self.user_id == self.user.id
+        assert self.payload == payload
+
+        # Test invalid payload
+        import pickle
+        payload = dict(somekey='somevalue')
+        self.post(
+            'receivereventlistresource',
+            urlargs=dict(receiver_id='test-receiver'),
+            data=pickle.dumps(payload),
+            is_json=False,
+            headers=[('Content-Type', 'application/python-pickle')],
+            code=415,
+        )
+
+        # Test invalid payload, with wrong content-type
+        import pickle
+        self.post(
+            'receivereventlistresource',
+            urlargs=dict(receiver_id='test-receiver'),
+            data=pickle.dumps(payload),
+            is_json=False,
+            headers=[('Content-Type', 'application/json')],
+            code=400,
+        )
+
+
+class WebHooksScopesTestCase(APITestCase):
+    def setUp(self):
+        from invenio.modules.accounts.models import User
+        self.user = User(
+            email='info@invenio-software.org', nickname='tester'
+        )
+        self.user.password = "tester"
+        db.session.add(self.user)
+        db.session.commit()
+        self.create_oauth_token(self.user.id, scopes=[""])
+
+    def tearDown(self):
+        self.remove_oauth_token()
+        if self.user:
+            db.session.delete(self.user)
+            db.session.commit()
+
+    def callable(self, event):
+        pass
+
+    def test_405_methods_no_scope(self):
+        methods = [
+            self.get, self.put, self.delete, self.head, self.options,
+            self.patch
+        ]
+
+        for m in methods:
+            m(
+                'receivereventlistresource',
+                urlargs=dict(receiver_id='test-receiver'),
+                code=405,
+            )
+
+    def test_webhook_post(self):
+        r = Receiver(self.callable)
+        Receiver.register('test-receiver-no-scope', r)
+
+        payload = dict(somekey='somevalue')
+        self.post(
+            'receivereventlistresource',
+            urlargs=dict(receiver_id='test-receiver-no-scope'),
+            data=payload,
+            code=403,
+        )
diff --git a/invenio/modules/webhooks/testsuite/test_receivers.py b/invenio/modules/webhooks/testsuite/test_receivers.py
new file mode 100644
index 000000000..a2bb55ccc
--- /dev/null
+++ b/invenio/modules/webhooks/testsuite/test_receivers.py
@@ -0,0 +1,133 @@
+# -*- coding: utf-8 -*-
+##
+## This file is part of Invenio.
+## Copyright (C) 2014 CERN.
+##
+## Invenio is free software; you can redistribute it and/or
+## modify it under the terms of the GNU General Public License as
+## published by the Free Software Foundation; either version 2 of the
+## License, or (at your option) any later version.
+##
+## Invenio is distributed in the hope that it will be useful, but
+## WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+## General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with Invenio; if not, write to the Free Software Foundation, Inc.,
+## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
+
+from __future__ import absolute_import
+
+import json
+from flask_registry import RegistryError
+from invenio.testsuite import InvenioTestCase
+
+
+from ..models import Event, Receiver, InvalidPayload, CeleryReceiver, \
+    ReceiverDoesNotExists
+
+from invenio.celery import celery
+
+
+class ReceiverTestCase(InvenioTestCase):
+    def setUp(self):
+        self.called = 0
+        self.payload = None
+        self.user_id = None
+        # Force synchronously task running
+        celery.conf['CELERY_ALWAYS_EAGER'] = True
+
+        @celery.task(ignore_result=True)
+        def test_task(event_state):
+            e = Event()
+            e.__setstate__(event_state)
+            self.called += 1
+            self.payload = e.payload
+            self.user_id = e.user_id
+
+        self.task_callable = test_task
+
+    def tearDown(self):
+        self.called = None
+        self.payload = None
+        self.user_id = None
+
+    def callable(self, event):
+        self.called += 1
+        self.payload = event.payload
+        self.user_id = event.user_id
+
+    def callable_wrong_signature(self):
+        self.called += 1
+
+    def test_receiver_registration(self):
+        r = Receiver(self.callable)
+        r_invalid = Receiver(self.callable_wrong_signature)
+
+        Receiver.register('test-receiver', r)
+        Receiver.register('test-invalid', r_invalid)
+
+        assert 'test-receiver' in Receiver.all()
+        assert Receiver.get('test-receiver') == r
+
+        # Double registration
+        self.assertRaises(RegistryError, Receiver.register, 'test-receiver', r)
+
+        Receiver.unregister('test-receiver')
+        assert 'test-receiver' not in Receiver.all()
+
+        Receiver.register('test-receiver', r)
+
+        # JSON payload parsing
+        payload = json.dumps(dict(somekey='somevalue'))
+        headers = [('Content-Type', 'application/json')]
+        with self.app.test_request_context(headers=headers, data=payload):
+            r.consume_event(2)
+            assert self.called == 1
+            assert self.payload == json.loads(payload)
+            assert self.user_id == 2
+
+            self.assertRaises(TypeError, r_invalid.consume_event, 2)
+            assert self.called == 1
+
+        # Form encoded values payload parsing
+        payload = dict(somekey='somevalue')
+        with self.app.test_request_context(method='POST', data=payload):
+            r.consume_event(2)
+            assert self.called == 2
+            assert self.payload == dict(somekey=['somevalue'])
+
+        # Test invalid post data
+        with self.app.test_request_context(method='POST', data="invaliddata"):
+            self.assertRaises(InvalidPayload, r.consume_event, 2)
+
+        # Test Celery Receiver
+        rcelery = CeleryReceiver(self.task_callable)
+        CeleryReceiver.register('celery-receiver', rcelery)
+
+        # Form encoded values payload parsing
+        payload = dict(somekey='somevalue')
+        with self.app.test_request_context(method='POST', data=payload):
+            rcelery.consume_event(1)
+
+        assert self.called == 3
+        assert self.payload == dict(somekey=['somevalue'])
+        assert self.user_id == 1
+
+    def test_unknown_receiver(self):
+        self.assertRaises(ReceiverDoesNotExists, Receiver.get, 'unknown')
+
+    def test_hookurl(self):
+        r = Receiver(self.callable)
+        Receiver.register('test-receiver', r)
+
+        assert Receiver.get_hook_url('test-receiver', 'token') != \
+            'http://test.local/?access_token=token'
+
+        self.app.config['WEBHOOKS_DEBUG_RECEIVER_URLS'] = {
+            'test-receiver': 'http://test.local/?access_token=%(token)s'
+        }
+
+        assert Receiver.get_hook_url('test-receiver', 'token') == \
+            'http://test.local/?access_token=token'
diff --git a/invenio/modules/webhooks/upgrades/__init__.py b/invenio/modules/webhooks/upgrades/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/invenio/modules/webhooks/views.py b/invenio/modules/webhooks/views.py
new file mode 100644
index 000000000..a63bc26c8
--- /dev/null
+++ b/invenio/modules/webhooks/views.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+##
+## This file is part of Invenio.
+## Copyright (C) 2014 CERN.
+##
+## Invenio is free software; you can redistribute it and/or
+## modify it under the terms of the GNU General Public License as
+## published by the Free Software Foundation; either version 2 of the
+## License, or (at your option) any later version.
+##
+## Invenio is distributed in the hope that it will be useful, but
+## WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+## General Public License for more details.
+##
+## You should have received a copy of the GNU General Public License
+## along with Invenio; if not, write to the Free Software Foundation, Inc.,
+## 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
+
+from flask import Blueprint
+
+blueprint = Blueprint(
+    'webhooks',
+    __name__,
+)
+
+
+@blueprint.before_app_first_request
+def register_scopes():
+    """
+    Register OAuth2 scopes for webhooks module
+    """
+    from invenio.modules.oauth2server.registry import scopes
+    scopes.register('webhooks:read', dict(is_public=False, desc=''))
+    scopes.register('webhooks:write', dict(is_public=False, desc=''))
+    scopes.register('webhooks:event', dict(is_public=False, desc=''))