{"id":13398886,"url":"https://github.com/zapier/django-rest-hooks","last_synced_at":"2025-10-21T19:32:38.172Z","repository":{"id":10441601,"uuid":"12608031","full_name":"zapier/django-rest-hooks","owner":"zapier","description":":love_letter: Add webhook subscriptions to your Django app.","archived":true,"fork":false,"pushed_at":"2021-01-27T00:02:34.000Z","size":93,"stargazers_count":555,"open_issues_count":17,"forks_count":131,"subscribers_count":27,"default_branch":"master","last_synced_at":"2024-04-26T03:44:41.279Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"isc","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/zapier.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE.md","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2013-09-05T03:06:07.000Z","updated_at":"2024-04-26T03:44:41.279Z","dependencies_parsed_at":"2022-08-07T05:15:48.863Z","dependency_job_id":null,"html_url":"https://github.com/zapier/django-rest-hooks","commit_stats":null,"previous_names":[],"tags_count":11,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zapier%2Fdjango-rest-hooks","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zapier%2Fdjango-rest-hooks/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zapier%2Fdjango-rest-hooks/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zapier%2Fdjango-rest-hooks/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/zapier","download_url":"https://codeload.github.com/zapier/django-rest-hooks/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":213166865,"owners_count":15546854,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-07-30T19:00:32.462Z","updated_at":"2025-10-21T19:32:32.908Z","avatar_url":"https://github.com/zapier.png","language":"Python","funding_links":[],"categories":["Python"],"sub_categories":[],"readme":"[![Travis CI Build](https://img.shields.io/travis/zapier/django-rest-hooks/master.svg)](https://travis-ci.org/zapier/django-rest-hooks)\n[![PyPI Download](https://img.shields.io/pypi/v/django-rest-hooks.svg)](https://pypi.python.org/pypi/django-rest-hooks)\n[![PyPI Status](https://img.shields.io/pypi/status/django-rest-hooks.svg)](https://pypi.python.org/pypi/django-rest-hooks)\n\n## What are Django REST Hooks?\n\n\nREST Hooks are fancier versions of webhooks. Traditional webhooks are usually\nmanaged manually by the user, but REST Hooks are not! They encourage RESTful\naccess to the hooks (or subscriptions) themselves. Add one, two or 15 hooks for\nany combination of event and URLs, then get notificatied in real-time by our\nbundled threaded callback mechanism.\n\nThe best part is: by reusing Django's great signals framework, this library is\ndead simple. Here's how to get started:\n\n1. Add `'rest_hooks'` to installed apps in settings.py.\n2. Define your `HOOK_EVENTS` in settings.py.\n3. Start sending hooks!\n\nUsing our **built-in actions**, zero work is required to support *any* basic `created`,\n`updated`, and `deleted` actions across any Django model. We also allow for\n**custom actions** (IE: beyond **C**R**UD**) to be simply defined and triggered\nfor any model, as well as truly custom events that let you send arbitrary\npayloads.\n\nBy default, this library will just POST Django's JSON serialization of a model,\nbut you can alternatively provide a `serialize_hook` method to customize payloads.\n\n*Please note:* this package does not implement any UI/API code, it only\nprovides a handy framework or reference implementation for which to build upon.\nIf you want to make a Django form or API resource, you'll need to do that yourself\n(though we've provided some example bits of code below).\n\n\n### Changelog\n\n#### Version 1.6.0:\n\nImprovements:\n\n* Default handler of `raw_hook_event` uses the same logic as other handlers\n  (see \"Backwards incompatible changes\" for details).\n\n* Lookup of event_name by model+action_name now has a complexity of `O(1)`\n  instead of `O(len(settings.HOOK_EVENTS))`\n\n* `HOOK_CUSTOM_MODEL` is now similar to `AUTH_USER_MODEL`: must be of the form\n  `app_label.model_name` (for django 1.7+). If old value is of the form\n  `app_label.models.model_name` then it's automatically adapted.\n\n* `rest_hooks.models.Hook` is now really \"swappable\", so table creation is\n  skipped if you have different `settings.HOOK_CUSTOM_MODEL`\n\n* `rest_hooks.models.AbstractHook.deliver_hook` now accepts a callable as\n  `payload_override` argument (must accept 2 arguments: hook, instance). This\n  was added to support old behavior of `raw_custom_event`.\n\nFixes:\n\n* HookAdmin.form now honors `settings.HOOK_CUSTOM_MODEL`\n\n* event_name determined from action+model is now consistent between runs (see\n  \"Backwards incompatible changes\")\n\nBackwards incompatible changes:\n\n* Dropped support for django 1.4\n* Custom `HOOK_FINDER`-s should accept and handle new argument `payload_override`.\n  Built-in finder `rest_hooks.utls.find_and_fire_hook` already does this.\n* If several event names in `settings.HOOK_EVENTS` share the same\n  `'app_label.model.action'` (including `'app_label.model.action+'`) then\n  `django.core.exceptions.ImproperlyConfigured` is raised\n* Receiver of `raw_hook_event` now uses the same logic as receivers of other\n  signals: checks event_name against settings.HOOK_EVENTS, verifies model (if\n  instance is passed), uses `HOOK_FINDER`. Old behaviour can be achieved by\n  using `trust_event_name=True`, or `instance=None` to fire a signal.\n* If you have `settings.HOOK_CUSTOM_MODEL` of the form different than\n  `app_label.models.model_name` or `app_label.model_name`, then it must\n  be changed to `app_label.model_name`.\n\n\n### Development\n\nRunning the tests for Django REST Hooks is very easy, just:\n\n```\ngit clone https://github.com/zapier/django-rest-hooks \u0026\u0026 cd django-rest-hooks\n```\n\nNext, you'll want to make a virtual environment (we recommend using virtualenvwrapper\nbut you could skip this we suppose) and then install dependencies:\n\n```\nmkvirtualenv django-rest-hooks\npip install -r devrequirements.txt\n```\n\nNow you can run the tests!\n\n```\npython runtests.py\n```\n\n### Requirements\n\n* Python 2 or 3 (tested on 2.7, 3.3, 3.4, 3.6)\n* Django 1.5+ (tested on 1.5, 1.6, 1.7, 1.8, 1.9, 1.10, 1.11, 2.0)\n\n### Installing \u0026 Configuring\n\nWe recommend pip to install Django REST Hooks:\n\n```\npip install django-rest-hooks\n```\n\nNext, you'll need to add `rest_hooks` to `INSTALLED_APPS` and configure\nyour `HOOK_EVENTS` setting:\n\n```python\n### settings.py ###\n\nINSTALLED_APPS = (\n    # other apps here...\n    'rest_hooks',\n)\n\nHOOK_EVENTS = {\n    # 'any.event.name': 'App.Model.Action' (created/updated/deleted)\n    'book.added':       'bookstore.Book.created',\n    'book.changed':     'bookstore.Book.updated+',\n    'book.removed':     'bookstore.Book.deleted',\n    # and custom events, no extra meta data needed\n    'book.read':         'bookstore.Book.read',\n    'user.logged_in':    None\n}\n\n### bookstore/models.py ###\n\nclass Book(models.Model):\n    # NOTE: it is important to have a user property\n    # as we use it to help find and trigger each Hook\n    # which is specific to users. If you want a Hook to\n    # be triggered for all users, add '+' to built-in Hooks\n    # or pass user_override=False for custom_hook events\n    user = models.ForeignKey('auth.User', on_delete=models.CASCADE)\n    # maybe user is off a related object, so try...\n    # user = property(lambda self: self.intermediary.user)\n\n    title = models.CharField(max_length=128)\n    pages = models.PositiveIntegerField()\n    fiction = models.BooleanField()\n\n    # ... other fields here ...\n\n    def serialize_hook(self, hook):\n        # optional, there are serialization defaults\n        # we recommend always sending the Hook\n        # metadata along for the ride as well\n        return {\n            'hook': hook.dict(),\n            'data': {\n                'id': self.id,\n                'title': self.title,\n                'pages': self.pages,\n                'fiction': self.fiction,\n                # ... other fields here ...\n            }\n        }\n\n    def mark_as_read(self):\n        # models can also have custom defined events\n        from rest_hooks.signals import hook_event\n        hook_event.send(\n            sender=self.__class__,\n            action='read',\n            instance=self # the Book object\n        )\n```\n\nFor the simplest experience, you'll just piggyback off the standard ORM which will\nhandle the basic `created`, `updated` and `deleted` signals \u0026 events:\n\n```python\n\u003e\u003e\u003e from django.contrib.auth.models import User\n\u003e\u003e\u003e from rest_hooks.models import Hook\n\u003e\u003e\u003e jrrtolkien = User.objects.create(username='jrrtolkien')\n\u003e\u003e\u003e hook = Hook(user=jrrtolkien,\n                event='book.added',\n                target='http://example.com/target.php')\n\u003e\u003e\u003e hook.save()     # creates the hook and stores it for later...\n\u003e\u003e\u003e from bookstore.models import Book\n\u003e\u003e\u003e book = Book(user=jrrtolkien,\n                title='The Two Towers',\n                pages=327,\n                fiction=True)\n\u003e\u003e\u003e book.save()     # fires off 'bookstore.Book.created' hook automatically\n...\n```\n\n\u003e NOTE: If you try to register an invalid event hook (not listed on HOOK_EVENTS in settings.py)\nyou will get a **ValidationError**.\n\nNow that the book has been created, `http://example.com/target.php` will get:\n\n```\nPOST http://example.com/target.php \\\n    -H Content-Type: application/json \\\n    -d '{\"hook\": {\n           \"id\":      123,\n           \"event\":   \"book.added\",\n           \"target\":  \"http://example.com/target.php\"},\n         \"data\": {\n           \"title\":   \"The Two Towers\",\n           \"pages\":   327,\n           \"fiction\": true}}'\n```\n\nYou can continue the example, triggering two more hooks in a similar method. However,\nsince we have no hooks set up for `'book.changed'` or `'book.removed'`, they wouldn't get\ntriggered anyways.\n\n```python\n...\n\u003e\u003e\u003e book.title += ': Deluxe Edition'\n\u003e\u003e\u003e book.pages = 352\n\u003e\u003e\u003e book.save()     # would fire off 'bookstore.Book.updated' hook automatically\n\u003e\u003e\u003e book.delete()   # would fire off 'bookstore.Book.deleted' hook automatically\n```\n\nYou can also fire custom events with an arbitrary payload:\n\n```python\nfrom rest_hooks.signals import raw_hook_event\n\nuser = User.objects.get(id=123)\nraw_hook_event.send(\n    sender=None,\n    event_name='user.logged_in',\n    payload={\n        'username': user.username,\n        'email': user.email,\n        'when': datetime.datetime.now().isoformat()\n    },\n    user=user # required: used to filter Hooks\n)\n```\n\n\n### How does it work?\n\nDjango has a stellar [signals framework](https://docs.djangoproject.com/en/dev/topics/signals/), all\nREST Hooks does is register to receive all `post_save` (created/updated) and `post_delete` (deleted)\nsignals. It then filters them down by:\n\n1. Which `App.Model.Action` actually have an event registered in `settings.HOOK_EVENTS`.\n2. After it verifies that a matching event exists, it searches for matching Hooks via the ORM.\n3. Any Hooks that are found for the User/event combination get sent a payload via POST.\n\n\n### How would you interact with it in the real world?\n\n**Let's imagine for a second that you've plugged REST Hooks into your API**.\nOne could definitely provide a user interface to create hooks themselves via\na standard browser \u0026 HTML based CRUD interface, but the real magic is when\nthe Hook resource is part of an API.\n\nThe basic target functionality is:\n\n```shell\nPOST http://your-app.com/api/hooks?username=me\u0026api_key=abcdef \\\n    -H Content-Type: application/json \\\n    -d '{\"target\":    \"http://example.com/target.php\",\n         \"event\":     \"book.added\"}'\n```\n\nNow, whenever a Book is created (either via an ORM, a Django form, admin, etc...),\n`http://example.com/target.php` will get:\n\n```shell\nPOST http://example.com/target.php \\\n    -H Content-Type: application/json \\\n    -d '{\"hook\": {\n           \"id\":      123,\n           \"event\":   \"book.added\",\n           \"target\":  \"http://example.com/target.php\"},\n         \"data\": {\n           \"title\":   \"Structure and Interpretation of Computer Programs\",\n           \"pages\":   657,\n           \"fiction\": false}}'\n```\n\n*It is important to note that REST Hooks will handle all of this hook\ncallback logic for you automatically.*\n\nBut you can stop it anytime you like with a simple:\n\n```\nDELETE http://your-app.com/api/hooks/123?username=me\u0026api_key=abcdef\n```\n\nIf you already have a REST API, this should be relatively straightforward,\nbut if not, Tastypie is a great choice.\n\nSome reference [Tastypie](http://tastypieapi.org/) or [Django REST framework](http://django-rest-framework.org/): + REST Hook code is below.\n\n#### Tastypie\n\n```python\n### resources.py ###\n\nfrom tastypie.resources import ModelResource\nfrom tastypie.authentication import ApiKeyAuthentication\nfrom tastypie.authorization import Authorization\nfrom rest_hooks.models import Hook\n\nclass HookResource(ModelResource):\n    def obj_create(self, bundle, request=None, **kwargs):\n        return super(HookResource, self).obj_create(bundle,\n                                                    request,\n                                                    user=request.user)\n\n    def apply_authorization_limits(self, request, object_list):\n        return object_list.filter(user=request.user)\n\n    class Meta:\n        resource_name = 'hooks'\n        queryset = Hook.objects.all()\n        authentication = ApiKeyAuthentication()\n        authorization = Authorization()\n        allowed_methods = ['get', 'post', 'delete']\n        fields = ['event', 'target']\n\n### urls.py ###\n\nfrom tastypie.api import Api\n\nv1_api = Api(api_name='v1')\nv1_api.register(HookResource())\n\nurlpatterns = patterns('',\n    (r'^api/', include(v1_api.urls)),\n)\n```\n#### Django REST framework (3.+)\n\n```python\n### serializers.py ###\n\nfrom django.conf import settings\nfrom rest_framework import serializers, exceptions\n\nfrom rest_hooks.models import Hook\n\n\nclass HookSerializer(serializers.ModelSerializer):\n    def validate_event(self, event):\n        if event not in settings.HOOK_EVENTS:\n            err_msg = \"Unexpected event {}\".format(event)\n            raise exceptions.ValidationError(detail=err_msg, code=400)\n        return event    \n    \n    class Meta:\n        model = Hook\n        fields = '__all__'\n        read_only_fields = ('user',)\n\n### views.py ###\n\nfrom rest_framework import viewsets\n\nfrom rest_hooks.models import Hook\n\nfrom .serializers import HookSerializer\n\n\nclass HookViewSet(viewsets.ModelViewSet):\n    \"\"\"\n    Retrieve, create, update or destroy webhooks.\n    \"\"\"\n    queryset = Hook.objects.all()\n    model = Hook\n    serializer_class = HookSerializer\n\n    def perform_create(self, serializer):\n        serializer.save(user=self.request.user)\n\n### urls.py ###\n\nfrom rest_framework import routers\n\nfrom . import views\n\nrouter = routers.SimpleRouter(trailing_slash=False)\nrouter.register(r'webhooks', views.HookViewSet, 'webhook')\n\nurlpatterns = router.urls\n```\n\n### Some gotchas:\n\nInstead of doing blocking HTTP requests inside of signals, we've opted\nfor a simple Threading pool that should handle the majority of use cases.\n\nHowever, if you use Celery, we'd *really* recommend using a simple task\nto handle this instead of threads. A quick example:\n\n```python\n### settings.py ###\n\nHOOK_DELIVERER = 'path.to.tasks.deliver_hook_wrapper'\n\n\n### tasks.py ###\n\nfrom celery.task import Task\n\nimport json\nimport requests\n\n\nclass DeliverHook(Task):\n    max_retries = 5\n\n    def run(self, target, payload, instance_id=None, hook_id=None, **kwargs):\n        \"\"\"\n        target:     the url to receive the payload.\n        payload:    a python primitive data structure\n        instance_id:   a possibly None \"trigger\" instance ID\n        hook_id:       the ID of defining Hook object\n        \"\"\"\n        try:\n            response = requests.post(\n                url=target,\n                data=json.dumps(payload),\n                headers={'Content-Type': 'application/json'}\n            )\n            if response.status_code \u003e= 500:\n                response.raise_for_status()\n        except requests.ConnectionError:\n            delay_in_seconds = 2 ** self.request.retries\n            self.retry(countdown=delay_in_seconds)\n\n\ndef deliver_hook_wrapper(target, payload, instance, hook):\n    # instance is None if using custom event, not built-in\n    if instance is not None:\n        instance_id = instance.id\n    else:\n        instance_id = None\n    # pass ID's not objects because using pickle for objects is a bad thing\n    kwargs = dict(target=target, payload=payload,\n                  instance_id=instance_id, hook_id=hook.id)\n    DeliverHook.apply_async(kwargs=kwargs)\n\n```\n\nWe also don't handle retries or cleanup. Generally, if you get a `410` or\na bunch of `4xx` or `5xx`, you should delete the Hook and let the user know.\n\n### Extend the Hook model:\n\nThe default `Hook` model fields can be extended using the `AbstractHook` model.\nFor example, to add a `is_active` field on your hooks:\n\n```python\n### settings.py ###\n\nHOOK_CUSTOM_MODEL = 'path.to.models.CustomHook'\n\n### models.py ###\n\nfrom django.db import models\nfrom rest_hooks.models import AbstractHook\n\nclass CustomHook(AbstractHook):\n    is_active = models.BooleanField(default=True)\n```\n\nThe extended `CustomHook` model can be combined with a the `HOOK_FINDER` setting\nfor advanced QuerySet filtering. \n\n```python\n### settings.py ###\n\nHOOK_FINDER = 'path.to.find_and_fire_hook'\n\n### utils.py ###\n\nfrom .models import CustomHook\n\ndef find_and_fire_hook(event_name, instance, **kwargs):\n    filters = {\n        'event': event_name,\n        'is_active': True,\n    }\n\n    hooks = CustomHook.objects.filter(**filters)\n    for hook in hooks:\n        hook.deliver_hook(instance)\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fzapier%2Fdjango-rest-hooks","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fzapier%2Fdjango-rest-hooks","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fzapier%2Fdjango-rest-hooks/lists"}