├── .gitignore ├── LICENSE ├── README.md ├── node ├── .env.template ├── Dockerfile ├── README.md ├── db │ ├── .gitignore │ └── mysql │ │ └── .gitignore ├── docker-compose.yml ├── index.js ├── package-lock.json ├── package.json ├── src │ ├── js │ │ ├── contacts-controller.js │ │ ├── db-connector.js │ │ ├── db-helper.js │ │ ├── events-service.js │ │ ├── kafka-helper.js │ │ ├── oauth-controller.js │ │ ├── utils.js │ │ ├── webhooks-controller.js │ │ └── webhooks-helper.js │ ├── public │ │ ├── css │ │ │ └── main.css │ │ ├── favicon-32x32.webp │ │ └── js │ │ │ ├── contacts.js │ │ │ └── login.js │ └── views │ │ ├── contacts.pug │ │ ├── error.pug │ │ ├── includes │ │ ├── footer.pug │ │ ├── head.pug │ │ ├── header.pug │ │ └── layout.pug │ │ └── login.pug └── tools │ └── wait-for-it.sh ├── php ├── .env.template ├── .gitignore ├── Dockerfile ├── README.md ├── composer.json ├── composer.lock ├── db │ ├── .gitignore │ └── mysql │ │ └── .gitignore ├── docker-compose.yml ├── docker │ ├── ngrok │ │ ├── .gitignore │ │ ├── Dockerfile │ │ ├── index.js │ │ ├── ngrok.yml │ │ ├── package.json │ │ └── yarn.lock │ └── supervisor │ │ └── processes.conf ├── sql │ └── base.sql └── src │ ├── Components │ └── Paginator.php │ ├── Helpers │ ├── DBClientHelper.php │ ├── HubspotClientHelper.php │ ├── KafkaHelper.php │ ├── OAuth2Helper.php │ ├── UrlHelper.php │ ├── WebhooksHelper.php │ └── functions.php │ ├── Repositories │ └── EventsRepository.php │ ├── actions │ ├── ajax │ │ └── events.php │ ├── oauth │ │ ├── authorize.php │ │ ├── callback.php │ │ └── login.php │ └── webhooks │ │ ├── delete.php │ │ ├── events.php │ │ ├── handle.php │ │ └── init.php │ ├── console │ └── webhooks │ │ └── consumer.php │ ├── public │ ├── .htaccess │ ├── css │ │ └── main.css │ ├── index.php │ └── js │ │ ├── events.js │ │ └── login.js │ ├── routes │ ├── protected.php │ └── public.php │ └── views │ ├── _partials │ ├── footer.php │ ├── header.php │ └── pagination.php │ ├── error.php │ ├── oauth │ └── login.php │ └── webhooks │ ├── events.php │ └── init.php ├── python ├── .env.template ├── .gitignore ├── README.md ├── db │ └── .gitkeep ├── docker-compose.yml ├── docker │ └── web │ │ └── Dockerfile ├── requirements.txt └── src │ ├── app.py │ ├── auth │ ├── __init__.py │ ├── auth_required.py │ └── hubspot_signature_required.py │ ├── helpers │ ├── hubspot.py │ ├── oauth.py │ ├── reverse_proxied.py │ └── webhooks.py │ ├── routes │ ├── __init__.py │ ├── events.py │ ├── init.py │ ├── oauth.py │ └── webhooks.py │ ├── services │ ├── db.py │ └── logger.py │ ├── static │ ├── js │ │ └── main.js │ └── styles │ │ └── main.css │ └── templates │ ├── events │ └── list.html │ ├── init │ └── readme.html │ ├── layout.html │ └── oauth │ └── login.html └── ruby ├── .env.template ├── .gitignore ├── .ruby-version ├── Dockerfile ├── Gemfile ├── Gemfile.lock ├── README.md ├── Rakefile ├── app ├── assets │ ├── config │ │ └── manifest.js │ ├── images │ │ └── .keep │ ├── javascripts │ │ ├── application.js │ │ ├── cable.js │ │ └── channels │ │ │ └── .keep │ └── stylesheets │ │ └── application.css ├── controllers │ ├── application_controller.rb │ ├── concerns │ │ ├── .keep │ │ └── exception_handler.rb │ ├── events_controller.rb │ ├── home_controller.rb │ ├── oauth │ │ └── authorization_controller.rb │ └── webhooks_controller.rb ├── helpers │ └── application_helper.rb ├── lib │ └── services │ │ └── hubspot │ │ ├── authorization │ │ ├── authorize.rb │ │ ├── get_authorization_uri.rb │ │ └── tokens │ │ │ ├── base.rb │ │ │ ├── generate.rb │ │ │ └── refresh.rb │ │ ├── contacts │ │ └── get_batch.rb │ │ └── webhooks │ │ ├── configure_target_url.rb │ │ ├── create_or_activate_subscription.rb │ │ ├── handle.rb │ │ └── pause_active_subscriptions.rb ├── models │ ├── application_record.rb │ ├── concerns │ │ └── .keep │ ├── event.rb │ └── token.rb └── views │ ├── events │ └── index.html.erb │ ├── home │ └── index.html.erb │ ├── layouts │ └── application.html.erb │ ├── oauth │ └── authorization │ │ └── login.html.erb │ └── shared │ └── _header.html.erb ├── bin ├── bundle ├── rails ├── rake ├── setup ├── spring ├── update └── yarn ├── config.ru ├── config ├── application.rb ├── boot.rb ├── cable.yml ├── database.yml ├── environment.rb ├── environments │ ├── development.rb │ ├── production.rb │ └── test.rb ├── initializers │ ├── assets.rb │ ├── filter_parameter_logging.rb │ └── wrap_parameters.rb ├── locales │ └── en.yml ├── puma.rb ├── routes.rb └── spring.rb ├── db ├── migrate │ ├── 20191202170347_create_tokens.rb │ └── 20200130113853_create_events.rb └── schema.rb ├── docker-compose.yml ├── docker-entrypoint.sh ├── lib ├── assets │ └── .keep └── tasks │ └── .keep ├── log └── .keep ├── package.json ├── public ├── 404.html ├── 422.html ├── 500.html ├── apple-touch-icon-precomposed.png ├── apple-touch-icon.png ├── favicon.ico ├── robots.txt └── sample.png └── tmp └── .keep /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | .env-test 4 | 5 | # Node 6 | node_modules/ 7 | 8 | #Php 9 | vendor/ 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HubSpot Webhooks sample app 2 | 3 | This is a sample app for the HubSpot [client libraries](https://developers.hubspot.com/docs/api/overview). This sample app demonstrates how to work with Hubspot webhooks. 4 | 5 | ## Reference 6 | 7 | - [Webhooks API](https://developers.hubspot.com/docs/api/webhooks) 8 | 9 | ## How to run locally 10 | 11 | 1. The first step is to [create a HubSpot developer account](https://developers.hubspot.com/docs/api/developer-tools-overview). This is where you will create and manage HubSpot apps. 12 | 2. Next [create an app](https://developers.hubspot.com/docs/api/creating-an-app). On the "App info" tab, You will be prompted to fill out some basic information about your app. This includes name, description, logo, etc. 13 | On the "Auth" tab, assign the scope `crm.objects.contacts`. 14 | 3. Copy the .env.template file into a file named .env in the folder of the language you want to use. For example: 15 | 16 | ``` 17 | cp node/.env.template node/.env 18 | ``` 19 | 20 | 4. Paste your HubSpot API Key as the value for HUBSPOT_API_KEY in .env 21 | 5. Follow the language instructions on how to run. 22 | 23 | ## Supported languages 24 | 25 | * [JavaScript (Node)](node/README.md) 26 | * [Php](php/README.md) 27 | * [Python](python/README.md) 28 | * [Ruby](ruby/README.md) 29 | -------------------------------------------------------------------------------- /node/.env.template: -------------------------------------------------------------------------------- 1 | NGROK_AUTHTOKEN= 2 | HUBSPOT_CLIENT_ID= 3 | HUBSPOT_CLIENT_SECRET= 4 | HUBSPOT_APPLICATION_ID= 5 | HUBSPOT_DEVELOPER_API_KEY= 6 | -------------------------------------------------------------------------------- /node/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.17.0 2 | 3 | WORKDIR /app 4 | COPY ./package.json /app/ 5 | COPY ./package-lock.json /app/ 6 | RUN npm install 7 | 8 | EXPOSE 3000 9 | -------------------------------------------------------------------------------- /node/README.md: -------------------------------------------------------------------------------- 1 | # HubSpot-nodejs webhooks sample app 2 | 3 | This is a sample app for the [hubspot-nodejs SDK](https://www.npmjs.com/package/@hubspot/api-client). 4 | Currently, this app focuses on demonstrating the functionality of [Webhooks API](https://developers.hubspot.com/docs/api/webhooks), contact update/creation/deletion in particular. 5 | 6 | Please note that the Webhooks events are not sent in chronological order with respect to the creation time. Events might be sent in large numbers, for example when the user imports large number of contacts or deletes a large list of contacts. 7 | The application demonstrates the use of Queues (Kafka in case of this application - see [kafka-helper.js](js/kafka-helper.js)) to process webhooks events. 8 | 9 | Common webhook processing practice consists of few steps: 10 | 11 | 1. Handle methods receive the request sent by the webook and immediately place payload on the queue [webhooks-controller.js](js/webhooks-controller.js) 12 | 2. Message consumer instance(s) is running in a separate process, typically on multiple nodes in a cloud, such as AWS [events-service.js](js/events-service.js) 13 | 3. Consumer stores webhook events in the database potentially calling an API to get full record of the object that triggered the event 14 | - This application uses MySQL, the methods working with the database can be seen in [db-helper.js](js/db-helper.js) 15 | 4. Other services/objects fetch the events data from the database sorted by timestamp of the event [db-helper.js](js/db-helper.js) 16 | 17 | ### Note on the Data Base 18 | 19 | This application uses MySQL database to store the events coming from Webhooks. There is a events table: 20 | 21 | ```SQL 22 | create table if not exists events ( 23 | id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, 24 | event_type VARCHAR(255), 25 | property_name VARCHAR(255), 26 | property_value VARCHAR(255), 27 | object_id bigint default null, 28 | event_id bigint default null, 29 | occurred_at bigint default null, 30 | shown tinyint(1) default 0, 31 | created_at datetime default CURRENT_TIMESTAMP 32 | );` 33 | ``` 34 | 35 | Please note that event_id sent by HubSpot needs to be stored as int 36 | 37 | This application uses MySQL database to store tokens info. There is a tokens table: 38 | 39 | ```SQL 40 | create table if not exists tokens ( 41 | id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, 42 | refresh_token VARCHAR(255) default null, 43 | access_token VARCHAR(255) default null, 44 | expires_in bigint default null, 45 | created_at datetime default CURRENT_TIMESTAMP, 46 | updated_at datetime default CURRENT_TIMESTAMP 47 | ); 48 | ``` 49 | 50 | This application uses MySQL database to store webhooks initialization info. There is a urls table: 51 | 52 | ```SQL 53 | create table if not exists urls ( 54 | id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, 55 | url VARCHAR(255) default null, 56 | webhooks_initialized boolean default 0 57 | ); 58 | ``` 59 | 60 | ### Setup App 61 | 62 | Make sure you have [Docker Compose](https://docs.docker.com/compose/) installed and you have [Ngrok](https://ngrok.com/) account. 63 | 64 | ### Configure 65 | 66 | 1. Copy .env.template to .env 67 | 2. Paste your HUBSPOT_CLIENT_ID, HUBSPOT_CLIENT_SECRET, HUBSPOT_APPLICATION_ID & HUBSPOT_DEVELOPER_API_KEY. 68 | 3. Paste your NGROK_AUTHTOKEN ([You can get it in your ngrok account](https://dashboard.ngrok.com/get-started/your-authtoken)) 69 | 70 | ### Running 71 | 72 | The best way to run this project (with the least configuration), is using docker compose. Change to the webroot and start it 73 | 74 | ```bash 75 | docker-compose up --build 76 | ``` 77 | 78 | Copy the Redirect URL from the console and update your application to use it. 79 | Give the change some time to propagate to the HubSpot OAuth servers. 80 | 81 | Copy Ngrok url from console and designate this on your app's Auth settings page. Now you should now be able to navigate to that url and use the application. 82 | 83 | ### Configure OAuth 84 | 85 | Required redirect URL should look like https://***.ngrok-free.app/auth/oauth-callback 86 | Every time the app is restarted you should update the redirect URL. 87 | [Learn more.](https://developers.hubspot.com/docs/api/oauth-quickstart-guide) 88 | 89 | ### NOTE about Ngrok Too Many Connections error 90 | 91 | If you are using Ngrok free plan and testing the application with large amount of import/deletions of Contacts you are likely to see Ngrok "Too Many Connections" error. 92 | This is caused by a large amount of weebhooks events being sent to Ngrok tunnel. To avoid it you can deploy sample applications on your server w/o Ngrok or upgrade to Ngrok Enterprise version 93 | 94 | ### NOTE about webhooks 95 | 96 | Application automatically configures webhooks after successful authorization: 97 | 98 | - creates if not exist `contact.creation`, `contact.deletion`, `contact.propertyChange` for `firstname` and `contact.propertyChange` for `lastname` subscriptions; 99 | - deactivates subscriptions with `eventType` different than `contact.creation`, `contact.deletion` or `contact.propertyChange`; 100 | - change subscription link to https://***.ngrok-free.app/webhooks 101 | -------------------------------------------------------------------------------- /node/db/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | !mysql 4 | -------------------------------------------------------------------------------- /node/db/mysql/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /node/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.5' 2 | 3 | services: 4 | zookeeper: 5 | container_name: node-webhooks-app-zookeeper 6 | image: zookeeper:3.8.1 7 | ports: 8 | - 2181:2181 9 | kafka: 10 | container_name: node-webhooks-app-kafka 11 | image: wurstmeister/kafka:2.13-2.8.1 12 | ports: 13 | - 9092:9092 14 | environment: 15 | KAFKA_ADVERTISED_HOST_NAME: kafka 16 | KAFKA_ADVERTISED_PORT: 9092 17 | KAFKA_CREATE_TOPICS: "events:1:1" 18 | KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 19 | volumes: 20 | - /var/run/docker.sock:/var/run/docker.sock 21 | depends_on: 22 | - zookeeper 23 | db: 24 | container_name: node-webhooks-app-db 25 | image: mysql:8.0 26 | command: mysqld --default-authentication-plugin=mysql_native_password 27 | volumes: 28 | - ./db/mysql:/var/lib/mysql 29 | ports: 30 | - 3306:3306 31 | environment: 32 | MYSQL_ROOT_PASSWORD: root 33 | MYSQL_DATABASE: events 34 | MYSQL_USER: events 35 | MYSQL_PASSWORD: events 36 | logging: 37 | driver: none 38 | 39 | webhooks_web: 40 | container_name: node-webhooks-app-web 41 | env_file: 42 | - .env 43 | environment: 44 | KAFKA_BROKER_LIST: kafka:9092 45 | KAFKA_GROUP_ID: events 46 | EVENT_TOPIC: events 47 | MYSQL_HOST: db 48 | MYSQL_DATABASE: events 49 | MYSQL_USER: events 50 | MYSQL_PASSWORD: events 51 | build: 52 | context: . 53 | dockerfile: Dockerfile 54 | volumes: 55 | - ./src/js/:/app/js/ 56 | - ./src/public/:/app/public/ 57 | - ./src/views/:/app/views/ 58 | - ./index.js:/app/index.js 59 | - ./db:/app/db 60 | - ./tools:/app/tools 61 | ports: 62 | - 3000:3000 63 | command: ./tools/wait-for-it.sh db:3306 -t 60 --strict -- npm run dev 64 | depends_on: 65 | - kafka 66 | - db 67 | -------------------------------------------------------------------------------- /node/index.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const path = require('path') 3 | const ngrok = require('ngrok') 4 | const hubspot = require('@hubspot/api-client') 5 | const express = require('express') 6 | const Promise = require('bluebird') 7 | const bodyParser = require('body-parser') 8 | const dbHelper = require('./js/db-helper') 9 | const dbConnector = require('./js/db-connector') 10 | const kafkaHelper = require('./js/kafka-helper') 11 | const eventsService = require('./js/events-service') 12 | const oauthController = require('./js/oauth-controller') 13 | const contactsController = require('./js/contacts-controller') 14 | const utils = require('./js/utils') 15 | const webhooksController = require('./js/webhooks-controller') 16 | const webhooksHelper = require('./js/webhooks-helper') 17 | 18 | const PORT = 3000 19 | const CLIENT_ID = process.env.HUBSPOT_CLIENT_ID 20 | const CLIENT_SECRET = process.env.HUBSPOT_CLIENT_SECRET 21 | const APPLICATION_ID = process.env.HUBSPOT_APPLICATION_ID 22 | const DEVELOPER_API_KEY = process.env.HUBSPOT_DEVELOPER_API_KEY 23 | const NGROK_AUTHTOKEN = process.env.NGROK_AUTHTOKEN 24 | const REFRESH_TOKEN = 'refresh_token' 25 | 26 | let hubspotClient 27 | let tokens = {} 28 | 29 | const checkEnv = (req, res, next) => { 30 | if (_.startsWith(req.url, '/error')) return next() 31 | 32 | if (_.isNil(CLIENT_ID)) return res.redirect('/error?msg=Please set HUBSPOT_CLIENT_ID env variable to proceed') 33 | if (_.isNil(CLIENT_SECRET)) 34 | return res.redirect('/error?msg=Please set HUBSPOT_CLIENT_SECRET env variable to proceed') 35 | if (_.isNil(APPLICATION_ID)) 36 | return res.redirect('/error?msg=Please set HUBSPOT_APPLICATION_ID env variable to proceed') 37 | if (_.isNil(DEVELOPER_API_KEY)) 38 | return res.redirect('/error?msg=Please set HUBSPOT_DEVELOPER_API_KEY env variable to proceed') 39 | 40 | next() 41 | } 42 | 43 | const isTokenExpired = () => { 44 | return Date.now() >= Date.parse(tokens.updated_at) + tokens.expires_in * 1000 45 | } 46 | 47 | const setupHubspotClient = async (req, res, next) => { 48 | if (_.startsWith(req.url, '/error')) return next() 49 | if (_.startsWith(req.url, '/login')) return next() 50 | 51 | if (tokens.initialized && hubspotClient && !isTokenExpired()) { 52 | req.hubspotClient = hubspotClient 53 | next() 54 | return 55 | } 56 | 57 | if (tokens.initialized && isTokenExpired()) { 58 | tokens.initialized = false 59 | } 60 | 61 | if (_.isNil(tokens.refresh_token)) { 62 | console.log('Missed tokens, check DB') 63 | tokens = (await dbHelper.getTokens()) || {} 64 | console.log('Tokens from DB:', tokens) 65 | } 66 | 67 | if (_.isNil(hubspotClient)) { 68 | console.log('Creating HubSpot api wrapper instance') 69 | hubspotClient = new hubspot.Client({ developerApiKey: DEVELOPER_API_KEY }) 70 | } 71 | 72 | req.hubspotClient = hubspotClient 73 | 74 | if (!tokens.initialized && !_.isNil(tokens.refresh_token)) { 75 | console.log('Need to initialized tokens!') 76 | 77 | if (isTokenExpired()) { 78 | console.log('HubSpot: need to refresh token') 79 | // Create OAuth 2.0 Access Token and Refresh Tokens 80 | // POST /oauth/v1/token 81 | // https://developers.hubspot.com/docs/api/intro-to-auth 82 | const result = await hubspotClient.oauth.tokensApi.create( 83 | REFRESH_TOKEN, 84 | undefined, 85 | undefined, 86 | CLIENT_ID, 87 | CLIENT_SECRET, 88 | tokens.refresh_token, 89 | ) 90 | 91 | tokens = await dbHelper.updateTokens(result) 92 | console.log('Updated tokens', tokens) 93 | } 94 | 95 | console.log('HubSpot: set access token') 96 | req.hubspotClient.setAccessToken(tokens.access_token) 97 | tokens.initialized = true 98 | console.log('Tokens are initialized') 99 | } else if (!_.startsWith(req.url, '/auth')) { 100 | console.log('Not initialized tokens!') 101 | return res.redirect('/login') 102 | } 103 | 104 | next() 105 | } 106 | 107 | const setupWebhooksSubscriptions = async (req, res, next) => { 108 | try { 109 | if (_.startsWith(req.url, '/error')) return next() 110 | if (_.startsWith(req.url, '/login')) return next() 111 | if (_.startsWith(req.url, '/auth')) return next() 112 | 113 | const urlInfo = await dbHelper.getUrlInfo() 114 | 115 | if (_.isEmpty(urlInfo)) { 116 | return res.redirect('/error?msg=Cannot get url info') 117 | } 118 | 119 | if (!urlInfo.webhooks_initialized) { 120 | await webhooksHelper.setupWebhooksSubscriptions(urlInfo.url, req.hubspotClient) 121 | await dbHelper.setWebhooksInitializedForUrl(urlInfo.url) 122 | } 123 | 124 | next() 125 | } catch (e) { 126 | console.error(e) 127 | res.redirect(`/error?msg=${e.message}`) 128 | } 129 | } 130 | 131 | const app = express() 132 | 133 | app.use(express.static('public')) 134 | app.set('view engine', 'pug') 135 | app.set('views', path.join(__dirname, 'views')) 136 | app.use( 137 | bodyParser.urlencoded({ 138 | limit: '50mb', 139 | extended: true, 140 | }), 141 | ) 142 | app.use((req, res, next) => { 143 | console.log(req.method, req.url) 144 | next() 145 | }) 146 | app.use(checkEnv) 147 | app.use(setupHubspotClient) 148 | app.use(setupWebhooksSubscriptions) 149 | app.use( 150 | bodyParser.json({ 151 | limit: '50mb', 152 | extended: true, 153 | verify: webhooksController.getWebhookVerification(), 154 | }), 155 | ) 156 | 157 | app.get('/', (req, res) => { 158 | res.redirect('/contacts') 159 | }) 160 | 161 | app.get('/login', async (req, res) => { 162 | if (tokens.initialized) return res.redirect('/') 163 | const redirectUri = `${utils.getHostUrl(req)}/auth/oauth-callback` 164 | res.render('login', {redirectUri}) 165 | }) 166 | 167 | app.use('/auth', oauthController.getRouter()) 168 | app.use('/contacts', contactsController.getRouter()) 169 | app.use('/webhooks', webhooksController.getRouter()) 170 | 171 | app.get('/error', (req, res) => { 172 | res.render('error', { error: req.query.msg }) 173 | }) 174 | 175 | app.use((error, req, res, next) => { 176 | res.render('error', { error: error.message }) 177 | }) 178 | ;(async () => { 179 | try { 180 | await dbConnector.init() 181 | await kafkaHelper.init(eventsService.getHandler()) 182 | const server = app.listen(PORT, () => { 183 | console.log(`Listening on port: ${PORT}`) 184 | return Promise.delay(100) 185 | .then(() => ngrok.connect({addr: PORT, authtoken: NGROK_AUTHTOKEN })) 186 | .tap((url) => console.log('Use %s to connect to this application.', url)) 187 | .tap((url) => console.log('Please update your app to use %s/auth/oauth-callback as Redirect URL.', url)) 188 | .then(dbHelper.saveUrl) 189 | .catch(async (e) => { 190 | console.log('Error during app start. ', e) 191 | await dbConnector.close() 192 | server.close(() => { 193 | console.log('Process terminated') 194 | }) 195 | }) 196 | }) 197 | 198 | process.on('SIGTERM', async () => { 199 | await dbConnector.close() 200 | 201 | server.close(() => { 202 | console.log('Process terminated') 203 | }) 204 | }) 205 | } catch (e) { 206 | console.log('Error during app start. ', e) 207 | } 208 | })() 209 | -------------------------------------------------------------------------------- /node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hubspot-webhooks", 3 | "version": "1.0.0", 4 | "engines": { 5 | "node": ">= 10.13.0" 6 | }, 7 | "description": "hubspot-node client sample applications", 8 | "main": "index.js", 9 | "scripts": { 10 | "start": "node index.js", 11 | "dev": "nodemon index.js" 12 | }, 13 | "keywords": [ 14 | "hubspot", 15 | "contacts", 16 | "webhooks", 17 | "sample", 18 | "example" 19 | ], 20 | "author": "hubspot", 21 | "license": "Apache-2.0", 22 | "dependencies": { 23 | "@hubspot/api-client": "9.*", 24 | "bluebird": "^3.7.2", 25 | "body-parser": "^1.20.2", 26 | "express": "^4.18.2", 27 | "if-env": "^1.0.4", 28 | "kafka-node": "^5.0.0", 29 | "lodash": "^4.17.21", 30 | "mysql": "^2.18.1", 31 | "ngrok": "^4.3.3", 32 | "pug": "^3.0.2" 33 | }, 34 | "devDependencies": { 35 | "nodemon": "^3.0.1" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /node/src/js/contacts-controller.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const express = require('express') 3 | const router = new express.Router() 4 | const dbHelper = require('./db-helper') 5 | const utils = require('./utils') 6 | 7 | const EVENTS_COUNT_PER_PAGE = 25 8 | 9 | const getEventForView = (event) => { 10 | const type = _.chain(event) 11 | .get('event_type') 12 | .split('.') 13 | .last() 14 | .value() 15 | const name = _.get(event, 'property_name') 16 | const value = _.get(event, 'property_value') 17 | 18 | return { type, name, value } 19 | } 20 | 21 | const getFullName = (contact) => { 22 | const firstName = _.get(contact, 'properties.firstname') || '' 23 | const lastName = _.get(contact, 'properties.lastname') || '' 24 | return `${firstName} ${lastName}` 25 | } 26 | 27 | const prepareContactsForView = (events, contacts) => { 28 | return _.reduce( 29 | events, 30 | (eventsForView, event) => { 31 | const contactId = _.get(event, 'object_id') 32 | 33 | if (_.isNil(eventsForView[contactId])) { 34 | const contact = _.find(contacts, { id: contactId.toString() }) 35 | const name = contact ? getFullName(contact) : 'Deleted' 36 | eventsForView[contactId] = { name, events: [] } 37 | } 38 | 39 | const eventForView = getEventForView(event) 40 | eventsForView[contactId].events.push(eventForView) 41 | return eventsForView 42 | }, 43 | {}, 44 | ) 45 | } 46 | 47 | const getPaginationConfig = async (offset) => { 48 | const totalCount = await dbHelper.getEventsCount() 49 | const pagesCount = Math.ceil(totalCount / EVENTS_COUNT_PER_PAGE) 50 | 51 | const rawPaginationConfig = _.map(Array(pagesCount), (v, index) => { 52 | const link = `/contacts/?offset=${index * EVENTS_COUNT_PER_PAGE}` 53 | const aClass = index * EVENTS_COUNT_PER_PAGE === offset ? 'active' : '' 54 | return { label: index + 1, link, aClass } 55 | }) 56 | 57 | if (rawPaginationConfig.length < 2) return [] 58 | 59 | return rawPaginationConfig.length === 2 60 | ? rawPaginationConfig 61 | : _.concat([{ label: '<<', link: '/contacts' }], rawPaginationConfig, [ 62 | { label: '>>', link: `/contacts?offset=${(pagesCount - 1) * EVENTS_COUNT_PER_PAGE}` }, 63 | ]) 64 | } 65 | 66 | exports.getRouter = () => { 67 | router.get('/', async (req, res) => { 68 | try { 69 | const offset = req.query.offset ? parseInt(req.query.offset) : 0 70 | const limit = req.query.limit ? parseInt(req.query.limit) : EVENTS_COUNT_PER_PAGE 71 | 72 | const contactIds = await dbHelper.getContactIds(offset, limit) 73 | const paginationConfig = await getPaginationConfig(offset) 74 | 75 | const batchRead = { 76 | properties: [], 77 | inputs: _.map(contactIds, (contactId) => { 78 | return { id: contactId } 79 | }), 80 | } 81 | 82 | console.log('Calling hubspotClient.crm.contacts.batchApi.read API method. Retrieve contacts.') 83 | // Get a batch of contacts by id's 84 | // POST /crm/v3/objects/contacts/batch/read 85 | // https://developers.hubspot.com/docs/api/crm/contacts 86 | const contactsResponse = await req.hubspotClient.crm.contacts.batchApi.read(batchRead) 87 | utils.logJson(contactsResponse.body) 88 | 89 | const events = await dbHelper.getEvents(contactIds) 90 | const contacts = prepareContactsForView(events, contactsResponse.results) 91 | await dbHelper.setAllWebhooksEventsShown() 92 | 93 | res.render('contacts', { contacts, paginationConfig }) 94 | } catch (e) { 95 | console.error(e) 96 | res.redirect(`/error?msg=${e.message}`) 97 | } 98 | }) 99 | 100 | return router 101 | } 102 | -------------------------------------------------------------------------------- /node/src/js/db-connector.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const mysql = require('mysql') 3 | const Promise = require('bluebird') 4 | 5 | let connection = null 6 | 7 | const MYSQL_HOST = process.env.MYSQL_HOST 8 | const MYSQL_USER = process.env.MYSQL_USER 9 | const MYSQL_DATABASE = process.env.MYSQL_DATABASE 10 | const MYSQL_PASSWORD = process.env.MYSQL_PASSWORD 11 | 12 | const EVENTS_TABLE_INIT = `create table if not exists events ( 13 | id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, 14 | event_type VARCHAR(255) default "N/A", 15 | property_name VARCHAR(255) default null, 16 | property_value VARCHAR(255) default null, 17 | object_id bigint default null, 18 | event_id bigint default null, 19 | occurred_at bigint default null, 20 | shown tinyint(1) default 0, 21 | created_at datetime default CURRENT_TIMESTAMP 22 | );` 23 | 24 | const TOKENS_TABLE_INIT = `create table if not exists tokens ( 25 | id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, 26 | refresh_token VARCHAR(255) default null, 27 | access_token VARCHAR(255) default null, 28 | expires_in bigint default null, 29 | created_at datetime default CURRENT_TIMESTAMP, 30 | updated_at datetime default CURRENT_TIMESTAMP 31 | );` 32 | 33 | const URLS_TABLE_INIT = `create table if not exists urls ( 34 | id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, 35 | url VARCHAR(255) default null, 36 | webhooks_initialized boolean default 0 37 | );` 38 | 39 | exports.init = async () => { 40 | try { 41 | connection = new mysql.createConnection({ 42 | host: MYSQL_HOST, 43 | user: MYSQL_USER, 44 | password: MYSQL_PASSWORD, 45 | database: MYSQL_DATABASE, 46 | }) 47 | 48 | connection.connectAsync = Promise.promisify(connection.connect) 49 | connection.queryAsync = Promise.promisify(connection.query) 50 | 51 | console.log('connecting to DB') 52 | await connection.connectAsync() 53 | 54 | console.log('init tables') 55 | await connection.queryAsync(EVENTS_TABLE_INIT) 56 | await connection.queryAsync(TOKENS_TABLE_INIT) 57 | await connection.queryAsync(URLS_TABLE_INIT) 58 | } catch (e) { 59 | console.error('DB is not available') 60 | console.error(e) 61 | } 62 | } 63 | 64 | exports.close = async () => { 65 | if (connection) connection.end() 66 | } 67 | 68 | exports.run = (sql) => { 69 | console.log(sql) 70 | return _.isNull(connection) ? Promise.reject(new Error('DB not initialized!')) : connection.queryAsync(sql) 71 | } 72 | -------------------------------------------------------------------------------- /node/src/js/db-helper.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const Promise = require('bluebird') 3 | const dbConnector = require('./db-connector') 4 | 5 | const GET_EVENTS_COUNT = 'select count(distinct object_id) as result from events' 6 | const GET_NEW_EVENTS_COUNT = 'select count(*) from events where shown = 0' 7 | const SET_EVENTS_SHOWN = 'update events set shown = 1 where shown = 0' 8 | const GET_TOKENS = `select * from tokens ORDER BY updated_at DESC limit 1` 9 | const GET_URL_INFO = `select * from urls ORDER BY id DESC limit 1` 10 | 11 | const getStringValueForSQL = (value) => { 12 | return _.isNil(value) ? null : `"${value}"` 13 | } 14 | 15 | module.exports = { 16 | saveUrl: (url) => { 17 | const SAVE_URL = `insert into urls (url, webhooks_initialized) values ("${url}", "0")` 18 | return dbConnector.run(SAVE_URL) 19 | }, 20 | 21 | getUrlInfo: async () => { 22 | const result = await dbConnector.run(GET_URL_INFO) 23 | return result[0] 24 | }, 25 | 26 | setWebhooksInitializedForUrl: (url) => { 27 | const UPDATE_URL = `update urls set webhooks_initialized = TRUE where url = "${url}"` 28 | return dbConnector.run(UPDATE_URL) 29 | }, 30 | 31 | getTokens: async () => { 32 | const result = await dbConnector.run(GET_TOKENS) 33 | return result[0] 34 | }, 35 | 36 | saveTokens: (tokens) => { 37 | const SAVE_TOKENS = `insert into tokens (refresh_token, access_token, expires_in) values ("${tokens.refreshToken}", "${tokens.accessToken}", ${tokens.expiresIn})` 38 | return dbConnector.run(SAVE_TOKENS) 39 | }, 40 | 41 | updateTokens: async (tokens) => { 42 | const UPDATE_TOKENS = `update tokens set access_token = '${tokens.accessToken}', updated_at = CURRENT_TIMESTAMP where refresh_token = "${tokens.refreshToken}"` 43 | const GET_TOKENS = `select * from tokens where refresh_token = "${tokens.refreshToken}"` 44 | 45 | await dbConnector.run(UPDATE_TOKENS) 46 | const result = await dbConnector.run(GET_TOKENS) 47 | return result[0] 48 | }, 49 | 50 | addEvents: (events) => { 51 | console.log(events.length) 52 | const valuesToInsert = _.chain(events) 53 | .map((event) => { 54 | const propertyName = getStringValueForSQL(event.propertyName) 55 | const propertyValue = getStringValueForSQL(event.propertyValue) 56 | return `(${event.eventId}, "${event.subscriptionType}", ${propertyName}, ${propertyValue}, ${event.objectId}, ${event.occurredAt})` 57 | }) 58 | .join(',') 59 | .value() 60 | 61 | const INSERT_EVENT_SQL = `insert into events (event_id, event_type, property_name, property_value, object_id, occurred_at) values ${valuesToInsert}` 62 | return dbConnector.run(INSERT_EVENT_SQL) 63 | }, 64 | 65 | getContactIds: async (offset, limit) => { 66 | const GET_CONTACT_IDS = `select distinct object_id from events limit ${limit} offset ${offset}` 67 | const result = await dbConnector.run(GET_CONTACT_IDS) 68 | return _.map(result, 'object_id') 69 | }, 70 | 71 | getEvents: (contactIds) => { 72 | if (_.isEmpty(contactIds)) return Promise.resolve() 73 | 74 | const GET_ALL_EVENTS = `select * from events where object_id in (${_.toString(contactIds)})` 75 | return dbConnector.run(GET_ALL_EVENTS) 76 | }, 77 | 78 | getEventsCount: async () => { 79 | const result = await dbConnector.run(GET_EVENTS_COUNT) 80 | return _.get(result, '0.result') || 0 81 | }, 82 | 83 | setAllWebhooksEventsShown: () => dbConnector.run(SET_EVENTS_SHOWN), 84 | 85 | getNewEventsCount: async () => { 86 | const QUERY_KEY = 'count(*)' 87 | const eventsCountResponse = await dbConnector.run(GET_NEW_EVENTS_COUNT) 88 | return eventsCountResponse[0][QUERY_KEY] 89 | }, 90 | } 91 | -------------------------------------------------------------------------------- /node/src/js/events-service.js: -------------------------------------------------------------------------------- 1 | const dbHelper = require('./db-helper') 2 | 3 | exports.getHandler = () => { 4 | return (message) => { 5 | const events = JSON.parse(message.value) 6 | return dbHelper.addEvents(events) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /node/src/js/kafka-helper.js: -------------------------------------------------------------------------------- 1 | const kafka = require('kafka-node') 2 | const Promise = require('bluebird') 3 | 4 | const KAFKA_EVENT_TOPIC = process.env.EVENT_TOPIC || 'events' 5 | const KAFKA_GROUP_ID = process.env.KAFKA_GROUP_ID || 'events' 6 | const KAFKA_HOST = process.env.KAFKA_BROKER_LIST 7 | 8 | let producer 9 | let consumer 10 | 11 | const initProducer = () => { 12 | return new Promise((resolve, reject) => { 13 | const Producer = kafka.Producer 14 | const producerClient = new kafka.KafkaClient({ kafkaHost: KAFKA_HOST }) 15 | const producer = new Producer(producerClient) 16 | 17 | producer.on('ready', () => { 18 | console.log('Producer ready. Refresh metadata') 19 | producerClient.refreshMetadata([KAFKA_EVENT_TOPIC], (error) => { 20 | if (error) { 21 | console.error('Producer refresh metadata error:', error) 22 | reject(error) 23 | } 24 | resolve(producer) 25 | }) 26 | }) 27 | 28 | producer.on('error', (err) => { 29 | console.log('Producer error') 30 | console.error(err) 31 | }) 32 | }) 33 | } 34 | 35 | const initConsumer = (eventsHandler) => { 36 | return new Promise((resolve, reject) => { 37 | const Consumer = kafka.Consumer 38 | const consumerClient = new kafka.KafkaClient({ kafkaHost: KAFKA_HOST }) 39 | 40 | const consumer = new Consumer(consumerClient, [{ topic: KAFKA_EVENT_TOPIC }], { 41 | groupId: KAFKA_GROUP_ID, 42 | autoCommit: true, 43 | }) 44 | 45 | consumer.on('error', (err) => { 46 | console.log('Consumer error') 47 | console.error(err) 48 | }) 49 | 50 | console.log('Consumer ready. Refresh metadata') 51 | consumerClient.refreshMetadata([KAFKA_EVENT_TOPIC], (error, data) => { 52 | if (error) { 53 | console.error('Consumer refresh metadata error', error) 54 | reject(error) 55 | } else { 56 | consumer.on('message', (message) => { 57 | console.log('Received', message) 58 | eventsHandler(message) 59 | }) 60 | resolve(consumer) 61 | } 62 | }) 63 | }) 64 | } 65 | 66 | exports.init = async (eventsHandler) => { 67 | if (!producer) { 68 | producer = await initProducer() 69 | } 70 | if (!consumer) { 71 | consumer = await initConsumer(eventsHandler) 72 | } 73 | } 74 | 75 | exports.send = (event) => { 76 | return new Promise((resolve, reject) => { 77 | console.log('Sending', event) 78 | producer.send( 79 | [ 80 | { 81 | topic: KAFKA_EVENT_TOPIC, 82 | messages: JSON.stringify(event), 83 | key: '', 84 | }, 85 | ], 86 | (error, data) => { 87 | if (error) { 88 | console.error(error) 89 | reject(error) 90 | } else resolve(data) 91 | }, 92 | ) 93 | }) 94 | } 95 | -------------------------------------------------------------------------------- /node/src/js/oauth-controller.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const express = require('express') 3 | const router = new express.Router() 4 | const dbHelper = require('./db-helper') 5 | const url = require('url') 6 | const utils = require('./utils') 7 | 8 | const SCOPE = 'crm.objects.contacts.read' 9 | const CLIENT_ID = process.env.HUBSPOT_CLIENT_ID 10 | const CLIENT_SECRET = process.env.HUBSPOT_CLIENT_SECRET 11 | const AUTHORIZATION_CODE = 'authorization_code' 12 | 13 | exports.getRouter = () => { 14 | router.get('/oauth', async (req, res) => { 15 | const redirectUri = `${utils.getHostUrl(req)}/auth/oauth-callback` 16 | // Use the client to get authorization Url 17 | // https://www.npmjs.com/package/@hubspot/api-client#obtain-your-authorization-url 18 | const authorizationUrl = req.hubspotClient.oauth.getAuthorizationUrl(CLIENT_ID, redirectUri, SCOPE) 19 | console.log('Authorization Url:', authorizationUrl) 20 | 21 | res.redirect(authorizationUrl) 22 | }) 23 | 24 | router.get('/oauth-callback', async (req, res) => { 25 | const code = _.get(req, 'query.code') 26 | const redirectUri = `${utils.getHostUrl(req)}/auth/oauth-callback` 27 | // Get OAuth 2.0 Access Token and Refresh Tokens 28 | // POST /oauth/v1/token 29 | // https://developers.hubspot.com/docs/api/working-with-oauth 30 | console.log('Retrieving access token by code:', code) 31 | const getTokensResponse = await req.hubspotClient.oauth.tokensApi.create( 32 | AUTHORIZATION_CODE, 33 | code, 34 | redirectUri, 35 | CLIENT_ID, 36 | CLIENT_SECRET, 37 | ) 38 | await dbHelper.saveTokens(getTokensResponse) 39 | res.redirect('/') 40 | }) 41 | 42 | return router 43 | } 44 | -------------------------------------------------------------------------------- /node/src/js/utils.js: -------------------------------------------------------------------------------- 1 | const url = require('url') 2 | 3 | exports.logJson = (data) => { 4 | console.log('Response', JSON.stringify(data, null, 2)) 5 | } 6 | 7 | exports.getHostUrl = (req) => { 8 | return url.format({ 9 | protocol: 'https', 10 | hostname: req.get('host'), 11 | }) 12 | } -------------------------------------------------------------------------------- /node/src/js/webhooks-controller.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const router = new express.Router() 3 | const dbHelper = require('./db-helper') 4 | const hubspot = require('@hubspot/api-client') 5 | 6 | const utils = require('./utils') 7 | const kafkaHelper = require('./kafka-helper') 8 | 9 | const SIGNATURE_HEADER = 'X-HubSpot-Signature' 10 | const SIGNATURE_VERSION_HEADER = 'X-HubSpot-Signature-Version' 11 | 12 | exports.getRouter = () => { 13 | router.post('/', async (req, res) => { 14 | const events = req.body 15 | 16 | console.log('Received hook events:') 17 | utils.logJson(events) 18 | await kafkaHelper.send(events) 19 | res.sendStatus(200) 20 | }) 21 | 22 | router.get('/new', async (req, res) => { 23 | const notShownEventsCount = await dbHelper.getNewEventsCount() 24 | res.status(200).jsonp({ notShownEventsCount }) 25 | }) 26 | 27 | return router 28 | } 29 | 30 | exports.getWebhookVerification = () => { 31 | return async (req, res, buf) => { 32 | const originalUrl = req.originalUrl 33 | 34 | if (originalUrl !== '/webhooks') return 35 | 36 | const urlInfo = await dbHelper.getUrlInfo() 37 | const webhooksUrl = `${urlInfo.url}${req.originalUrl}` 38 | 39 | try { 40 | const requestBody = buf.toString() 41 | const signature = req.header(SIGNATURE_HEADER) 42 | const clientSecret = process.env.HUBSPOT_CLIENT_SECRET 43 | const signatureVersion = req.header(SIGNATURE_VERSION_HEADER) 44 | 45 | if ( 46 | hubspot.Signature.isValid({ 47 | signature, 48 | clientSecret, 49 | requestBody, 50 | signatureVersion, 51 | url: webhooksUrl, 52 | method: req.method 53 | }) 54 | ) 55 | return 56 | } catch (e) { 57 | console.log(e) 58 | } 59 | 60 | throw new Error('Unauthorized webhook or error with request processing!') 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /node/src/js/webhooks-helper.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const Promise = require('bluebird') 3 | const utils = require('./utils') 4 | 5 | const APPLICATION_ID = process.env.HUBSPOT_APPLICATION_ID 6 | const WEBHOOKS_SUBSCRIPTIONS = [ 7 | { 8 | eventType: 'contact.propertyChange', 9 | propertyName: 'firstname', 10 | }, 11 | { 12 | eventType: 'contact.propertyChange', 13 | propertyName: 'lastname', 14 | }, 15 | { 16 | eventType: 'contact.creation', 17 | }, 18 | { 19 | eventType: 'contact.deletion', 20 | }, 21 | ] 22 | 23 | let hubspotClient 24 | 25 | const getAllWebhooksSubscriptions = async () => { 26 | console.log( 27 | 'Calling hubspotClient.webhooks.subscriptionsApi.getAll API method. Retrieve all webhooks subscriptions.', 28 | ) 29 | // Retrieve all webhooks subscriptions for application 30 | // GET /webhooks/v3/{appId}/subscriptions 31 | // https://developers.hubspot.com/docs/api/webhooks 32 | const response = await hubspotClient.webhooks.subscriptionsApi.getAll(APPLICATION_ID) 33 | utils.logJson(response) 34 | 35 | return response.results 36 | } 37 | 38 | const createWebhooksSubscription = async (webhooksSubscription) => { 39 | console.log('Calling hubspotClient.webhooks.subscriptionsApi.create API method. Create webhooks subscription.') 40 | // Create webhooks subscription 41 | // POST /webhooks/v3/{appId}/subscriptions 42 | // https://developers.hubspot.com/docs/api/webhooks 43 | const contactsResponse = await hubspotClient.webhooks.subscriptionsApi.create(APPLICATION_ID, webhooksSubscription) 44 | utils.logJson(contactsResponse) 45 | 46 | return contactsResponse 47 | } 48 | 49 | const updateAllWebhooksSubscriptions = async (webhooksSubscriptions, active) => { 50 | const inputs = _.map(webhooksSubscriptions, (webhooksSubscription) => ({ id: webhooksSubscription.id, active })) 51 | 52 | if (_.isEmpty(inputs)) { 53 | return Promise.resolve() 54 | } 55 | 56 | console.log('Calling hubspotClient.webhooks.subscriptionsApi.updateBatch API method. Update webhooks subscription.') 57 | // Update webhooks subscriptions 58 | // POST /webhooks/v3/{appId}/subscriptions/batch/update 59 | // https://developers.hubspot.com/docs/api/webhooks 60 | const response = await hubspotClient.webhooks.subscriptionsApi.updateBatch(APPLICATION_ID, { inputs }) 61 | utils.logJson(response) 62 | } 63 | 64 | const configureWebhooksSubscriptionsSettings = async (targetUrl) => { 65 | console.log( 66 | 'Calling hubspotClient.webhooks.settingsApi.configure API method. Configure webhooks subscriptions settings.', 67 | ) 68 | // Configure webhooks subscriptions settings 69 | // POST /webhooks/v3/{appId}/settings 70 | // https://developers.hubspot.com/docs/api/webhooks 71 | const response = await hubspotClient.webhooks.settingsApi.configure(APPLICATION_ID, { targetUrl }) 72 | utils.logJson(response) 73 | } 74 | 75 | const getWebhooksSubscriptionsToActivate = (allWebhooksSubscriptions) => 76 | _.filter(allWebhooksSubscriptions, (webhooksSubscription) => 77 | _.find(WEBHOOKS_SUBSCRIPTIONS, { eventType: webhooksSubscription.eventType }), 78 | ) 79 | 80 | const getWebhooksSubscriptionsToDeActivate = (allWebhooksSubscriptions) => _.filter(allWebhooksSubscriptions, 'active') 81 | 82 | const getWebhooksSubscriptionsToCreate = (allWebhooksSubscriptions) => 83 | WEBHOOKS_SUBSCRIPTIONS.filter( 84 | (webhooksSubscription) => !_.find(allWebhooksSubscriptions, webhooksSubscription), 85 | ).map((webhooksSubscription) => _.assign({}, webhooksSubscription, { active: true })) 86 | 87 | const createNotExistedWebhooksSubscriptions = (allWebhooksSubscriptions) => { 88 | const webhooksSubscriptionsToCreate = getWebhooksSubscriptionsToCreate(allWebhooksSubscriptions) 89 | 90 | return Promise.map(webhooksSubscriptionsToCreate, createWebhooksSubscription) 91 | } 92 | 93 | const setupClient = (client) => { 94 | hubspotClient = client 95 | } 96 | 97 | exports.setupWebhooksSubscriptions = async (url, hubspotClient) => { 98 | console.log('Started Webhooks Subscriptions setup') 99 | setupClient(hubspotClient) 100 | const allWebhooksSubscriptions = await getAllWebhooksSubscriptions() 101 | const webhooksSubscriptionsToDeActivate = getWebhooksSubscriptionsToDeActivate(allWebhooksSubscriptions) 102 | await updateAllWebhooksSubscriptions(webhooksSubscriptionsToDeActivate, false) 103 | await configureWebhooksSubscriptionsSettings(`${url}/webhooks`) 104 | const webhooksSubscriptionsToActivate = getWebhooksSubscriptionsToActivate(allWebhooksSubscriptions) 105 | await updateAllWebhooksSubscriptions(webhooksSubscriptionsToActivate, true) 106 | await createNotExistedWebhooksSubscriptions(allWebhooksSubscriptions) 107 | console.log('Finished Webhooks Subscriptions setup') 108 | } 109 | -------------------------------------------------------------------------------- /node/src/public/css/main.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding-bottom: 2rem; 3 | padding-top: 2rem; 4 | } 5 | 6 | .navigation { 7 | background: #f4f5f6; 8 | border-bottom: .1rem solid #d1d1d1; 9 | display: block; 10 | height: 5.2rem; 11 | left: 0; 12 | max-width: 100%; 13 | width: 100%; 14 | } 15 | 16 | .navigation .container { 17 | padding-bottom: 0; 18 | padding-top: 0 19 | } 20 | 21 | .navigation .navigation-list { 22 | list-style: none; 23 | margin-bottom: 0; 24 | } 25 | 26 | .navigation .navigation-item { 27 | float: left; 28 | margin-bottom: 0; 29 | margin-left: 2.5rem; 30 | position: relative 31 | } 32 | 33 | .navigation .navigation-title, .navigation .title { 34 | color: #606c76; 35 | position: relative 36 | } 37 | 38 | .navigation .navigation-link, .navigation .navigation-title, .navigation .title { 39 | display: inline; 40 | font-size: 1.6rem; 41 | line-height: 5.2rem; 42 | padding: 0; 43 | text-decoration: none 44 | } 45 | 46 | .navigation .navigation-link.active { 47 | color: #606c76 48 | } 49 | 50 | .authorize-button { 51 | text-align: center; 52 | } 53 | 54 | .event.deletion { 55 | color: white; 56 | background-color: red; 57 | } 58 | 59 | .event.creation { 60 | color: #3c763d; 61 | background-color: #dff0d8; 62 | } 63 | 64 | .event.propertyChange { 65 | background-color: #d8aa65; 66 | color: #ffffff; 67 | } 68 | 69 | .event.propertyChange span:first-child { 70 | background-color: #08080885; 71 | } 72 | 73 | .alert-not-shown-events { 74 | display: none; 75 | } 76 | 77 | .pagination { 78 | flex-wrap: wrap; 79 | align-items: center; 80 | justify-content: center; 81 | padding-bottom: 1rem; 82 | font-size: 1.5rem; 83 | font-weight: bold; 84 | } 85 | .pagination a { 86 | border: 0.1rem solid #9b4dca; 87 | padding: 0.5rem 1.5rem; 88 | border-radius: 0.4rem; 89 | } 90 | .pagination a.active { 91 | background-color: #9b4dca; 92 | color: #fff; 93 | } 94 | 95 | .text-center { 96 | text-align: center; 97 | } 98 | -------------------------------------------------------------------------------- /node/src/public/favicon-32x32.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HubSpot/sample-apps-webhooks/4976692a89a15d66a4556555496b39c69db537a3/node/src/public/favicon-32x32.webp -------------------------------------------------------------------------------- /node/src/public/js/contacts.js: -------------------------------------------------------------------------------- 1 | function requestNotShownEventsCount() { 2 | return new Promise((resolve) => { 3 | $.getJSON("/webhooks/new", (data) => { 4 | const { notShownEventsCount } = data; 5 | resolve(notShownEventsCount); 6 | }); 7 | }); 8 | } 9 | 10 | async function displayNotShownEventsAlertIfNeed() { 11 | const notShownEventsCount = await requestNotShownEventsCount(); 12 | if (notShownEventsCount > 0) { 13 | $('.alert-not-shown-events').show(); 14 | } 15 | } 16 | 17 | $(document).ready(() => { 18 | $('.alert-not-shown-events').click(() => { 19 | document.location.reload(); 20 | return false; 21 | }); 22 | 23 | setInterval(displayNotShownEventsAlertIfNeed, 10000); 24 | }); 25 | -------------------------------------------------------------------------------- /node/src/public/js/login.js: -------------------------------------------------------------------------------- 1 | document.getElementById("copyBtn").onclick = async() => { 2 | let text = document.getElementById('redirectURL').textContent 3 | await navigator.clipboard.writeText(text) 4 | 5 | alert('Copied') 6 | } 7 | -------------------------------------------------------------------------------- /node/src/views/contacts.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | 3 | block scripts 4 | script(type='application/javascript' src='/js/contacts.js') 5 | 6 | block content 7 | .container 8 | h3(class='alert-not-shown-events') New webhooks are received. 9 | =' ' 10 | a(href='#') Reload the page to see updates 11 | table 12 | thead 13 | tr 14 | th ContactID 15 | th Name 16 | th Events 17 | 18 | tbody 19 | each contact, contactId in contacts 20 | tr 21 | td #{contactId} 22 | td #{contact.name} 23 | td 24 | each hookEvent in contact.events 25 | .row 26 | span(class=`event ${hookEvent.type}`) #{hookEvent.type} 27 | = ' ' 28 | span #{hookEvent.name} 29 | = ' ' 30 | span #{hookEvent.value} 31 | = ' ' 32 | if Object.keys(contacts).length === 0 33 | span There are no HubSpot webhooks events yet 34 | .row(class='pagination') 35 | each page in paginationConfig 36 | a(href=page.link class=page.aClass) #{page.label} 37 | -------------------------------------------------------------------------------- /node/src/views/error.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | block content 3 | .container 4 | h4 Error 5 | p #{error} 6 | -------------------------------------------------------------------------------- /node/src/views/includes/footer.pug: -------------------------------------------------------------------------------- 1 | block footerScripts 2 | .footer 3 | .container 4 | -------------------------------------------------------------------------------- /node/src/views/includes/head.pug: -------------------------------------------------------------------------------- 1 | head 2 | meta(charset='UTF-8') 3 | meta(name='description' content='HubSpot JavaScript Sample Webhooks') 4 | title HubSpot JavaScript Sample Webhooks 5 | link(rel='stylesheet' href='//fonts.googleapis.com/css?family=Roboto:300,300italic,700,700italic') 6 | link(rel='stylesheet' href='//cdnjs.cloudflare.com/ajax/libs/normalize/5.0.0/normalize.css') 7 | link(rel='stylesheet' href='//cdnjs.cloudflare.com/ajax/libs/milligram/1.3.0/milligram.css') 8 | link(rel='stylesheet' href='/css/main.css') 9 | // Fav Icon 10 | link(href='/favicon-32x32.webp' rel='shortcut icon') 11 | link(href='/favicon-32x32.webp' rel='apple-touch-icon') 12 | script(type='application/javascript' src='https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js') 13 | block scripts 14 | -------------------------------------------------------------------------------- /node/src/views/includes/header.pug: -------------------------------------------------------------------------------- 1 | header 2 | .navigation 3 | .container 4 | a(class='navigation-title' href='/') 5 | h3(class='title') HubSpot JavaScript Sample Webhooks 6 | 7 | ul(class='navigation-list float-right') 8 | li(class='navigation-item') 9 | a(class='navigation-link' href='/login') OAuth2 10 | -------------------------------------------------------------------------------- /node/src/views/includes/layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang='en') 3 | include head 4 | body 5 | main(class='wrapper') 6 | include header 7 | block content 8 | include footer 9 | -------------------------------------------------------------------------------- /node/src/views/login.pug: -------------------------------------------------------------------------------- 1 | extends includes/layout 2 | 3 | block footerScripts 4 | script(type='application/javascript' src='/js/login.js') 5 | 6 | block content 7 | .container.text-center 8 | h3 In order to continue please update the redirect URL on Auth settings page of your app 9 | h4 Redirect URL 10 | pre#redirectURL #{redirectUri} 11 | button.button-primary#copyBtn Copy 12 | h3 After that authorize via OAuth 13 | .authorize-button 14 | a(class='button' href='/auth/oauth') Authorize 15 | -------------------------------------------------------------------------------- /node/tools/wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | 4 | WAITFORIT_cmdname=${0##*/} 5 | 6 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 7 | 8 | usage() 9 | { 10 | cat << USAGE >&2 11 | Usage: 12 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 13 | -h HOST | --host=HOST Host or IP under test 14 | -p PORT | --port=PORT TCP port under test 15 | Alternatively, you specify the host and port as host:port 16 | -s | --strict Only execute subcommand if the test succeeds 17 | -q | --quiet Don't output any status messages 18 | -t TIMEOUT | --timeout=TIMEOUT 19 | Timeout in seconds, zero for no timeout 20 | -- COMMAND ARGS Execute command with args after the test finishes 21 | USAGE 22 | exit 1 23 | } 24 | 25 | wait_for() 26 | { 27 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 28 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 29 | else 30 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 31 | fi 32 | WAITFORIT_start_ts=$(date +%s) 33 | while : 34 | do 35 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 36 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 37 | WAITFORIT_result=$? 38 | else 39 | (echo > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 40 | WAITFORIT_result=$? 41 | fi 42 | if [[ $WAITFORIT_result -eq 0 ]]; then 43 | WAITFORIT_end_ts=$(date +%s) 44 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 45 | break 46 | fi 47 | sleep 1 48 | done 49 | return $WAITFORIT_result 50 | } 51 | 52 | wait_for_wrapper() 53 | { 54 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 55 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 56 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 57 | else 58 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 59 | fi 60 | WAITFORIT_PID=$! 61 | trap "kill -INT -$WAITFORIT_PID" INT 62 | wait $WAITFORIT_PID 63 | WAITFORIT_RESULT=$? 64 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 65 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 66 | fi 67 | return $WAITFORIT_RESULT 68 | } 69 | 70 | # process arguments 71 | while [[ $# -gt 0 ]] 72 | do 73 | case "$1" in 74 | *:* ) 75 | WAITFORIT_hostport=(${1//:/ }) 76 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 77 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 78 | shift 1 79 | ;; 80 | --child) 81 | WAITFORIT_CHILD=1 82 | shift 1 83 | ;; 84 | -q | --quiet) 85 | WAITFORIT_QUIET=1 86 | shift 1 87 | ;; 88 | -s | --strict) 89 | WAITFORIT_STRICT=1 90 | shift 1 91 | ;; 92 | -h) 93 | WAITFORIT_HOST="$2" 94 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 95 | shift 2 96 | ;; 97 | --host=*) 98 | WAITFORIT_HOST="${1#*=}" 99 | shift 1 100 | ;; 101 | -p) 102 | WAITFORIT_PORT="$2" 103 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 104 | shift 2 105 | ;; 106 | --port=*) 107 | WAITFORIT_PORT="${1#*=}" 108 | shift 1 109 | ;; 110 | -t) 111 | WAITFORIT_TIMEOUT="$2" 112 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 113 | shift 2 114 | ;; 115 | --timeout=*) 116 | WAITFORIT_TIMEOUT="${1#*=}" 117 | shift 1 118 | ;; 119 | --) 120 | shift 121 | WAITFORIT_CLI=("$@") 122 | break 123 | ;; 124 | --help) 125 | usage 126 | ;; 127 | *) 128 | echoerr "Unknown argument: $1" 129 | usage 130 | ;; 131 | esac 132 | done 133 | 134 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 135 | echoerr "Error: you need to provide a host and port to test." 136 | usage 137 | fi 138 | 139 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} 140 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 141 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 142 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 143 | 144 | # check to see if timeout is from busybox? 145 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 146 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 147 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 148 | WAITFORIT_ISBUSY=1 149 | WAITFORIT_BUSYTIMEFLAG="-t" 150 | 151 | else 152 | WAITFORIT_ISBUSY=0 153 | WAITFORIT_BUSYTIMEFLAG="" 154 | fi 155 | 156 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 157 | wait_for 158 | WAITFORIT_RESULT=$? 159 | exit $WAITFORIT_RESULT 160 | else 161 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 162 | wait_for_wrapper 163 | WAITFORIT_RESULT=$? 164 | else 165 | wait_for 166 | WAITFORIT_RESULT=$? 167 | fi 168 | fi 169 | 170 | if [[ $WAITFORIT_CLI != "" ]]; then 171 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 172 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 173 | exit $WAITFORIT_RESULT 174 | fi 175 | exec "${WAITFORIT_CLI[@]}" 176 | else 177 | exit $WAITFORIT_RESULT 178 | fi 179 | -------------------------------------------------------------------------------- /php/.env.template: -------------------------------------------------------------------------------- 1 | HUBSPOT_APPLICATION_ID= 2 | HUBSPOT_CLIENT_ID= 3 | HUBSPOT_CLIENT_SECRET= 4 | HUBSPOT_DEVELOPER_API_KEY= 5 | NGROK_AUTHTOKEN= 6 | -------------------------------------------------------------------------------- /php/.gitignore: -------------------------------------------------------------------------------- 1 | db/* 2 | .php-cs-fixer.cache 3 | -------------------------------------------------------------------------------- /php/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.0.14-apache 2 | 3 | ENV APACHE_DOCUMENT_ROOT=/app/src/public 4 | RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf 5 | RUN sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf 6 | RUN a2enmod rewrite 7 | 8 | RUN docker-php-ext-install mysqli pdo pdo_mysql 9 | RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer 10 | RUN apt-get update && apt-get install -y git zip unzip 11 | 12 | RUN mkdir -p /app 13 | ADD ./composer.json /app 14 | ADD ./composer.lock /app 15 | 16 | WORKDIR /app 17 | RUN /usr/local/bin/composer install 18 | 19 | RUN apt-get install -y supervisor 20 | COPY ./docker/supervisor/processes.conf /etc/supervisor/conf.d/apache.conf 21 | CMD ["/usr/bin/supervisord"] 22 | -------------------------------------------------------------------------------- /php/README.md: -------------------------------------------------------------------------------- 1 | # HubSpot-php sample Webhooks app 2 | 3 | The application demonstrates the use of Queues (Kafka in case of this application - see KafkaHelper.php) to process webhooks events. 4 | 5 | Please note that the Webhooks events are not sent in chronological order with respect to the creation time. Events might be sent in large numbers, for example when the user imports large number of contacts or deletes a large list of contacts. 6 | 7 | Common webhook processing practice consists of few steps: 8 | 9 | 1. Handle methods receive the request sent by the webook and immediately place payload on the queue handle.php 10 | 2. Message consumer instance(s) is running in a separate process, typically on multiple nodes in a cloud, such as AWS сonsumer.php 11 | 3. Consumer stores webhook events in the database potentially calling an API to get full record of the object that triggered the event 12 | - This application uses MySQL, the methods working with the database can be seen in EventsRepository.php 13 | 4. Other services/objects fetch the events data from the database sorted by timestamp of the event EventsRepository.php 14 | 15 | Please see the documentation on [Creating an app in HubSpot](https://developers.hubspot.com/docs-beta/creating-an-app) 16 | 17 | ### HubSpot Public API links used in this application 18 | 19 | - [Read a batch of contact objects by ID](https://developers.hubspot.com/docs-beta/crm/contacts) 20 | 21 | ### Note on the Data Base 22 | 23 | This application uses MySQL database to store the events coming from Webhooks. There is a single events table: 24 | 25 | ```SQL 26 | create table if not exists events 27 | ( 28 | id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, 29 | event_type VARCHAR(255), 30 | object_id int default null, 31 | event_id bigint default null, 32 | occurred_at bigint default null, 33 | propertyName varchar(255) default null, 34 | propertyValue varchar(255) default null, 35 | created_at datetime default CURRENT_TIMESTAMP 36 | ); 37 | ``` 38 | 39 | Please note that event_id sent by HubSpot needs to be stored as int 40 | 41 | ### Setup App 42 | 43 | Make sure you have [Docker Compose](https://docs.docker.com/compose/) installed and you have [Ngrok](https://ngrok.com/) account. 44 | 45 | ### Configure 46 | 47 | 1. Copy .env.template to .env 48 | 2. Paste your HUBSPOT_CLIENT_ID, HUBSPOT_CLIENT_SECRET, HUBSPOT_APPLICATION_ID & HUBSPOT_DEVELOPER_API_KEY. 49 | 3. Paste your NGROK_AUTHTOKEN ([You can get it in your ngrok account](https://dashboard.ngrok.com/get-started/your-authtoken)) 50 | 51 | ### Running 52 | 53 | The best way to run this project (with the least configuration), is using docker compose. Change to the webroot and start it 54 | 55 | ```bash 56 | docker-compose up --build 57 | ``` 58 | 59 | Copy Ngrok url from console and designate this on your app's Auth settings page. Now you should now be able to navigate to that url and use the application. 60 | 61 | ### Configure OAuth 62 | 63 | Required redirect URL should look like https://***.ngrok-free.app/oauth/callback 64 | Every time the app is restarted you should update the redirect URL. 65 | [Learn more.](https://developers.hubspot.com/docs/api/oauth-quickstart-guide) 66 | 67 | ### NOTE about Ngrok Too Many Connections error 68 | 69 | If you are using Ngrok free plan and testing the application with large amount of import/deletions of Contacts you are likely to see Ngrok "Too Many Connections" error. 70 | This is caused by a large amount of weebhooks events being sent to Ngrok tunnel. To avoid it you can deploy sample applications on your server w/o Ngrok or upgrade to Ngrok Enterprise version 71 | 72 | ### Configure webhooks 73 | 74 | Required webhooks url should look like https://***.ngrok-free.app/webhooks/handle 75 | 76 | Following [Webhooks Setup](https://developers.hubspot.com/docs/methods/webhooks/webhooks-overview) guide please note: 77 | 78 | - Every time the app is restarted you should update the webhooks url 79 | - The app supports `contact.creation` and `contact.deletion` subscription types only 80 | - Subscription are paused by default. You need to activate them manually after creating 81 | 82 | ### HubSpot Signature 83 | 84 | To help improve security, HubSpot webhooks are sent with signature so you can verify that it came from HubSpot. This sample application shows how to do that verification. You can read more about validation in general here: https://developers.hubspot.com/docs/api/webhooks/validating-requests. 85 | The source code for validating webhooks is at [HubSpot\Utils\Webhooks](../../lib/Utils/Webhooks.php) and [an usage example](./src/actions/webhooks/handle.php) 86 | -------------------------------------------------------------------------------- /php/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "minimum-stability": "stable", 3 | "require": { 4 | "hubspot/api-client": "*", 5 | "ext-curl": "*", 6 | "ext-json": "*", 7 | "ext-sqlite3": "*", 8 | "nmred/kafka-php": "0.2.*", 9 | "ext-pdo": "*", 10 | "byjg/migration": "4.*" 11 | }, 12 | "autoload": { 13 | "files": [ 14 | "src/Helpers/functions.php" 15 | ], 16 | "psr-4": { 17 | "": "src/" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /php/db/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | !mysql 4 | -------------------------------------------------------------------------------- /php/db/mysql/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /php/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.5' 2 | 3 | services: 4 | zookeeper: 5 | container_name: php-webhooks-app-zookeeper 6 | image: zookeeper:3.8.1 7 | ports: 8 | - "2181:2181" 9 | kafka: 10 | container_name: php-webhooks-app-kafka 11 | image: wurstmeister/kafka:2.13-2.8.1 12 | ports: 13 | - "9092:9092" 14 | depends_on: 15 | - zookeeper 16 | environment: 17 | KAFKA_ADVERTISED_HOST_NAME: kafka 18 | KAFKA_ADVERTISED_PORT: 9092 19 | KAFKA_CREATE_TOPICS: "events:1:1" 20 | KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 21 | volumes: 22 | - /var/run/docker.sock:/var/run/docker.sock 23 | web: 24 | container_name: php-webhooks-app-web 25 | env_file: 26 | - .env 27 | environment: 28 | KAFKA_BROKER_LIST: kafka:9092 29 | KAFKA_REFRESH_INTERVAL_MS: 1000 30 | KAFKA_BROKER_VERSION: 1.0.0 31 | KAFKA_PRODUCE_INTERVAL: 500 32 | KAFKA_GROUP_ID: events 33 | EVENT_TOPIC: events 34 | DB_HOST: db 35 | DB_NAME: events 36 | DB_USERNAME: events 37 | DB_PASSWORD: events 38 | build: 39 | dockerfile: ./Dockerfile 40 | context: ./ 41 | depends_on: 42 | - kafka 43 | - db 44 | ports: 45 | - 8999:80 46 | volumes: 47 | - ./src:/app/src 48 | - ./db:/app/db 49 | - ./sql:/app/sql 50 | db: 51 | container_name: php-webhooks-app-db 52 | image: mysql:8.0 53 | command: mysqld --default-authentication-plugin=mysql_native_password 54 | volumes: 55 | - ./db/mysql:/var/lib/mysql 56 | ports: 57 | - 3306:3306 58 | logging: 59 | driver: none 60 | environment: 61 | MYSQL_ROOT_PASSWORD: root 62 | MYSQL_DATABASE: events 63 | MYSQL_USER: events 64 | MYSQL_PASSWORD: events 65 | ngrok: 66 | container_name: php-webhooks-app-ngrok 67 | build: ./docker/ngrok/ 68 | ports: 69 | - 4040:4040 70 | environment: 71 | TARGET_HOST: web 72 | TARGET_PORT: 80 73 | NGROK_TOKEN: "${NGROK_AUTHTOKEN}" 74 | depends_on: 75 | - web 76 | -------------------------------------------------------------------------------- /php/docker/ngrok/.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | *.log 3 | npm-debug.log* 4 | node_modules 5 | -------------------------------------------------------------------------------- /php/docker/ngrok/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | 3 | COPY package.json /package.json 4 | COPY yarn.lock /yarn.lock 5 | COPY ngrok.yml /ngrok.yml 6 | COPY index.js /index.js 7 | 8 | RUN yarn 9 | 10 | EXPOSE 4040 11 | 12 | CMD ["npm", "start"] 13 | -------------------------------------------------------------------------------- /php/docker/ngrok/index.js: -------------------------------------------------------------------------------- 1 | const ngrok = require("ngrok"); 2 | 3 | if (!process.env.TARGET_HOST || !process.env.TARGET_PORT) { 4 | throw new Error( 5 | "The following env variables are required: TARGET_HOST, TARGET_PORT" 6 | ); 7 | process.exit(1); 8 | } 9 | 10 | const targetAddress = `${process.env.TARGET_HOST}:${process.env.TARGET_PORT}`; 11 | 12 | const validRegions = ["us", "eu", "au", "ap"]; 13 | const region = process.env.NGROK_REGION 14 | ? validRegions.includes(process.env.NGROK_REGION.toLowerCase()) 15 | ? process.env.NGROK_REGION.toLowerCase() 16 | : "us" 17 | : "us"; 18 | 19 | 20 | const options = { 21 | proto: 'http', 22 | addr: targetAddress, 23 | auth: process.env.NGROK_AUTH, 24 | region: region, 25 | authtoken: process.env.NGROK_TOKEN, 26 | configPath: "/ngrok.yml" 27 | }; 28 | 29 | ngrok 30 | .connect(options) 31 | .then(url => { 32 | console.log(`The ngrok tunnel is active`); 33 | console.log(`${url} ---> ${targetAddress}`); 34 | }) 35 | .catch(error => { 36 | console.error(error); 37 | process.exit(1); 38 | }); 39 | -------------------------------------------------------------------------------- /php/docker/ngrok/ngrok.yml: -------------------------------------------------------------------------------- 1 | web_addr: 0.0.0.0:4040 2 | -------------------------------------------------------------------------------- /php/docker/ngrok/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "index.js", 3 | "scripts": { 4 | "start": "node index.js" 5 | }, 6 | "dependencies": { 7 | "ngrok": "^4.3.3" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /php/docker/supervisor/processes.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | 4 | [program:webhook-consumer] 5 | command=php /app/src/console/webhooks/consumer.php 6 | autostart=true 7 | autorestart=true 8 | numprocs=1 9 | stdout_logfile=/var/log/supervisor/webhook-consumer-out.log 10 | 11 | [program:apache] 12 | command=apache2-foreground 13 | autostart=true 14 | numprocs=1 15 | -------------------------------------------------------------------------------- /php/sql/base.sql: -------------------------------------------------------------------------------- 1 | create table if not exists events 2 | ( 3 | id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, 4 | event_type VARCHAR(255), 5 | object_id int default null, 6 | event_id bigint default null, 7 | occurred_at bigint default null, 8 | propertyName varchar(255) default null, 9 | propertyValue varchar(255) default null, 10 | created_at datetime default CURRENT_TIMESTAMP 11 | ); 12 | -------------------------------------------------------------------------------- /php/src/Components/Paginator.php: -------------------------------------------------------------------------------- 1 | count = $count; 51 | $this->url = $url; 52 | $this->endPage = $this->outOnPage; 53 | $this->init(); 54 | } 55 | 56 | public function init() 57 | { 58 | $this->countPages = intval(ceil($this->count / $this->perPage)); 59 | if (array_key_exists('page', $_GET) && intval($_GET['page'])) { 60 | $this->page = intval($_GET['page']); 61 | if ($this->page > $this->countPages) { 62 | throw new Exception('Page not found.', 404); 63 | } 64 | } 65 | if ($this->countPages >= $this->outOnPage) { 66 | $count = $this->outOnPage - 1; 67 | $half = ceil($this->outOnPage / 2) - 1; 68 | $startPage = $this->page - $half; 69 | $endPage = $startPage + $count; 70 | if ($endPage > $this->countPages) { 71 | $this->endPage = $this->countPages; 72 | $this->startPage = $this->endPage - $count; 73 | } elseif ($startPage > 0) { 74 | $this->startPage = $startPage; 75 | $this->endPage = $endPage; 76 | } 77 | 78 | return; 79 | } 80 | if ($this->endPage > $this->countPages) { 81 | $this->endPage = $this->countPages; 82 | } 83 | } 84 | 85 | public function getPagesCount(): int 86 | { 87 | return $this->countPages; 88 | } 89 | 90 | public function getStartPage(): int 91 | { 92 | return $this->startPage; 93 | } 94 | 95 | public function getEndPage(): int 96 | { 97 | return $this->endPage; 98 | } 99 | 100 | public function getPrevPage(): int 101 | { 102 | if ($this->page > 1) { 103 | return $this->page - 1; 104 | } 105 | 106 | return $this->countPages; 107 | } 108 | 109 | public function getNextPage() 110 | { 111 | if ($this->page != $this->countPages) { 112 | return $this->page + 1; 113 | } 114 | 115 | return 1; 116 | } 117 | 118 | public function getUrl(int $page): string 119 | { 120 | return $this->url.'?page='.$page; 121 | } 122 | 123 | public function getPage(): int 124 | { 125 | return $this->page; 126 | } 127 | 128 | public function getFrom(): int 129 | { 130 | return ($this->page - 1) * $this->perPage; 131 | } 132 | 133 | public function getPerPage(): int 134 | { 135 | return $this->perPage; 136 | } 137 | 138 | public function getCount(): int 139 | { 140 | return $this->count; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /php/src/Helpers/DBClientHelper.php: -------------------------------------------------------------------------------- 1 | setAttribute(PDO::ATTR_EMULATE_PREPARES, false); 19 | self::$dbClient = $pdo; 20 | } 21 | 22 | return self::$dbClient; 23 | } 24 | 25 | public static function runMigrations() 26 | { 27 | $uri = 'mysql://'.$_ENV['DB_USERNAME'].':'.$_ENV['DB_PASSWORD'].'@'.$_ENV['DB_HOST'].'/'.$_ENV['DB_NAME']; 28 | $connectionUri = new Uri($uri); 29 | Migration::registerDatabase(MySqlDatabase::class); 30 | $migration = new Migration($connectionUri, __DIR__.'/../../sql'); 31 | 32 | try { 33 | $migration->getCurrentVersion(); 34 | } catch (\Throwable $t) { 35 | $migration->reset(); 36 | } 37 | $migration->update($version = null); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /php/src/Helpers/HubspotClientHelper.php: -------------------------------------------------------------------------------- 1 | setMetadataRefreshIntervalMs($_ENV['KAFKA_REFRESH_INTERVAL_MS']); 20 | $config->setMetadataBrokerList($_ENV['KAFKA_BROKER_LIST']); 21 | $config->setBrokerVersion($_ENV['KAFKA_BROKER_VERSION']); 22 | $config->setRequiredAck(1); 23 | $config->setIsAsyn(false); 24 | $config->setProduceInterval($_ENV['KAFKA_PRODUCE_INTERVAL']); 25 | static::$producer = new Producer(); 26 | } 27 | 28 | return static::$producer; 29 | } 30 | 31 | public static function getConsumer(array $topics) 32 | { 33 | $config = ConsumerConfig::getInstance(); 34 | $config->setTopics($topics); 35 | if (!static::$consumer) { 36 | $config->setMetadataRefreshIntervalMs($_ENV['KAFKA_REFRESH_INTERVAL_MS']); 37 | $config->setMetadataBrokerList($_ENV['KAFKA_BROKER_LIST']); 38 | $config->setBrokerVersion($_ENV['KAFKA_BROKER_VERSION']); 39 | $config->setGroupId($_ENV['KAFKA_GROUP_ID']); 40 | static::$consumer = new Consumer(); 41 | } 42 | 43 | return static::$consumer; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /php/src/Helpers/OAuth2Helper.php: -------------------------------------------------------------------------------- 1 | $tokens->getAccessToken(), 38 | 'refresh_token' => $tokens->getRefreshToken(), 39 | 'expires_in' => $tokens->getExpiresIn(), 40 | 'expires_at' => time() + $tokens['expires_in'] * 0.95, 41 | ]; 42 | } 43 | 44 | public static function isAuthenticated(): bool 45 | { 46 | return isset($_SESSION[static::SESSION_TOKENS_KEY]); 47 | } 48 | 49 | public static function refreshAndGetAccessToken(): string 50 | { 51 | if (empty($_SESSION[static::SESSION_TOKENS_KEY])) { 52 | throw new \Exception('Please authorize via OAuth2'); 53 | } 54 | 55 | $tokens = $_SESSION[static::SESSION_TOKENS_KEY]; 56 | 57 | if (time() > $tokens['expires_at']) { 58 | $tokens = Factory::create()->auth()->oAuth()->tokensApi()->create( 59 | 'refresh_token', 60 | null, 61 | static::getRedirectUri(), 62 | static::getClientId(), 63 | static::getClientSecret(), 64 | $tokens['refresh_token'] 65 | ); 66 | 67 | self::saveTokenResponse($tokens); 68 | } 69 | 70 | return $tokens['access_token']; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /php/src/Helpers/UrlHelper.php: -------------------------------------------------------------------------------- 1 | 0) { 14 | $subscriptionRequests = []; 15 | 16 | foreach ($ids as $id) { 17 | $subscriptionRequest = new SubscriptionBatchUpdateRequest(); 18 | $subscriptionRequest->setId($id); 19 | $subscriptionRequest->setActive($activity); 20 | 21 | $subscriptionRequests[] = $subscriptionRequest; 22 | } 23 | 24 | $subscriptionsRequest = new BatchInputSubscriptionBatchUpdateRequest(); 25 | $subscriptionsRequest->setInputs($subscriptionRequests); 26 | 27 | HubspotClientHelper::createFactoryWithDeveloperAPIKey() 28 | ->webhooks()->subscriptionsApi() 29 | ->updateBatch($appId, $subscriptionsRequest) 30 | ; 31 | } 32 | } 33 | 34 | public static function getActiveSubscriptions(array $subscriptions): array 35 | { 36 | $results = []; 37 | foreach ($subscriptions as $subscription) { 38 | if ($subscription->getActive()) { 39 | $results[] = $subscription->getId(); 40 | } 41 | } 42 | 43 | return $results; 44 | } 45 | 46 | public static function getNecessarySubscriptions(array $subscriptions): array 47 | { 48 | $results = [ 49 | 'contact.creation' => null, 50 | 'contact.propertyChange' => null, 51 | 'contact.deletion' => null, 52 | ]; 53 | 54 | foreach ($subscriptions as $subscription) { 55 | if ( 56 | array_key_exists($subscription->getEventType(), $results) 57 | && ( 58 | (null == $subscription->getPropertyName()) 59 | || ('firstname' == $subscription->getPropertyName()) 60 | ) 61 | ) { 62 | $results[$subscription->getEventType()] = $subscription->getId(); 63 | } 64 | } 65 | 66 | return $results; 67 | } 68 | 69 | public static function createSubscriptions($appId, $subscriptions) 70 | { 71 | foreach ($subscriptions as $eventType => $subscriptionId) { 72 | if (is_null($subscriptionId)) { 73 | $request = new SubscriptionCreateRequest(); 74 | $request->setEventType($eventType); 75 | $request->setActive(true); 76 | 77 | if ('contact.propertyChange' == $eventType) { 78 | $request->setPropertyName('firstname'); 79 | } 80 | 81 | HubspotClientHelper::createFactoryWithDeveloperAPIKey() 82 | ->webhooks() 83 | ->subscriptionsApi()->create($appId, $request); 84 | } 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /php/src/Helpers/functions.php: -------------------------------------------------------------------------------- 1 | prepare("insert into events (event_id, event_type, object_id, occurred_at{$values}) values (?, ?, ?, ?{$places})"); 23 | $query->execute($params); 24 | } 25 | 26 | public static function findLastModifiedObjectsIds(int $from = 0, int $perPage = 0) 27 | { 28 | $db = DBClientHelper::getClient(); 29 | $limit = 'LIMIT 10'; 30 | $options = []; 31 | if ($perPage) { 32 | $limit = 'LIMIT ? , ?'; 33 | $options = [$from, $perPage]; 34 | } 35 | $query = $db->prepare("SELECT object_id FROM events GROUP BY object_id ORDER BY MAX(id) DESC {$limit};"); 36 | $query->execute($options); 37 | $objectsIds = []; 38 | foreach ($query->fetchAll() as $row) { 39 | $objectsIds[] = $row['object_id']; 40 | } 41 | 42 | return $objectsIds; 43 | } 44 | 45 | public static function getEventsCount() 46 | { 47 | $db = DBClientHelper::getClient(); 48 | $query = $db->query('select COUNT(distinct object_id) as count from events '); 49 | 50 | return $query->fetchColumn(0); 51 | } 52 | 53 | public static function findEventTypesByObjectId($objectId) 54 | { 55 | $db = DBClientHelper::getClient(); 56 | $query = $db->prepare('select event_type, propertyName, propertyValue from events where object_id = ? order by occurred_at asc'); 57 | $query->execute([$objectId]); 58 | $events = []; 59 | foreach ($query->fetchAll() as $row) { 60 | $events[] = $row; 61 | } 62 | 63 | return $events; 64 | } 65 | 66 | public static function getNotShownEventsCount(int $timestamp): int 67 | { 68 | $db = DBClientHelper::getClient(); 69 | $query = $db->prepare('select count(*) from events where UNIX_TIMESTAMP(created_at) > ?;'); 70 | $query->execute([$timestamp]); 71 | 72 | return $query->fetchColumn(0); 73 | } 74 | 75 | public static function deleteAll() 76 | { 77 | $db = DBClientHelper::getClient(); 78 | $db->exec('delete from events'); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /php/src/actions/ajax/events.php: -------------------------------------------------------------------------------- 1 | $notShownEventsCount, 13 | ]); 14 | -------------------------------------------------------------------------------- /php/src/actions/oauth/authorize.php: -------------------------------------------------------------------------------- 1 | auth()->oAuth()->tokensApi()->create( 8 | 'authorization_code', 9 | $_GET['code'], 10 | OAuth2Helper::getRedirectUri(), 11 | OAuth2Helper::getClientId(), 12 | OAuth2Helper::getClientSecret() 13 | ); 14 | 15 | OAuth2Helper::saveTokenResponse($tokens); 16 | 17 | header('Location: /'); 18 | -------------------------------------------------------------------------------- /php/src/actions/oauth/login.php: -------------------------------------------------------------------------------- 1 | "creation" 12 | $event['event_type'] = explode('.', $event['event_type'])[1]; 13 | 14 | return $event; 15 | } 16 | 17 | $hubSpot = HubspotClientHelper::createFactory(); 18 | $paginator = new Paginator(EventsRepository::getEventsCount(), '/webhooks/events'); 19 | $contactsIds = EventsRepository::findLastModifiedObjectsIds($paginator->getFrom(), $paginator->getPerPage()); 20 | 21 | if (count($contactsIds) > 0) { 22 | $request = new BatchReadInputSimplePublicObjectId(); 23 | $request->setInputs(array_map(function ($id) { 24 | $contactId = new SimplePublicObjectId(); 25 | $contactId->setId($id); 26 | 27 | return $contactId; 28 | }, $contactsIds)); 29 | 30 | $response = $hubSpot->crm()->contacts()->batchApi()->read($request); 31 | 32 | $names = []; 33 | if (!empty($response->getResults())) { 34 | foreach ($response->getResults() as $object) { 35 | $names[$object->getId()] = trim($object->getProperties()['firstname'] 36 | .' '.$object->getProperties()['lastname']); 37 | } 38 | } 39 | 40 | $contacts = array_map(function ($id) use ($names) { 41 | $name = null; 42 | if (array_key_exists($id, $names)) { 43 | $name = $names[$id]; 44 | } 45 | 46 | return [ 47 | 'id' => $id, 48 | 'events' => array_map('formatEvent', EventsRepository::findEventTypesByObjectId($id)), 49 | 'name' => $name, 50 | ]; 51 | }, $contactsIds); 52 | } else { 53 | $contacts = []; 54 | } 55 | 56 | include __DIR__.'/../../views/webhooks/events.php'; 57 | -------------------------------------------------------------------------------- /php/src/actions/webhooks/handle.php: -------------------------------------------------------------------------------- 1 | $_SERVER['HTTP_X_HUBSPOT_SIGNATURE'], 10 | 'secret' => $_ENV['HUBSPOT_CLIENT_SECRET'], 11 | 'requestBody' => $requestBody, 12 | 'httpUri' => $_SERVER['REQUEST_URI'], 13 | 'httpMethod' => $_SERVER['REQUEST_METHOD'], 14 | 'signatureVersion' => $_SERVER['HTTP_X_HUBSPOT_SIGNATURE_VERSION'], 15 | ])) { 16 | header('HTTP/1.1 401 Unauthorized'); 17 | 18 | exit(); 19 | } 20 | 21 | $events = json_decode($requestBody, true); 22 | 23 | foreach ($events as $event) { 24 | KafkaHelper::getProducer()->send([ 25 | [ 26 | 'topic' => getEnvParam('EVENT_TOPIC', 'events'), 27 | 'value' => json_encode($event), 28 | 'key' => '', 29 | ], 30 | ]); 31 | } 32 | -------------------------------------------------------------------------------- /php/src/actions/webhooks/init.php: -------------------------------------------------------------------------------- 1 | webhooks() 19 | ; 20 | 21 | $appId = getEnvOrException('HUBSPOT_APPLICATION_ID'); 22 | 23 | $subscriptions = $webhooksClient->subscriptionsApi()->getAll($appId); 24 | 25 | $activeSubscriptions = WebhooksHelper::getActiveSubscriptions($subscriptions->getResults()); 26 | $necessarySubscriptions = WebhooksHelper::getNecessarySubscriptions($subscriptions->getResults()); 27 | 28 | WebhooksHelper::updateSubscriptions($appId, $activeSubscriptions, false); 29 | 30 | $request = new SettingsChangeRequest(); 31 | $request->setTargetUrl($appUrl); 32 | 33 | $response = $webhooksClient->settingsApi() 34 | ->configure($appId, $request) 35 | ; 36 | 37 | WebhooksHelper::createSubscriptions( 38 | $appId, 39 | $necessarySubscriptions 40 | ); 41 | 42 | WebhooksHelper::updateSubscriptions( 43 | $appId, 44 | array_filter(array_values($necessarySubscriptions)), 45 | true 46 | ); 47 | 48 | $settings = $webhooksClient->settingsApi()->getAll($appId); 49 | 50 | if (($settings instanceof SettingsResponse) && ($settings->getTargetUrl() == $appUrl)) { 51 | $_SESSION['init'] = true; 52 | } else { 53 | throw new Exception('Something went wrong...'); 54 | } 55 | 56 | header('Location: /webhooks/events'); 57 | -------------------------------------------------------------------------------- /php/src/console/webhooks/consumer.php: -------------------------------------------------------------------------------- 1 | start(function ($topic, $part, $message): void { 10 | $event = (array) json_decode($message['message']['value']); 11 | 12 | var_dump($event); 13 | 14 | \Repositories\EventsRepository::saveEvent($event); 15 | }) 16 | ; 17 | -------------------------------------------------------------------------------- /php/src/public/.htaccess: -------------------------------------------------------------------------------- 1 | RewriteEngine On 2 | RewriteBase / 3 | RewriteCond %{REQUEST_FILENAME} !-d 4 | RewriteCond %{REQUEST_FILENAME} !-f 5 | RewriteRule ^(.+)$ index.php [QSA,L] 6 | -------------------------------------------------------------------------------- /php/src/public/css/main.css: -------------------------------------------------------------------------------- 1 | .wrapper .container { 2 | padding-bottom: 2rem; 3 | padding-top: 2rem; 4 | } 5 | 6 | .navigation { 7 | background: #f4f5f6; 8 | border-bottom: .1rem solid #d1d1d1; 9 | display: block; 10 | height: 5.2rem; 11 | left: 0; 12 | max-width: 100%; 13 | width: 100%; 14 | } 15 | 16 | .navigation .container { 17 | padding-bottom: 0; 18 | padding-top: 0 19 | } 20 | 21 | .navigation .navigation-list { 22 | list-style: none; 23 | margin-bottom: 0; 24 | } 25 | 26 | .navigation .navigation-item { 27 | float: left; 28 | margin-bottom: 0; 29 | margin-left: 2.5rem; 30 | position: relative 31 | } 32 | 33 | .navigation .navigation-title, .navigation .title { 34 | color: #606c76; 35 | position: relative 36 | } 37 | 38 | .navigation .navigation-link, .navigation .navigation-title, .navigation .title { 39 | display: inline; 40 | font-size: 1.6rem; 41 | line-height: 5.2rem; 42 | padding: 0; 43 | text-decoration: none 44 | } 45 | 46 | .row.authorize-button { 47 | justify-content: center; 48 | } 49 | 50 | .event.deletion { 51 | color: white; 52 | background-color: red; 53 | } 54 | 55 | .event.creation { 56 | color: #3c763d; 57 | background-color: #dff0d8; 58 | } 59 | 60 | .event.propertyChange { 61 | background-color: #d8aa65; 62 | color: #ffffff; 63 | } 64 | 65 | .event.propertyChange span:first-child { 66 | background-color: #08080885; 67 | } 68 | 69 | .hidden { 70 | display: none; 71 | } 72 | 73 | .pagination { 74 | margin: 0rem; 75 | padding-bottom: 1rem; 76 | font-size: 1.5rem; 77 | font-weight: bold; 78 | } 79 | .pagination a { 80 | border: 0.1rem solid #9b4dca; 81 | padding: 0.5rem 1.5rem; 82 | border-right: none; 83 | } 84 | .pagination a:first-child { 85 | border-radius: 0.4rem; 86 | padding: 0.5rem 1rem; 87 | border-radius: 0.4rem 0rem 0rem 0.4rem; 88 | } 89 | .pagination a:last-child { 90 | border-radius: 0.4rem; 91 | border-right: 0.1rem solid #9b4dca; 92 | padding: 0.5rem 1rem; 93 | border-radius: 0rem 0.4rem 0.4rem 0rem; 94 | } 95 | 96 | .pagination a.active { 97 | background-color: #9b4dca; 98 | color: #fff; 99 | } 100 | 101 | .text-center { 102 | text-align: center; 103 | } 104 | 105 | .justify-content-center { 106 | justify-content: center; 107 | } 108 | -------------------------------------------------------------------------------- /php/src/public/index.php: -------------------------------------------------------------------------------- 1 | getMessage(); 50 | 51 | include __DIR__.'/../views/error.php'; 52 | 53 | exit(); 54 | } 55 | -------------------------------------------------------------------------------- /php/src/public/js/events.js: -------------------------------------------------------------------------------- 1 | function reloadPage() { 2 | document.location.reload(); 3 | } 4 | 5 | function requestNotShownEventsCount() { 6 | return new Promise((resolve) => { 7 | $.getJSON("/ajax/events?mark=" + $('#alert-not-shown-events').attr('datetime-mark') , data => { 8 | const { notShownEventsCount } = data; 9 | resolve(notShownEventsCount); 10 | }); 11 | }); 12 | } 13 | 14 | async function displayNotShownEventsAlertIfNeed() { 15 | const notShownEventsCount = await requestNotShownEventsCount(); 16 | if (notShownEventsCount > 0) { 17 | $('#empty-message').hide(); 18 | $('#alert-not-shown-events').show(); 19 | } 20 | } 21 | 22 | $(document).ready(async () => { 23 | setInterval(displayNotShownEventsAlertIfNeed, 10000); 24 | $('#alert-not-shown-events').click(() => { 25 | reloadPage(); 26 | return false; 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /php/src/public/js/login.js: -------------------------------------------------------------------------------- 1 | function copyRedirectURL() { 2 | let field = document.createElement('input') 3 | field.value = document.getElementById('redirectURL').textContent 4 | 5 | document.body.appendChild(field) 6 | field.select() 7 | 8 | document.execCommand('copy') 9 | document.body.removeChild(field) 10 | 11 | alert('Copied') 12 | } 13 | -------------------------------------------------------------------------------- /php/src/routes/protected.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /php/src/views/_partials/header.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | HubSpot PHP sample webhooks app 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 29 |
30 | -------------------------------------------------------------------------------- /php/src/views/_partials/pagination.php: -------------------------------------------------------------------------------- 1 | getPagesCount() > 1) { ?> 2 | 12 | 13 | -------------------------------------------------------------------------------- /php/src/views/error.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | 5 | 6 | -------------------------------------------------------------------------------- /php/src/views/oauth/login.php: -------------------------------------------------------------------------------- 1 | 5 |
 6 | // src/actions/oauth/authorize.php - Generate URL for OAuth
 7 | $authUrl = HubSpot\Utils\OAuth2::getAuthUrl(
 8 |     'ClientID',
 9 |     'Redirect Uri',
10 |     ['Scopes']
11 | );
12 | 
13 |
14 |

In order to continue please update the redirect URL on Auth settings page of your app

15 |

Redirect URL

16 |
17 |
18 | 19 |
20 |

After that authorize via OAuth

21 |
22 | Authorize 23 |
24 |
25 | 26 | -------------------------------------------------------------------------------- /php/src/views/webhooks/events.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | getCount() > 0) { ?> 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 26 | 27 | 28 | 29 |
Contact IDContact NameEvents
20 | 21 | :: 24 | 25 |
30 | 31 |

Webhooks haven't been received yet.

32 | 33 | 38 | -------------------------------------------------------------------------------- /php/src/views/webhooks/init.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Initialization Script

4 |

Press Go button to subscribe to the following events and set Target URL to

5 |
6 |
7 |
8 | Events 9 |
    10 |
  • Contact's Creation (contact.creation)
  • 11 |
  • Changing of Contact's Fist Name (contact.propertyChange)
  • 12 |
  • Contact's Deletion (contact.deletion)
  • 13 |
14 |
15 | // src/actions/webhooks/init.php 
16 | //set Target URL
17 | $request = new SettingsChangeRequest();
18 | $request->setTargetUrl($appUrl);
19 | 
20 | $hubSpot->webhooks()->settingsApi()
21 |     ->configureSettings($appId, $request);
22 | 
23 | //Subscribe to an event
24 | $request = new \HubSpot\Client\Webhooks\Model\SubscriptionCreateRequest();
25 | $request->setEventType('contact.propertyChange');
26 | $request->setPropertyName('firstname');
27 | $request->setActive(true);
28 | 
29 | $hubSpot->webhooks()
30 |     ->subscriptionsApi()->subscribe($appId, $request);
31 |     
32 |
33 | 34 |
35 |
36 |
37 |
38 | 39 | 40 | -------------------------------------------------------------------------------- /python/.env.template: -------------------------------------------------------------------------------- 1 | HUBSPOT_APPLICATION_ID= 2 | HUBSPOT_CLIENT_ID= 3 | HUBSPOT_CLIENT_SECRET= 4 | HUBSPOT_DEVELOPER_API_KEY= 5 | -------------------------------------------------------------------------------- /python/.gitignore: -------------------------------------------------------------------------------- 1 | */__pycache__ 2 | */*/__pycache__ 3 | db/mysql 4 | -------------------------------------------------------------------------------- /python/README.md: -------------------------------------------------------------------------------- 1 | # HubSpot-python sample Webhooks app 2 | 3 | Please note that the Webhooks events are not sent in chronological order with respect to the creation time. Events might be sent in large numbers, for example when the user imports large number of contacts or deletes a large list of contacts. 4 | 5 | Common webhook processing practice consists of few steps: 6 | 1. Handle methods receive the request sent by the webook and immediately place payload on the queue handle.php 7 | 2. Consumer stores webhook events in the database potentially calling an API to get full record of the object that triggered the event 8 | - This application uses MySQL, [SQLAlchemy](https://www.sqlalchemy.org/) ORM 9 | 3. Other services/objects fetch the events data from the database 10 | 11 | ### Note on the Data Base 12 | This application uses MySQL database to store the events coming from Webhooks. There is a single events table: 13 | ``` 14 | create table if not exists events 15 | ( 16 | id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, 17 | event_type VARCHAR(255), 18 | object_id int default null, 19 | event_id bigint default null, 20 | occurred_at bigint default null, 21 | property_name varchar(255) default null, 22 | property_value varchar(255) default null, 23 | created_at datetime default CURRENT_TIMESTAMP 24 | ); 25 | ``` 26 | Please note that event_id sent by HubSpot needs to be stored as int 27 | 28 | ### Setup App 29 | 30 | Make sure you have [Docker Compose](https://docs.docker.com/compose/) installed. 31 | 32 | ### Configure 33 | 34 | 1. Copy .env.template to .env 35 | 2. Paste your HUBSPOT_CLIENT_ID, HUBSPOT_CLIENT_SECRET, HUBSPOT_APPLICATION_ID and HUBSPOT_DEVELOPER_API_KEY 36 | 37 | ### Running 38 | 39 | The best way to run this project (with the least configuration), is using docker compose. Change to the webroot and start it 40 | 41 | ```bash 42 | docker-compose up --build 43 | ``` 44 | 45 | Copy Ngrok url from console. Now you should now be able to navigate to that url and use the application. 46 | 47 | ### NOTE about Ngrok Too Many Connections error 48 | 49 | If you are using Ngrok free plan and testing the application with large amount of import/deletions of Contacts you are likely to see Ngrok "Too Many Connections" error. 50 | This is caused by a large amount of weebhooks events being sent to Ngrok tunnel. To avoid it you can deploy sample applications on your server w/o Ngrok or upgrade to Ngrok Enterprise version 51 | 52 | ### HubSpot Signature 53 | To help improve security, HubSpot webhooks are sent with signature so you can verify that it came from HubSpot. This sample application shows how to do that verification. You can read more about validation in general here: https://developers.hubspot.com/docs/api/webhooks/validating-requests. 54 | The source code for validating webhooks is [an usage example](./src/routes/webhooks.py). 55 | 56 | ### Process with the app 57 | 58 | 1. Authorize your app with Hubpost OAuth (Press "Authorize" button). 59 | 2. Subscribe to Hubspot Webhooks (Press "Continue" button). 60 | 3. Create some Hubspot Contacts. You can use this [Sample App](https://github.com/HubSpot/sample-apps-manage-crm-objects) to do so. 61 | 62 | ``` 63 | python cli.py -m create -t contact -p '{"email":"brianhalligan@email.com","firstname":"Brian","lastname":"Halligan"}' 64 | ``` 65 | 66 | 4. Reload /events page to check recieved updates. 67 | -------------------------------------------------------------------------------- /python/db/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HubSpot/sample-apps-webhooks/4976692a89a15d66a4556555496b39c69db537a3/python/db/.gitkeep -------------------------------------------------------------------------------- /python/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.5' 2 | 3 | services: 4 | web: 5 | container_name: python-webhooks-app-web 6 | ports: 7 | - 5000:5000 8 | env_file: 9 | - .env 10 | environment: 11 | DB_URL: "mysql+pymysql://events:events@db/events" 12 | build: 13 | dockerfile: ./docker/web/Dockerfile 14 | context: ./ 15 | depends_on: 16 | - db 17 | volumes: 18 | - ./src:/app/src 19 | command: bash -c "dockerize -wait-retry-interval 5s -timeout 30s -wait tcp://db:3306 && python -m flask run --host=0.0.0.0" 20 | db: 21 | image: mysql:8.0 22 | command: mysqld --default-authentication-plugin=mysql_native_password 23 | volumes: 24 | - ./db/mysql:/var/lib/mysql 25 | ports: 26 | - 3306:3306 27 | logging: 28 | driver: none 29 | environment: 30 | MYSQL_ROOT_PASSWORD: root 31 | MYSQL_DATABASE: events 32 | MYSQL_USER: events 33 | MYSQL_PASSWORD: events 34 | ngrok: 35 | image: gtriggiano/ngrok-tunnel 36 | ports: 37 | - 4040:4040 38 | environment: 39 | TARGET_HOST: web 40 | TARGET_PORT: 5000 41 | depends_on: 42 | - web 43 | -------------------------------------------------------------------------------- /python/docker/web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | RUN mkdir -p /app 4 | ADD ./requirements.txt /app 5 | WORKDIR /app 6 | RUN pip install -r requirements.txt 7 | 8 | ENV DOCKERIZE_VERSION v0.6.1 9 | ADD https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz /tmp 10 | RUN tar -C /usr/local/bin -xzvf /tmp/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz 11 | 12 | WORKDIR /app/src 13 | 14 | CMD [ "python", "-m" , "flask", "run", "--host=0.0.0.0"] 15 | -------------------------------------------------------------------------------- /python/requirements.txt: -------------------------------------------------------------------------------- 1 | hubspot-api-client==3.7.2 2 | Flask==1.1.2 3 | sqlalchemy==1.3.17 4 | pymysql==0.9.3 5 | -------------------------------------------------------------------------------- /python/src/app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, redirect, url_for 2 | import routes 3 | from helpers.reverse_proxied import ReverseProxied 4 | from services.db import create_db_schema 5 | 6 | app = Flask(__name__) 7 | app.wsgi_app = ReverseProxied(app.wsgi_app) 8 | 9 | app.secret_key = b'_5#y2L"F4Q8z\n\xec]/' 10 | 11 | app.register_blueprint(routes.oauth, url_prefix="/oauth") 12 | app.register_blueprint(routes.init, url_prefix="/init") 13 | app.register_blueprint(routes.webhooks, url_prefix="/webhooks") 14 | app.register_blueprint(routes.events, url_prefix="/events") 15 | 16 | create_db_schema() 17 | 18 | @app.route("/") 19 | def init(): 20 | return redirect(url_for("init.readme")) 21 | 22 | if __name__ == "__main__": 23 | app.run(host="0.0.0.0", debug=True) 24 | -------------------------------------------------------------------------------- /python/src/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from .auth_required import auth_required 2 | from .hubspot_signature_required import hubspot_signature_required 3 | -------------------------------------------------------------------------------- /python/src/auth/auth_required.py: -------------------------------------------------------------------------------- 1 | from flask import redirect, url_for 2 | from functools import wraps 3 | from helpers.oauth import is_authorized 4 | 5 | 6 | def auth_required(func): 7 | @wraps(func) 8 | def check_auth(*args, **kwargs): 9 | if not is_authorized(): 10 | return redirect(url_for("oauth.login")) 11 | 12 | return func(*args, **kwargs) 13 | 14 | return check_auth 15 | -------------------------------------------------------------------------------- /python/src/auth/hubspot_signature_required.py: -------------------------------------------------------------------------------- 1 | from os import getenv 2 | import urllib.parse 3 | from flask import request 4 | from functools import wraps 5 | from hubspot.exceptions import InvalidSignatureError 6 | from hubspot.utils.webhooks import validate_signature 7 | 8 | 9 | def hubspot_signature_required(func): 10 | @wraps(func) 11 | def _validate_signature(*args, **kwargs): 12 | try: 13 | validate_signature( 14 | signature=request.headers["X-HubSpot-Signature"], 15 | signature_version=request.headers["X-HubSpot-Signature-Version"], 16 | http_uri=urllib.parse.unquote(request.url), 17 | http_method=request.method, 18 | request_body=request.data.decode("utf-8"), 19 | client_secret=getenv("HUBSPOT_CLIENT_SECRET"), 20 | ) 21 | return func(*args, **kwargs) 22 | except InvalidSignatureError: 23 | return "", 403 24 | 25 | return _validate_signature 26 | -------------------------------------------------------------------------------- /python/src/helpers/hubspot.py: -------------------------------------------------------------------------------- 1 | from os import getenv 2 | from hubspot import HubSpot 3 | from .oauth import refresh_and_get_access_token, is_authorized 4 | 5 | 6 | def create_client(): 7 | if is_authorized(): 8 | return HubSpot(access_token=refresh_and_get_access_token()) 9 | 10 | return HubSpot() 11 | 12 | 13 | def create_client_with_developer_api_key(): 14 | return HubSpot(api_key=getenv("HUBSPOT_DEVELOPER_API_KEY")) 15 | -------------------------------------------------------------------------------- /python/src/helpers/oauth.py: -------------------------------------------------------------------------------- 1 | import time 2 | import os 3 | from flask import session, request 4 | from hubspot import HubSpot 5 | 6 | TOKENS_KEY = "tokens" 7 | 8 | 9 | def save_tokens(tokens_response): 10 | tokens = { 11 | "access_token": tokens_response.access_token, 12 | "refresh_token": tokens_response.refresh_token, 13 | "expires_in": tokens_response.expires_in, 14 | "expires_at": time.time() + tokens_response.expires_in * 0.95, 15 | } 16 | session[TOKENS_KEY] = tokens 17 | 18 | return tokens 19 | 20 | 21 | def is_authorized(): 22 | return TOKENS_KEY in session 23 | 24 | 25 | def get_redirect_uri(): 26 | return request.url_root + "oauth/callback" 27 | 28 | 29 | def refresh_and_get_access_token(): 30 | if TOKENS_KEY not in session: 31 | raise Exception("No refresh token is specified") 32 | tokens = session[TOKENS_KEY] 33 | if time.time() > tokens["expires_at"]: 34 | tokens = HubSpot().auth.oauth.default_api.create_token( 35 | grant_type="refresh_token", 36 | redirect_uri=get_redirect_uri(), 37 | refresh_token=tokens["refresh_token"], 38 | client_id=os.environ.get("HUBSPOT_CLIENT_ID"), 39 | client_secret=os.environ.get("HUBSPOT_CLIENT_SECRET"), 40 | ) 41 | tokens = save_tokens(tokens) 42 | 43 | return tokens["access_token"] 44 | -------------------------------------------------------------------------------- /python/src/helpers/reverse_proxied.py: -------------------------------------------------------------------------------- 1 | class ReverseProxied(object): 2 | def __init__(self, app): 3 | self.app = app 4 | 5 | def __call__(self, environ, start_response): 6 | scheme = environ.get("HTTP_X_FORWARDED_PROTO") 7 | if scheme: 8 | environ["wsgi.url_scheme"] = scheme 9 | return self.app(environ, start_response) 10 | -------------------------------------------------------------------------------- /python/src/helpers/webhooks.py: -------------------------------------------------------------------------------- 1 | import json 2 | from hubspot import Client 3 | from hubspot.webhooks import ( 4 | SubscriptionCreateRequest, 5 | BatchInputSubscriptionBatchUpdateRequest, 6 | SubscriptionPatchRequest, 7 | SettingsChangeRequest, 8 | ApiException, 9 | SubscriptionBatchUpdateRequest, 10 | ) 11 | 12 | 13 | def pause_active_subscriptions(hubspot_client: Client, app_id: str): 14 | subscriptions = hubspot_client.webhooks.subscriptions_api.get_all(app_id=app_id) 15 | active_subscriptions = [s for s in subscriptions.results if s.active] 16 | 17 | if len(active_subscriptions) > 0: 18 | inputs = [ 19 | SubscriptionBatchUpdateRequest(id=s.id, active=False) 20 | for s in active_subscriptions 21 | ] 22 | batch_input_subscription_batch_update_request = ( 23 | BatchInputSubscriptionBatchUpdateRequest( 24 | inputs=inputs, 25 | ) 26 | ) 27 | hubspot_client.webhooks.subscriptions_api.update_batch( 28 | app_id=app_id, 29 | batch_input_subscription_batch_update_request=batch_input_subscription_batch_update_request, 30 | ) 31 | 32 | 33 | def configure_target_url(hubspot_client: Client, app_id: str, target_url: str): 34 | settings_change_request = SettingsChangeRequest(target_url=target_url) 35 | hubspot_client.webhooks.settings_api.configure( 36 | app_id=app_id, 37 | settings_change_request=settings_change_request, 38 | ) 39 | 40 | 41 | def create_or_activate_subscription( 42 | hubspot_client: Client, app_id: str, event_type: str, property_name=None 43 | ): 44 | try: 45 | subscription_create_request = SubscriptionCreateRequest( 46 | active=True, 47 | event_type=event_type, 48 | property_name=property_name, 49 | ) 50 | hubspot_client.webhooks.subscriptions_api.create( 51 | app_id=app_id, 52 | subscription_create_request=subscription_create_request, 53 | ) 54 | except ApiException as e: 55 | existing_subscription_id = json.loads( 56 | json.loads(e.body)["context"]["subscriptionIds"][0] 57 | )[0] 58 | subscription_patch_request = SubscriptionPatchRequest(active=True) 59 | hubspot_client.webhooks.subscriptions_api.update( 60 | subscription_id=existing_subscription_id, 61 | app_id=app_id, 62 | subscription_patch_request=subscription_patch_request, 63 | ) 64 | -------------------------------------------------------------------------------- /python/src/routes/__init__.py: -------------------------------------------------------------------------------- 1 | from .oauth import module as oauth 2 | from .init import module as init 3 | from .webhooks import module as webhooks 4 | from .events import module as events 5 | -------------------------------------------------------------------------------- /python/src/routes/events.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from flask import Blueprint, render_template, request, jsonify 3 | from auth import auth_required 4 | from services.db import session, Event 5 | from helpers.hubspot import create_client 6 | from hubspot.crm.contacts import ( 7 | BatchReadInputSimplePublicObjectId, 8 | SimplePublicObjectId, 9 | ) 10 | 11 | module = Blueprint("events", __name__) 12 | 13 | 14 | @module.route("/") 15 | @auth_required 16 | def list(): 17 | events = session.query(Event).order_by(Event.occurred_at.desc()).limit(50).all() 18 | session.commit() 19 | 20 | return render_template( 21 | "events/list.html", 22 | events=events, 23 | now=datetime.datetime.now(), 24 | ) 25 | 26 | 27 | @module.route("/updates") 28 | def updates(): 29 | after = datetime.datetime.fromisoformat(request.args.get("after")) 30 | updates_count = session.query(Event).filter(Event.created_at > after).count() 31 | session.commit() 32 | 33 | return jsonify( 34 | { 35 | "updatesCount": updates_count, 36 | } 37 | ) 38 | -------------------------------------------------------------------------------- /python/src/routes/init.py: -------------------------------------------------------------------------------- 1 | import os 2 | from flask import Blueprint, render_template, redirect, url_for, request 3 | from auth import auth_required 4 | from helpers.hubspot import create_client_with_developer_api_key 5 | from helpers.webhooks import ( 6 | create_or_activate_subscription, 7 | configure_target_url, 8 | pause_active_subscriptions, 9 | ) 10 | 11 | module = Blueprint("init", __name__) 12 | 13 | 14 | @module.route("/") 15 | @auth_required 16 | def readme(): 17 | target_url = url_for("webhooks.handle", _external=True) 18 | return render_template("init/readme.html", target_url=target_url) 19 | 20 | 21 | @module.route("/", methods=["POST"]) 22 | @auth_required 23 | def start(): 24 | hubspot = create_client_with_developer_api_key() 25 | app_id = os.getenv("HUBSPOT_APPLICATION_ID") 26 | 27 | pause_active_subscriptions(hubspot_client=hubspot, app_id=app_id) 28 | 29 | target_url = url_for("webhooks.handle", _external=True) 30 | configure_target_url(hubspot_client=hubspot, app_id=app_id, target_url=target_url) 31 | 32 | subscriptions = [ 33 | {"event_type": "contact.propertyChange", "property_name": "firstname"}, 34 | {"event_type": "contact.creation"}, 35 | {"event_type": "contact.deletion"}, 36 | ] 37 | for subscription in subscriptions: 38 | create_or_activate_subscription( 39 | hubspot_client=hubspot, app_id=app_id, **subscription 40 | ) 41 | 42 | return redirect(url_for("events.list")) 43 | -------------------------------------------------------------------------------- /python/src/routes/oauth.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, render_template, redirect, request 2 | from hubspot.utils.oauth import get_auth_url 3 | import os 4 | from helpers.oauth import save_tokens, get_redirect_uri 5 | from helpers.hubspot import create_client 6 | 7 | module = Blueprint("oauth", __name__) 8 | 9 | 10 | @module.route("/login") 11 | def login(): 12 | return render_template("oauth/login.html") 13 | 14 | 15 | @module.route("/authorize") 16 | def authorize(): 17 | auth_url = get_auth_url( 18 | scopes=("contacts",), 19 | client_id=os.environ.get("HUBSPOT_CLIENT_ID"), 20 | redirect_uri=get_redirect_uri(), 21 | ) 22 | 23 | return redirect(auth_url) 24 | 25 | 26 | @module.route("/callback") 27 | def callback(): 28 | hubspot = create_client() 29 | tokens_response = hubspot.auth.oauth.default_api.create_token( 30 | grant_type="authorization_code", 31 | code=request.args.get("code"), 32 | redirect_uri=get_redirect_uri(), 33 | client_id=os.environ.get("HUBSPOT_CLIENT_ID"), 34 | client_secret=os.environ.get("HUBSPOT_CLIENT_SECRET"), 35 | ) 36 | save_tokens(tokens_response) 37 | 38 | return redirect("/") 39 | -------------------------------------------------------------------------------- /python/src/routes/webhooks.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, request 2 | import json 3 | import os 4 | import datetime 5 | from services.db import Event, session 6 | from auth import hubspot_signature_required 7 | 8 | module = Blueprint("webhooks", __name__) 9 | 10 | @module.route("/handle", methods=["POST"]) 11 | @hubspot_signature_required 12 | def handle(): 13 | messages = json.loads(request.data) 14 | 15 | for message in messages: 16 | event = Event() 17 | event.event_type = message["subscriptionType"] 18 | event.event_id = message["eventId"] 19 | event.object_id = message["objectId"] 20 | event.occurred_at = datetime.datetime.fromtimestamp(message["occurredAt"] // 1000) 21 | 22 | if "propertyName" in message: 23 | event.property_name = message["propertyName"] 24 | 25 | if "propertyValue" in message: 26 | event.property_value = message["propertyValue"] 27 | 28 | session.add(event) 29 | session.commit() 30 | 31 | return "", 204 32 | -------------------------------------------------------------------------------- /python/src/services/db.py: -------------------------------------------------------------------------------- 1 | import os 2 | from sqlalchemy.orm import sessionmaker 3 | from sqlalchemy import ( 4 | create_engine, 5 | Column, 6 | Integer, 7 | BigInteger, 8 | DateTime, 9 | VARCHAR, 10 | func, 11 | ) 12 | from sqlalchemy.ext.declarative import declarative_base 13 | 14 | 15 | engine = create_engine(os.getenv("DB_URL")) 16 | Session = sessionmaker(bind=engine) 17 | session = Session() 18 | 19 | Base = declarative_base() 20 | 21 | 22 | class Event(Base): 23 | __tablename__ = "events" 24 | 25 | id = Column(Integer, primary_key=True) 26 | event_type = Column(VARCHAR(length=255)) 27 | object_id = Column(Integer) 28 | event_id = Column(BigInteger) 29 | 30 | property_name = Column(VARCHAR(length=255)) 31 | property_value = Column(VARCHAR(length=255)) 32 | 33 | occurred_at = Column(DateTime) 34 | created_at = Column(DateTime, default=func.now()) 35 | 36 | 37 | def create_db_schema(): 38 | Base.metadata.create_all(engine, tables=[Event.__table__]) 39 | -------------------------------------------------------------------------------- /python/src/services/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | logging.basicConfig( 5 | stream=sys.stdout, 6 | level=logging.INFO, 7 | format="[%(asctime)s] {%(filename)s:%(lineno)d} %(levelname)s - %(message)s", 8 | ) 9 | 10 | logger = logging.getLogger() 11 | -------------------------------------------------------------------------------- /python/src/static/js/main.js: -------------------------------------------------------------------------------- 1 | function requestNotShownEventsCount() { 2 | return new Promise((resolve) => { 3 | $.getJSON("/events/updates?after=" + $('#updates-alert').attr('data-now') , data => { 4 | const { updatesCount } = data; 5 | resolve(updatesCount); 6 | }); 7 | }); 8 | } 9 | 10 | async function displayNotShownEventsAlertIfNeed() { 11 | const updatesCount = await requestNotShownEventsCount(); 12 | if (updatesCount > 0) { 13 | $('#no-webhooks-alert').hide(); 14 | $('#updates-alert').show(); 15 | } 16 | } 17 | 18 | $(document).ready(async () => { 19 | setInterval(displayNotShownEventsAlertIfNeed, 10000); 20 | }); 21 | -------------------------------------------------------------------------------- /python/src/static/styles/main.css: -------------------------------------------------------------------------------- 1 | .wrapper .container { 2 | padding-bottom: 2rem; 3 | padding-top: 2rem; 4 | } 5 | 6 | .navigation { 7 | background: #f4f5f6; 8 | border-bottom: .1rem solid #d1d1d1; 9 | display: block; 10 | height: 5.2rem; 11 | left: 0; 12 | max-width: 100%; 13 | width: 100%; 14 | } 15 | 16 | .navigation .container { 17 | padding-bottom: 0; 18 | padding-top: 0 19 | } 20 | 21 | .navigation .navigation-list { 22 | list-style: none; 23 | margin-bottom: 0; 24 | } 25 | 26 | .navigation .navigation-item { 27 | float: left; 28 | margin-bottom: 0; 29 | margin-left: 2.5rem; 30 | position: relative 31 | } 32 | 33 | .navigation .navigation-title, .navigation .title { 34 | color: #606c76; 35 | position: relative 36 | } 37 | 38 | .navigation .navigation-link, .navigation .navigation-title, .navigation .title { 39 | display: inline; 40 | font-size: 1.6rem; 41 | line-height: 5.2rem; 42 | padding: 0; 43 | text-decoration: none 44 | } 45 | 46 | .navigation .navigation-link.active { 47 | color: #606c76 48 | } 49 | 50 | .hidden { 51 | display: none; 52 | } 53 | 54 | .event.deletion { 55 | color: white; 56 | background-color: red; 57 | } 58 | 59 | .event.creation { 60 | color: #3c763d; 61 | background-color: #dff0d8; 62 | } 63 | 64 | .event.propertyChange { 65 | background-color: #d8aa65; 66 | color: #ffffff; 67 | } 68 | 69 | .authorize-button { 70 | justify-content: center; 71 | } 72 | 73 | .text-center { 74 | text-align: center; 75 | } 76 | 77 | -------------------------------------------------------------------------------- /python/src/templates/events/list.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block static %} 4 | 5 | 6 | {% endblock %} 7 | 8 | {% block content %} 9 |
10 |
11 | 13 | {% if events | length > 0 %} 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {% for event in events %} 24 | 25 | 26 | 32 | 35 | 36 | {% endfor %} 37 | 38 |
Contact IDEventOccurred At
{{ event.object_id }} 27 | {{ event.event_type }} 28 | {% if event.property_name %} 29 | ({{ event.property_name }} to "{{ event.property_value }}") 30 | {% endif %} 31 | 33 | {{ event.occurred_at }} 34 |
39 | {% else %} 40 |

No webhooks have been received so far.

41 | {% endif %} 42 |
43 |
44 | 45 | {% endblock %} 46 | -------------------------------------------------------------------------------- /python/src/templates/init/readme.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block content %} 4 | 5 |
6 |
7 |

Webhooks initialization:

8 | 9 |
1. All active subscriptions will be paused
10 |
2. Target url will be updated to {{ target_url }}
11 |
3. Following subscriptions will be created and activated:
12 |
    13 |
  • Contact's Creation (contact.creation)
  • 14 |
  • Changing of Contact's Fist Name (contact.propertyChange)
  • 15 |
  • Contact's Deletion (contact.deletion)
  • 16 |
17 | 18 |
19 | // src/routes/init.py
20 | pause_active_subscriptions(hubspot_client=hubspot, app_id=app_id)
21 | 
22 | target_url = url_for("webhooks.handle", _external=True)
23 | configure_target_url(hubspot_client=hubspot, app_id=app_id, target_url=target_url)
24 | 
25 | subscriptions = [
26 |     {"event_type": "contact.propertyChange", "property_name": "firstname"},
27 |     {"event_type": "contact.creation"},
28 |     {"event_type": "contact.deletion"},
29 | ]
30 | for subscription in subscriptions:
31 |     create_or_activate_subscription(
32 |         hubspot_client=hubspot,
33 |         app_id=app_id,
34 |         **subscription,
35 |     )
36 | 
37 |
38 | 39 |
40 |
41 |
42 | 43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /python/src/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | HubSpot Python sample webhooks app 6 | 7 | 8 | 9 | 10 | {% block static %}{% endblock %} 11 | 12 | 13 |
14 | 32 |
33 | {% block content %} 34 | {% endblock %} 35 |
36 |
37 | 38 | 39 | -------------------------------------------------------------------------------- /python/src/templates/oauth/login.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block content %} 4 |
 5 | // src/routes/oauth.py - Generate URL for OAuth
 6 | from hubspot.utils.oauth import get_auth_url
 7 | auth_url = get_auth_url(
 8 |     scopes=('contacts',),
 9 |     client_id=os.environ.get('HUBSPOT_CLIENT_ID'),
10 |     redirect_uri=get_redirect_uri(),
11 | )
12 | 
13 | 14 |

In order to continue please authorize via OAuth

15 |
16 | Authorize 17 |
18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /ruby/.env.template: -------------------------------------------------------------------------------- 1 | HUBSPOT_APPLICATION_ID= 2 | HUBSPOT_CLIENT_ID= 3 | HUBSPOT_CLIENT_SECRET= 4 | HUBSPOT_DEVELOPER_API_KEY= 5 | -------------------------------------------------------------------------------- /ruby/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore the default SQLite database. 11 | /db/*.sqlite3 12 | /db/*.sqlite3-journal 13 | db/mysql 14 | 15 | # Ignore all logfiles and tempfiles. 16 | /log/* 17 | /tmp/* 18 | !/log/.keep 19 | !/tmp/.keep 20 | 21 | # Ignore uploaded files in development 22 | /storage/* 23 | !/storage/.keep 24 | 25 | /node_modules 26 | /yarn-error.log 27 | 28 | /public/assets 29 | .byebug_history 30 | 31 | # Ignore master key for decrypting credentials and more. 32 | /config/master.key 33 | 34 | .rubocop.yml 35 | .env 36 | .env.test -------------------------------------------------------------------------------- /ruby/.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-2.6.3 -------------------------------------------------------------------------------- /ruby/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:2.6.3 2 | RUN apt-get update -qq && apt-get install -y mariadb-client nodejs 3 | 4 | RUN mkdir /hubspot-api-client 5 | 6 | ADD ./ /hubspot-api-client 7 | 8 | WORKDIR /hubspot-api-client 9 | RUN gem install bundler 10 | RUN bundle install 11 | 12 | # Add a script to be executed every time the container starts. 13 | COPY ./docker-entrypoint.sh /usr/bin/ 14 | RUN chmod +x /usr/bin/docker-entrypoint.sh 15 | ENTRYPOINT ["docker-entrypoint.sh"] 16 | EXPOSE 3000 17 | 18 | # Start the main process. 19 | CMD ["rails", "server", "-b", "0.0.0.0"] 20 | -------------------------------------------------------------------------------- /ruby/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | ruby '2.6.3' 5 | 6 | gem 'rails', '~> 5.2.4' 7 | gem 'puma', '~> 4.3.5' 8 | gem 'sass-rails', '~> 5.0' 9 | gem 'uglifier', '>= 1.3.0' 10 | gem 'jbuilder', '~> 2.5' 11 | gem 'mysql2', '~> 0.5.2' 12 | gem 'hubspot-api-client' 13 | gem 'mimemagic', github: 'mimemagicrb/mimemagic', ref: '01f92d86d15d85cfd0f20dabd025dcbd36a8a60f' 14 | 15 | group :development, :test do 16 | gem 'pry' 17 | gem 'pry-byebug' 18 | gem 'dotenv-rails', require: 'dotenv/rails-now' 19 | gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] 20 | gem 'rspec-rails', '~> 3.5' 21 | gem 'capybara' 22 | gem 'capybara-mechanize', '~> 1.11' 23 | gem 'selenium-webdriver', '3.4.3' 24 | gem 'geckodriver-helper' 25 | end 26 | 27 | group :development do 28 | gem 'web-console', '>= 3.3.0' 29 | gem 'listen', '>= 3.0.5', '< 3.2' 30 | end 31 | 32 | group :test do 33 | gem 'database_cleaner' 34 | gem 'faraday', '~> 0.17.1' 35 | gem 'faker', :git => 'https://github.com/faker-ruby/faker.git', :branch => 'master' 36 | end 37 | 38 | gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] 39 | -------------------------------------------------------------------------------- /ruby/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: https://github.com/faker-ruby/faker.git 3 | revision: d32beae1ed41353c9b74db8b30512deb2366a0cf 4 | branch: master 5 | specs: 6 | faker (2.15.1) 7 | i18n (>= 1.6, < 2) 8 | 9 | GIT 10 | remote: https://github.com/mimemagicrb/mimemagic.git 11 | revision: 01f92d86d15d85cfd0f20dabd025dcbd36a8a60f 12 | ref: 01f92d86d15d85cfd0f20dabd025dcbd36a8a60f 13 | specs: 14 | mimemagic (0.3.5) 15 | 16 | GEM 17 | remote: https://rubygems.org/ 18 | specs: 19 | actioncable (5.2.4.4) 20 | actionpack (= 5.2.4.4) 21 | nio4r (~> 2.0) 22 | websocket-driver (>= 0.6.1) 23 | actionmailer (5.2.4.4) 24 | actionpack (= 5.2.4.4) 25 | actionview (= 5.2.4.4) 26 | activejob (= 5.2.4.4) 27 | mail (~> 2.5, >= 2.5.4) 28 | rails-dom-testing (~> 2.0) 29 | actionpack (5.2.4.4) 30 | actionview (= 5.2.4.4) 31 | activesupport (= 5.2.4.4) 32 | rack (~> 2.0, >= 2.0.8) 33 | rack-test (>= 0.6.3) 34 | rails-dom-testing (~> 2.0) 35 | rails-html-sanitizer (~> 1.0, >= 1.0.2) 36 | actionview (5.2.4.4) 37 | activesupport (= 5.2.4.4) 38 | builder (~> 3.1) 39 | erubi (~> 1.4) 40 | rails-dom-testing (~> 2.0) 41 | rails-html-sanitizer (~> 1.0, >= 1.0.3) 42 | activejob (5.2.4.4) 43 | activesupport (= 5.2.4.4) 44 | globalid (>= 0.3.6) 45 | activemodel (5.2.4.4) 46 | activesupport (= 5.2.4.4) 47 | activerecord (5.2.4.4) 48 | activemodel (= 5.2.4.4) 49 | activesupport (= 5.2.4.4) 50 | arel (>= 9.0) 51 | activestorage (5.2.4.4) 52 | actionpack (= 5.2.4.4) 53 | activerecord (= 5.2.4.4) 54 | marcel (~> 0.3.1) 55 | activesupport (5.2.4.4) 56 | concurrent-ruby (~> 1.0, >= 1.0.2) 57 | i18n (>= 0.7, < 2) 58 | minitest (~> 5.1) 59 | tzinfo (~> 1.1) 60 | addressable (2.7.0) 61 | public_suffix (>= 2.0.2, < 5.0) 62 | archive-zip (0.12.0) 63 | io-like (~> 0.3.0) 64 | arel (9.0.0) 65 | bindex (0.8.1) 66 | builder (3.2.4) 67 | byebug (11.1.3) 68 | capybara (3.34.0) 69 | addressable 70 | mini_mime (>= 0.1.3) 71 | nokogiri (~> 1.8) 72 | rack (>= 1.6.0) 73 | rack-test (>= 0.6.3) 74 | regexp_parser (~> 1.5) 75 | xpath (~> 3.2) 76 | capybara-mechanize (1.11.0) 77 | capybara (>= 2.4.4, < 4) 78 | mechanize (~> 2.7.0) 79 | childprocess (0.9.0) 80 | ffi (~> 1.0, >= 1.0.11) 81 | coderay (1.1.3) 82 | concurrent-ruby (1.1.7) 83 | connection_pool (2.2.3) 84 | crass (1.0.6) 85 | database_cleaner (1.8.5) 86 | diff-lcs (1.4.4) 87 | domain_name (0.5.20190701) 88 | unf (>= 0.0.5, < 1.0.0) 89 | dotenv (2.7.6) 90 | dotenv-rails (2.7.6) 91 | dotenv (= 2.7.6) 92 | railties (>= 3.2) 93 | erubi (1.10.0) 94 | ethon (0.12.0) 95 | ffi (>= 1.3.0) 96 | execjs (2.7.0) 97 | faraday (0.17.3) 98 | multipart-post (>= 1.2, < 3) 99 | ffi (1.14.2) 100 | geckodriver-helper (0.24.0) 101 | archive-zip (~> 0.7) 102 | globalid (0.4.2) 103 | activesupport (>= 4.2.0) 104 | http-cookie (1.0.3) 105 | domain_name (~> 0.5) 106 | hubspot-api-client (9.2.0) 107 | json (~> 2.1, >= 2.1.0) 108 | typhoeus (~> 1.4.0) 109 | i18n (1.8.5) 110 | concurrent-ruby (~> 1.0) 111 | io-like (0.3.1) 112 | jbuilder (2.10.1) 113 | activesupport (>= 5.0.0) 114 | json (2.5.1) 115 | listen (3.1.5) 116 | rb-fsevent (~> 0.9, >= 0.9.4) 117 | rb-inotify (~> 0.9, >= 0.9.7) 118 | ruby_dep (~> 1.2) 119 | loofah (2.8.0) 120 | crass (~> 1.0.2) 121 | nokogiri (>= 1.5.9) 122 | mail (2.7.1) 123 | mini_mime (>= 0.1.1) 124 | marcel (0.3.3) 125 | mimemagic (~> 0.3.2) 126 | mechanize (2.7.7) 127 | domain_name (~> 0.5, >= 0.5.1) 128 | http-cookie (~> 1.0) 129 | mime-types (>= 1.17.2) 130 | net-http-digest_auth (~> 1.1, >= 1.1.1) 131 | net-http-persistent (>= 2.5.2) 132 | nokogiri (~> 1.6) 133 | ntlm-http (~> 0.1, >= 0.1.1) 134 | webrick (~> 1.7) 135 | webrobots (>= 0.0.9, < 0.2) 136 | method_source (1.0.0) 137 | mime-types (3.3.1) 138 | mime-types-data (~> 3.2015) 139 | mime-types-data (3.2020.1104) 140 | mini_mime (1.0.2) 141 | mini_portile2 (2.5.0) 142 | minitest (5.14.2) 143 | multipart-post (2.1.1) 144 | mysql2 (0.5.3) 145 | net-http-digest_auth (1.4.1) 146 | net-http-persistent (4.0.1) 147 | connection_pool (~> 2.2) 148 | nio4r (2.5.4) 149 | nokogiri (1.11.1) 150 | mini_portile2 (~> 2.5.0) 151 | racc (~> 1.4) 152 | ntlm-http (0.1.1) 153 | pry (0.13.1) 154 | coderay (~> 1.1) 155 | method_source (~> 1.0) 156 | pry-byebug (3.9.0) 157 | byebug (~> 11.0) 158 | pry (~> 0.13.0) 159 | public_suffix (4.0.6) 160 | puma (4.3.7) 161 | nio4r (~> 2.0) 162 | racc (1.5.2) 163 | rack (2.2.3) 164 | rack-test (1.1.0) 165 | rack (>= 1.0, < 3) 166 | rails (5.2.4.4) 167 | actioncable (= 5.2.4.4) 168 | actionmailer (= 5.2.4.4) 169 | actionpack (= 5.2.4.4) 170 | actionview (= 5.2.4.4) 171 | activejob (= 5.2.4.4) 172 | activemodel (= 5.2.4.4) 173 | activerecord (= 5.2.4.4) 174 | activestorage (= 5.2.4.4) 175 | activesupport (= 5.2.4.4) 176 | bundler (>= 1.3.0) 177 | railties (= 5.2.4.4) 178 | sprockets-rails (>= 2.0.0) 179 | rails-dom-testing (2.0.3) 180 | activesupport (>= 4.2.0) 181 | nokogiri (>= 1.6) 182 | rails-html-sanitizer (1.3.0) 183 | loofah (~> 2.3) 184 | railties (5.2.4.4) 185 | actionpack (= 5.2.4.4) 186 | activesupport (= 5.2.4.4) 187 | method_source 188 | rake (>= 0.8.7) 189 | thor (>= 0.19.0, < 2.0) 190 | rake (13.0.3) 191 | rb-fsevent (0.10.4) 192 | rb-inotify (0.10.1) 193 | ffi (~> 1.0) 194 | regexp_parser (1.8.2) 195 | rspec-core (3.9.3) 196 | rspec-support (~> 3.9.3) 197 | rspec-expectations (3.9.4) 198 | diff-lcs (>= 1.2.0, < 2.0) 199 | rspec-support (~> 3.9.0) 200 | rspec-mocks (3.9.1) 201 | diff-lcs (>= 1.2.0, < 2.0) 202 | rspec-support (~> 3.9.0) 203 | rspec-rails (3.9.1) 204 | actionpack (>= 3.0) 205 | activesupport (>= 3.0) 206 | railties (>= 3.0) 207 | rspec-core (~> 3.9.0) 208 | rspec-expectations (~> 3.9.0) 209 | rspec-mocks (~> 3.9.0) 210 | rspec-support (~> 3.9.0) 211 | rspec-support (3.9.4) 212 | ruby_dep (1.5.0) 213 | rubyzip (1.3.0) 214 | sass (3.7.4) 215 | sass-listen (~> 4.0.0) 216 | sass-listen (4.0.0) 217 | rb-fsevent (~> 0.9, >= 0.9.4) 218 | rb-inotify (~> 0.9, >= 0.9.7) 219 | sass-rails (5.1.0) 220 | railties (>= 5.2.0) 221 | sass (~> 3.1) 222 | sprockets (>= 2.8, < 4.0) 223 | sprockets-rails (>= 2.0, < 4.0) 224 | tilt (>= 1.1, < 3) 225 | selenium-webdriver (3.4.3) 226 | childprocess (~> 0.5) 227 | rubyzip (~> 1.0) 228 | sprockets (3.7.2) 229 | concurrent-ruby (~> 1.0) 230 | rack (> 1, < 3) 231 | sprockets-rails (3.2.2) 232 | actionpack (>= 4.0) 233 | activesupport (>= 4.0) 234 | sprockets (>= 3.0.0) 235 | thor (1.0.1) 236 | thread_safe (0.3.6) 237 | tilt (2.0.10) 238 | typhoeus (1.4.0) 239 | ethon (>= 0.9.0) 240 | tzinfo (1.2.9) 241 | thread_safe (~> 0.1) 242 | uglifier (4.2.0) 243 | execjs (>= 0.3.0, < 3) 244 | unf (0.1.4) 245 | unf_ext 246 | unf_ext (0.0.7.7) 247 | web-console (3.7.0) 248 | actionview (>= 5.0) 249 | activemodel (>= 5.0) 250 | bindex (>= 0.4.0) 251 | railties (>= 5.0) 252 | webrick (1.7.0) 253 | webrobots (0.1.2) 254 | websocket-driver (0.7.3) 255 | websocket-extensions (>= 0.1.0) 256 | websocket-extensions (0.1.5) 257 | xpath (3.2.0) 258 | nokogiri (~> 1.8) 259 | 260 | PLATFORMS 261 | ruby 262 | 263 | DEPENDENCIES 264 | byebug 265 | capybara 266 | capybara-mechanize (~> 1.11) 267 | database_cleaner 268 | dotenv-rails 269 | faker! 270 | faraday (~> 0.17.1) 271 | geckodriver-helper 272 | hubspot-api-client 273 | jbuilder (~> 2.5) 274 | listen (>= 3.0.5, < 3.2) 275 | mimemagic! 276 | mysql2 (~> 0.5.2) 277 | pry 278 | pry-byebug 279 | puma (~> 4.3.5) 280 | rails (~> 5.2.4) 281 | rspec-rails (~> 3.5) 282 | sass-rails (~> 5.0) 283 | selenium-webdriver (= 3.4.3) 284 | tzinfo-data 285 | uglifier (>= 1.3.0) 286 | web-console (>= 3.3.0) 287 | 288 | RUBY VERSION 289 | ruby 2.6.3p62 290 | 291 | BUNDLED WITH 292 | 1.17.3 293 | -------------------------------------------------------------------------------- /ruby/README.md: -------------------------------------------------------------------------------- 1 | # HubSpot-ruby sample Webhooks app 2 | 3 | ### Note on the Data Base 4 | This application uses MySQL database to store the events coming from Webhooks. There is a single events table: 5 | ``` 6 | create table if not exists events 7 | ( 8 | id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, 9 | event_type VARCHAR(255), 10 | object_id int default null, 11 | event_id bigint default null, 12 | occurred_at bigint default null, 13 | property_name varchar(255) default null, 14 | property_value varchar(255) default null, 15 | created_at datetime default CURRENT_TIMESTAMP 16 | ); 17 | ``` 18 | Please note that event_id sent by HubSpot needs to be stored as int 19 | 20 | ### Setup App 21 | 22 | Make sure you have [Docker Compose](https://docs.docker.com/compose/) installed. 23 | 24 | ### Configure 25 | 26 | 1. Copy .env.template to .env 27 | 2. Paste your HUBSPOT_CLIENT_ID, HUBSPOT_CLIENT_SECRET, HUBSPOT_APPLICATION_ID and HUBSPOT_DEVELOPER_API_KEY 28 | 29 | ### Running 30 | 31 | The best way to run this project (with the least configuration), is using docker compose. Change to the webroot and start it 32 | 33 | ```bash 34 | docker-compose up --build 35 | ``` 36 | 37 | Copy Ngrok url from console. Now you should now be able to navigate to that url and use the application. 38 | 39 | ### NOTE about Ngrok Too Many Connections error 40 | 41 | If you are using Ngrok free plan and testing the application with large amount of import/deletions of Contacts you are likely to see Ngrok "Too Many Connections" error. 42 | This is caused by a large amount of weebhooks events being sent to Ngrok tunnel. To avoid it you can deploy sample applications on your server w/o Ngrok or upgrade to Ngrok Enterprise version 43 | 44 | ### HubSpot Signature 45 | To help improve security, HubSpot webhooks are sent with signature so you can verify that it came from HubSpot. This sample application shows how to do that verification. You can read more about validation in general here: https://developers.hubspot.com/docs/api/webhooks/validating-requests. 46 | The source code for validating webhooks is at [an usage example](./src/routes/webhooks.py). 47 | 48 | ### Process with the app 49 | 50 | 1. Authorize your app with Hubpost OAuth (Press "Authorize" button). 51 | 2. Subscribe to Hubspot Webhooks (Press "Start" button). 52 | 3. Create some Hubspot Contacts. You can use this [Sample App](https://github.com/HubSpot/sample-apps-manage-crm-objects) to do so. 53 | 54 | ``` 55 | ruby cli.rb -m create -t contact -p '{"email":"brianhalligan@email.com","firstname":"Brian","lastname":"Halligan"}' 56 | ``` 57 | 58 | 4. Reload /events page to check recieved updates. 59 | -------------------------------------------------------------------------------- /ruby/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative 'config/application' 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /ruby/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../javascripts .js 3 | //= link_directory ../stylesheets .css 4 | -------------------------------------------------------------------------------- /ruby/app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HubSpot/sample-apps-webhooks/4976692a89a15d66a4556555496b39c69db537a3/ruby/app/assets/images/.keep -------------------------------------------------------------------------------- /ruby/app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, or any plugin's 5 | // vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. JavaScript code in this file should be added after the last require_* statement. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require rails-ujs 14 | //= require activestorage 15 | //= require_tree . 16 | 17 | 18 | function reloadPage() { 19 | document.location.reload(); 20 | } 21 | 22 | function requestNotShownEventsCount() { 23 | return new Promise((resolve) => { 24 | $.getJSON("/events/not_shown_count?time_mark=" + $('#alert-not-shown-events').attr('datetime-mark'), (response) => { 25 | const { count } = response; 26 | console.log(count) 27 | resolve(count); 28 | }); 29 | }); 30 | } 31 | 32 | async function displayNotShownEventsAlertIfNeed() { 33 | const notShownEventsCount = await requestNotShownEventsCount(); 34 | if (notShownEventsCount > 0) { 35 | $('#empty-message').hide(); 36 | $('#alert-not-shown-events').show(); 37 | } 38 | } 39 | 40 | $(document).ready(async () => { 41 | setInterval(displayNotShownEventsAlertIfNeed, 10000); 42 | $('#alert-not-shown-events').click(() => { 43 | reloadPage(); 44 | return false; 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /ruby/app/assets/javascripts/cable.js: -------------------------------------------------------------------------------- 1 | // Action Cable provides the framework to deal with WebSockets in Rails. 2 | // You can generate new channels where WebSocket features live using the `rails generate channel` command. 3 | // 4 | //= require action_cable 5 | //= require_self 6 | //= require_tree ./channels 7 | 8 | (function() { 9 | this.App || (this.App = {}); 10 | 11 | App.cable = ActionCable.createConsumer(); 12 | 13 | }).call(this); 14 | -------------------------------------------------------------------------------- /ruby/app/assets/javascripts/channels/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HubSpot/sample-apps-webhooks/4976692a89a15d66a4556555496b39c69db537a3/ruby/app/assets/javascripts/channels/.keep -------------------------------------------------------------------------------- /ruby/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's 6 | * vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | 17 | .wrapper .container { 18 | padding-bottom: 2rem; 19 | padding-top: 2rem; 20 | } 21 | 22 | .navigation { 23 | background: #f4f5f6; 24 | border-bottom: .1rem solid #d1d1d1; 25 | display: block; 26 | height: 5.2rem; 27 | left: 0; 28 | max-width: 100%; 29 | width: 100%; 30 | } 31 | 32 | .navigation .container { 33 | padding-bottom: 0; 34 | padding-top: 0 35 | } 36 | 37 | .navigation .navigation-list { 38 | list-style: none; 39 | margin-bottom: 0; 40 | } 41 | 42 | .navigation .navigation-item { 43 | float: left; 44 | margin-bottom: 0; 45 | margin-left: 2.5rem; 46 | position: relative 47 | } 48 | 49 | .navigation .navigation-title, .navigation .title { 50 | color: #606c76; 51 | position: relative 52 | } 53 | 54 | .navigation .navigation-link, .navigation .navigation-title, .navigation .title { 55 | display: inline; 56 | font-size: 1.6rem; 57 | line-height: 5.2rem; 58 | padding: 0; 59 | text-decoration: none 60 | } 61 | 62 | .navigation .navigation-link.active { 63 | color: #606c76 64 | } 65 | 66 | .hidden { 67 | display: none; 68 | } 69 | 70 | .event.deletion { 71 | color: white; 72 | background-color: red; 73 | } 74 | 75 | .event.creation { 76 | color: #3c763d; 77 | background-color: #dff0d8; 78 | } 79 | 80 | .event.propertyChange { 81 | background-color: #d8aa65; 82 | color: #ffffff; 83 | } 84 | 85 | .authorize-button { 86 | justify-content: center; 87 | } 88 | 89 | .text-center { 90 | text-align: center; 91 | } 92 | 93 | -------------------------------------------------------------------------------- /ruby/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | include ExceptionHandler 3 | 4 | before_action :check_env_variables 5 | 6 | helper_method :authorized? 7 | 8 | private 9 | 10 | def authorized? 11 | session['tokens'].present? 12 | end 13 | 14 | def check_env_variables 15 | missing_vars = %w[HUBSPOT_CLIENT_ID HUBSPOT_CLIENT_SECRET].select { |var| ENV[var].blank? } 16 | raise(ExceptionHandler::HubspotError.new, "Please specify #{missing_vars.join(', ')} in .env") if missing_vars.present? 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /ruby/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HubSpot/sample-apps-webhooks/4976692a89a15d66a4556555496b39c69db537a3/ruby/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /ruby/app/controllers/concerns/exception_handler.rb: -------------------------------------------------------------------------------- 1 | module ExceptionHandler 2 | extend ActiveSupport::Concern 3 | 4 | class HubspotError < StandardError; end 5 | 6 | included do 7 | rescue_from HubspotError do |error| 8 | @error = error 9 | render template: 'events/index' 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /ruby/app/controllers/events_controller.rb: -------------------------------------------------------------------------------- 1 | class EventsController < ApplicationController 2 | before_action :authorize 3 | 4 | def index 5 | @events = Event.all 6 | end 7 | 8 | def not_shown_count 9 | render json: { count: Event.where('created_at > ?', params[:time_mark].to_datetime).count } 10 | end 11 | 12 | private 13 | 14 | def authorize 15 | unless authorized? 16 | redirect_to login_path and return unless Token.any? 17 | 18 | session['tokens'] = Token.instance.attributes 19 | end 20 | 21 | session['tokens'] = Services::Hubspot::Authorization::Tokens::Refresh.new(tokens: session['tokens'], request: request).call 22 | Services::Hubspot::Authorization::Authorize.new(tokens: session['tokens']).call 23 | end 24 | end -------------------------------------------------------------------------------- /ruby/app/controllers/home_controller.rb: -------------------------------------------------------------------------------- 1 | class HomeController < ApplicationController 2 | before_action :authorize 3 | 4 | def index 5 | @target_url = url_for(controller: :webhooks, action: :callback, only_path: false, protocol: 'https') 6 | end 7 | 8 | def start 9 | ::Hubspot.configure do |config| 10 | config.api_key = { 'hapikey' => ENV['HUBSPOT_DEVELOPER_API_KEY'] } 11 | end 12 | 13 | app_id = ENV['HUBSPOT_APPLICATION_ID'] 14 | Services::Hubspot::Webhooks::PauseActiveSubscriptions.new(app_id: app_id).call 15 | 16 | target_url = url_for(controller: :webhooks, action: :callback, only_path: false, protocol: 'https') 17 | Services::Hubspot::Webhooks::ConfigureTargetUrl.new(app_id: app_id, target_url: target_url).call 18 | 19 | subscriptions = [ 20 | {"event_type": "contact.propertyChange", "property_name": "firstname"}, 21 | {"event_type": "contact.creation"}, 22 | {"event_type": "contact.deletion"}, 23 | ] 24 | 25 | subscriptions.each do |subscription| 26 | Services::Hubspot::Webhooks::CreateOrActivateSubscription.new(app_id: app_id, **subscription).call 27 | end 28 | 29 | redirect_to controller: :events, action: :index 30 | end 31 | 32 | private 33 | 34 | def authorize 35 | unless authorized? 36 | redirect_to login_path and return unless Token.any? 37 | 38 | session['tokens'] = Token.instance.attributes 39 | end 40 | 41 | session['tokens'] = Services::Hubspot::Authorization::Tokens::Refresh.new(tokens: session['tokens'], request: request).call 42 | Services::Hubspot::Authorization::Authorize.new(tokens: session['tokens']).call 43 | end 44 | end -------------------------------------------------------------------------------- /ruby/app/controllers/oauth/authorization_controller.rb: -------------------------------------------------------------------------------- 1 | module Oauth 2 | class AuthorizationController < ApplicationController 3 | def authorize 4 | url = Services::Hubspot::Authorization::GetAuthorizationUri.new(request: request).call 5 | redirect_to url 6 | end 7 | 8 | def callback 9 | session[:tokens] = Services::Hubspot::Authorization::Tokens::Generate.new( 10 | code: params[:code], 11 | request: request 12 | ).call 13 | Services::Hubspot::Authorization::Authorize.new(tokens: session[:tokens]).call 14 | redirect_to '/' 15 | end 16 | 17 | def login;end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /ruby/app/controllers/webhooks_controller.rb: -------------------------------------------------------------------------------- 1 | class WebhooksController < ApplicationController 2 | skip_before_action :verify_authenticity_token, only: [:callback] 3 | 4 | def callback 5 | webhooks = JSON.parse(request.raw_post) 6 | webhooks.each { |webhook| Services::Hubspot::Webhooks::Handle.new(webhook: webhook, request: request).call } 7 | render json: {} 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /ruby/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /ruby/app/lib/services/hubspot/authorization/authorize.rb: -------------------------------------------------------------------------------- 1 | module Services 2 | module Hubspot 3 | module Authorization 4 | class Authorize 5 | def initialize(tokens:) 6 | @tokens = tokens 7 | end 8 | 9 | def call 10 | ::Hubspot.configure do |config| 11 | config.access_token = @tokens[:access_token] 12 | end 13 | end 14 | end 15 | end 16 | end 17 | end -------------------------------------------------------------------------------- /ruby/app/lib/services/hubspot/authorization/get_authorization_uri.rb: -------------------------------------------------------------------------------- 1 | module Services 2 | module Hubspot 3 | module Authorization 4 | class GetAuthorizationUri 5 | CALLBACK_PATH = '/oauth/callback'.freeze 6 | 7 | def initialize(request:) 8 | @request = request 9 | end 10 | 11 | def call 12 | check_presence_of_credentials 13 | 14 | ::Hubspot::OAuthHelper.authorize_url( 15 | client_id: ENV['HUBSPOT_CLIENT_ID'], 16 | redirect_uri: redirect_uri, 17 | scope: %w[contacts] 18 | ) 19 | end 20 | 21 | private 22 | 23 | def redirect_uri 24 | @request.protocol + @request.host_with_port + CALLBACK_PATH 25 | end 26 | 27 | def check_presence_of_credentials 28 | return if ENV['HUBSPOT_CLIENT_ID'].present? 29 | 30 | raise(ExceptionHandler::HubspotError.new, 'Please specify HUBSPOT_CLIENT_ID in .env') 31 | end 32 | end 33 | end 34 | end 35 | end -------------------------------------------------------------------------------- /ruby/app/lib/services/hubspot/authorization/tokens/base.rb: -------------------------------------------------------------------------------- 1 | module Services 2 | module Hubspot 3 | module Authorization 4 | module Tokens 5 | class Base 6 | CALLBACK_PATH = '/oauth/callback'.freeze 7 | 8 | def expires_at(expires_in) 9 | Time.current + (expires_in * 0.95).round 10 | end 11 | 12 | private 13 | 14 | def redirect_uri 15 | @request.protocol + @request.host_with_port + CALLBACK_PATH 16 | end 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /ruby/app/lib/services/hubspot/authorization/tokens/generate.rb: -------------------------------------------------------------------------------- 1 | module Services 2 | module Hubspot 3 | module Authorization 4 | module Tokens 5 | class Generate < Tokens::Base 6 | def initialize(code:, request:) 7 | @code = code 8 | @request = request 9 | end 10 | 11 | def call 12 | default_api = ::Hubspot::OAuth::DefaultApi.new 13 | tokens = default_api.create_token( 14 | grant_type: :authorization_code, 15 | code: @code, 16 | redirect_uri: redirect_uri, 17 | client_id: ENV['HUBSPOT_CLIENT_ID'], 18 | client_secret: ENV['HUBSPOT_CLIENT_SECRET'], 19 | return_type: 'Object' 20 | ) 21 | tokens[:expires_at] = expires_at(tokens[:expires_in]) 22 | tokens 23 | end 24 | end 25 | end 26 | end 27 | end 28 | end -------------------------------------------------------------------------------- /ruby/app/lib/services/hubspot/authorization/tokens/refresh.rb: -------------------------------------------------------------------------------- 1 | module Services 2 | module Hubspot 3 | module Authorization 4 | module Tokens 5 | class Refresh < Tokens::Base 6 | def initialize(tokens:, request:) 7 | @tokens = tokens 8 | @request = request 9 | end 10 | 11 | def call 12 | @tokens = refresh_tokens if Time.current > @tokens[:expires_at] 13 | @tokens 14 | end 15 | 16 | private 17 | 18 | def refresh_tokens 19 | default_api = ::Hubspot::OAuth::DefaultApi.new 20 | tokens = default_api.create_token( 21 | grant_type: :refresh_token, 22 | refresh_token: @tokens[:refresh_token], 23 | redirect_uri: redirect_uri, 24 | client_id: ENV['HUBSPOT_CLIENT_ID'], 25 | client_secret: ENV['HUBSPOT_CLIENT_SECRET'], 26 | return_type: 'Object' 27 | ) 28 | tokens[:expires_at] = expires_at(tokens[:expires_in]) 29 | tokens 30 | end 31 | end 32 | end 33 | end 34 | end 35 | end -------------------------------------------------------------------------------- /ruby/app/lib/services/hubspot/contacts/get_batch.rb: -------------------------------------------------------------------------------- 1 | module Services 2 | module Hubspot 3 | module Contacts 4 | class GetBatch 5 | def initialize(ids) 6 | @ids = ids 7 | end 8 | 9 | def call 10 | contact_id_objects = ::Hubspot::Crm::Contacts::BatchReadInputSimplePublicObjectId.new( 11 | inputs: @ids.map { |id| ::Hubspot::Crm::Contacts::SimplePublicObjectId.new(id: id) } 12 | ) 13 | batch = ::Hubspot::Crm::Contacts::BatchApi.new.read(contact_id_objects, auth_names: 'oauth2').results 14 | 15 | contact_names = names(batch) 16 | @ids.map do |id| 17 | { 18 | id: id, 19 | events: Event.where(object_id: id).order(:occured_at), 20 | name: contact_names[id] 21 | } 22 | end 23 | end 24 | 25 | private 26 | 27 | def names(contact_objects) 28 | contact_objects ||= [] 29 | contact_objects.each_with_object({}) do |contact, hash| 30 | hash[contact.id.to_i] = [contact.properties['firstname'], contact.properties['lastname']].join(' ') 31 | end 32 | end 33 | end 34 | end 35 | end 36 | end -------------------------------------------------------------------------------- /ruby/app/lib/services/hubspot/webhooks/configure_target_url.rb: -------------------------------------------------------------------------------- 1 | module Services 2 | module Hubspot 3 | module Webhooks 4 | class ConfigureTargetUrl 5 | def initialize(app_id:, target_url:) 6 | @app_id = app_id 7 | @target_url = target_url 8 | end 9 | 10 | def call 11 | ::Hubspot::Webhooks::SettingsApi.new.configure( 12 | @app_id, 13 | settings_change_request(@target_url) 14 | ) 15 | end 16 | 17 | private 18 | 19 | def settings_change_request(target_url) 20 | ::Hubspot::Webhooks::SettingsChangeRequest.new(target_url: target_url) 21 | end 22 | end 23 | end 24 | end 25 | end -------------------------------------------------------------------------------- /ruby/app/lib/services/hubspot/webhooks/create_or_activate_subscription.rb: -------------------------------------------------------------------------------- 1 | module Services 2 | module Hubspot 3 | module Webhooks 4 | class CreateOrActivateSubscription 5 | def initialize(app_id:, event_type:, property_name: nil) 6 | @app_id = app_id 7 | @event_type = event_type 8 | @property_name = property_name 9 | end 10 | 11 | def call 12 | begin 13 | request = subscription_create_request(event_type: @event_type, property_name: @property_name) 14 | ::Hubspot::Webhooks::SubscriptionsApi.new.create( 15 | @app_id, 16 | request 17 | ) 18 | rescue => e 19 | existing_subscription_id = JSON.parse(JSON.parse(e.response_body)['context']['subscriptionIds'][0])[0] 20 | ::Hubspot::Webhooks::SubscriptionsApi.new.update( 21 | existing_subscription_id, 22 | @app_id, 23 | subscription_patch_request: subscription_patch_request 24 | ) 25 | end 26 | end 27 | 28 | private 29 | 30 | def subscription_create_request(event_type:, property_name:) 31 | ::Hubspot::Webhooks::SubscriptionCreateRequest.new( 32 | active: true, 33 | event_type: event_type, 34 | property_name: property_name, 35 | ) 36 | end 37 | 38 | def subscription_patch_request 39 | ::Hubspot::Webhooks::SubscriptionPatchRequest.new(enabled: true) 40 | end 41 | end 42 | end 43 | end 44 | end -------------------------------------------------------------------------------- /ruby/app/lib/services/hubspot/webhooks/handle.rb: -------------------------------------------------------------------------------- 1 | module Services 2 | module Hubspot 3 | module Webhooks 4 | class Handle 5 | def initialize(webhook:, request:) 6 | @webhook = webhook 7 | @request = request 8 | end 9 | 10 | def call 11 | validate_signature 12 | create_event 13 | end 14 | 15 | private 16 | 17 | def create_event 18 | event = Event.new( 19 | event_type: @webhook['subscriptionType'].split('.').last, 20 | object_id: @webhook['objectId'], 21 | event_id: @webhook['eventId'], 22 | occured_at: @webhook['occurredAt'] 23 | ) 24 | 25 | if event.event_type == 'propertyChange' 26 | event.assign_attributes( 27 | property_name: @webhook['propertyName'], 28 | property_value: @webhook['propertyValue'] 29 | ) 30 | end 31 | event.save! 32 | end 33 | 34 | def validate_signature 35 | ::Hubspot::Helpers::WebhooksHelper.validate_signature( 36 | signature: @request.headers['X-HubSpot-Signature'], 37 | signature_version: @request.headers['X-HubSpot-Signature-Version'], 38 | http_uri: @request.base_url, 39 | request_body: @request.raw_post, 40 | client_secret: ENV["HUBSPOT_CLIENT_SECRET"] 41 | ) 42 | end 43 | end 44 | end 45 | end 46 | end -------------------------------------------------------------------------------- /ruby/app/lib/services/hubspot/webhooks/pause_active_subscriptions.rb: -------------------------------------------------------------------------------- 1 | module Services 2 | module Hubspot 3 | module Webhooks 4 | class PauseActiveSubscriptions 5 | def initialize(app_id:) 6 | @app_id = app_id 7 | end 8 | 9 | def call 10 | return false if active_subscriptions.empty? 11 | 12 | inputs = active_subscriptions.map do |subscription| 13 | ::Hubspot::Webhooks::SubscriptionBatchUpdateRequest.new( 14 | id: subscription.id, 15 | active: false 16 | ) 17 | end 18 | 19 | batch_input_subscription_batch_update_request = 20 | ::Hubspot::Webhooks::BatchInputSubscriptionBatchUpdateRequest.new(inputs: inputs) 21 | 22 | ::Hubspot::Webhooks::SubscriptionsApi.new.update_batch( 23 | @app_id, 24 | batch_input_subscription_batch_update_request 25 | ) 26 | end 27 | 28 | private 29 | 30 | def active_subscriptions 31 | return @active_subscriptions if @active_subscriptions.present? 32 | 33 | subscriptions = ::Hubspot::Webhooks::SubscriptionsApi.new.get_all(@app_id).results 34 | @active_subscriptions = subscriptions.filter(&:active) 35 | end 36 | end 37 | end 38 | end 39 | end -------------------------------------------------------------------------------- /ruby/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /ruby/app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HubSpot/sample-apps-webhooks/4976692a89a15d66a4556555496b39c69db537a3/ruby/app/models/concerns/.keep -------------------------------------------------------------------------------- /ruby/app/models/event.rb: -------------------------------------------------------------------------------- 1 | class Event < ApplicationRecord 2 | def occured_at 3 | Time.at(self[:occured_at].to_i / 1000) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /ruby/app/models/token.rb: -------------------------------------------------------------------------------- 1 | class Token < ApplicationRecord 2 | class TokenAlreadyExist < RuntimeError; end 3 | 4 | before_create :confirm_singularity 5 | 6 | def self.instance 7 | first_or_create! 8 | end 9 | 10 | private 11 | 12 | def confirm_singularity 13 | raise(TokenAlreadyExist, 'There can be only one.') if Token.any? 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /ruby/app/views/events/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <% if @error.present? %> 3 |

<%= @error.message %>

4 | <% else %> 5 | 6 | <% if @events.any? %> 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | <% @events.each do |event| %> 17 | 18 | 19 | 22 | 25 | 26 | <% end %> 27 | 28 |
Contact IDEventOccurred At
<%= event.object_id %> 20 | <%= event.event_type %> 21 | 23 | <%= event.occured_at %> 24 |
29 | <% else %> 30 |

Webhooks haven't been received yet.

31 | <% end %> 32 | <% end %> 33 |
-------------------------------------------------------------------------------- /ruby/app/views/home/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <% if @error.present? %> 3 |

<%= @error.message %>

4 | <% else %> 5 |
6 |
7 |

Webhooks initialization:

8 | 9 |
1. All active subscriptions will be paused
10 |
2. Target url will be updated to <%= @target_url %>
11 |
3. Following subscriptions will be created and activated:
12 |
    13 |
  • Contact's Creation (contact.creation)
  • 14 |
  • Changing of Contact's Fist Name (contact.propertyChange)
  • 15 |
  • Contact's Deletion (contact.deletion)
  • 16 |
17 | 18 |
19 |   ::Hubspot.configure do |config|
20 |     config.api_key = { 'hapikey' => ENV['HUBSPOT_DEVELOPER_API_KEY'] }
21 |   end
22 | 
23 |   app_id = ENV['HUBSPOT_APPLICATION_ID']
24 |   Services::Hubspot::Webhooks::PauseActiveSubscriptions.new(app_id: app_id).call
25 | 
26 |   target_url = url_for(controller: :webhooks, action: :callback, only_path: false, protocol: 'https')
27 |   Services::Hubspot::Webhooks::ConfigureTargetUrl.new(app_id: app_id, target_url: target_url).call
28 | 
29 |   subscriptions = [
30 |     {"event_type": "contact.propertyChange", "property_name": "firstname"},
31 |     {"event_type": "contact.creation"},
32 |     {"event_type": "contact.deletion"},
33 |   ]
34 | 
35 |   subscriptions.each do |subscription|
36 |     Services::Hubspot::Webhooks::CreateOrActivateSubscription.new(app_id: app_id, **subscription).call
37 |   end
38 | 
39 |   redirect_to controller: :events, action: :index
40 |         
41 | <%= form_with(url: "/start", method: "post", local: true) do %> 42 | <%= submit_tag("Start") %> 43 | <% end %> 44 |
45 |
46 | <% end %> 47 |
48 | -------------------------------------------------------------------------------- /ruby/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | HubSpot Ruby Sample Webhooks App 6 | <%= csrf_meta_tags %> 7 | <%= csp_meta_tag %> 8 | 9 | 10 | 11 | 12 | 13 | <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %> 14 | <%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %> 15 | 16 | 17 | 18 | <%= render partial: "shared/header"%> 19 | <%= yield %> 20 | 21 | 22 | -------------------------------------------------------------------------------- /ruby/app/views/oauth/authorization/login.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
 3 |     // app/lib/services/authorization/get_authorization_uri.rb - Generate URL for OAuth
 4 | 
 5 |     ::Hubspot::OAuthHelper.authorize_url(
 6 |       client_id: 'client_id',
 7 |       redirect_uri: redirect_uri,
 8 |       scope: %w[contacts]
 9 |     )
10 |   
11 |

In order to continue please authorize via OAuth

12 |
13 | Authorize 14 |
15 |
-------------------------------------------------------------------------------- /ruby/app/views/shared/_header.html.erb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ruby/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /ruby/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path('../spring', __FILE__) 4 | rescue LoadError => e 5 | raise unless e.message.include?('spring') 6 | end 7 | APP_PATH = File.expand_path('../config/application', __dir__) 8 | require_relative '../config/boot' 9 | require 'rails/commands' 10 | -------------------------------------------------------------------------------- /ruby/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path('../spring', __FILE__) 4 | rescue LoadError => e 5 | raise unless e.message.include?('spring') 6 | end 7 | require_relative '../config/boot' 8 | require 'rake' 9 | Rake.application.run 10 | -------------------------------------------------------------------------------- /ruby/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | include FileUtils 4 | 5 | # path to your application root. 6 | APP_ROOT = File.expand_path('..', __dir__) 7 | 8 | def system!(*args) 9 | system(*args) || abort("\n== Command #{args} failed ==") 10 | end 11 | 12 | chdir APP_ROOT do 13 | # This script is a starting point to setup your application. 14 | # Add necessary setup steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | # Install JavaScript dependencies if using Yarn 21 | # system('bin/yarn') 22 | 23 | # puts "\n== Copying sample files ==" 24 | # unless File.exist?('config/database.yml') 25 | # cp 'config/database.yml.sample', 'config/database.yml' 26 | # end 27 | 28 | puts "\n== Preparing database ==" 29 | system! 'bin/rails db:setup' 30 | 31 | puts "\n== Removing old logs and tempfiles ==" 32 | system! 'bin/rails log:clear tmp:clear' 33 | 34 | puts "\n== Restarting application server ==" 35 | system! 'bin/rails restart' 36 | end 37 | -------------------------------------------------------------------------------- /ruby/bin/spring: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # This file loads Spring without using Bundler, in order to be fast. 4 | # It gets overwritten when you run the `spring binstub` command. 5 | 6 | unless defined?(Spring) 7 | require 'rubygems' 8 | require 'bundler' 9 | 10 | lockfile = Bundler::LockfileParser.new(Bundler.default_lockfile.read) 11 | spring = lockfile.specs.detect { |spec| spec.name == 'spring' } 12 | if spring 13 | Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path 14 | gem 'spring', spring.version 15 | require 'spring/binstub' 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /ruby/bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | include FileUtils 4 | 5 | # path to your application root. 6 | APP_ROOT = File.expand_path('..', __dir__) 7 | 8 | def system!(*args) 9 | system(*args) || abort("\n== Command #{args} failed ==") 10 | end 11 | 12 | chdir APP_ROOT do 13 | # This script is a way to update your development environment automatically. 14 | # Add necessary update steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | # Install JavaScript dependencies if using Yarn 21 | # system('bin/yarn') 22 | 23 | puts "\n== Updating database ==" 24 | system! 'bin/rails db:migrate' 25 | 26 | puts "\n== Removing old logs and tempfiles ==" 27 | system! 'bin/rails log:clear tmp:clear' 28 | 29 | puts "\n== Restarting application server ==" 30 | system! 'bin/rails restart' 31 | end 32 | -------------------------------------------------------------------------------- /ruby/bin/yarn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_ROOT = File.expand_path('..', __dir__) 3 | Dir.chdir(APP_ROOT) do 4 | begin 5 | exec "yarnpkg", *ARGV 6 | rescue Errno::ENOENT 7 | $stderr.puts "Yarn executable was not detected in the system." 8 | $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install" 9 | exit 1 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /ruby/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative 'config/environment' 4 | 5 | run Rails.application 6 | -------------------------------------------------------------------------------- /ruby/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative 'boot' 2 | 3 | require 'rails/all' 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | 9 | module WebhooksContactsApp 10 | class Application < Rails::Application 11 | # Initialize configuration defaults for originally generated Rails version. 12 | config.load_defaults 5.2 13 | 14 | # Settings in config/environments/* take precedence over those specified here. 15 | # Application configuration can go into files in config/initializers 16 | # -- all .rb files in that directory are automatically loaded after loading 17 | # the framework and any gems in your application. 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /ruby/config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 2 | 3 | require 'bundler/setup' # Set up gems listed in the Gemfile. 4 | -------------------------------------------------------------------------------- /ruby/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: async 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: webhooks-contacts-app_production 11 | -------------------------------------------------------------------------------- /ruby/config/database.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | adapter: mysql2 3 | pool: 5 4 | username: <%= ENV['DB_USERNAME'] %> 5 | password: <%= ENV['DB_PASSWORD'] %> 6 | host: <%= ENV['DB_HOST'] %> 7 | port: <%= ENV['DB_PORT'] %> 8 | 9 | development: 10 | <<: *default 11 | database: <%= ENV['DB_NAME'] %>_development 12 | 13 | production: 14 | <<: *default 15 | database: <%= ENV['DB_NAME'] %> -------------------------------------------------------------------------------- /ruby/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative 'application' 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /ruby/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable/disable caching. By default caching is disabled. 16 | # Run rails dev:cache to toggle caching. 17 | if Rails.root.join('tmp', 'caching-dev.txt').exist? 18 | config.action_controller.perform_caching = true 19 | 20 | config.cache_store = :memory_store 21 | config.public_file_server.headers = { 22 | 'Cache-Control' => "public, max-age=#{2.days.to_i}" 23 | } 24 | else 25 | config.action_controller.perform_caching = false 26 | 27 | config.cache_store = :null_store 28 | end 29 | 30 | # Store uploaded files on the local file system (see config/storage.yml for options) 31 | config.active_storage.service = :local 32 | 33 | # Don't care if the mailer can't send. 34 | config.action_mailer.raise_delivery_errors = false 35 | 36 | config.action_mailer.perform_caching = false 37 | 38 | # Print deprecation notices to the Rails logger. 39 | config.active_support.deprecation = :log 40 | 41 | # Raise an error on page load if there are pending migrations. 42 | config.active_record.migration_error = :page_load 43 | 44 | # Highlight code that triggered database queries in logs. 45 | config.active_record.verbose_query_logs = true 46 | 47 | # Debug mode disables concatenation and preprocessing of assets. 48 | # This option may cause significant delays in view rendering with a large 49 | # number of complex assets. 50 | config.assets.debug = true 51 | 52 | # Suppress logger output for asset requests. 53 | config.assets.quiet = true 54 | 55 | # Raises error for missing translations 56 | # config.action_view.raise_on_missing_translations = true 57 | 58 | # Use an evented file watcher to asynchronously detect changes in source code, 59 | # routes, locales, etc. This feature depends on the listen gem. 60 | config.file_watcher = ActiveSupport::EventedFileUpdateChecker 61 | end 62 | -------------------------------------------------------------------------------- /ruby/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both threaded web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 18 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 19 | # config.require_master_key = true 20 | 21 | # Disable serving static files from the `/public` folder by default since 22 | # Apache or NGINX already handles this. 23 | config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? 24 | 25 | # Compress JavaScripts and CSS. 26 | config.assets.js_compressor = :uglifier 27 | # config.assets.css_compressor = :sass 28 | 29 | # Do not fallback to assets pipeline if a precompiled asset is missed. 30 | config.assets.compile = false 31 | 32 | # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb 33 | 34 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 35 | # config.action_controller.asset_host = 'http://assets.example.com' 36 | 37 | # Specifies the header that your server uses for sending files. 38 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 39 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 40 | 41 | # Store uploaded files on the local file system (see config/storage.yml for options) 42 | config.active_storage.service = :local 43 | 44 | # Mount Action Cable outside main process or domain 45 | # config.action_cable.mount_path = nil 46 | # config.action_cable.url = 'wss://example.com/cable' 47 | # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] 48 | 49 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 50 | # config.force_ssl = true 51 | 52 | # Use the lowest log level to ensure availability of diagnostic information 53 | # when problems arise. 54 | config.log_level = :debug 55 | 56 | # Prepend all log lines with the following tags. 57 | config.log_tags = [ :request_id ] 58 | 59 | # Use a different cache store in production. 60 | # config.cache_store = :mem_cache_store 61 | 62 | # Use a real queuing backend for Active Job (and separate queues per environment) 63 | # config.active_job.queue_adapter = :resque 64 | # config.active_job.queue_name_prefix = "webhooks-contacts-app_#{Rails.env}" 65 | 66 | config.action_mailer.perform_caching = false 67 | 68 | # Ignore bad email addresses and do not raise email delivery errors. 69 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 70 | # config.action_mailer.raise_delivery_errors = false 71 | 72 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 73 | # the I18n.default_locale when a translation cannot be found). 74 | config.i18n.fallbacks = true 75 | 76 | # Send deprecation notices to registered listeners. 77 | config.active_support.deprecation = :notify 78 | 79 | # Use default logging formatter so that PID and timestamp are not suppressed. 80 | config.log_formatter = ::Logger::Formatter.new 81 | 82 | # Use a different logger for distributed setups. 83 | # require 'syslog/logger' 84 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') 85 | 86 | if ENV["RAILS_LOG_TO_STDOUT"].present? 87 | logger = ActiveSupport::Logger.new(STDOUT) 88 | logger.formatter = config.log_formatter 89 | config.logger = ActiveSupport::TaggedLogging.new(logger) 90 | end 91 | 92 | # Do not dump schema after migrations. 93 | config.active_record.dump_schema_after_migration = false 94 | end 95 | -------------------------------------------------------------------------------- /ruby/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure public file server for tests with Cache-Control for performance. 16 | config.public_file_server.enabled = true 17 | config.public_file_server.headers = { 18 | 'Cache-Control' => "public, max-age=#{1.hour.to_i}" 19 | } 20 | 21 | # Show full error reports and disable caching. 22 | config.consider_all_requests_local = true 23 | config.action_controller.perform_caching = false 24 | 25 | # Raise exceptions instead of rendering exception templates. 26 | config.action_dispatch.show_exceptions = false 27 | 28 | # Disable request forgery protection in test environment. 29 | config.action_controller.allow_forgery_protection = false 30 | 31 | # Store uploaded files on the local file system in a temporary directory 32 | config.active_storage.service = :test 33 | 34 | config.action_mailer.perform_caching = false 35 | 36 | # Tell Action Mailer not to deliver emails to the real world. 37 | # The :test delivery method accumulates sent emails in the 38 | # ActionMailer::Base.deliveries array. 39 | config.action_mailer.delivery_method = :test 40 | 41 | # Print deprecation notices to the stderr. 42 | config.active_support.deprecation = :stderr 43 | 44 | # Raises error for missing translations 45 | # config.action_view.raise_on_missing_translations = true 46 | config.active_support.escape_html_entities_in_json = false 47 | end 48 | -------------------------------------------------------------------------------- /ruby/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = '1.0' 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | # Add Yarn node_modules folder to the asset load path. 9 | Rails.application.config.assets.paths << Rails.root.join('node_modules') 10 | 11 | # Precompile additional assets. 12 | # application.js, application.css, and all non-JS/CSS in the app/assets 13 | # folder are already added. 14 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 15 | -------------------------------------------------------------------------------- /ruby/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /ruby/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /ruby/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at http://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /ruby/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | threads threads_count, threads_count 9 | 10 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 11 | # 12 | port ENV.fetch("PORT") { 3000 } 13 | 14 | # Specifies the `environment` that Puma will run in. 15 | # 16 | environment ENV.fetch("RAILS_ENV") { "development" } 17 | 18 | # Specifies the number of `workers` to boot in clustered mode. 19 | # Workers are forked webserver processes. If using threads and workers together 20 | # the concurrency of the application would be max `threads` * `workers`. 21 | # Workers do not work on JRuby or Windows (both of which do not support 22 | # processes). 23 | # 24 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 25 | 26 | # Use the `preload_app!` method when specifying a `workers` number. 27 | # This directive tells Puma to first boot the application and load code 28 | # before forking the application. This takes advantage of Copy On Write 29 | # process behavior so workers use less memory. 30 | # 31 | # preload_app! 32 | 33 | # Allow puma to be restarted by `rails restart` command. 34 | plugin :tmp_restart 35 | -------------------------------------------------------------------------------- /ruby/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | get '/events', to: 'events#index' 3 | get '/events/not_shown_count', to: 'events#not_shown_count' 4 | get '/oauth', to: 'oauth/authorization#authorize' 5 | get '/oauth/callback', to: 'oauth/authorization#callback' 6 | post '/webhooks/callback', to: 'webhooks#callback' 7 | get '/login', to: 'oauth/authorization#login' 8 | get '/', to: 'home#index' 9 | post '/start', to: 'home#start' 10 | 11 | root to: 'events#index' 12 | end 13 | -------------------------------------------------------------------------------- /ruby/config/spring.rb: -------------------------------------------------------------------------------- 1 | %w[ 2 | .ruby-version 3 | .rbenv-vars 4 | tmp/restart.txt 5 | tmp/caching-dev.txt 6 | ].each { |path| Spring.watch(path) } 7 | -------------------------------------------------------------------------------- /ruby/db/migrate/20191202170347_create_tokens.rb: -------------------------------------------------------------------------------- 1 | class CreateTokens < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table :tokens do |t| 4 | t.string :refresh_token 5 | t.string :access_token 6 | t.datetime :expires_in 7 | t.datetime :expires_at 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /ruby/db/migrate/20200130113853_create_events.rb: -------------------------------------------------------------------------------- 1 | class CreateEvents < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table :events do |t| 4 | t.string :event_type 5 | t.integer :object_id 6 | t.string :event_id 7 | t.string :occured_at 8 | t.string :property_name 9 | t.string :property_value 10 | 11 | t.timestamps 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /ruby/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # Note that this schema.rb definition is the authoritative source for your 6 | # database schema. If you need to create the application database on another 7 | # system, you should be using db:schema:load, not running all the migrations 8 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 9 | # you'll amass, the slower it'll run and the greater likelihood for issues). 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 2020_01_30_113853) do 14 | 15 | create_table "events", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| 16 | t.string "event_type" 17 | t.integer "object_id" 18 | t.string "event_id" 19 | t.string "occured_at" 20 | t.string "property_name" 21 | t.string "property_value" 22 | t.datetime "created_at", null: false 23 | t.datetime "updated_at", null: false 24 | end 25 | 26 | create_table "tokens", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| 27 | t.string "refresh_token" 28 | t.string "access_token" 29 | t.datetime "expires_in" 30 | t.datetime "expires_at" 31 | end 32 | 33 | end 34 | -------------------------------------------------------------------------------- /ruby/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | web: 5 | env_file: 6 | - .env 7 | environment: 8 | DB_HOST: db 9 | DB_NAME: events 10 | DB_USERNAME: events 11 | DB_PASSWORD: events 12 | build: 13 | dockerfile: ./Dockerfile 14 | context: ./ 15 | command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails db:migrate RAILS_ENV=development && bundle exec rails s -p 3000 -b '0.0.0.0'" 16 | depends_on: 17 | - db 18 | ports: 19 | - 3000:3000 20 | db: 21 | image: mysql:8.0 22 | command: mysqld --default-authentication-plugin=mysql_native_password 23 | volumes: 24 | - ./db/mysql:/var/lib/mysql 25 | ports: 26 | - 3306:3306 27 | logging: 28 | driver: none 29 | environment: 30 | MYSQL_ROOT_PASSWORD: root 31 | MYSQL_DATABASE: events_development 32 | MYSQL_USER: events 33 | MYSQL_PASSWORD: events 34 | ngrok: 35 | image: gtriggiano/ngrok-tunnel 36 | ports: 37 | - 4040:4040 38 | environment: 39 | TARGET_HOST: web 40 | TARGET_PORT: 3000 41 | depends_on: 42 | - web -------------------------------------------------------------------------------- /ruby/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Remove a potentially pre-existing server.pid for Rails. 5 | rm -f /myapp/tmp/pids/server.pid 6 | 7 | # Then exec the container's main process (what's set as CMD in the Dockerfile). 8 | exec "$@" 9 | -------------------------------------------------------------------------------- /ruby/lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HubSpot/sample-apps-webhooks/4976692a89a15d66a4556555496b39c69db537a3/ruby/lib/assets/.keep -------------------------------------------------------------------------------- /ruby/lib/tasks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HubSpot/sample-apps-webhooks/4976692a89a15d66a4556555496b39c69db537a3/ruby/lib/tasks/.keep -------------------------------------------------------------------------------- /ruby/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HubSpot/sample-apps-webhooks/4976692a89a15d66a4556555496b39c69db537a3/ruby/log/.keep -------------------------------------------------------------------------------- /ruby/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webhooks-contacts-app", 3 | "private": true, 4 | "dependencies": {} 5 | } 6 | -------------------------------------------------------------------------------- /ruby/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /ruby/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /ruby/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /ruby/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HubSpot/sample-apps-webhooks/4976692a89a15d66a4556555496b39c69db537a3/ruby/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /ruby/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HubSpot/sample-apps-webhooks/4976692a89a15d66a4556555496b39c69db537a3/ruby/public/apple-touch-icon.png -------------------------------------------------------------------------------- /ruby/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HubSpot/sample-apps-webhooks/4976692a89a15d66a4556555496b39c69db537a3/ruby/public/favicon.ico -------------------------------------------------------------------------------- /ruby/public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /ruby/public/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HubSpot/sample-apps-webhooks/4976692a89a15d66a4556555496b39c69db537a3/ruby/public/sample.png -------------------------------------------------------------------------------- /ruby/tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HubSpot/sample-apps-webhooks/4976692a89a15d66a4556555496b39c69db537a3/ruby/tmp/.keep --------------------------------------------------------------------------------