This guide sets up a one-way, real-time feed from HubSpot to Odoo. When someone ticks a box on a HubSpot contact, the contact is sent to Odoo as soon as HubSpot reports the change, and is created there or updated if Odoo already has it. It works on HubSpot's free tier: HubSpot sends a webhook, a short script fetches the contact and passes it to an Odoo automation rule, and the rule finds or creates the contact. The Odoo steps were tested on Odoo 18 and Odoo 19 and checked in the Odoo 17 and 20 source code.
It is written for the person who owns the integration: an administrator who is comfortable pasting a few lines of code, or a developer who wants the shortest route that uses only standard Odoo.
Before you start
- Odoo version: Odoo 17 or later, Community or Enterprise. The On webhook trigger does not exist in Odoo 16.
- Odoo module: Automation Rules must be installed. It is not listed as an app, so in the Apps app remove the default Apps filter before searching for it.
- Odoo access: an administrator with access to Settings, working in developer mode, which is Odoo's setting that shows the technical menus. Only that role can see and edit Python code in actions.
- HubSpot access: a super admin, because only super admins can create private apps.
- A place to run the middle step: any small server or automation tool that HubSpot can reach on the internet and that can reach your Odoo database.
- Terms used: an automation rule is an Odoo record that runs actions when a trigger fires. A webhook is a web address that another system calls to report an event. A private app is HubSpot's way of giving one account an access token and webhook subscriptions without building a public app.
Why a middle step is needed
HubSpot lists Webhooks and the Contacts, Companies and Deals APIs as available on its Free tier, and a private app can subscribe to changes on a contact property. The webhook does not carry the contact, though. Each request is a list of up to 100 events, and each event holds the record ID, the property that changed and its new value:
[
{
"objectId": 1246965,
"propertyName": "send_to_odoo",
"propertyValue": "true",
"occurredAt": 1760900000000,
"attemptNumber": 0
}
]
Real events carry a few more IDs, such as the subscription and the HubSpot account. There is no email or name in them, so something has to read the full contact from HubSpot first. Odoo cannot do that part: the code an automation rule runs has no HTTP client available. Odoo's rule expressions also treat the webhook body as a single object, so a list sent straight from HubSpot fails with error 500.
HubSpot does not guarantee that each event arrives once or in order. It retries a failed delivery up to 10 times over 24 hours, and it counts as failed any error response, a failed connection, or no reply within five seconds. The Odoo side therefore has to be safe to run twice, which is why the rule below finds the contact by email before it creates one.
Step 1: Mark the records in HubSpot
- In HubSpot, click the settings icon in the top bar, then Properties in the left sidebar, and choose contact properties.
- Create a property with the field type Single checkbox and a label such as Send to Odoo. Note its internal name, for example send_to_odoo, because HubSpot does not let you change it later.
- Go to Development, then Legacy apps in the left sidebar, and create a private app. Create it on its own, not through a project: private apps created with projects do not support webhooks.
- On the app's Scopes tab, add crm.objects.contacts.read. Copy the access token, and the client secret from the Auth tab.
Only contacts someone ticks reach Odoo, so a marketing import does not flood the database.
Step 2: Create the Odoo automation rule
- With developer mode on, open the Settings app and go to Technical > Automation > Automation Rules, then click New.
- Name the rule, set Model to Contact and Trigger to On webhook, and save. The rule's address now appears, labelled Secret URL in Odoo 19 and 20 and URL in Odoo 17 and 18. It looks like https://your-odoo-address/web/hook/ followed by a long random code.
- Replace the Target Record expression with this one, which finds the contact by email or creates it:
(model.search([('email', '=ilike', payload['email'])], limit=1)
or model.create({'name': payload['name'], 'email': payload['email']}))
- Turn on Log Calls while you test.
- On the Actions To Do tab, add an action, set Type to Execute Code, and paste this into the Code tab. It fills in the remaining fields and keeps the current value when HubSpot sends a blank:
record.write({
'name': payload.get('name') or record.name,
'phone': payload.get('phone') or record.phone,
})
The Target Record expression must use model, not env. The help text under the field lists env, but in testing on Odoo 18 and 19 an expression using it failed with error 500. The default expression is meant for calls from another Odoo database.
Step 3: Run the middle step
The middle step receives HubSpot's list, checks that it came from HubSpot, reads each ticked contact and posts one object per contact to the Odoo URL, in this shape:
{"email": "ada@example.com", "name": "Ada Tester", "phone": "+1 403 555 0303"}
Here is a minimal version in Python with Flask. It checks the X-HubSpot-Signature header, which for webhook events is a SHA-256 hash of the client secret followed by the request body:
import hashlib, os
import requests
from flask import Flask, request, abort
app = Flask(__name__)
TOKEN = os.environ["HUBSPOT_TOKEN"] # private app access token
SECRET = os.environ["HUBSPOT_CLIENT_SECRET"] # private app client secret
ODOO_URL = os.environ["ODOO_WEBHOOK_URL"] # the automation rule's URL
CONTACT = "https://api.hubapi.com/crm/objects/2026-09/contacts/"
@app.post("/hubspot")
def hubspot():
body = request.get_data()
expected = hashlib.sha256(SECRET.encode() + body).hexdigest()
if request.headers.get("X-HubSpot-Signature") != expected:
abort(401)
for event in request.get_json():
if str(event.get("propertyValue")).lower() != "true":
continue # box was unticked
c = requests.get(
CONTACT + str(event["objectId"]),
params={"properties": "firstname,lastname,email,phone"},
headers={"Authorization": "Bearer " + TOKEN},
timeout=10,
).json()["properties"]
if not c.get("email"):
continue # nothing to match on in Odoo
name = " ".join(filter(None, [c.get("firstname"), c.get("lastname")]))
requests.post(ODOO_URL, json={
"email": c["email"],
"name": name or c["email"],
"phone": c.get("phone") or "",
}, timeout=10).raise_for_status()
return "", 204
The contact path uses HubSpot's date-versioned API; use the latest version shown in HubSpot's Contacts API reference. If Odoo returns an error, the script returns one to HubSpot, which then retries. Automation tools such as n8n or Make can do the same work with a webhook trigger and two HTTP request steps.
Back in HubSpot, open the private app's Webhooks tab, click Edit webhooks, enter the middle step's address, and subscribe to Contacts with Property changed on the Send to Odoo property.
Check that it worked
- In HubSpot, tick Send to Odoo on a test contact that has an email address.
- In Odoo, open the Contacts app and search for that email. The contact should appear shortly. If it does not, check the logs in the last step.
- Change the contact's phone in HubSpot, untick the box, then tick it again. The same Odoo contact should show the new phone, and there should still be only one.
- In Odoo, open the rule and click Logs to see each call and its payload. HubSpot's private app also logs successful and failed deliveries.
Contacts created this way show the Public user as their creator, because the webhook runs without a login.
Limits to plan around
- Treat the URL as a password. Anyone who has it can call the rule without logging in. If it leaks, use Renew in Odoo 19 and 20, or Rotate Secret in Odoo 17 and 18, and update the middle step.
- Bulk ticking. HubSpot private apps on Free are limited to 100 API calls per 10 seconds, and a batch of 100 events, or one slow reply from Odoo, can take longer than HubSpot's five seconds. Repeats are harmless here, but for large batches the middle step should reply first and process the events afterwards.
- Contacts without an email are skipped, because the email is what the rule matches on.
- One way only. Unticking the box or deleting the contact in HubSpot leaves the Odoo contact in place.
- Several databases on one server. On a self-hosted server that serves several databases without a database filter, a call to the URL returns 404 unless it names the database, for example with an X-Odoo-Database header.
Steps verified on Odoo 17 to 20. Menus and labels can differ on other versions.
Common questions
Can HubSpot Free send data to Odoo in real time?
Yes. HubSpot lists Webhooks and the Contacts API as available on Free, and a private app created by a super admin can subscribe to contact property changes. A small middle step then reads the contact and posts it to an Odoo automation rule with the On webhook trigger.
Why can HubSpot not post straight to the Odoo webhook?
HubSpot sends a list of events that hold only record IDs and the changed property, while Odoo reads a webhook body as one object. Sent directly, the list fails with error 500 and there is no email or name to match on.
Which Odoo versions have the On webhook trigger?
Odoo 17, 18, 19 and 20 have it in the Automation Rules module, in both Community and Enterprise. Odoo 16 does not.
Will HubSpot create duplicate contacts in Odoo?
HubSpot can deliver the same event more than once and retries failed deliveries up to 10 times over 24 hours. A Target Record expression that searches by email before creating keeps it to one contact per email address.
Does the HubSpot workflow Send a webhook action work on Free?
No. HubSpot lists that workflow action under Data Hub Professional and Enterprise. On Free, use a private app's webhook subscriptions instead.
Talk to the team.
If you are setting this up, or Odoo is not behaving the way this guide describes, book a 30-minute call. We will look at your configuration and tell you what it needs. Calgary studio, in-house team, no offshore handoffs.
Book the call →