skip to content
san.is
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/hubspot

Every 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:

  1. Receives all webhooks at a single endpoint
  2. Validates the webhook signature
  3. Looks up the destination for that portal
  4. 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 Service

Implementation

const express = require('express');
const crypto = require('crypto');
const axios = require('axios');
const app = express();
app.use(express.json());
// Customer configuration database
const 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 endpoint
app.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 queues
app.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 queue
webhookQueue.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:

  1. HubSpot retries with exponential backoff
  2. 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

  1. One URL, many customers: Build a proxy/router to handle the single-endpoint constraint
  2. Always validate signatures: Use v3 signature validation with constant-time comparison
  3. Respond fast, process async: Return 200 immediately, process in background
  4. Queue for reliability: Use a message queue for retry logic and scalability
  5. Isolate customer data: Never let one customer’s webhook affect another’s data
  6. 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.