HubSpot API Testing Guide: Testing CRM Integrations and Workflows

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:

  1. Go to HubSpot Settings → Integrations → Private Apps
  2. Create an app with the scopes your integration needs
  3. 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=1

For 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_ids

Testing 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"}), 200

Testing 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 5m

If 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.

Read more