HubSpot API Testing Guide: Testing CRM Integrations and Workflows
HubSpot's v3 API uses standard REST/JSON, making it accessible to any HTTP testing tool. This guide covers testing contacts, deals, workflows, webhooks, and custom objects — with patterns for keeping tests isolated and fast.
HubSpot API Overview
HubSpot's v3 API is well-structured and consistent:
- Base URL:
https://api.hubapi.com - Authentication: Private App tokens (
Bearer pat-xx-...) or OAuth - Objects: Contacts, Companies, Deals, Tickets, Products, custom objects
- Associations: Relationships between objects (contact ↔ deal, deal ↔ company)
- Workflows: Automation triggered by object property changes
The v3 API follows REST conventions reliably — it's one of the better CRM APIs to test.
Authentication
Private App tokens are the recommended approach for testing:
- Go to HubSpot Settings → Integrations → Private Apps
- Create an app with the scopes your integration needs
- Copy the token (format:
pat-na1-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
export HUBSPOT_API_KEY=<span class="hljs-string">"pat-na1-your-token-here"
<span class="hljs-comment"># Test authentication
curl -H <span class="hljs-string">"Authorization: Bearer $HUBSPOT_API_KEY" \
https://api.hubapi.com/crm/v3/objects/contacts?<span class="hljs-built_in">limit=1For testing environments, create a HubSpot Developer sandbox — a free test account that mirrors production schemas without affecting real data.
Testing Contact Operations
Create and Verify a Contact
import requests
import os
import pytest
BASE_URL = "https://api.hubapi.com"
HEADERS = {
"Authorization": f"Bearer {os.environ['HUBSPOT_API_KEY']}",
"Content-Type": "application/json"
}
@pytest.fixture
def contact_id():
"""Create a test contact and clean it up after the test."""
# Create
response = requests.post(
f"{BASE_URL}/crm/v3/objects/contacts",
headers=HEADERS,
json={
"properties": {
"email": "atf-test@example.com",
"firstname": "ATF",
"lastname": "Test",
"phone": "+15550001234",
"company": "Test Corp"
}
}
)
assert response.status_code == 201, f"Create failed: {response.text}"
contact_id = response.json()["id"]
yield contact_id
# Cleanup
requests.delete(
f"{BASE_URL}/crm/v3/objects/contacts/{contact_id}",
headers=HEADERS
)
def test_create_contact_sets_all_properties(contact_id):
response = requests.get(
f"{BASE_URL}/crm/v3/objects/contacts/{contact_id}",
headers=HEADERS,
params={"properties": "email,firstname,lastname,phone,company"}
)
assert response.status_code == 200
props = response.json()["properties"]
assert props["email"] == "atf-test@example.com"
assert props["firstname"] == "ATF"
assert props["lastname"] == "Test"
def test_update_contact_lifecyclestage(contact_id):
response = requests.patch(
f"{BASE_URL}/crm/v3/objects/contacts/{contact_id}",
headers=HEADERS,
json={"properties": {"lifecyclestage": "marketingqualifiedlead"}}
)
assert response.status_code == 200
# Verify the update
get_response = requests.get(
f"{BASE_URL}/crm/v3/objects/contacts/{contact_id}",
headers=HEADERS,
params={"properties": "lifecyclestage"}
)
assert get_response.json()["properties"]["lifecyclestage"] == "marketingqualifiedlead"Testing Deal Pipelines
Deals moving through pipeline stages are a core HubSpot workflow. Test the full progression:
@pytest.fixture
def deal_with_contact(contact_id):
"""Create a deal associated with the test contact."""
# Create deal
deal_response = requests.post(
f"{BASE_URL}/crm/v3/objects/deals",
headers=HEADERS,
json={
"properties": {
"dealname": "ATF Test Deal",
"amount": "5000",
"dealstage": "appointmentscheduled",
"pipeline": "default",
"closedate": "2026-12-31"
}
}
)
assert deal_response.status_code == 201
deal_id = deal_response.json()["id"]
# Associate with contact
assoc_response = requests.put(
f"{BASE_URL}/crm/v3/objects/deals/{deal_id}/associations/contacts/{contact_id}/deal_to_contact",
headers=HEADERS
)
assert assoc_response.status_code == 200
yield deal_id
# Cleanup
requests.delete(f"{BASE_URL}/crm/v3/objects/deals/{deal_id}", headers=HEADERS)
def test_deal_stage_progression(deal_with_contact):
deal_id = deal_with_contact
stages = [
"qualifiedtobuy",
"presentationscheduled",
"decisionmakerboughtin",
"contractsent",
"closedwon"
]
for stage in stages:
response = requests.patch(
f"{BASE_URL}/crm/v3/objects/deals/{deal_id}",
headers=HEADERS,
json={"properties": {"dealstage": stage}}
)
assert response.status_code == 200, f"Failed to set stage {stage}: {response.text}"
# Verify final state
final = requests.get(
f"{BASE_URL}/crm/v3/objects/deals/{deal_id}",
headers=HEADERS,
params={"properties": "dealstage,hs_is_closed_won"}
)
assert final.json()["properties"]["dealstage"] == "closedwon"
assert final.json()["properties"]["hs_is_closed_won"] == "true"
def test_deal_contact_association_visible(deal_with_contact, contact_id):
deal_id = deal_with_contact
# Verify from deal side
response = requests.get(
f"{BASE_URL}/crm/v3/objects/deals/{deal_id}/associations/contacts",
headers=HEADERS
)
assert response.status_code == 200
associated_ids = [r["id"] for r in response.json()["results"]]
assert contact_id in associated_idsTesting Webhooks
HubSpot sends webhooks when object properties change. Test your webhook handler:
Setting Up a Local Webhook Receiver for Tests
# webhook_handler.py — your integration's webhook receiver
from flask import Flask, request, jsonify
import hmac
import hashlib
app = Flask(__name__)
received_events = []
@app.route('/hubspot/webhook', methods=['POST'])
def receive_webhook():
# Verify signature
signature = request.headers.get('X-HubSpot-Signature-v3')
secret = os.environ['HUBSPOT_WEBHOOK_SECRET']
body = request.get_data()
timestamp = request.headers.get('X-HubSpot-Request-Timestamp')
uri = 'https://yourdomain.com/hubspot/webhook'
expected = hmac.new(
secret.encode(),
f"{uri}{body.decode()}{timestamp}".encode(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
return jsonify({"error": "Invalid signature"}), 401
events = request.get_json()
received_events.extend(events)
return jsonify({"status": "ok"}), 200Testing the Webhook with ngrok
# test_webhooks.py
import subprocess
import time
import threading
@pytest.fixture(scope="session")
def webhook_server():
"""Start a local Flask server and expose via ngrok."""
# Start Flask in background
flask_thread = threading.Thread(target=lambda: app.run(port=5001))
flask_thread.daemon = True
flask_thread.start()
# Start ngrok
ngrok_process = subprocess.Popen(
["ngrok", "http", "5001", "--log", "stdout"],
stdout=subprocess.PIPE
)
time.sleep(2) # Wait for ngrok to initialize
# Get public URL from ngrok API
ngrok_url = requests.get("http://localhost:4040/api/tunnels").json()
public_url = ngrok_url["tunnels"][0]["public_url"]
yield public_url
ngrok_process.terminate()
def test_contact_property_change_triggers_webhook(webhook_server, contact_id):
received_events.clear()
# Register the webhook subscription (idempotent)
requests.post(
f"{BASE_URL}/webhooks/v3/{os.environ['HUBSPOT_APP_ID']}/subscriptions",
headers=HEADERS,
json={
"eventType": "contact.propertyChange",
"propertyName": "lifecyclestage",
"active": True,
"targetUrl": f"{webhook_server}/hubspot/webhook"
}
)
# Trigger the change
requests.patch(
f"{BASE_URL}/crm/v3/objects/contacts/{contact_id}",
headers=HEADERS,
json={"properties": {"lifecyclestage": "subscriber"}}
)
# Wait for HubSpot to deliver the webhook (usually <5s in sandbox)
deadline = time.time() + 30
while time.time() < deadline:
matching = [e for e in received_events
if e.get("objectId") == int(contact_id)
and e.get("propertyName") == "lifecyclestage"]
if matching:
assert matching[0]["propertyValue"] == "subscriber"
return
time.sleep(1)
pytest.fail("Webhook not received within 30 seconds")Testing Custom Objects
HubSpot Custom Objects let you model domain-specific data. Test them like standard objects:
# Custom object: "Project" with properties: project_name, budget, status
def test_create_custom_object_project():
# You need your custom object type ID (from schema)
CUSTOM_OBJECT_TYPE = "p_project" # or the numeric ID
response = requests.post(
f"{BASE_URL}/crm/v3/objects/{CUSTOM_OBJECT_TYPE}",
headers=HEADERS,
json={
"properties": {
"project_name": "ATF Test Project",
"budget": "25000",
"status": "active"
}
}
)
assert response.status_code == 201
project_id = response.json()["id"]
# Verify
get_response = requests.get(
f"{BASE_URL}/crm/v3/objects/{CUSTOM_OBJECT_TYPE}/{project_id}",
headers=HEADERS,
params={"properties": "project_name,budget,status"}
)
assert get_response.json()["properties"]["project_name"] == "ATF Test Project"
# Cleanup
requests.delete(f"{BASE_URL}/crm/v3/objects/{CUSTOM_OBJECT_TYPE}/{project_id}", headers=HEADERS)CI Integration
# GitHub Actions
name: HubSpot Integration Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
env:
HUBSPOT_API_KEY: ${{ secrets.HUBSPOT_SANDBOX_API_KEY }}
HUBSPOT_APP_ID: ${{ secrets.HUBSPOT_APP_ID }}
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: pip install -r requirements-test.txt
- name: Run HubSpot API tests
run: pytest tests/hubspot/ -v --junitxml=results/hubspot-tests.xml
- name: Publish test results
uses: actions/upload-artifact@v4
if: always()
with:
name: hubspot-test-results
path: results/Key practice: use a HubSpot Developer sandbox (not production) for all CI tests. Sandboxes are free, isolated, and safe to create/delete data in.
Monitoring HubSpot Integrations
Test coverage validates behavior during development. In production, monitor the integration endpoints:
# Monitor your HubSpot webhook handler
helpmetest health hubspot-webhook-receiver 5m
<span class="hljs-comment"># Monitor an integration API that syncs to HubSpot
helpmetest health crm-sync-service 5mIf your webhook handler goes down, HubSpot retries for 24 hours. A 5-minute health check catches the outage before HubSpot's retry queue backs up.
Summary
HubSpot API testing benefits from the v3 API's consistent REST/JSON interface. Key patterns:
- Use Private Apps (not OAuth) for CI testing — simpler auth, no redirect needed
- Use a Developer sandbox for all automated tests — free and isolated from production data
- Create and clean up test data in each test — don't rely on existing sandbox records
- Test association creation explicitly — it's a separate API call from object creation
- Use ngrok or similar for webhook testing in local and CI environments
The combination of unit-level API tests and production monitoring gives complete coverage of your HubSpot integration.