A team ships a HubSpot webhook integration on Friday afternoon and wonders on Monday why three deals are missing in the downstream system. The webhook fired, returned 200, but their handler crashed silently after writing the response. Webhooks look easy in the docs and turn into a quiet data-loss surface in production. The good news: a small number of patterns prevent most of the failures.
Where webhooks live in HubSpot
Webhook subscriptions are a public-app-only feature. Private apps get the workflow webhook action (outbound) and API polling (inbound) instead. If you need real-time push for a private use case, build a public app installed only in your own portal.
Public app -> webhook subscriptions, OAuth, marketplace-eligible
Private app -> workflow webhook action, polling, single portal
Subscribing to events
Configure subscriptions in the developer portal under your app’s Webhooks settings. Each subscription is an object + event type:
contact.creation
contact.propertyChange (per property or all)
contact.deletion
contact.merge
deal.creation
deal.propertyChange
ticket.priorityChange
company.creation
You configure one target URL per app. Route by event type on your end with a single dispatcher rather than spreading logic across many handlers.
Signature verification
HubSpot signs every webhook payload with your app’s client secret. Verify on every receipt:
import crypto from "crypto";
function verifySignature(req, secret) {
const signature = req.headers["x-hubspot-signature-v3"];
const timestamp = req.headers["x-hubspot-request-timestamp"];
const ageMs = Date.now() - Number(timestamp);
if (ageMs > 5 * 60 * 1000) return false; // reject old replays
const baseString = req.method + req.url + req.rawBody + timestamp;
const expected = crypto
.createHmac("sha256", secret)
.update(baseString)
.digest("base64");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
Without verification, anyone with your URL can spoof events.
Idempotency because retries happen
HubSpot retries failed deliveries with exponential backoff over 24 hours. Your handler will see duplicate events. Build an idempotency layer keyed on eventId:
async function handleEvent(event) {
const seen = await cache.get(`hs:event:${event.eventId}`);
if (seen) return { status: "duplicate" };
await cache.set(`hs:event:${event.eventId}`, "1", { ttl: 7 * 86400 });
await processEvent(event);
}
Process the event after caching the eventId so a crash mid-process still allows retry.
Acknowledge fast, process async
HubSpot expects a 200 within a few seconds. A handler that synchronously calls three other APIs will time out and trigger retries. Pattern: receive, validate, push to a queue, return 200, process from the queue.
export default async function handler(req, res) {
if (!verifySignature(req, process.env.HS_SECRET)) {
return res.status(401).end();
}
await queue.send(req.body);
res.status(200).end();
}
Monitoring and alerting
Log every webhook receipt with eventId, subscriptionType, and processing outcome. Set alerts on:
- Zero webhooks received in 30 minutes during business hours
- Spike of >5x baseline rate
- Signature verification failures > 1% of receipts
- Processing errors > 0.5% over 1 hour
- Queue depth > threshold
Silent failures are the worst kind. Zero-receipt alerts catch upstream platform incidents and middleware outages early.
Replay and backfill
When something breaks, you need a replay path. Either keep a 7-day window of raw payloads in cold storage and replay through your handler, or rebuild the affected window via API polling. Decide before the outage, not during.
What to do this week
Audit your webhook handlers for signature verification, add idempotency keyed on eventId, and configure a zero-receipt alert before next month’s quiet weekend reveals a 3-day gap.