A SaaS app that installs into customers' HubSpot accounts should get CRM changes through app webhook subscriptions. They are defined once on your app and fire for every account that installs it. Workflow webhook actions are a different tool, built by each customer inside their own workflows. The decisions that matter for your team come after that choice. Your handler has to verify every request, survive duplicates and retries, and keep the success rate HubSpot's certification review measures.
Webhooks are part of the app you ship and maintain across every customer account, not a one-time setup. Appnigma's marketplace apps service covers that case. It delivers "a native app, marketplace listing preparation, and review support" for HubSpot and Salesforce. If you are a HubSpot admin sending workflow data to another tool, HubSpot's guide to using webhooks in workflows is the page you want.
Updated October 2026. Checked against HubSpot's webhooks API guide, the request validation guide, the developer platform webhook configuration reference and the certification requirements. This update fixes the signature code, the retry and throttling rules and the certification threshold. It adds the project-based app setup and the webhooks journal, and it removes a case study and anecdotes that had no source.
App webhooks vs workflow webhooks, which should a SaaS app use?
HubSpot gives an app developer two ways to receive pushed events and one way to pull missed ones. They differ in who sets them up and how far they reach.
| System | Who configures it | Reach | Signature | Best for |
|---|---|---|---|---|
| App webhook subscriptions | You, once, on the app | Every account that installs the app | X-HubSpot-Signature-v3 | CRM sync and change reactions in a Marketplace app |
| Workflow webhook action | Each customer, inside a workflow (Data Hub Professional or Enterprise) | One account, one workflow | v2 X-HubSpot-Signature | Customer-defined triggers |
| Webhooks journal API | You, by API | Your app's subscriptions, last three days | OAuth client credentials | Catching up after an outage |
HubSpot's webhooks API guide says subscriptions are set "on the app level, not for individual accounts" and that "any account that installs your app by going through the OAuth flow will be subscribed." That is the property a Marketplace app needs. The customer installs, approves scopes and events start flowing, with nothing to build on their side.
The workflow action fits a narrower job. HubSpot's workflow webhooks guide lists it for Data Hub Professional and Enterprise, so not every customer can use it. The customer decides which records and conditions call your URL, and coverage depends on every customer building the workflow correctly. Its failure handling differs as well. HubSpot retries a failed workflow webhook "for up to three days," and "workflows will not retry after receiving 4XX series response status codes," except 429. HubSpot's certification requirements include a sample review note for exactly this pattern: "Your app currently requires users to manually configure workflow webhook actions." The suggested fix is to build custom workflow actions into the app instead. A custom action appears in the customer's workflow editor as your app's own step.
There is no separate form submission webhook for apps. A HubSpot form submission reaches an outside URL through a workflow with a webhook action, or your app reads submissions through the Forms API. Older guides, including an earlier version of this page, listed form webhooks as a third system.
Configuring subscriptions on the developer platform
Where you define subscriptions depends on how your app is built. Apps on the current developer platform are projects. The webhook configuration reference puts them in a webhooks folder under src/app/ in a file named *-hsmeta.json, deployed with the rest of the project.
{
"uid": "crm_webhooks",
"type": "webhooks",
"config": {
"settings": {
"targetUrl": "https://api.yourapp.com/hubspot/webhooks",
"maxConcurrentRequests": 10
},
"subscriptions": {
"crmObjects": [
{ "subscriptionType": "object.creation", "objectType": "contact", "active": true }
],
"legacyCrmObjects": [
{ "subscriptionType": "deal.propertyChange", "propertyName": "dealstage", "active": true },
{ "subscriptionType": "contact.deletion", "active": true }
],
"hubEvents": [
{ "subscriptionType": "contact.privacyDeletion", "active": true }
]
}
}
}crmObjects takes the newer generic object.* subscription types, which HubSpot's generic webhooks guide still labels beta. legacyCrmObjects and hubEvents hold the classic types such as deal.propertyChange and contact.privacyDeletion. Legacy public apps still manage the same settings on the app's Webhooks page in the developer account, or through the /app-webhooks/2026-09/{appId} endpoints authenticated with a developer API key. New ones can't be created anymore. HubSpot's changelog says legacy public app creation was disabled on June 23, 2026 for existing accounts, so a new app starts as a project.
A few rules from the webhooks API guide shape the design. Each subscription needs the matching read scope, such as crm.objects.contacts.read for contact events or tickets for ticket events. You also can't remove a scope while an active subscription depends on it, so plan scopes before the listing goes live.
A property change subscription names one property. If a customer's account doesn't have that property, the account sends nothing for it, and hs_lastmodifieddate can't be subscribed to at all. An app can hold "a maximum of 1000 subscriptions per application." Changes to the URL, concurrency or subscriptions "can be cached for up to five minutes," which matters when you test right after a deploy.
What a delivery contains and what it leaves out
Every delivery is a JSON array of events. HubSpot's webhooks API guide says each request "can contain up to 100 events." Your handler routes on portalId, which identifies the customer's account. subscriptionType and objectId say what changed, and attemptNumber starts at 0 and rises with each retry.
[
{
"eventId": 3816279340,
"subscriptionId": 25,
"portalId": 33,
"appId": 1160452,
"occurredAt": 1462216307945,
"subscriptionType": "deal.propertyChange",
"attemptNumber": 0,
"objectId": 1246965,
"propertyName": "dealstage",
"propertyValue": "closedwon",
"changeSource": "CRM_UI"
}
]The payload is a notification, not the record. It carries the changed property and its new value, so anything else means a CRM API call with that account's OAuth token.
Three documented behaviors decide how you store events. Order isn't guaranteed, and the guide says to use occurredAt "to determine when the event that triggered the notification occurred." Duplicates happen too. HubSpot "does not guarantee that you'll only get a single notification for an event," and eventId "is not guaranteed to be unique." Some actions also send pairs. An associationChange fires once for each side of the association, and a privacy deletion triggers the ordinary deletion event as well.
So the idempotency key can't be eventId alone. A safe key combines portalId, subscriptionId, objectId, occurredAt and the property name where there is one.
Worked from the documented limits, here is what a customer import does to your backend. HubSpot allows 10 concurrent requests per installed account, and each request can hold up to 100 events. So one account can have up to 1,000 events in flight against your endpoint at once. If your handler fetches each record with a single GET, a 5,000 contact import turns into 5,000 API calls charged to that customer's account. HubSpot's contacts API guide says "batch operations are limited to 100 records at a time," so grouping the IDs from each delivery into batch reads cuts that to about 50 calls. The usage guidelines limit a Marketplace app to "110 requests every 10 seconds" for each installing account. At that rate, 5,000 single reads take at least seven and a half minutes of that customer's API budget, and the 50 batch reads fit in one window. The webhook POSTs themselves "will not count against your app's API rate limits," per the webhooks guide. The follow-up reads do.
Verifying the X-HubSpot-Signature-v3 header
Every app webhook request carries an X-HubSpot-Signature-v3 header and an X-HubSpot-Request-Timestamp header. HubSpot's request validation guide describes the check. Build a UTF-8 string of the method, the full request URI, the raw body and the timestamp. Compute an HMAC SHA-256 of it with your app's client secret, base64 encode it and compare. The guide also says to "reject the request if the timestamp is older than 5 minutes."
The URI is where most implementations fail. HubSpot's own example signs https:// plus the hostname plus the path and query, not the path alone. It also decodes a short list of URL-encoded characters in the query string before hashing. Behind a load balancer or proxy, rebuild the URI from the public hostname HubSpot called, not the internal one your server sees.
import crypto from "node:crypto";
// HubSpot's guide lists the encoded characters to decode in the query string
const DECODE = {
"%3A": ":", "%2F": "/", "%3F": "?", "%40": "@", "%21": "!", "%24": "$",
"%27": "'", "%28": "(", "%29": ")", "%2A": "*", "%2C": ",", "%3B": ";"
};
export function isFromHubSpot(req, clientSecret, publicHost) {
const timestamp = req.get("X-HubSpot-Request-Timestamp");
if (!timestamp || Date.now() - Number(timestamp) > 5 * 60 * 1000) return false;
let uri = `https://${publicHost}${req.originalUrl}`.split("#")[0];
const q = uri.indexOf("?");
if (q !== -1) {
uri = uri.slice(0, q + 1) + uri.slice(q + 1).replace(/%3A|%2F|%3F|%40|%21|%24|%27|%28|%29|%2A|%2C|%3B/gi,
(m) => DECODE[m.toUpperCase()]);
}
const body = req.rawBody ? req.rawBody.toString("utf8") : "";
const expected = crypto.createHmac("sha256", clientSecret)
.update(`${req.method}${uri}${body}${timestamp}`, "utf8")
.digest("base64");
const given = req.get("X-HubSpot-Signature-v3") || "";
return given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}Hash the raw body bytes. If your framework parses JSON before your code runs, keep a copy of the raw buffer, because re-serialized JSON rarely matches byte for byte. The length check before timingSafeEqual matters too, since Node throws when the two buffers differ in length. Workflow webhook actions and app cards sign differently. They send X-HubSpot-Signature-Version: v2 with a plain SHA-256 of the client secret, method, URI and body. A shared endpoint has to branch on that header.
How does HubSpot retry a failed webhook delivery?
| Limit | Value | Source |
|---|---|---|
| Response timeout | 5 seconds per batch | Webhooks API guide |
| Events per request | Up to 100 | Webhooks API guide |
| Concurrent requests per installed account | 10 by default, set above 5 | Webhooks API guide |
| Retries for app webhooks | Up to 10, over 24 hours | Webhooks API guide |
| Retries for workflow webhook actions | Up to 3 days, no retry on 4xx except 429 | Workflow webhooks guide |
| Subscriptions per app | 1,000 | Webhooks API guide |
| Signature timestamp window | 5 minutes | Request validation guide |
HubSpot treats three outcomes as failures. The webhooks guide lists them as a connection that can't open, a response that takes "longer than five seconds," and any 4xx or 5xx status. A failed notification is retried "up to 10 times," and "these retries will be spread out over the next 24 hours, with varying delays between requests." HubSpot adds some randomization so a burst of failures doesn't come back all at once.
Two consequences are easy to miss. First, a 4xx is retried like a 5xx. An endpoint that returns 401 or 404 for an account it no longer recognizes, such as one that uninstalled, asks HubSpot to send the same batch ten more times. Return 200 and drop events you've decided to ignore. Second, the five second clock covers the whole batch of up to 100 events, not each one. Workflow webhook actions behave the other way on 4xx and stop retrying, so one endpoint serving both can't share a single error policy.
The handler that holds up under load does three things. It verifies the signature and writes the raw batch to a durable queue. Then it returns 200 and lets a worker do the CRM reads and your own business logic. A worker can take as long as it needs, and a slow downstream system no longer turns into retries. Retries also raise attemptNumber, so log it per account. A rising count is an early sign that a customer's traffic is outgrowing your endpoint.
Recovering missed events with the webhooks journal
Retries end after 24 hours, so a longer outage needs a way to catch up. HubSpot's webhooks journal and management APIs (version 2026-09) give apps a way to read event history. You subscribe through the management API, then read journal entries "for the past three days" in chronological order using a client credentials token. A snapshots API captures CRM objects at a point in time. The journal is also where app install and uninstall events appear, as app_lifecycle_event entries with actions such as APP_INSTALL. The classic subscription list has no uninstall event, so this is how your backend learns that a customer removed the app and its tokens should be retired. HubSpot says journal reads are optimized for checking object changes, "especially compared to the alternative of using the CRM search API."
The journal subscriptions are install-specific and managed by API only, separate from the webhooks-hsmeta.json component. For a SaaS app it works as the recovery plan. If your endpoint goes down on a Friday, a journal read on Monday fills the gap. Without it, the fallback is a search-based resync of every affected account.
What certification measures in your webhook traffic
Webhook handling shows up in HubSpot's Marketplace certification review in two places. The certification requirements ask apps to "maintain an average success rate above 95% across all activities." Activities cover OAuth API requests, signed webhook requests and extension requests together. So the 95% is not a webhook-only figure, and it doesn't cut off retries. Every non-2xx your endpoint returns counts against the same number as your API errors.
The second place is capacity. One sample review note reads that webhook subscriptions "frequently fail with 500 and 503 errors" and asks the developer to handle the volume, "currently limited to 150 per second." Certification also needs at least 60 active installs from accounts unaffiliated with your company, so these numbers are measured on real customer traffic, not a test account. HubSpot charges no fee to list or certify an app.
Read this as a link between handler design and the listing. An endpoint that does its work before returning 200 starts timing out during customer imports. Each timeout is a failed delivery and a retry, and both pull the success rate down at the point a reviewer looks at it. A queue-first handler keeps that number tied to whether your server is up, not to how slow your downstream work is. More on the review itself is in our guide to HubSpot app certification requirements.
How HubSpot webhooks compare to Salesforce Change Data Capture
Teams shipping both a HubSpot app and a Salesforce managed package often ask which Salesforce feature maps to HubSpot app webhooks. The closest match is Change Data Capture, which publishes record changes as events without Apex triggers. The delivery model is the opposite, though. HubSpot pushes HTTPS POSTs to your URL. Salesforce change events go to the event bus, where your subscriber reads them through Pub/Sub API, a gRPC API. Salesforce stores those events for 72 hours and lets a client replay from a Replay ID.
Our comparison of Salesforce Platform Events and Change Data Capture covers the Salesforce side in more depth. The design rules carry over between the two. Make handlers idempotent, keep a recovery path for missed events and route every event to the right customer account. The code that receives the events does not carry over.
Mistakes that break webhook handlers after install
- Signing the path instead of the full URI, which fails every v3 check behind a proxy.
- Parsing the body before hashing it, so the signature never matches.
- Deduplicating on
eventId, which HubSpot says isn't unique. - Returning 4xx for uninstalled or unknown accounts, which triggers ten more retries.
- Doing CRM reads inside the request, so imports hit the five second timeout.
- Fetching one record per event when batch reads take 100 IDs.
- Assuming events arrive in order instead of comparing
occurredAt. - Forgetting the five minute settings cache and debugging a change that hasn't applied yet.
Frequently Asked Questions
What is the difference between HubSpot app webhooks and workflow webhooks?
App webhooks are subscriptions on your app that fire for every account that installs it. Workflow webhooks are an action each customer adds to their own workflow, so they cover only that workflow.
How many times does HubSpot retry a failed webhook?
Up to 10 times, spread over 24 hours with randomized delays. Timeouts over five seconds, connection failures and any 4xx or 5xx response all trigger a retry.
Do HubSpot webhooks count against API rate limits?
No. The webhook POSTs HubSpot sends don't count against your app's API limits, but the CRM calls your handler makes afterward do.
Does HubSpot have a form submission webhook?
Not for apps. Form submissions reach an external URL through a workflow webhook action, or your app reads them through the Forms API.
Can Appnigma build our HubSpot app's webhook handling?
Yes. Appnigma's marketplace apps service builds the native app, including its webhook subscriptions, and provides "HubSpot listing review and certification support where eligible."
About the author
Sunny Chauhan is the co-founder and CEO of Appnigma AI, which builds and maintains customer integrations and native Salesforce and HubSpot apps for B2B SaaS teams. Before Appnigma, he was on Salesforce's AppExchange team, where he worked with more than 300 SaaS partners. He has spent close to a decade on the Salesforce platform, including work at Zennify, and holds Salesforce certifications including Platform Developer II, Platform App Builder, Administrator, Data Cloud Consultant, and AI Associate.
Related Articles
- HubSpot App Certification Requirements
- How to Build a HubSpot App Your Customers Can Install
- HubSpot OAuth and Scopes Reference
- HubSpot App Cards and UI Extensions for SaaS Apps
- HubSpot Custom Integration Build Guide
Sources
- HubSpot Developers, Webhooks API guide
- HubSpot Developers, Configure a webhook subscription
- HubSpot Developers, Validating requests
- HubSpot Developers, Webhooks journal and management APIs
- HubSpot Developers, App certification requirements
- HubSpot Developers, Custom workflow actions
- HubSpot Developers, Contacts API guide
- HubSpot Knowledge Base, Trigger webhooks in HubSpot contact-based workflows
- HubSpot Developers, Create generic webhook subscriptions (BETA)
- HubSpot Developers, API usage guidelines and limits
- HubSpot Developer Changelog, Legacy public app creation sunset
- Salesforce Developers, Keep your external data current with Change Data Capture
- Salesforce Developers, Pub/Sub API and the expanded event bus



