├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── requirements.txt ├── setup.py ├── shopify_webhook ├── __init__.py ├── decorators.py ├── helpers.py ├── signals.py ├── tests │ ├── __init__.py │ ├── test_app_proxy.py │ ├── test_webhook.py │ └── urls.py └── views.py └── test.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 15 | django: ["Django<4.0", "Django<4.2", "Django<5", "Django<5.1"] 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install "${{ matrix.django }}" 27 | pip install -r requirements.txt 28 | - name: Tests 29 | run: | 30 | python test.py 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.pyc 3 | MANIFEST 4 | build 5 | dist 6 | django_shopify_webhook.egg-info 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## Unreleased 5 | No unreleased changes. 6 | 7 | ## 0.8.0 - 2025-02-28 8 | ### Added 9 | - Add webhook_triggered_at header to request data 10 | 11 | ## 0.7.0 - 2023-10-31 12 | ### Added 13 | - Add Webhook ID argument to signals (Fixes #13) 14 | 15 | ## 0.6.0 - 2022-05-08 16 | ### Added 17 | - Add support for Django 4 18 | 19 | ### Changed 20 | - Update hmac not valid status code 21 | 22 | ### Removed 23 | - Signals: remove deprecated provided_args argument 24 | 25 | ## 0.5.1 - 2019-12-23 26 | ### Changed 27 | - Fixed long description for package upload 28 | 29 | ## 0.5.0 - 2019-12-23 30 | ### Changed 31 | - Drop support for Python 2 32 | - Update test matrix 33 | 34 | ## 0.4.0 - 2018-05-31 35 | ### Changed 36 | - Add support for Django 2 37 | - Drop test matrix support for older versions 38 | 39 | ## 0.3.1 - 2017-01-20 40 | ### Changed 41 | - Fix for Python 3.6 string compatibility 42 | 43 | ## 0.3.0 - 2016-02-10 44 | ### Changed 45 | - Better Python2/3 compatibility 46 | - Fixed packaging bug 47 | 48 | ## 0.2.6 - 2016-01-16 49 | ### Added 50 | - Initial public release. 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stlk/django-shopify-webhook/62da4587c888ec5a546cbe5686fbfef086902c8b/LICENSE -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Django Shopify Webhook 2 | ====================== 3 | 4 | [![PyPI version](https://badge.fury.io/py/django-shopify-webhook.svg)](http://badge.fury.io/py/django-shopify-webhook) 5 | [![Tests](https://github.com/discolabs/django-shopify-webhook/actions/workflows/ci.yml/badge.svg)](https://github.com/discolabs/django-shopify-webhook/actions/workflows/ci.yml) 6 | 7 | This Django package aims to make it easy to add webhook-handling behaviour into 8 | your Django app. It provides: 9 | 10 | - A `WebhookView` for catching and verifying webhooks sent from Shopify, and 11 | triggering the appropriate webhook signal. 12 | 13 | - `webhook`, `carrier_request` and `app_proxy` view decorators that validate 14 | these various types of request. 15 | 16 | - A number of `WebhookSignal`s that can be listened to and handled by your 17 | application. 18 | 19 | This packaged is maintained by [Josef Rousek](https://rousek.name/). 20 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django >=2.2 2 | setuptools >=5.7 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | version = __import__('shopify_webhook').__version__ 4 | 5 | setup( 6 | name = 'django-shopify-webhook', 7 | version = version, 8 | description = 'A package for the creation of Shopify Apps using the Embedded App SDK.', 9 | long_description = open('README.md').read(), 10 | long_description_content_type='text/markdown', 11 | author = 'Gavin Ballard', 12 | author_email = 'gavin@discolabs.com', 13 | url = 'https://github.com/stlk/django-shopify-webhook', 14 | license = 'None', 15 | 16 | packages = find_packages(), 17 | 18 | install_requires = [ 19 | 'django >=3.2', 20 | 'setuptools >=5.7' 21 | ], 22 | 23 | zip_safe = True, 24 | classifiers = [], 25 | ) 26 | -------------------------------------------------------------------------------- /shopify_webhook/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (0, 8, 0) 2 | __version__ = '.'.join(map(str, VERSION)) 3 | __author__ = 'Gavin Ballard' 4 | 5 | 6 | WEBHOOK_TOPICS = [ 7 | 'app/uninstalled', 8 | 'carts/create', 9 | 'carts/update', 10 | 'checkouts/create', 11 | 'checkouts/update', 12 | 'checkouts/delete', 13 | 'collections/create', 14 | 'collections/update', 15 | 'collections/delete', 16 | 'customer_groups/create', 17 | 'customer_groups/update', 18 | 'customer_groups/delete', 19 | 'customers/create', 20 | 'customers/disable', 21 | 'customers/delete', 22 | 'customers/enable', 23 | 'customers/update', 24 | 'disputes/create', 25 | 'disputes/update', 26 | 'fulfillments/create', 27 | 'fulfillments/update', 28 | 'orders/create', 29 | 'orders/delete', 30 | 'orders/updated', 31 | 'orders/paid', 32 | 'orders/cancelled', 33 | 'orders/fulfilled', 34 | 'orders/partially_fulfilled', 35 | 'order_transactions/create', 36 | 'products/create', 37 | 'products/update', 38 | 'products/delete', 39 | 'refunds/create', 40 | 'shop/update' 41 | ] 42 | -------------------------------------------------------------------------------- /shopify_webhook/decorators.py: -------------------------------------------------------------------------------- 1 | import json 2 | from functools import wraps 3 | 4 | from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse 5 | from django.conf import settings 6 | 7 | from .helpers import domain_is_valid, hmac_is_valid, proxy_signature_is_valid 8 | 9 | 10 | class HttpResponseMethodNotAllowed(HttpResponse): 11 | status_code = 405 12 | 13 | class HttpResponseUnauthorized(HttpResponse): 14 | status_code = 401 15 | 16 | 17 | def webhook(f): 18 | """ 19 | A view decorator that checks and validates a Shopify Webhook request. 20 | """ 21 | 22 | @wraps(f) 23 | def wrapper(request, *args, **kwargs): 24 | # Ensure the request is a POST request. 25 | if request.method != 'POST': 26 | return HttpResponseMethodNotAllowed() 27 | 28 | # Try to get required headers and decode the body of the request. 29 | try: 30 | topic = request.META['HTTP_X_SHOPIFY_TOPIC'] 31 | domain = request.META['HTTP_X_SHOPIFY_SHOP_DOMAIN'] 32 | hmac = request.META['HTTP_X_SHOPIFY_HMAC_SHA256'] if 'HTTP_X_SHOPIFY_HMAC_SHA256' in request.META else None 33 | webhook_id = request.META['HTTP_X_SHOPIFY_WEBHOOK_ID'] 34 | triggered_at = request.META['HTTP_X_SHOPIFY_TRIGGERED_AT'] 35 | data = json.loads(request.body.decode('utf-8')) 36 | except (KeyError, ValueError) as e: 37 | return HttpResponseBadRequest() 38 | 39 | # Verify the domain. 40 | if not domain_is_valid(domain): 41 | return HttpResponseBadRequest() 42 | 43 | # Verify the HMAC. 44 | if not hmac_is_valid(request.body, settings.SHOPIFY_APP_API_SECRET, hmac): 45 | return HttpResponseUnauthorized() 46 | 47 | # Otherwise, set properties on the request object and return. 48 | request.webhook_topic = topic 49 | request.webhook_data = data 50 | request.webhook_domain = domain 51 | request.webhook_id = webhook_id 52 | request.webhook_triggered_at = triggered_at 53 | return f(request, *args, **kwargs) 54 | 55 | return wrapper 56 | 57 | 58 | def carrier_request(f): 59 | """ 60 | A view decorator that checks and validates a CarrierService request from Shopify. 61 | """ 62 | 63 | @wraps(f) 64 | def wrapper(request, *args, **kwargs): 65 | # Ensure the request is a POST request. 66 | if request.method != 'POST': 67 | return HttpResponseMethodNotAllowed() 68 | 69 | # Try to get required headers and decode the body of the request. 70 | try: 71 | domain = request.META['HTTP_X_SHOPIFY_SHOP_DOMAIN'] 72 | hmac = request.META['HTTP_X_SHOPIFY_HMAC_SHA256'] if 'HTTP_X_SHOPIFY_HMAC_SHA256' in request.META else None 73 | data = json.loads(request.body) 74 | except (KeyError, ValueError) as e: 75 | return HttpResponseBadRequest() 76 | 77 | # Verify the domain. 78 | if not domain_is_valid(domain): 79 | return HttpResponseBadRequest() 80 | 81 | # Verify the HMAC. 82 | if not hmac_is_valid(request.body, settings.SHOPIFY_APP_API_SECRET, hmac): 83 | return HttpResponseForbidden() 84 | 85 | # Otherwise, set properties on the request object and return. 86 | request.carrier_request_data = data 87 | request.carrier_request_domain = domain 88 | return f(request, *args, **kwargs) 89 | 90 | return wrapper 91 | 92 | 93 | def app_proxy(f): 94 | """ 95 | A view decorator that checks and validates a Shopify Application proxy request. 96 | """ 97 | 98 | @wraps(f) 99 | def wrapper(request, *args, **kwargs): 100 | 101 | # Verify the signature. 102 | if not proxy_signature_is_valid(request, settings.SHOPIFY_APP_API_SECRET): 103 | return HttpResponseBadRequest() 104 | 105 | return f(request, *args, **kwargs) 106 | 107 | return wrapper 108 | -------------------------------------------------------------------------------- /shopify_webhook/helpers.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | import hashlib, base64, hmac 3 | 4 | 5 | def get_signal_name_for_topic(webhook_topic): 6 | """ 7 | Convert a Shopify Webhook topic (eg "orders/create") to the equivalent Pythonic method name (eg "orders_create"). 8 | """ 9 | return webhook_topic.replace('/', '_') 10 | 11 | 12 | def domain_is_valid(domain): 13 | """ 14 | Check whether the given domain is a valid source for webhook request. 15 | """ 16 | if domain is None: 17 | return False 18 | return len(domain) > 0 19 | 20 | 21 | def get_hmac(body, secret): 22 | """ 23 | Calculate the HMAC value of the given request body and secret as per Shopify's documentation for Webhook requests. 24 | See: http://docs.shopify.com/api/tutorials/using-webhooks#verify-webhook 25 | """ 26 | hash = hmac.new(secret.encode('utf-8'), body, hashlib.sha256) 27 | return base64.b64encode(hash.digest()).decode() 28 | 29 | 30 | def hmac_is_valid(body, secret, hmac_to_verify): 31 | """ 32 | Return True if the given hmac_to_verify matches that calculated from the given body and secret. 33 | """ 34 | return get_hmac(body, secret) == hmac_to_verify 35 | 36 | 37 | def get_proxy_signature(query_dict, secret): 38 | """ 39 | Calculate the signature of the given query dict as per Shopify's documentation for proxy requests. 40 | See: http://docs.shopify.com/api/tutorials/application-proxies#security 41 | """ 42 | 43 | # Sort and combine query parameters into a single string. 44 | sorted_params = '' 45 | for key in sorted(query_dict.keys()): 46 | sorted_params += "{0}={1}".format(key, ",".join(query_dict.getlist(key))) 47 | 48 | signature = hmac.new(secret.encode('utf-8'), sorted_params.encode('utf-8'), hashlib.sha256) 49 | return signature.hexdigest() 50 | 51 | 52 | def proxy_signature_is_valid(request, secret): 53 | """ 54 | Return true if the calculated signature matches that present in the query string of the given request. 55 | """ 56 | 57 | # Allow skipping of validation with an explicit setting. 58 | # If setting not present, skip if in debug mode by default. 59 | skip_validation = getattr(settings, 'SKIP_APP_PROXY_VALIDATION', settings.DEBUG) 60 | if skip_validation: 61 | return True 62 | 63 | # Create a mutable version of the GET parameters. 64 | query_dict = request.GET.copy() 65 | 66 | # Extract the signature we're going to verify. If no signature's present, the request is invalid. 67 | try: 68 | signature_to_verify = query_dict.pop('signature')[0] 69 | except KeyError: 70 | return False 71 | 72 | calculated_signature = get_proxy_signature(query_dict, secret) 73 | 74 | # Try to use compare_digest() to reduce vulnerability to timing attacks. 75 | # If it's not available, just fall back to regular string comparison. 76 | try: 77 | return hmac.compare_digest(calculated_signature.encode('utf-8'), signature_to_verify.encode('utf-8')) 78 | except AttributeError: 79 | return calculated_signature == signature_to_verify 80 | -------------------------------------------------------------------------------- /shopify_webhook/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | 4 | class WebhookSignal(Signal): 5 | """ 6 | A class wrapping Signal with the common arguments for Webhooks. 7 | Common arguments: 8 | * domain 9 | * topic 10 | * data 11 | * webhook_id 12 | * triggered_at 13 | """ 14 | pass 15 | 16 | 17 | # Define a generic webhook_received signal that triggers for all webhooks. 18 | webhook_received = WebhookSignal() 19 | 20 | 21 | # Define topic-specific signals. 22 | orders_create = WebhookSignal() 23 | orders_delete = WebhookSignal() 24 | orders_updated = WebhookSignal() 25 | orders_paid = WebhookSignal() 26 | orders_cancelled = WebhookSignal() 27 | orders_fulfilled = WebhookSignal() 28 | orders_partially_fulfilled = WebhookSignal() 29 | order_transactions_create = WebhookSignal() 30 | carts_create = WebhookSignal() 31 | carts_update = WebhookSignal() 32 | checkouts_create = WebhookSignal() 33 | checkouts_update = WebhookSignal() 34 | checkouts_delete = WebhookSignal() 35 | refunds_create = WebhookSignal() 36 | products_create = WebhookSignal() 37 | products_update = WebhookSignal() 38 | products_delete = WebhookSignal() 39 | collections_create = WebhookSignal() 40 | collections_update = WebhookSignal() 41 | collections_delete = WebhookSignal() 42 | customer_groups_create = WebhookSignal() 43 | customer_groups_update = WebhookSignal() 44 | customer_groups_delete = WebhookSignal() 45 | customers_create = WebhookSignal() 46 | customers_enable = WebhookSignal() 47 | customers_disable = WebhookSignal() 48 | customers_update = WebhookSignal() 49 | customers_delete = WebhookSignal() 50 | fulfillments_create = WebhookSignal() 51 | fulfillments_update = WebhookSignal() 52 | shop_update = WebhookSignal() 53 | disputes_create = WebhookSignal() 54 | disputes_update = WebhookSignal() 55 | app_uninstalled = WebhookSignal() 56 | -------------------------------------------------------------------------------- /shopify_webhook/tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os, sys 2 | import json 3 | 4 | from django.test import TestCase 5 | from django.urls import reverse 6 | from django.conf import settings 7 | 8 | from ..helpers import get_hmac 9 | 10 | 11 | class WebhookTestCase(TestCase): 12 | """ 13 | A base class for running tests on Shopify webhooks. Can be used by `shopify_webhook` tests here, or by other 14 | packages that utilise webhook behaviour. 15 | """ 16 | 17 | def setUp(self): 18 | """ 19 | Set up the test case, primarily by getting a reference to the webhook endpoint to be used for testing. 20 | """ 21 | super(WebhookTestCase, self).setUp() 22 | self.webhook_url = reverse('webhook') 23 | 24 | def post_shopify_webhook(self, topic = None, domain = None, data = None, webhook_id = None, headers = None, send_hmac = True): 25 | """ 26 | Simulate a webhook being sent to the application's webhook endpoint with the provided parameters. 27 | """ 28 | # Set defaults. 29 | domain = 'test.myshopify.com' if domain is None else domain 30 | data = {} if data is None else data 31 | headers = {} if headers is None else headers 32 | 33 | # Dump data as a JSON string. 34 | data = json.dumps(data) 35 | 36 | # Add required headers. 37 | headers['HTTP_X_SHOPIFY_TEST'] = 'true' 38 | headers['HTTP_X_SHOPIFY_SHOP_DOMAIN'] = domain 39 | headers['HTTP_X_SHOPIFY_TRIGGERED_AT'] = '2025-01-22T13:54:50.415605767Z' 40 | 41 | # Add optional headers. 42 | if topic: 43 | headers['HTTP_X_SHOPIFY_TOPIC'] = topic 44 | if send_hmac: 45 | headers['HTTP_X_SHOPIFY_HMAC_SHA256'] = str(get_hmac(data.encode("latin-1"), settings.SHOPIFY_APP_API_SECRET)) 46 | if webhook_id: 47 | headers['HTTP_X_SHOPIFY_WEBHOOK_ID'] = webhook_id 48 | 49 | return self.client.post(self.webhook_url, data = data, content_type = 'application/json', **headers) 50 | 51 | def read_fixture(self, name): 52 | """ 53 | Read a .json fixture with the specified name, parse it as JSON and return. 54 | Currently makes the assumption that a directory named 'fixtures' containing .json files exists and is located 55 | in the same directory as the file running the tests. 56 | """ 57 | fixture_path = "{0}/fixtures/{1}.json".format(os.path.dirname(sys.modules[self.__module__].__file__), name, format) 58 | with open(fixture_path, 'rb') as f: 59 | return json.loads(f.read()) 60 | -------------------------------------------------------------------------------- /shopify_webhook/tests/test_app_proxy.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.test.client import RequestFactory 3 | from django.http import HttpResponse, QueryDict 4 | from django.utils.decorators import method_decorator 5 | 6 | from ..decorators import app_proxy 7 | from ..helpers import get_proxy_signature 8 | 9 | 10 | class AppProxyTestCase(TestCase): 11 | 12 | def setUp(self): 13 | self.request_factory = RequestFactory() 14 | 15 | @method_decorator(app_proxy) 16 | def proxy_view(self, request, *args, **kwargs): 17 | return HttpResponse('OK') 18 | 19 | def get_proxy_request(self, signature = None): 20 | # Add common parameters to the request. 21 | data = { 22 | 'extra': ['1', '2'], 23 | 'shop': 'shop-name.myshopify.com', 24 | 'path_prefix': '/apps/awesome_reviews', 25 | 'timestamp': '1317327555' 26 | } 27 | 28 | if signature is not None: 29 | data['signature'] = signature 30 | 31 | request = self.request_factory.get('/proxy/', data = data) 32 | return request 33 | 34 | def test_signature_calculation(self): 35 | # Use the example provided in the Shopify documentation to verify our signature calculation works correctly. 36 | # See: http://docs.shopify.com/api/tutorials/application-proxies#security 37 | query_dict = QueryDict('extra=1&extra=2&shop=shop-name.myshopify.com&path_prefix=%2Fapps%2Fawesome_reviews×tamp=1317327555') 38 | shared_secret = 'hush' 39 | expected_signature = 'a9718877bea71c2484f91608a7eaea1532bdf71f5c56825065fa4ccabe549ef3' 40 | calculated_signature = get_proxy_signature(query_dict, shared_secret) 41 | self.assertEqual(calculated_signature, expected_signature) 42 | 43 | def test_missing_signature_is_bad_request(self): 44 | request = self.get_proxy_request() 45 | response = self.proxy_view(request) 46 | self.assertEqual(response.status_code, 400) 47 | 48 | def test_invalid_signature_is_bad_request(self): 49 | request = self.get_proxy_request(signature = 'invalid') 50 | response = self.proxy_view(request) 51 | self.assertEqual(response.status_code, 400) 52 | 53 | def test_valid_signature_is_ok(self): 54 | request = self.get_proxy_request(signature = 'a9718877bea71c2484f91608a7eaea1532bdf71f5c56825065fa4ccabe549ef3') 55 | response = self.proxy_view(request) 56 | self.assertEqual(response.status_code, 200) -------------------------------------------------------------------------------- /shopify_webhook/tests/test_webhook.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import receiver 2 | 3 | from ..signals import webhook_received, orders_create 4 | from . import WebhookTestCase 5 | 6 | 7 | class WebhookViewTestCase(WebhookTestCase): 8 | 9 | def test_get_method_not_allowed(self): 10 | response = self.client.get(self.webhook_url) 11 | self.assertEqual(response.status_code, 405, 'GET request returns 405 (Method Not Allowed).') 12 | 13 | def test_empty_post_message_is_bad_request(self): 14 | response = self.post_shopify_webhook() 15 | self.assertEqual(response.status_code, 400, 'Empty POST request returns 400 (Bad Request).') 16 | 17 | def test_no_hmac_is_forbidden(self): 18 | response = self.post_shopify_webhook(topic = 'orders/create', data = {'id': 123}, webhook_id = 'b54557e4-bdd9-4b37-8a5f-bf7d70bcd043', send_hmac = False) 19 | self.assertEqual(response.status_code, 401, 'POST orders/create request with no HMAC returns 401 (Forbidden).') 20 | 21 | def test_invalid_hmac_is_forbidden(self): 22 | response = self.post_shopify_webhook(topic = 'orders/create', data = {'id': 123}, webhook_id = 'b54557e4-bdd9-4b37-8a5f-bf7d70bcd043', headers = {'HTTP_X_SHOPIFY_HMAC_SHA256': 'invalid'}, send_hmac = False) 23 | self.assertEqual(response.status_code, 401, 'POST orders/create request with invalid HMAC returns 401 (Forbidden).') 24 | 25 | def test_unknown_topic_is_bad_request(self): 26 | response = self.post_shopify_webhook(topic = 'tests/invalid', data = {'id': 123}, webhook_id = 'b54557e4-bdd9-4b37-8a5f-bf7d70bcd043') 27 | self.assertEqual(response.status_code, 400, 'POST tests/invalid request with valid HMAC returns 400 (Bad Request).') 28 | 29 | def test_missing_domain_is_bad_request(self): 30 | response = self.post_shopify_webhook(topic = 'orders/create', domain = '', data = {'id': 123}, webhook_id = 'b54557e4-bdd9-4b37-8a5f-bf7d70bcd043') 31 | self.assertEqual(response.status_code, 400, 'POST orders/create request with missing domain returns 400 (Bad Request).') 32 | 33 | def test_valid_hmac_is_ok(self): 34 | response = self.post_shopify_webhook(topic = 'orders/create', data = {'id': 123}, webhook_id = 'b54557e4-bdd9-4b37-8a5f-bf7d70bcd043') 35 | self.assertEqual(response.status_code, 200, 'POST orders/create request with valid HMAC returns 200 (OK).') 36 | 37 | def test_webook_received_signal_triggered(self): 38 | data = {'id': 123456} 39 | 40 | # Create a test signal receiver for the generic webhook received signal. 41 | @receiver(webhook_received) 42 | def test_webhook_received_receiver(sender, data, **kwargs): 43 | test_webhook_received_receiver.data = data 44 | test_webhook_received_receiver.data = None 45 | 46 | response = self.post_shopify_webhook(topic = 'fulfillments/update', data = data, webhook_id = 'b54557e4-bdd9-4b37-8a5f-bf7d70bcd043') 47 | self.assertEqual(data, test_webhook_received_receiver.data, 'POST fulfillments/update correctly triggered webhook_received signal.') 48 | 49 | def test_order_created_signal_triggered(self): 50 | data = {'id': 123456} 51 | 52 | # Create a test signal receiver for the order/created topic. 53 | @receiver(orders_create) 54 | def test_order_create_receiver(sender, data, **kwargs): 55 | test_order_create_receiver.data = data 56 | test_order_create_receiver.data = None 57 | 58 | response = self.post_shopify_webhook(topic = 'orders/create', data = data, webhook_id = 'b54557e4-bdd9-4b37-8a5f-bf7d70bcd043') 59 | self.assertEqual(data, test_order_create_receiver.data, 'POST orders/create correctly triggered order_created signal.') 60 | -------------------------------------------------------------------------------- /shopify_webhook/tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from ..views import WebhookView 3 | 4 | 5 | urlpatterns = [ 6 | path('webhook/', WebhookView.as_view(), name = 'webhook'), 7 | ] 8 | -------------------------------------------------------------------------------- /shopify_webhook/views.py: -------------------------------------------------------------------------------- 1 | from django.views.generic import View, TemplateView 2 | from django.utils.decorators import method_decorator 3 | from django.views.decorators.csrf import csrf_exempt 4 | from django.http.response import HttpResponse, HttpResponseBadRequest 5 | from django.conf import settings 6 | 7 | from .decorators import webhook, app_proxy 8 | from .helpers import get_signal_name_for_topic 9 | from . import signals 10 | 11 | 12 | class WebhookView(View): 13 | """ 14 | A view to be used as the endpoint for webhook requests from Shopify. 15 | Accepts only the POST method and utilises the @webhook view decorator to validate the request. 16 | """ 17 | 18 | @method_decorator(csrf_exempt) 19 | @method_decorator(webhook) 20 | def dispatch(self, request, *args, **kwargs): 21 | """ 22 | The dispatch() method simply calls the parent dispatch method, but is required as method decorators need to be 23 | applied to the dispatch() method rather than to individual HTTP verb methods (eg post()). 24 | """ 25 | return super(WebhookView, self).dispatch(request, *args, **kwargs) 26 | 27 | def post(self, request, *args, **kwargs): 28 | """ 29 | Receive a webhook POST request. 30 | """ 31 | 32 | # Convert the topic to a signal name and trigger it. 33 | signal_name = get_signal_name_for_topic(request.webhook_topic) 34 | try: 35 | signals.webhook_received.send_robust(self, domain = request.webhook_domain, topic = request.webhook_topic, webhook_id = request.webhook_id, data = request.webhook_data, triggered_at = request.webhook_triggered_at) 36 | getattr(signals, signal_name).send_robust(self, domain = request.webhook_domain, topic = request.webhook_topic, webhook_id = request.webhook_id, data = request.webhook_data, triggered_at = request.webhook_triggered_at) 37 | except AttributeError: 38 | return HttpResponseBadRequest() 39 | 40 | # All good, return a 200. 41 | return HttpResponse('OK') 42 | 43 | 44 | class LiquidTemplateView(TemplateView): 45 | """ 46 | A view extending Django's base TemplateView that provides conveniences for returning a 47 | liquid-templated view from an app proxy request. 48 | """ 49 | 50 | content_type = getattr(settings, 'LIQUID_TEMPLATE_CONTENT_TYPE', 'application/liquid; charset=utf-8') 51 | 52 | @method_decorator(app_proxy) 53 | def dispatch(self, request, *args, **kwargs): 54 | return super(LiquidTemplateView, self).dispatch(request, *args, **kwargs) 55 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import django 3 | from django.conf import settings 4 | 5 | settings.configure( 6 | DEBUG = True, 7 | DATABASES = { 8 | 'default': { 9 | 'ENGINE': 'django.db.backends.sqlite3', 10 | } 11 | }, 12 | INSTALLED_APPS = ( 13 | 'django.contrib.auth', 14 | 'django.contrib.contenttypes', 15 | 'shopify_webhook', 16 | ), 17 | MIDDLEWARE_CLASSES = (), 18 | ROOT_URLCONF = 'shopify_webhook.tests.urls', 19 | SHOPIFY_APP_API_SECRET = 'hush', 20 | ) 21 | 22 | django.setup() 23 | 24 | from django.test.runner import DiscoverRunner 25 | 26 | test_runner = DiscoverRunner() 27 | failures = test_runner.run_tests(['shopify_webhook']) 28 | if failures: 29 | sys.exit(failures) 30 | --------------------------------------------------------------------------------