# HubSpot Webhooks: Payloads, Retries and the v3 Signature

> HubSpot webhooks in production: app subscriptions vs workflow actions, batched payloads, the 5-second timeout, retries and v3 signature checks.

Source: https://webhooker.eu/blog/hubspot-webhooks-guide
Last updated: 2026-09-21

HubSpot webhooks are HTTPS POST requests that HubSpot sends to a URL you control when a CRM record is created, changed, deleted, merged or associated. “HubSpot webhooks” covers two separate features: the Webhooks API, where an app subscribes to events such as `contact.creation` or `deal.propertyChange`, and the **Send a webhook** action inside workflows. App webhooks arrive as a JSON array of up to 100 small notifications, must be answered within five seconds, and are retried up to 10 times over 24 hours. Requests are signed with your app’s client secret, and the current scheme is `X-HubSpot-Signature-v3`.

This guide covers both features, the payload, timeouts and retries, v3 verification in Node and Python, and the GDPR side of contact data. Every HubSpot fact links to HubSpot’s own documentation, checked on 21 September 2026.

## What are the two kinds of HubSpot webhooks?

They share a name and little else. One is a developer feature configured on an app. The other is a step that any HubSpot user adds in the workflow editor.

*Table: the HubSpot Webhooks API for apps compared with the workflow “Send a webhook” action.*

