← Back to Blog
Guide hubspot webhooks crm integration

How to Receive HubSpot Webhooks

HubSpot batches events into an array, delivers them out of order, and signs them three different ways. None of that is in the setup screen.

JW

Jason Warner

July 29, 2026

How to Receive HubSpot Webhooks

HubSpot webhooks save you from polling the CRM API every few minutes and burning your rate limit to discover that nothing changed. Contact updated, deal moved stage, form submitted — pushed to you when it happens.

They also behave differently from every other webhook you've integrated, in three specific ways that the setup screen doesn't warn you about.

Getting the Subscription Created

The first surprise is where the settings live. Webhooks are configured on the app, not on your portal, so if you've been hunting through portal settings looking for something like Stripe's webhook page, that's why you can't find it.

In your developer account, open the app, go to Webhooks, set a target URL, then create subscriptions for the events you want — contact.propertyChange, deal.creation, and so on. Activate them, then install the app on the portal.

The URL has to be public HTTPS and live before you save, because HubSpot validates it on the spot. No localhost.

Surprise One: You Get an Array

Nearly every provider sends one event per request. HubSpot sends a batch:

[
  {
    "eventId": 1234567890,
    "subscriptionType": "contact.propertyChange",
    "objectId": 451,
    "propertyName": "lifecyclestage",
    "propertyValue": "customer",
    "occurredAt": 1735689600000
  },
  {
    "eventId": 1234567891,
    "subscriptionType": "contact.propertyChange",
    "objectId": 452,
    "propertyName": "email",
    "propertyValue": "new@example.com",
    "occurredAt": 1735689601000
  }
]

A handler written for a single object either throws on a missing field or, more insidiously, processes events[0] and drops the rest without complaining. Iterate:

const events = await req.json();
for (const event of events) {
  await handle(event);
}

And here's the bit that matters: the batch isn't transactional. If event 7 of 20 throws and you fail the whole request, HubSpot redelivers all twenty, so the first six run a second time. Every handler has to be safe to repeat.

Surprise Two: Order Isn't Guaranteed

occurredAt is a millisecond timestamp and it's the only ordering you can trust. Delivery order isn't.

Which means you can receive a contact's lifecycle stage as customer, then receive lead a moment later because the older event arrived second. Write that blindly and you've just demoted a paying customer.

if (event.occurredAt < existing.lastUpdatedAt) return;   // stale

One line, and it's the difference between a CRM that reflects reality and one that occasionally reverses itself for no visible reason.

Surprise Three: Three Signature Versions

HubSpot has shipped three schemes, and which you get depends on how the app is configured.

v1 is a plain SHA-256 of the client secret concatenated with the body — no timestamp, so nothing stops replay. v2 folds in the method and URL, which breaks the moment a proxy rewrites the URL to something other than what HubSpot signed. v3 is HMAC-SHA256 over method, URI, body, and a timestamp, delivered in X-HubSpot-Signature-V3.

Use v3, and reject anything older than five minutes:

const raw = await req.text();
const timestamp = req.headers.get('x-hubspot-request-timestamp');

if (Date.now() - Number(timestamp) > 5 * 60 * 1000) {
  return new Response('stale', { status: 400 });
}

const base = `POST${fullUrl}${raw}${timestamp}`;
const expected = crypto.createHmac('sha256', clientSecret).update(base).digest('base64');

As always, verify against the raw body before parsing. Reserializing changes the bytes and the hash will never line up.

The 24-Hour Cliff

HubSpot wants a 2xx within a few seconds and retries failures for 24 hours.

That sounds generous until you picture an outage that starts on Friday evening. By the time anyone looks on Monday, the window has closed, and unlike Stripe there's no "resend" button for old events — those property changes are simply gone. Your CRM and your database now disagree, permanently, and you have no list of what you missed.

So the usual advice applies with more urgency than normal: verify, store the batch somewhere durable, return 200, and do the real work in a background job.

Why I'd Put a Buffer in Front

Pointing HubSpot at Bluejay Relay mostly exists to solve that 24-hour cliff. The batch is captured and stored the moment it arrives, so a weekend outage becomes a delivery backlog instead of permanent data loss, and you can replay the affected window once you're back.

It helps with the debugging too. CRM integrations go wrong in fiddly ways — a property that isn't the type you assumed, a value that arrives as a string on Tuesdays — and having the exact payload HubSpot sent, rather than your parsed version of it, is most of the investigation. Fan-out is handy here as well: one subscription can feed your app, your warehouse, and a Slack channel without configuring three of them in HubSpot.

Questions That Come Up

Why isn't my webhook firing at all? Usually a subscription created but never activated, or an app that hasn't been installed on the portal. Check the subscription status on the app's webhook page.

Can I get webhooks without building an app? No. It's an app-level feature, so even a purely internal integration needs a developer account and an app.

Does a propertyChange event include the whole record? No — you get the object ID and the single property that changed. If you need the rest, fetch it from the CRM API.


Never lose a CRM event to a 24-hour retry window. Try Bluejay Relay free.

#hubspot #webhooks #crm #integration

Build more reliable webhook workflows.

Capture, transform, and retry webhooks with full observability. Free to start, no credit card.