How the webhook URL works. It is a public address that records whatever is sent to it and
answers 200. An inbox keeps the 50 most recent requests, accepts bodies up to 256 KB, and is
deleted 24 hours after it was created — or sooner if you press Delete URL or our server
restarts, because requests are held in memory and never written to disk.
Anyone who has the URL can read what it received. Use sandbox and test-mode accounts, do not route production traffic or customer data through it, and delete it when you are done. Privacy details.
What is a HubSpot webhook?
A HubSpot webhook is an HTTP POST that HubSpot sends to an app's target URL when something the app subscribed to changes in a connected account: a contact is created, a deal property changes, a company is deleted. Subscriptions belong to an app, not to a single account, so one endpoint receives events from every account that installed it, distinguished by portalId.
The body is always an array, even when it holds one event. Each element is a notification rather than a record: it says which object changed (objectId), what kind of change it was (subscriptionType) and, for a property change, the property's name and new value. To get the rest of the record you call the CRM API with that id.
What a HubSpot webhook request looks like
The request line and headers as they reach your server, followed by the body. Ids, tokens and the signature are placeholders.
POST /your/webhook/path HTTP/1.1
Host: your-server.example
X-HubSpot-Signature-v3: mW8Ld0vVPxGK1c5vCmM1Qd0p3n1vC0mE9yJQmHk0b1E=
X-HubSpot-Request-Timestamp: 1767225600000
X-HubSpot-Signature: 9569219f8ba981ffa6f6f16aa0f48637d35d728c7e4d93d0d52efaa512af7900
X-HubSpot-Signature-Version: v1
Content-Type: application/json
[
{
"eventId": 100,
"subscriptionId": 1234567,
"portalId": 62515,
"appId": 54321,
"occurredAt": 1767225600000,
"subscriptionType": "contact.propertyChange",
"attemptNumber": 0,
"objectId": 123,
"changeSource": "CRM_UI",
"propertyName": "lifecyclestage",
"propertyValue": "customer"
},
{
"eventId": 101,
"subscriptionId": 1234568,
"portalId": 62515,
"appId": 54321,
"occurredAt": 1767225601000,
"subscriptionType": "contact.creation",
"attemptNumber": 0,
"objectId": 124,
"changeSource": "FORM"
}
]
occurredAt is in milliseconds, not seconds. attemptNumber starts at 0 and rises on each retry of the same notification. Events in one batch are not guaranteed to be in order, and neither are batches, so when two events touch the same record sort by occurredAt rather than by position in the array.
Headers HubSpot sends
| Header | Example | What it is |
|---|---|---|
X-HubSpot-Signature-v3 | mW8Ld0vVPxGK1c5vCmM1Qd0p3n1vC0mE9yJQmHk0b1E= | HMAC-SHA256, base64, over method + URL + body + timestamp. |
X-HubSpot-Request-Timestamp | 1767225600000 | When the request was sent, in milliseconds. Part of the signed string. |
X-HubSpot-Signature | 9569219f8ba981ffa6f6f16aa0f48637d35d728c7e4… | The older v1 signature: a SHA-256 hash of the client secret followed by the body. |
X-HubSpot-Signature-Version | v1 | Which scheme the older header above uses. |
Content-Type | application/json | The body is a JSON array. |
Header names are case-insensitive. Your framework may show them lower-cased, and HTTP/2 always does.
How HubSpot signs a webhook
For the v3 signature HubSpot concatenates four things with nothing between them — the HTTP method, the full URL it called, the raw request body and the value of X-HubSpot-Request-Timestamp — and computes an HMAC-SHA256 keyed with the app's client secret. The base64 digest travels in X-HubSpot-Signature-v3. HubSpot's guidance is to reject a request whose timestamp is more than five minutes old.
The URL is the part that goes wrong. It must be the address HubSpot requested, scheme and query string included, not whatever your application reconstructs behind a load balancer. Compute it over the raw request body exactly as received — before any JSON parsing — and compare with a constant-time function.
const crypto = require('crypto');
// url: the full target URL HubSpot called, e.g. https://your-server.example/hooks/hubspot
// rawBody: string of the body exactly as received
function verifyHubSpot(method, url, rawBody, signature, timestamp, clientSecret) {
if (Math.abs(Date.now() - Number(timestamp)) > 300000) return false;
const expected = crypto.createHmac('sha256', clientSecret)
.update(method + url + rawBody + timestamp).digest('base64');
const a = Buffer.from(signature || '');
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Batching, timeouts and retries
HubSpot groups notifications that occur close together into one request, up to a documented maximum per batch, so a handler has to loop over the array and must not assume a single element. Acknowledge the whole request with a 2xx quickly; per HubSpot's documentation at the time of writing the endpoint has about five seconds to respond.
A failed or slow request is retried, up to ten times spread over roughly the following day according to the same documentation, with the delay growing between attempts. Because a batch is retried as a unit, events you already processed can come back alongside ones you have not. De-duplicate on eventId together with subscriptionId.
Catch a real HubSpot webhook
- Press Create webhook URL at the top of this page and copy the address.
- In a HubSpot developer account, open your app (or create one for testing) and go to its Webhooks settings.
- Paste the address as the target URL and create a subscription, for example contact creation. Activate it.
- Install the app into a developer test account so there is a portal to generate events.
- Create a contact in that test account and read the batched array in the inbox above.
To check the signature of a caught request you need the app's client secret and the exact URL you registered. Use a test app; a client secret is a credential for the whole app, not only for webhooks.
To run a caught request against your own code, select it and press Open in API Tester: the method, headers and body are loaded into the request console, where you enter your server's public URL and send it. A body you edit will no longer match its signature — that is the signature doing its job.
Things that catch people out
- The body is an array. Code that reads
body.subscriptionTypegets nothing; iterate. - Notifications carry ids, not records. Fetch the object from the CRM API, and expect it to have changed again by the time you do.
- The signing key is the app's client secret, not an access token and not a per-webhook value. Rotating the secret changes every signature at once.
api-tester.com is independent and is not affiliated with HubSpot. Providers change header names, limits and retry schedules; treat the official HubSpot documentation as the source of truth.
Related examples and tools
Square Webhook Example
Square signs the notification URL together with the body, so a request that is perfectly genuine fails verification when your server disagrees about its own address.
Bitbucket Webhook Example
Bitbucket Cloud webhooks look like GitHub's at first glance and differ in the details that break copied code: the event name header, the signature header's name, and the shape of a push.
Jira Webhook Example
A Jira webhook delivers the whole issue plus a changelog of exactly what was edited.
Mailgun Webhook Example
Mailgun puts its signature inside the JSON body instead of in a header, and that signature covers a timestamp and a token rather than the event itself.
Typeform Webhook Example
A Typeform webhook sends one form submission: the questions as they were defined and the answers as they were given, in two separate arrays you have to join yourself.
Epoch Converter
Turn the millisecond occurredAt and timestamp values into readable dates.
Curl POST JSON
Send a JSON body like this one from a terminal.
Webhook Tester
The inbox on its own page, plus a guide to signatures, retries and idempotent handlers.
All Webhook Examples
30 providers side by side: content type, signature scheme and retry behaviour.
Other webhooks with a timestamp in the signature
References
- Official HubSpot documentation — The provider's own guide to the requests described on this page.
- HubSpot docs: validating requests — Each version of the signature and how to check it.
- RFC 2104: HMAC — The keyed-hash construction this signature is built on.
- Node.js: crypto.timingSafeEqual() — The constant-time comparison used in the code on this page.
These links leave api-tester.com, which is independent of the companies and projects they point to.
HubSpot webhook FAQ
What does a HubSpot webhook payload look like?
A JSON array of event objects. Each has eventId, subscriptionId, portalId, appId, occurredAt, subscriptionType, attemptNumber and objectId, plus propertyName and propertyValue for property-change events.
How do I validate X-HubSpot-Signature-v3?
Concatenate the HTTP method, the full URL HubSpot called, the raw request body and the X-HubSpot-Request-Timestamp value, compute an HMAC-SHA256 with the app client secret, base64-encode it and compare. Reject timestamps older than five minutes.
Why does a HubSpot webhook contain several events?
HubSpot batches notifications that happen close together into one request, so the body is always an array and may hold many events.
Does HubSpot retry failed webhooks?
Yes. Failed or slow deliveries are retried with growing delays, up to ten times over about a day according to HubSpot's documentation at the time of writing.
How do I get the full contact from a HubSpot webhook?
Use the objectId from the event to request the record from the CRM API. The webhook itself only identifies what changed.