|  | Webhooks API (apps) | “Send a webhook” workflow action |
| --- | --- | --- |
| Trigger | A subscription: `contact.creation`, `deal.propertyChange` and so on | The record reaches that step of the workflow |
| Plan | Set up on the app, no workflows tier involved | [Data Hub Professional or Enterprise](https://knowledge.hubspot.com/workflows/how-do-i-use-webhooks-with-hubspot-workflows) |
| Request | POST with a JSON array of notifications | POST or GET, one per enrolled record |
| Body | Ids and the changed property, nothing else | All properties of the record, or a custom selection |
| Authentication | Signature headers built from the app’s client secret | Request signature (you enter an app ID) or an API key in a header or query parameter |
| Retries | Up to 10 over 24 hours, on any `4xx` or `5xx` | Up to three days, not on `4xx` except `429` |

The first column comes from HubSpot’s [Webhooks API guide](https://developers.hubspot.com/docs/api-reference/legacy/webhooks/guide), the second from the knowledge base article linked in the table. An integration that reacts to every contact or deal change needs the Webhooks API. “When a deal reaches Closed Won, tell the billing system” is a job for the workflow action.

## Which events can an app subscribe to?

The classic subscription types follow an `object.event` pattern for six CRM objects: contacts, companies, deals, tickets, products and line items. Each supports `creation`, `deletion`, `propertyChange`, `merge` and `restore`, and all except products support `associationChange`. Contacts add `contact.privacyDeletion`, which fires when a user performs a GDPR delete. Conversations have their own set, including `conversation.creation` and `conversation.newMessage`.

The guide has a few rules that are easy to miss:

- A property change subscription names one property. You subscribe to `contact.propertyChange` for `lifecyclestage`, then again for `email`. `hs_lastmodifieddate` cannot be subscribed to, so there is no “anything changed” shortcut.
- Each type needs a scope, such as `crm.objects.contacts.read` for contact events. An app can hold at most 1,000 subscriptions.
- Association changes fire twice, once for each side.
- New subscriptions start paused, and setting changes can take up to five minutes to apply.

### Generic subscriptions and the new developer platform

HubSpot also has a newer format, [generic webhook subscriptions](https://developers.hubspot.com/docs/apps/legacy-apps/public-apps/create-generic-webhook-subscriptions), still marked beta. The type becomes `object.creation`, `object.propertyChange` and so on, and an `objectTypeId` field says which object it was. It reaches beyond the classic six to calls, meetings, notes, tasks, leads and invoices, plus custom objects in private apps. It does not cover `conversation.*` or `contact.privacyDeletion`.

Where you configure all this is changing. The Webhooks API guide is now filed under legacy apps, and [new legacy public apps can no longer be created](https://developers.hubspot.com/changelog/legacy-public-app-creation-sunset) as of June 2026, although existing ones keep working. Legacy private apps manage subscriptions in the app’s **Webhooks** tab and [not through the API](https://developers.hubspot.com/docs/apps/legacy-apps/private-apps/create-and-edit-webhook-subscriptions-in-private-apps). On the project-based platform, subscriptions are declared in a [`*-hsmeta.json` file in the project’s `webhooks` directory](https://developers.hubspot.com/docs/apps/developer-platform/add-features/configure-webhooks) with a `targetUrl`, a `maxConcurrentRequests` value, and three arrays: `crmObjects` for the generic format, `legacyCrmObjects` for classic types, and `hubEvents` for `contact.privacyDeletion` and `conversation.*`.

A beta [webhooks journal API](https://developers.hubspot.com/docs/api-reference/legacy/webhooks/webhooks-journal) also exists, with no target URL: your app polls a journal of the past three days of changes. That is a pull model, and the rest of this guide does not apply to it.

## What does a HubSpot webhook payload look like?

One request carries a JSON array. This is the example from HubSpot’s validation docs, reformatted:

```json
[
  {
    "eventId": 531833541,
    "subscriptionId": 3923621,
    "portalId": 48807704,
    "appId": 16111050,
    "occurredAt": 1752613920733,
    "subscriptionType": "contact.creation",
    "attemptNumber": 0,
    "objectId": 138017612137,
    "changeFlag": "CREATED",
    "changeSource": "CRM_UI",
    "sourceId": "userId:76023669"
  }
]
```

HubSpot says the batch size varies but stays under 100 notifications per request. A quiet account sends arrays of one, and a large contact import sends full batches. A handler written as `const event = req.body` passes every manual test and breaks on the first import.

*Table: fields in a HubSpot Webhooks API notification.*

| Field | What it holds |
| --- | --- |
| `objectId` | Id of the record that changed: contact id, company id, deal id, or thread id for conversations |
| `subscriptionType` | The subscription that matched, for example `contact.propertyChange` |
| `propertyName`, `propertyValue` | Only on property changes: the property and its new value |
| `occurredAt` | When the change happened, in milliseconds |
| `eventId` | Id of the triggering event. HubSpot states it is “not guaranteed to be unique” |
| `subscriptionId`, `appId` | Your subscription and your app |
| `portalId` | The HubSpot account where it happened |
| `attemptNumber` | Delivery attempt, starting at `0` |
| `changeSource` | What made the change: `CRM_UI`, `IMPORT`, an integration and so on |

Merge notifications add `primaryObjectId`, `mergedObjectIds` and `newObjectId`. Association notifications add `associationType`, `fromObjectId`, `toObjectId` and `associationRemoved`.

One inconsistency: the field table in HubSpot’s guide documents `subscriptionType`, while the JSON example on the same page shows `eventType`. Build your parser from a captured request.

### The payload is thin, so you call the API back

A `contact.creation` notification tells you that contact `138017612137` exists, without the name, the email or the company. A `propertyChange` gives you one property’s new value and not the old one. For most uses you follow up with a read, `GET /crm/v3/objects/contacts/{recordId}` with a `properties` list, or `POST /crm/v3/objects/contacts/batch/read` to fetch a whole batch in one call. Both are in the [contacts API guide](https://developers.hubspot.com/docs/api-reference/legacy/crm/objects/contacts/guide).

The callback counts against your [API rate limits](https://developers.hubspot.com/docs/developer-tooling/platform/usage-guidelines), even though HubSpot’s webhook requests to you do not. The record you fetch is the current state, not the state at `occurredAt`, which is what a sync job wants anyway. This is the notify-then-fetch pattern from our [webhook vs API comparison](https://webhooker.eu/blog/webhook-vs-api), and it tolerates duplicates and reordering well.

## How fast must your endpoint answer, and what does HubSpot retry?

For the Webhooks API, the guide gives exact numbers:

- A response that takes longer than five seconds counts as a failure.
- A connection that cannot be opened, a timeout, and any `4xx` or `5xx` status are all retried. That includes the `401` your own signature check returns.
- HubSpot makes up to 10 retries, spread over the next 24 hours, with varying and partly randomised delays.
- It keeps at most 10 requests in flight per account that installed your app, each holding up to 100 events. The throttling setting changes that limit, and the value must be greater than five.
- The target URL must be served over HTTPS.

One status code answers the whole batch. If notification 37 of 80 makes your handler throw and you return `500`, all 80 count as failed, including the 36 you already processed. Store first and process later.

The workflow action behaves differently. HubSpot [retries failed workflow webhooks for up to three days](https://knowledge.hubspot.com/workflows/how-do-i-use-webhooks-with-hubspot-workflows), starting one minute after the failure, at growing intervals capped at eight hours. It does not retry on `4xx` responses, with the exception of `429`, where it respects a `Retry-After` header. A broken signature check or a wrong path therefore loses workflow webhooks for good, while the same mistake on the Webhooks API burns a day of retries.

Both windows end. An endpoint that stays broken from Friday evening to Monday morning has outlived the 24 hours, and the guide documents no way to request an app webhook again. How such schedules work is covered in our piece on [retries, exponential backoff and jitter](https://webhooker.eu/blog/webhook-retries-exponential-backoff). What to do once a retry window has run out is in [webhook retries and replay](https://webhooker.eu/blog/webhook-retries-and-replay).

## How do you verify a HubSpot webhook signature?

HubSpot has three signature versions, described in its [request validation docs](https://developers.hubspot.com/docs/apps/developer-platform/build-apps/authentication/request-validation). All three use the app’s client secret. For a legacy private app it is on the **Auth** tab under **Show secret**.

*Table: HubSpot request signature versions.*

| Version | Headers | What is hashed | Output |
| --- | --- | --- | --- |
| v1 | `X-HubSpot-Signature`, `X-HubSpot-Signature-Version: v1` | SHA-256 of client secret + body | hex |
| v2 | `X-HubSpot-Signature`, `X-HubSpot-Signature-Version: v2` | SHA-256 of client secret + method + URI + body | hex |
| v3 | `X-HubSpot-Signature-v3`, `X-HubSpot-Request-Timestamp` | HMAC-SHA256, keyed with the client secret, of method + URI + body + timestamp | base64 |

HubSpot documents v1 for CRM events from the Webhooks API and v2 for workflow webhook actions and app cards, keeps both for backwards compatibility, and names v3 as the latest version. Use v3. The older two are plain hashes with the secret prepended, not HMACs, and neither includes a timestamp, so a captured request stays valid for ever.

The v3 rules:

1. Reject the request if `X-HubSpot-Request-Timestamp`, which is in milliseconds, is more than five minutes old. That window is the defence against [replayed requests](https://webhooker.eu/blog/webhook-replay-attacks-timestamp-tolerance).
2. Build the string `method + URI + body + timestamp`. The URI is the full URL HubSpot called, including `https://` and the host.
3. In the URI, decode a fixed list of escapes: `%3A`, `%2F`, `%3F`, `%40`, `%21`, `%24`, `%27`, `%28`, `%29`, `%2A`, `%2C` and `%3B`. Leave everything else encoded. HubSpot’s sample code applies this to the query string.
4. Compute HMAC-SHA256 with the client secret, base64-encode the result, and compare in constant time.

```js
const crypto = require("crypto");
const express = require("express");

const app = express();
const MAX_AGE_MS = 5 * 60 * 1000;
const DECODED_CHARACTERS = {
  "%3A": ":", "%2F": "/", "%3F": "?", "%40": "@", "%21": "!", "%24": "$",
  "%27": "'", "%28": "(", "%29": ")", "%2A": "*", "%2C": ",", "%3B": ";",
};

function normalizeSignedUri(fullUrl) {
  const [withoutFragment] = fullUrl.split("#");
  const queryStart = withoutFragment.indexOf("?");
  if (queryStart === -1) return withoutFragment;
  const query = withoutFragment
    .slice(queryStart + 1)
    .replace(
      /%(3A|2F|3F|40|21|24|27|28|29|2A|2C|3B)/gi,
      (match) => DECODED_CHARACTERS[match.toUpperCase()],
    );
  return withoutFragment.slice(0, queryStart + 1) + query;
}

function isValidHubSpotRequest(req) {
  const signature = req.get("X-HubSpot-Signature-v3") || "";
  const timestamp = req.get("X-HubSpot-Request-Timestamp") || "";
  if (!signature || !/^\d+$/.test(timestamp)) return false;
  if (Date.now() - Number(timestamp) > MAX_AGE_MS) return false;

  // PUBLIC_ORIGIN is the scheme and host HubSpot calls, e.g. https://api.example.com
  const signedUri = normalizeSignedUri(process.env.PUBLIC_ORIGIN + req.originalUrl);
  const expected = crypto
    .createHmac("sha256", process.env.HUBSPOT_CLIENT_SECRET)
    .update(req.method + signedUri)
    .update(req.body) // raw bytes, see express.raw below
    .update(timestamp)
    .digest("base64");

  const expectedBuffer = Buffer.from(expected);
  const receivedBuffer = Buffer.from(signature);
  return (
    expectedBuffer.length === receivedBuffer.length &&
    crypto.timingSafeEqual(expectedBuffer, receivedBuffer)
  );
}

app.post("/webhooks/hubspot", express.raw({ type: "application/json" }), (req, res) => {
  if (!isValidHubSpotRequest(req)) return res.status(401).send("bad signature");
  const notifications = JSON.parse(req.body);
  // store the notifications, then answer; see the next section
  res.sendStatus(200);
});
```

### The same check in Python

```python
import base64
import hashlib
import hmac
import re
import time

MAX_AGE_MS = 5 * 60 * 1000
DECODED_CHARACTERS = {
    "%3A": ":", "%2F": "/", "%3F": "?", "%40": "@", "%21": "!", "%24": "$",
    "%27": "'", "%28": "(", "%29": ")", "%2A": "*", "%2C": ",", "%3B": ";",
}
ENCODED_PATTERN = re.compile("|".join(DECODED_CHARACTERS), re.IGNORECASE)


def normalize_signed_uri(full_url):
    without_fragment = full_url.split("#", 1)[0]
    base, separator, query = without_fragment.partition("?")
    decoded_query = ENCODED_PATTERN.sub(
        lambda match: DECODED_CHARACTERS[match.group(0).upper()], query
    )
    return base + separator + decoded_query


def is_valid_hubspot_request(method, full_url, raw_body, headers, client_secret):
    signature = headers.get("X-HubSpot-Signature-v3", "")
    timestamp = headers.get("X-HubSpot-Request-Timestamp", "")
    if not signature or not timestamp.isdigit():
        return False
    if int(time.time() * 1000) - int(timestamp) > MAX_AGE_MS:
        return False

    signed_content = (
        (method + normalize_signed_uri(full_url)).encode("utf-8")
        + raw_body
        + timestamp.encode("utf-8")
    )
    expected = base64.b64encode(
        hmac.new(client_secret.encode("utf-8"), signed_content, hashlib.sha256).digest()
    ).decode("utf-8")
    return hmac.compare_digest(expected, signature)
```

In Flask, pass `request.get_data()` as `raw_body`. In Django, pass `request.body`. Both functions reproduce the signature of the worked example in HubSpot’s docs, which makes a good first unit test.

### Where v3 verification goes wrong

Most failures come from the URI, because it is part of the signature. Behind a load balancer your framework may see `http://` or an internal hostname, while HubSpot signed the public `https://` URL. Build the URI from configuration, as `PUBLIC_ORIGIN` does above. A trailing slash or a reordered query string also breaks the match.

The other three are quicker to check:

- HubSpot’s Node sample hashes `JSON.stringify(body)`. That works only while re-serialising reproduces HubSpot’s bytes exactly. Hash the raw bytes.
- The timestamp is in milliseconds. Comparing it with a clock in seconds rejects or accepts everything.
- Decode only the listed escapes. A blanket `decodeURIComponent` turns `%20` into a space and the hash no longer matches.

If it still fails, work through the [six usual causes of a failed signature check](https://webhooker.eu/blog/webhook-signature-verification-failed) in order, starting with the body.

## How do you handle duplicates and out-of-order events?

HubSpot promises neither. The guide says it “does not guarantee that you’ll receive these notifications in the order they occurred” and that the same notification can arrive more than once. With batch-level retries, duplicates are not rare.

Deduplicate, but not on `eventId` alone. HubSpot warns that `eventId` is not guaranteed to be unique, so a bare unique index on it can drop real events. We key on the combination of `portalId`, `subscriptionId`, `eventId`, `objectId` and `occurredAt`. A retry carries the same values with a higher `attemptNumber`, so it collapses into the row you already have. The storage patterns are in our guide to [idempotency keys for webhook consumers](https://webhooker.eu/blog/webhook-idempotency-keys).

In Postgres that is a unique constraint on those five columns and an `INSERT ... ON CONFLICT DO NOTHING` per notification. Run the inserts before `res.sendStatus(200)`, and let a worker pick up pending rows, [with `FOR UPDATE SKIP LOCKED`](https://webhooker.eu/blog/postgres-job-queue-skip-locked) if several workers share the table. The five-second budget is then spent on inserts and nothing else.

For ordering, use `occurredAt` per record. Two `propertyChange` notifications for the same contact can arrive reversed. Store the `occurredAt` of the last change you applied per `objectId` and property, and ignore anything older. If the worker re-reads the record from the CRM API, order matters even less. A GDPR delete fires both `contact.privacyDeletion` and `contact.deletion`, in no fixed order and not necessarily in the same batch, so match them on `objectId`. Strict FIFO is [a separate design decision](https://webhooker.eu/blog/ordered-webhook-delivery), and a CRM sync rarely needs it.

## How do you test HubSpot webhooks locally?

Each subscription has a **Test** button. In a legacy app, open the app’s **Webhooks** section, open a subscription’s details, confirm the test URL and click **Test**. For realistic payloads, create a contact or change a subscribed property in an account you use for testing.

The URL must be public and HTTPS, so `localhost` needs [a tunnel](https://webhooker.eu/blog/ngrok-alternatives-for-webhooks). The v3 signature includes the URL, so a tunnel hostname that changes on restart means updating both the HubSpot target URL and your `PUBLIC_ORIGIN`.

HubSpot’s guide suggests webhook.site for a first look, with a warning not to send “proprietary, confidential, or sensitive data of any kind” there. App payloads are mostly ids, but a `contact.propertyChange` on `email` carries an email address, and a workflow webhook with **Include all properties** carries the whole contact. Our open-source [webhook-tester](https://webhooker.eu/tools/webhook-tester) is a request inspector that runs in one Docker container on your own server, so captured requests stay with you. Hosted inspectors are compared in [webhook.site alternatives](https://webhooker.eu/blog/webhook-site-alternatives).

## Are HubSpot webhook payloads personal data under GDPR?

Usually yes. A contact id identifies a person, `propertyValue` can hold an email address or a phone number, and a workflow webhook can hold the full record. Our article on [whether webhooks are personal data](https://webhooker.eu/blog/are-webhooks-personal-data-gdpr) goes through the reasoning.

HubSpot [hosts accounts in several regions](https://knowledge.hubspot.com/account-security/hubspot-cloud-infrastructure-and-data-hosting-frequently-asked-questions), including an EU data centre in Germany on AWS. Yours is shown under **Privacy & Consent**, on the **Data Hosting** tab. That setting decides where HubSpot stores the account, not where the webhook goes. The request travels to whatever URL you registered, and the tunnel, the request inspector, the queue and the logs on that path are all part of your processing. An account hosted in Germany that posts contact changes to a US-hosted inspection tool has moved personal data out of the EU. Subscribe to `contact.privacyDeletion` too, because every system holding a copy of the contact has to delete it. If a relay or gateway sits on that path, it is a processor as well, and our [checklist for a GDPR-compliant webhook relay](https://webhooker.eu/blog/gdpr-compliant-webhook-relay) lists what to ask of it.

## Should you put a webhook gateway in front of HubSpot?

Disclosure: we build Webhooker, an EU-hosted [webhook gateway](https://webhooker.eu/blog/what-is-a-webhook-gateway). For HubSpot traffic it solves the delivery problem and only part of the verification problem.

Delivery first. HubSpot gives your endpoint five seconds and then 24 hours of retries. With a gateway you create a source and paste its ingest URL (`https://app.webhooker.eu/in/<token>`) into the app’s target URL. The gateway stores the request and answers `200` whether or not your service is up. It then delivers to your destinations with exponential backoff, six attempts over about five hours behind a per-destination circuit breaker, and parks what still fails in a [dead-letter queue you can replay](https://docs.webhooker.eu/deliver/retries-and-replay/). A HubSpot batch is stored and forwarded as one event with the array intact, and every delivery carries a stable `X-Webhooker-Event-Id`. Payloads and attempts are kept for 14 days on the free plan and 30 or 90 days on paid plans, which gives you the resend path HubSpot’s guide does not describe. Ingest, storage, delivery and backups run [in the EU](https://webhooker.eu/blog/eu-hosted-webhook-infrastructure), with a DPA on paid plans.

Verification is the weak spot. Webhooker has presets for Stripe, GitHub and Shopify and none for HubSpot. Its generic HMAC method checks a signature over the request body, optionally with a timestamp, as [the verification docs](https://docs.webhooker.eu/receive/verification/) describe. HubSpot’s v3 signature covers method, URI, body and timestamp, and v1 is a plain SHA-256 with the secret prepended, so a generic body HMAC can validate neither. Deliveries to your destination are [signed by Webhooker](https://docs.webhooker.eu/deliver/verify-signatures/) and do not carry HubSpot’s signature headers, so the HubSpot check cannot be re-run downstream either. That leaves three realistic setups:

- Workflow webhooks fit fully. In the **Send a webhook** action choose API key authentication in a request header, and set the source’s verification to API Key with the same header name and value. Requests without the key get `401` and are never delivered.
- App webhooks can go through the gateway, but the ingest URL’s unguessable token is then the only gate, so treat the URL as a secret. Thin payloads help: if your worker reads every record back from the CRM API with your own token, a forged notification can at most make you fetch a real record. Do not act on `propertyValue` from an unverified request.
- If the signature check is mandatory for app webhooks, point HubSpot straight at your own handler, verify v3 there, and live with HubSpot’s retry window.

The [free plan](https://webhooker.eu/pricing) covers 10,000 events a month, and one inbound request counts as one event however many destinations it reaches. If the first or second setup fits, [create a free source](https://app.webhooker.eu/register) and follow the [quick start](https://docs.webhooker.eu/guides/quickstart/).

## Frequently asked questions

### What is the difference between the HubSpot Webhooks API and a workflow webhook?

The Webhooks API belongs to an app: a developer subscribes to CRM events such as `contact.creation`, and HubSpot posts batches of thin notifications for every matching change. The workflow **Send a webhook** action is a step a HubSpot user adds to a workflow. It needs Data Hub Professional or Enterprise, sends one POST or GET per enrolled record, and can include all of the record’s properties.

### How do I validate the X-HubSpot-Signature-v3 header?

Concatenate the HTTP method, the full request URI, the raw request body and the value of `X-HubSpot-Request-Timestamp`. Compute HMAC-SHA256 over that string with your app’s client secret, base64-encode it, and compare it to the header in constant time. Reject requests whose timestamp is more than five minutes old, and decode only HubSpot’s listed URL escapes in the URI before hashing.

### Does HubSpot retry failed webhooks?

Yes. For the Webhooks API, HubSpot retries up to 10 times over 24 hours after a failed connection, a response slower than five seconds, or any `4xx` or `5xx` status. Workflow webhooks are retried for up to three days, but not after `4xx` responses other than `429`.

### Are HubSpot webhooks delivered in order, and only once?

No to both. HubSpot states that it does not guarantee order and that the same notification can arrive more than once. Use `occurredAt` to decide which change is newer for a record. Because `eventId` is documented as not guaranteed to be unique, deduplicate on a combination such as `portalId`, `subscriptionId`, `eventId`, `objectId` and `occurredAt`.
