HubSpot Integration
Receive and route HubSpot webhooks for contacts, companies, deals, and other CRM events.
Warning
Batched Payloads
HubSpot sends webhook events as a JSON array (batch), even when delivering a single event. Your transforms and filters should account for this array structure.
Setup
1. Create a Source in Hookbase
curl -X POST https://api.hookbase.app/api/sources \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "HubSpot Production",
"slug": "hubspot",
"provider": "hubspot",
"signingSecret": "your-app-client-secret",
"rejectInvalidSignatures": true
}'signingSecret is your app's Client secret, from the Auth tab of the app in your HubSpot
developer account. HubSpot issues it; Hookbase can't generate one.
Save your webhook URL:
https://api.hookbase.app/ingest/{orgSlug}/hubspot2. Configure HubSpot Webhooks
HubSpot webhooks are configured through Apps in a HubSpot Developer Account:
- Go to HubSpot Developer and sign in
- Navigate to Apps → select your app (or create one)
- Go to the Webhooks tab
- Set the Target URL to your Hookbase webhook URL
- Click Create subscription for each event you want to receive
- Select the object type (Contact, Company, Deal) and event type (Created, Updated, Deleted)
- Activate your subscriptions
Tip
HubSpot signs each request with your app's Client secret, found on the app's Auth tab. That is the value you pasted as signingSecret in step 1.
3. Create Routes
# Create destination for your CRM handler
curl -X POST https://api.hookbase.app/api/destinations \
-H "Authorization: Bearer whr_your_api_key" \
-d '{"name": "CRM Service", "slug": "crm-service", "url": "https://api.myapp.com/webhooks/hubspot"}'
# Create route
curl -X POST https://api.hookbase.app/api/routes \
-H "Authorization: Bearer whr_your_api_key" \
-d '{"name": "HubSpot to CRM Service", "sourceId": "src_...", "destinationId": "dst_..."}'Signature Verification
HubSpot uses v3 signature verification: a base64-encoded HMAC-SHA256, keyed with your app's Client secret, over this concatenation:
HTTP_METHOD + URI + request_body + timestampThe Client secret is the HMAC key, not part of the signed string. The URI is the full URL HubSpot called, with certain percent-encoded characters decoded as HubSpot's request validation docs describe. The signature is sent in the X-HubSpot-Signature-v3 header, and the timestamp is in the X-HubSpot-Request-Timestamp header.
Hookbase checks it automatically on sources with "provider": "hubspot". It hashes the URL the
request actually arrived on, so it works on custom domains too. Once events arrive with
signature_valid: true, keep rejectInvalidSignatures on to refuse anything that fails.
Info
Only v3 is checked. HubSpot also sends the older X-HubSpot-Signature (v1/v2) for backwards
compatibility. Workflow Send a webhook actions that carry only v1/v2 won't verify, so give them
their own source with rejectInvalidSignatures off.
Replay Attack Prevention
HubSpot's docs say to reject any request whose X-HubSpot-Request-Timestamp is older than
5 minutes. Hookbase does: the timestamp is in milliseconds and is part of the signed string, so
a request outside that window fails verification even with a valid signature.
Common Events
HubSpot sends events as a JSON array. Each element in the array represents a single event.
contact.creation
Triggered when a new contact is created in HubSpot.
{
"objectId": 1246965,
"propertyName": "lifecyclestage",
"propertyValue": "subscriber",
"changeSource": "CRM_UI",
"eventId": 3816279340,
"subscriptionId": 294235,
"portalId": 62515,
"appId": 54321,
"occurredAt": 1704067200000,
"subscriptionType": "contact.creation",
"attemptNumber": 0
}
"contact.propertyChange
Triggered when a contact property is updated.
{
"objectId": 1246965,
"propertyName": "email",
"propertyValue": "john.doe@example.com",
"changeSource": "CRM_UI",
"eventId": 3816279341,
"subscriptionId": 294236,
"portalId": 62515,
"appId": 54321,
"occurredAt": 1704067260000,
"subscriptionType": "contact.propertyChange",
"attemptNumber": 0
}
"deal.creation
Triggered when a new deal is created.
{
"objectId": 9843210,
"propertyName": "dealstage",
"propertyValue": "appointmentscheduled",
"changeSource": "CRM_UI",
"eventId": 3816279342,
"subscriptionId": 294237,
"portalId": 62515,
"appId": 54321,
"occurredAt": 1704067320000,
"subscriptionType": "deal.creation",
"attemptNumber": 0
}
"deal.propertyChange
Triggered when a deal property (e.g., stage, amount) is updated.
{
"objectId": 9843210,
"propertyName": "dealstage",
"propertyValue": "closedwon",
"changeSource": "CRM_UI",
"eventId": 3816279343,
"subscriptionId": 294238,
"portalId": 62515,
"appId": 54321,
"occurredAt": 1704067380000,
"subscriptionType": "deal.propertyChange",
"attemptNumber": 0
}
"company.creation
Triggered when a new company is created.
{
"objectId": 5678432,
"propertyName": "name",
"propertyValue": "Acme Corp",
"changeSource": "INTEGRATION",
"eventId": 3816279344,
"subscriptionId": 294239,
"portalId": 62515,
"appId": 54321,
"occurredAt": 1704067440000,
"subscriptionType": "company.creation",
"attemptNumber": 0
}
"HubSpot Subscription Types
| Category | Subscription Types |
|---|---|
| Contacts | contact.creation, contact.propertyChange, contact.deletion |
| Companies | company.creation, company.propertyChange, company.deletion |
| Deals | deal.creation, deal.propertyChange, deal.deletion |
Transform Examples
Warning
A transform cannot skip a delivery. return null does not suppress anything — the engine
serializes it, so your destination receives the four-byte body null with Content-Type: text/plain. Most endpoints answer that with a 400, which Hookbase records as a failed delivery
and then retries on the route's schedule.
Skipping is a route filter, and a filter condition reads one dotted path. That is enough for a single-object payload, but it cannot express "any element of this array matches" — so for a batched payload like this one there is no filter that suppresses the uninteresting batches. Return a body your destination can discard cheaply instead, or point the route at a service of your own and let it decide whether to post.
Slack Alert for New Deals
Since HubSpot sends batched events, iterate over the array and format each event:
function transform(payload) {
// payload is an array of events
const event = payload[0];
const timestamp = new Date(event.occurredAt).toISOString();
return {
blocks: [
{
type: "header",
text: {
type: "plain_text",
text: "New Deal Created in HubSpot"
}
},
{
type: "section",
fields: [
{
type: "mrkdwn",
text: `*Deal Stage:*\n${event.propertyValue}`
},
{
type: "mrkdwn",
text: `*Object ID:*\n${event.objectId}`
}
]
},
{
type: "section",
fields: [
{
type: "mrkdwn",
text: `*Source:*\n${event.changeSource}`
},
{
type: "mrkdwn",
text: `*Time:*\n${timestamp}`
}
]
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `Event ID: ${event.eventId} | Portal: ${event.portalId}`
}
]
}
]
};
}Database Format
Flatten each batched event into a normalized record:
function transform(payload) {
// Handle batched events — map each to a database record
return payload.map(event => ({
hubspot_event_id: String(event.eventId),
subscription_type: event.subscriptionType,
object_id: String(event.objectId),
property_name: event.propertyName || null,
property_value: event.propertyValue || null,
change_source: event.changeSource,
portal_id: String(event.portalId),
app_id: String(event.appId),
attempt_number: event.attemptNumber,
occurred_at: new Date(event.occurredAt).toISOString(),
received_at: new Date().toISOString()
}));
}Alert for Deal Stage Changes
function transform(payload) {
const dealEvents = payload.filter(
e => e.subscriptionType === 'deal.propertyChange' && e.propertyName === 'dealstage'
);
// Delivered, not skipped — see the callout above.
if (dealEvents.length === 0) return { alert: false, reason: 'no deal stage changes in this batch' };
const event = dealEvents[0];
const stageLabels = {
appointmentscheduled: 'Appointment Scheduled',
qualifiedtobuy: 'Qualified to Buy',
presentationscheduled: 'Presentation Scheduled',
decisionmakerboughtin: 'Decision Maker Bought In',
contractsent: 'Contract Sent',
closedwon: 'Closed Won',
closedlost: 'Closed Lost'
};
const stageName = stageLabels[event.propertyValue] || event.propertyValue;
return {
channel: "#sales-alerts",
username: "HubSpot Bot",
icon_emoji: event.propertyValue === 'closedwon' ? ":tada:" : ":briefcase:",
attachments: [{
color: event.propertyValue === 'closedwon' ? "good" : "#3498db",
title: `Deal Stage Changed: ${stageName}`,
fields: [
{ title: "Deal ID", value: String(event.objectId), short: true },
{ title: "New Stage", value: stageName, short: true },
{ title: "Source", value: event.changeSource, short: true },
{ title: "Portal", value: String(event.portalId), short: true }
],
footer: `Event ${event.eventId}`,
ts: Math.floor(event.occurredAt / 1000)
}]
};
}Filter Examples
Contact Events Only
{
"name": "Contact Events",
"logic": "OR",
"conditions": [
{
"field": "[0].subscriptionType",
"operator": "starts_with",
"value": "contact."
}
]
}Deal Events Only
{
"name": "Deal Events",
"logic": "OR",
"conditions": [
{
"field": "[0].subscriptionType",
"operator": "starts_with",
"value": "deal."
}
]
}Closed Won Deals
{
"name": "Closed Won Deals",
"logic": "AND",
"conditions": [
{
"field": "[0].subscriptionType",
"operator": "equals",
"value": "deal.propertyChange"
},
{
"field": "[0].propertyName",
"operator": "equals",
"value": "dealstage"
},
{
"field": "[0].propertyValue",
"operator": "equals",
"value": "closedwon"
}
]
}CRM UI Changes Only
Filter to only events triggered by manual changes in the HubSpot UI:
{
"name": "Manual CRM Changes",
"logic": "AND",
"conditions": [
{
"field": "[0].changeSource",
"operator": "equals",
"value": "CRM_UI"
}
]
}Headers
HubSpot sends these headers with webhooks:
| Header | Description |
|---|---|
X-HubSpot-Signature-v3 | HMAC-SHA256 signature (base64-encoded) |
X-HubSpot-Request-Timestamp | Unix timestamp (milliseconds) of the request |
Content-Type | application/json |
Hookbase does not forward these to your destination. A delivery is a fresh request with
Hookbase's own X-Delivery-ID and X-Event-ID plus the headers and auth you configured on the
destination — nothing HubSpot sent reaches your handler as a header. See
what headers a delivery carries.
Testing
Using HubSpot Test Endpoint
You can trigger test webhook events from your HubSpot app settings:
- Go to Apps → your app → Webhooks
- Click the Test button next to a subscription
- HubSpot will send a sample event to your configured URL
Create a Test Source
Create a separate source for development:
curl -X POST https://api.hookbase.app/api/sources \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "HubSpot Dev",
"slug": "hubspot-dev",
"provider": "hubspot",
"signingSecret": "your-dev-app-client-secret"
}'Best Practices
-
Handle batched payloads: Always treat the incoming payload as an array — HubSpot batches events even when sending a single one
-
Use event IDs for idempotency: Each event has a unique
eventId; use it to prevent duplicate processing -
Check
attemptNumber: HubSpot retries failed deliveries and increments this field — ensure your handlers are idempotent -
Separate dev and production: Use different HubSpot apps (and therefore different client secrets) for development and production
-
Subscribe selectively: Only subscribe to the object types and events you need to reduce noise
-
Keep rejection on: With the Client secret stored,
rejectInvalidSignaturesrefuses forged and replayed requests -
Monitor
changeSource: Use thechangeSourcefield to distinguish between UI changes, API changes, and integration-triggered changes
Troubleshooting
Signature Verification Failed
- Confirm the source's
providerishubspot. Acustomsource readsX-Signature,X-Webhook-SignatureandX-Hub-Signature-256, and HubSpot sends none of them - Confirm
signingSecretis the app's Client secret from the Auth tab, not an access token or the app ID - Confirm the Target URL in HubSpot is exactly your Hookbase ingest URL. HubSpot signs the URL it calls
- A rejected request older than 5 minutes means a delayed retry or a replay. Check the server clock if it happens to every event
- Workflow Send a webhook actions that send only v1/v2 signatures can't pass. Route them to a separate source with rejection off
Events Not Arriving
- Check that your webhook subscriptions are active in the HubSpot app settings
- Verify your app is installed on the target HubSpot portal
- Confirm the target URL is publicly accessible
- Check HubSpot's webhook logs under Monitoring → Webhooks in your developer account
Unexpected Payload Format
- Remember that HubSpot sends events as a JSON array, not a single object
- Batch size can vary — always iterate over the array
- The
propertyNameandpropertyValuefields may benullfor creation and deletion events
Duplicate Events
- HubSpot retries failed deliveries — check the
attemptNumberfield - Hookbase has no HubSpot event-ID extractor, so the
provider_iddeduplication strategy finds no key and does nothing for this source. Payload-hash dedup does not catch retries either, because each retry carries a differentattemptNumber - De-duplicate in your handler on each element's
eventId
Missing Property Changes
- HubSpot only sends events for properties you've subscribed to
- To receive all property changes, subscribe to
contact.propertyChange(orcompany.propertyChange,deal.propertyChange) without specifying individual properties - Check that the property exists and is not a calculated or read-only property