HubSpot Webhooks & Events
Overview
Set up HubSpot webhook subscriptions for CRM events (contact/company/deal creation, updates, deletions) with v3 signature verification and idempotent event handling.
Prerequisites
- HubSpot public app (webhooks require a public app, not a private app)
- Client secret from your app settings (for signature verification)
- HTTPS endpoint accessible from the internet
- Optional: Redis or database for idempotency
Instructions
Step 1: Understand HubSpot Webhook Events
HubSpot sends webhook events as batches of CRM change notifications:
[
{
"eventId": 100,
"subscriptionId": 1234,
"portalId": 12345678,
"appId": 98765,
"occurredAt": 1711234567890,
"subscriptionType": "contact.propertyChange",
"attemptNumber": 0,
"objectId": 123,
"propertyName": "lifecyclestage",
"propertyValue": "marketingqualifiedlead",
"changeSource": "CRM",
"sourceId": "userId:12345"
}
]
Available subscription types:
contact.creation,contact.deletion,contact.propertyChange,contact.privacyDeletioncompany.creation,company.deletion,company.propertyChangedeal.creation,deal.deletion,deal.propertyChangeticket.creation,ticket.deletion,ticket.propertyChangecontact.merge,company.merge,deal.mergecontact.associationChange,company.associationChange,deal.associationChange
Step 2: Set Up Webhook Endpoint with Signature Verification
import express from 'express';
import crypto from 'crypto';
const app = express();
// IMPORTANT: Use raw body for signature verification
app.post('/webhooks/hubspot',
express.raw({ type: 'application/json' }),
async (req, res) => {
// Verify signature (v3)
const signature = req.headers['x-hubspot-signature-v3'] as string;
const timestamp = req.headers['x-hubspot-request-timestamp'] as string;
if (!signature || !timestamp) {
// Fall back to v2 signature
const sigV2 = req.headers['x-hubspot-signature'] as string;
if (!verifySignatureV2(req.body.toString(), sigV2)) {
return res.status(401).json({ error: 'Invalid signature' });
}
} else {
const requestUri = `https://${req.headers.host}${req.originalUrl}`;
if (!verifySignatureV3(req.body.toString(), signature, timestamp, requestUri)) {
return res.status(401).json({ error: 'Invalid signature' });
}
}
// HubSpot sends events as an array
const events: HubSpotWebhookEvent[] = JSON.parse(req.body.toString());
// Respond immediately (HubSpot expects < 5 second response)
res.status(200).json({ received: true });
// Process events asynchronously
processEvents(events).catch(err =>
console.error('Event processing failed:', err)
);
}
);
Step 3: Signature Verification Functions
const CLIENT_SECRET = process.env.HUBSPOT_CLIENT_SECRET!;
// v3 signature (preferred)
function verifySignatureV3(
body: string, signature: string, timestamp: string, requestUri: string
): boolean {
// Reject timestamps older than 5 minutes
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) return false;
const sourceString = `POST${requestUri}${body}${timestamp}`;
const expected = crypto
.createHmac('sha256', CLIENT_SECRET)
.update(sourceString)
.digest('base64');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
// v2 signature (fallback)
function verifySignatureV2(body: string, signature: string): boolean {
const sourceString = CLIENT_SECRET + body;
const expected = crypto
.createHash('sha256')
.update(sourceString)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
Step 4: Event Handler with Idempotency
interface HubSpotWebhookEvent {
eventId: number;
subscriptionId: number;
portalId: number;
appId: number;
occurredAt: number;
subscriptionType: string;
attemptNumber: number;
objectId: number;
propertyName?: string;
propertyValue?: string;
changeSource?: string;
}
// Track processed events to prevent duplicates
const processedEvents = new Set<number>();
async function processEvents(events: HubSpotWebhookEvent[]): Promise<void> {
for (const event of events) {
// Idempotency: skip already-processed events
if (processedEvents.has(event.eventId)) {
console.log(`Skipping duplicate event: ${event.eventId}`);
continue;
}
try {
await handleEvent(event);
processedEvents.add(event.eventId);
// Clean up old event IDs (keep last 10,000)
if (processedEvents.size > 10000) {
const oldest = [...processedEvents].slice(0, 5000);
oldest.forEach(id => processedEvents.delete(id));
}
} catch (error) {
console.error(`Failed to process event ${event.eventId}:`, error);
}
}
}
async function handleEvent(event: HubSpotWebhookEvent): Promise<void> {
const { subscriptionType, objectId, propertyName, propertyValue } = event;
switch (subscriptionType) {
case 'contact.creation':
console.log(`New contact created: ${objectId}`);
// Sync to your database, send welcome email, etc.
break;
case 'contact.propertyChange':
console.log(`Contact ${objectId}: ${propertyName} = ${propertyValue}`);
if (propertyName === 'lifecyclestage' && propertyValue === 'customer') {
// Trigger onboarding workflow
}
break;
case 'deal.propertyChange':
if (propertyName === 'dealstage') {
console.log(`Deal ${objectId} moved to stage: ${propertyValue}`);
// Notify sales team, update dashboard, etc.
}
break;
case 'deal.creation':
console.log(`New deal created: ${objectId}`);
break;
case 'contact.deletion':
case 'contact.privacyDeletion':
console.log(`Contact ${objectId} deleted`);
// Remove from your systems (GDPR compliance)
break;
default:
console.log(`Unhandled event: ${subscriptionType} for object ${objectId}`);
}
}
Step 5: Register Webhook Subscriptions
Subscriptions are configured in your HubSpot public app settings, or via API:
// Create webhook subscription via API
async function createSubscription(
appId: number,
subscriptionType: string,
propertyName?: string
) {
const client = new hubspot.Client({
accessToken: process.env.HUBSPOT_DEVELOPER_API_KEY!,
});
await client.apiRequest({
method: 'POST',
path: `/webhooks/v3/${appId}/subscriptions`,
body: {
eventType: subscriptionType,
propertyName: propertyName || undefined,
active: true,
},
});
}
// Example: Subscribe to lifecycle stage changes
await createSubscription(appId, 'contact.propertyChange', 'lifecyclestage');
await createSubscription(appId, 'deal.creation');
await createSubscription(appId, 'deal.propertyChange', 'dealstage');
Output
- Webhook endpoint with v3 signature verification
- Event handler for contact, company, deal, and ticket events
- Idempotent processing preventing duplicate handling
- Replay protection via timestamp validation
Error Handling
| Issue | Cause | Solution |
|-------|-------|----------|
| Invalid signature | Wrong client secret | Verify in App Settings > Auth |
| Duplicate events | HubSpot retries | Implement event ID tracking |
| Timeout (no 200 response) | Slow processing | Respond immediately, process async |
| Missing events | Subscription inactive | Check subscription status in app settings |
| attemptNumber > 0 | Previous delivery failed | Normal retry behavior -- process normally |
Examples
Test Webhooks Locally
# Use ngrok to expose local server
ngrok http 3000
# Update webhook URL in HubSpot app settings:
# https://xxxx.ngrok.io/webhooks/hubspot
# Trigger a test: create a contact in HubSpot UI
# Watch your local logs for the webhook event
Resources
Next Steps
For performance optimization, see hubspot-performance-tuning.