HubSpot webhooks: app subscriptions, workflow webhooks and signature v3
To test HubSpot webhooks, point an app's target URL or a workflow webhook action at a free webhooks.sh bin (no signup, 48 hours, 100 requests a day) and watch each request arrive live. App webhooks batch up to 100 events per request and retry up to 10 times over 24 hours.
HubSpot has two webhook features: app webhooks that POST batches of CRM events to a target URL, and the workflow Send a webhook action that sends a request when an enrolled record reaches that step. Both can point at a free bin so you can inspect the exact headers and payload HubSpot sends. This page covers choosing between them, capturing app batches and workflow requests, checking X-HubSpot-Signature-v3, and how HubSpot handles retries.
App webhooks or workflow webhooks?
If you're a developer building an integration, use app webhooks; if you're an admin automating a process in your own HubSpot account, use the workflow action.
| Feature | App webhooks | Workflow Send a webhook |
|---|---|---|
| Set up in | App webhook settings or project config | Automation > Workflows |
| Requires | A HubSpot app with subscriptions | Data Hub Professional or Enterprise |
| Request | POST, JSON array of up to 100 events | POST with JSON, or GET |
| Signature | X-HubSpot-Signature-v3 | v2 signature header, or an API key |
| Built-in test | Test this subscription | Test action |
Send app webhooks to a free bin
- Create a free bin in your browser at view.webhooks.page or run
curl -X POST https://webhooks.sh/v1/binsto get youringest_urlandview_url. - Set the bin's ingest URL (
https://<bin>.webhooks.page/) as the target URL: for private apps, go toSettings > Integrations > Private apps > your app > Webhooks tab, and for project-based apps, setsettings.targetUrlin the webhooks config. - Subscribe to an event such as
contact.creationand clickTest this subscription, or create a contact in HubSpot. - Watch the batch arrive live in the bin viewer.
Changes to the webhook URL or subscription settings can take up to 5 minutes to take effect because HubSpot caches webhook settings.
# Create a free bin (or open view.webhooks.page)
curl -X POST https://webhooks.sh/v1/bins
# Use the ingest_url as the app's target URL, e.g.
# https://<bin>.webhooks.page/hubspot
What a batch looks like
Each app webhook request body is a JSON array of up to 100 events. Every event includes subscriptionType (like contact.creation or contact.propertyChange), objectId, occurredAt (a timestamp in milliseconds), and attemptNumber (which starts at 0 and counts retries), while propertyChange events also carry propertyName and propertyValue. HubSpot can send duplicate events, doesn't guarantee delivery order, and notes in its docs that eventId isn't guaranteed to be unique, so make your handler idempotent and check occurredAt to skip stale updates.
[
{
"eventId": 1,
"subscriptionId": 12345,
"portalId": 62515,
"appId": 54321,
"occurredAt": 1564113600000,
"subscriptionType": "contact.propertyChange",
"attemptNumber": 0,
"objectId": 123,
"propertyName": "email",
"propertyValue": "ada@example.com",
"changeSource": "CRM"
}
]
Verify X-HubSpot-Signature-v3
To verify X-HubSpot-Signature-v3, concatenate the HTTP method, the full request URI (https:// plus host, path, and query), the raw request body, and the X-HubSpot-Request-Timestamp header value, then compute the HMAC-SHA256 using your app's client secret, base64-encode the result, and compare it to the signature in constant time. Decode any URL-encoded characters from the list in HubSpot's docs in the URI before hashing, and reject requests where X-HubSpot-Request-Timestamp is more than 5 minutes old. Both headers appear in the webhooks.sh bin viewer alongside the exact path and timestamp HubSpot signed.
Workflow webhooks and app cards use signature v2 instead, which is flagged by X-HubSpot-Signature-Version: v2 and sets X-HubSpot-Signature to the SHA-256 hex digest of your client secret + method + URI + body (see the webhook security guide for more on signature verification).
import { createHmac, timingSafeEqual } from "node:crypto";
export function isFromHubSpot(req, rawBody, clientSecret) {
const ts = req.headers["x-hubspot-request-timestamp"];
if (Date.now() - Number(ts) > 5 * 60 * 1000) return false;
const uri = `https://${req.headers.host}${req.url}`;
const expected = createHmac("sha256", clientSecret)
.update(req.method + uri + rawBody + ts)
.digest("base64");
const a = Buffer.from(expected);
const b = Buffer.from(req.headers["x-hubspot-signature-v3"] ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
Test a workflow webhook action
- In HubSpot, go to
Automation > Workflowsand add the Send a webhook action from theData opssection. - Choose
POST, paste your bin's ingest URL (HubSpot requires HTTPS, whichhttps://<bin>.webhooks.page/uses), and pick an authentication method: eitherInclude request signature in header, which needs your HubSpot App ID, or an API key sent as a header or query parameter. - Click
Test actionto see the bin's200response in HubSpot while the request pops up in your bin viewer. - To capture a live request, turn the workflow on and enroll a test record.
If you send an API key in a header whose name contains key (or authorization, cookie, token, or secret), its value shows as redacted in the viewer.
Retries, timeouts and limits
A free bin responds with HTTP 200 right away, so HubSpot won't retry against it during normal testing, but a busy HubSpot account can burn through the free limit of 100 requests per day quickly. Once a bin hits 100 requests in a day, it returns 429 (daily_cap_reached), which HubSpot treats as a failed delivery and retries (see the webhook retries guide). Test from a quiet account, or use Solo if you're dealing with real volume. Here is how HubSpot's limits and retry rules work:
| Rule | Value |
|---|---|
| Events per request | Up to 100 |
| Response deadline | 5 seconds per batch |
| Retries | Up to 10, spread over 24 hours |
| Retried on | Connection failure, timeout, any 4xx or 5xx |
| Concurrency limit | 10 in-flight requests per account |
| Subscriptions per app | Up to 1,000 |
| Signature timestamp window | 5 minutes |
| Settings cache | Up to 5 minutes |
Keep the URL or move to localhost
You can claim a free bin from the bin page with a free Google sign-in to stop the 48-hour expiry timer and keep 30 days of rolling request history at the same URL. If you need more capacity or local forwarding, Solo ($12 a month on the pricing page) gives you a persistent named endpoint on webhooks.host with 250,000 requests a month, and the wsh CLI (wsh listen <endpoint> --to localhost:3000) relays live deliveries straight to your handler on localhost.
FAQ
How do I test HubSpot webhooks without deploying anything?
Point your HubSpot app's target URL or your workflow webhook URL at a free bin from https://view.webhooks.page/, then click Test this subscription or Test action, or trigger an event in your account to see the request arrive in the browser.
Which HubSpot plan includes workflow webhooks?
The Send a webhook workflow action requires Data Hub Professional or Enterprise.
How many events does HubSpot send per webhook request?
App webhooks batch up to 100 events into a single JSON array per HTTP request.
How do I validate X-HubSpot-Signature-v3?
Compute the base64 HMAC-SHA256 of the HTTP method + full URI + raw body + X-HubSpot-Request-Timestamp header using your app's client secret, compare it in constant time to X-HubSpot-Signature-v3, and reject timestamps older than 5 minutes.
Does HubSpot retry failed webhooks?
Yes, HubSpot retries failed notifications up to 10 times over the next 24 hours on connection failures, timeouts over 5 seconds, and any 4xx or 5xx response.
Why do I get the same HubSpot event twice?
HubSpot can deliver the same event more than once, doesn't guarantee order, and notes that eventId isn't guaranteed to be unique, so make your handler idempotent and check occurredAt before applying updates.
Last updated
Try it in 10 seconds.
Create a free bin at view.webhooks.page — no signup, no install. Point your sender at the URL and watch requests land live. Bins expire after 48 hours.