Webhook Patterns for Multi-Tenant HubSpot Apps
/ 7 min read
Table of Contents
Building a HubSpot public app that integrates with multiple customer accounts presents a fundamental challenge: HubSpot only allows one webhook URL per app. But when Customer A’s contact updates, you need to route that data to Customer A’s instance of your service, not Customer B’s.
This article covers the architectural patterns for handling webhooks across multiple HubSpot accounts, with code examples you can adapt.
The Single URL Constraint
When you register a HubSpot public app, you configure one webhook subscription URL:
https://yourservice.com/webhooks/hubspotEvery webhook event from every customer who installs your app hits this single endpoint. The payload includes a portalId identifying which HubSpot account triggered the event, but routing that data to the right destination is your responsibility.
Pattern 1: The Proxy/Router
The most common pattern is a lightweight proxy service that:
- Receives all webhooks at a single endpoint
- Validates the webhook signature
- Looks up the destination for that portal
- Forwards the payload to the correct downstream service
Architecture
HubSpot Account A ─┐ │HubSpot Account B ─┼──► Proxy Service ──┬──► Customer A's Service │ │HubSpot Account C ─┘ ├──► Customer B's Service │ └──► Customer C's ServiceImplementation
const express = require('express');const crypto = require('crypto');const axios = require('axios');
const app = express();app.use(express.json());
// Customer configuration databaseconst customerConfig = { '12345678': { // Portal ID destinationUrl: 'https://customer-a.service.com/hubspot-webhook', secretKey: 'customer-a-secret' }, '87654321': { destinationUrl: 'https://customer-b.service.com/hubspot-webhook', secretKey: 'customer-b-secret' }};
// Webhook endpointapp.post('/webhooks/hubspot', async (req, res) => { const signature = req.headers['x-hubspot-signature-v3']; const timestamp = req.headers['x-hubspot-request-timestamp'];
// Validate signature (see next section) if (!validateSignature(req, signature, timestamp)) { return res.status(401).send('Invalid signature'); }
// Process each event in the batch const events = req.body;
for (const event of events) { const portalId = event.portalId.toString(); const config = customerConfig[portalId];
if (!config) { console.warn(`Unknown portal: ${portalId}`); continue; }
// Forward to customer's service try { await axios.post(config.destinationUrl, event, { headers: { 'Content-Type': 'application/json', 'X-Webhook-Secret': config.secretKey }, timeout: 5000 }); } catch (error) { console.error(`Failed to forward to ${portalId}:`, error.message); // Implement retry logic here } }
res.status(200).send('OK');});Signature Validation
HubSpot signs webhook payloads using your app’s client secret. Always validate before processing:
V3 Signature Validation (Current Standard)
function validateSignature(req, signature, timestamp) { const CLIENT_SECRET = process.env.HUBSPOT_CLIENT_SECRET;
// Check timestamp is within 5 minutes const currentTime = Date.now(); const requestTime = parseInt(timestamp);
if (Math.abs(currentTime - requestTime) > 300000) { return false; // Request too old }
// Construct signature base string // Method + URL + Body + Timestamp const requestUri = `https://${req.headers.host}${req.originalUrl}`; const requestBody = JSON.stringify(req.body); const signatureBaseString = `POST${requestUri}${requestBody}${timestamp}`;
// Compute expected signature const expectedSignature = crypto .createHmac('sha256', CLIENT_SECRET) .update(signatureBaseString) .digest('base64');
// Constant-time comparison return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expectedSignature) );}Important: Raw Body Access
Express’s express.json() middleware parses the body, but signature validation needs the exact bytes HubSpot sent. Capture the raw body:
app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf.toString(); }}));
// Then in validation:const requestBody = req.rawBody;Pattern 2: Queue-Based Processing
For high-volume scenarios, queue webhooks for asynchronous processing:
Architecture
HubSpot ──► Webhook Receiver ──► Message Queue ──► Worker Pool │ ▼ ┌───────────────┐ │ Customer A │ │ Customer B │ │ Customer C │ └───────────────┘Implementation with Redis/Bull
const Queue = require('bull');const webhookQueue = new Queue('hubspot-webhooks', process.env.REDIS_URL);
// Receiver - just validates and queuesapp.post('/webhooks/hubspot', async (req, res) => { if (!validateSignature(req, ...)) { return res.status(401).send('Invalid'); }
// Queue each event for (const event of req.body) { await webhookQueue.add('process', event, { attempts: 3, backoff: { type: 'exponential', delay: 1000 } }); }
res.status(200).send('Queued');});
// Worker - processes from queuewebhookQueue.process('process', async (job) => { const event = job.data; const portalId = event.portalId.toString(); const config = await getCustomerConfig(portalId);
await axios.post(config.destinationUrl, event);});Benefits
- Decoupled processing: Receiver responds quickly; processing happens async
- Automatic retries: Queue handles failures and backoff
- Scalable: Add workers as volume grows
- Visibility: Monitor queue depth and processing times
Pattern 3: Database-Backed Routing
For apps with complex routing logic, store configuration in a database:
const { Pool } = require('pg');const pool = new Pool();
async function getRouting(portalId) { const result = await pool.query( 'SELECT destination_url, api_key, active FROM customer_config WHERE portal_id = $1', [portalId] );
if (result.rows.length === 0) { return null; }
const config = result.rows[0];
if (!config.active) { return null; // Customer has paused integration }
return config;}Schema Example
CREATE TABLE customer_config ( portal_id VARCHAR(20) PRIMARY KEY, destination_url VARCHAR(255) NOT NULL, api_key VARCHAR(255), active BOOLEAN DEFAULT true, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW());
CREATE TABLE webhook_log ( id SERIAL PRIMARY KEY, portal_id VARCHAR(20), event_type VARCHAR(50), object_type VARCHAR(50), object_id VARCHAR(50), processed_at TIMESTAMP DEFAULT NOW(), status VARCHAR(20), error_message TEXT);Handling Specific Event Types
Different event types may need different handling:
app.post('/webhooks/hubspot', async (req, res) => { // ... validation ...
for (const event of req.body) { const { subscriptionType, objectType, objectId, portalId } = event;
switch (subscriptionType) { case 'contact.creation': await handleContactCreated(portalId, objectId); break;
case 'contact.propertyChange': await handleContactUpdated(portalId, objectId, event.propertyName, event.propertyValue); break;
case 'deal.creation': await handleDealCreated(portalId, objectId); break;
case 'contact.deletion': await handleContactDeleted(portalId, objectId); break;
default: console.log(`Unhandled event type: ${subscriptionType}`); } }
res.status(200).send('OK');});Subscription Management
When a customer installs your app, set up their webhook subscriptions:
async function setupWebhookSubscriptions(accessToken, portalId) { const subscriptions = [ { eventType: 'contact.creation' }, { eventType: 'contact.propertyChange', propertyName: 'email' }, { eventType: 'contact.deletion' }, { eventType: 'deal.creation' }, { eventType: 'deal.propertyChange', propertyName: 'dealstage' } ];
for (const sub of subscriptions) { await axios.post( `https://api.hubapi.com/webhooks/v3/${APP_ID}/subscriptions`, { active: true, eventType: sub.eventType, propertyName: sub.propertyName }, { headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json' } } ); }}Error Handling and Retries
HubSpot expects a 2xx response within 5 seconds. If your endpoint fails or times out:
- HubSpot retries with exponential backoff
- After too many failures, HubSpot may disable the subscription
Design for reliability:
app.post('/webhooks/hubspot', async (req, res) => { // Respond immediately res.status(200).send('OK');
// Process asynchronously setImmediate(async () => { try { await processWebhooks(req.body); } catch (error) { // Log but don't crash console.error('Webhook processing failed:', error); await logFailedWebhook(req.body, error); } });});Security Considerations
Isolate Customer Data
Never let webhooks from Portal A affect Portal B’s data:
async function processWebhook(event) { const portalId = event.portalId;
// Get database connection scoped to this customer const customerDb = await getCustomerDatabase(portalId);
// All operations use customer-specific connection await customerDb.query('INSERT INTO events ...', [event]);}Rate Limiting
Protect downstream services from webhook floods:
const rateLimit = require('express-rate-limit');
const webhookLimiter = rateLimit({ windowMs: 60 * 1000, // 1 minute max: 1000, // per portal keyGenerator: (req) => { // Rate limit per portal, not globally return req.body[0]?.portalId || 'unknown'; }});
app.post('/webhooks/hubspot', webhookLimiter, ...);Secret Rotation
Support rotating webhook secrets without downtime:
function validateSignature(req, signature, timestamp) { const secrets = [ process.env.HUBSPOT_CLIENT_SECRET, process.env.HUBSPOT_CLIENT_SECRET_PREVIOUS // During rotation ].filter(Boolean);
for (const secret of secrets) { if (computeSignature(req, secret, timestamp) === signature) { return true; } }
return false;}Monitoring and Observability
Track webhook health:
const prometheus = require('prom-client');
const webhookCounter = new prometheus.Counter({ name: 'hubspot_webhooks_total', help: 'Total webhooks received', labelNames: ['portal_id', 'event_type', 'status']});
const webhookLatency = new prometheus.Histogram({ name: 'hubspot_webhook_processing_seconds', help: 'Webhook processing time', labelNames: ['portal_id', 'event_type']});
app.post('/webhooks/hubspot', async (req, res) => { const timer = webhookLatency.startTimer();
try { await processWebhooks(req.body); webhookCounter.inc({ status: 'success', ... }); } catch (error) { webhookCounter.inc({ status: 'error', ... }); throw error; } finally { timer(); }});Key Takeaways
- One URL, many customers: Build a proxy/router to handle the single-endpoint constraint
- Always validate signatures: Use v3 signature validation with constant-time comparison
- Respond fast, process async: Return 200 immediately, process in background
- Queue for reliability: Use a message queue for retry logic and scalability
- Isolate customer data: Never let one customer’s webhook affect another’s data
- Monitor everything: Track webhook volume, latency, and failures by customer
The proxy pattern is foundational for any HubSpot public app that needs to route data to customer-specific destinations. Get this right, and scaling to thousands of customers becomes straightforward.
Building HubSpot integrations and need architecture guidance? Connect with me on LinkedIn.