# HubSpot Webhooks: Verify Requests, Prevent Duplicate Actions and Recover Missed Updates

A reliable HubSpot webhook integration authenticates the original request, commits the accepted delivery to durable storage, and processes business actions separately. First identify whether the source is an app subscription, a workflow webhook action or the webhooks journal. Those mechanisms have different configuration, authentication and recovery contracts; treating them as one product causes avoidable missing updates and duplicate work.

Canonical URL: https://www.martechdevs.com/blog/hubspot-webhooks

By [Jafrin Akter Alo](https://www.martechdevs.com/authors/jafrin-akter-alo)

Data Engineer and Co-founder at [Martechdevs](https://www.martechdevs.com) and [Growth and Analytics Partners](https://www.growthandanalytics.com)

[LinkedIn](https://linkedin.com/in/jafrinalo)

Last updated: 2026-10-25

Next step: [Salesforce to HubSpot migration: plan, field mapping and cutover checklist](https://www.martechdevs.com/blog/salesforce-to-hubspot-migration)

A successful HTTP response means the sender received an acknowledgement. It does not establish that your CRM summary was updated, a downstream task was created once, or a missing customer record was recovered. Track those outcomes separately.

Our first implementation check is a controlled change followed all the way from its HubSpot record to the receiving system's durable record and final effect. That trace is more useful than a dashboard that only counts successful requests.

## Which HubSpot webhook mechanism are you using?

Use app subscriptions for subscribed CRM changes, workflow actions for an explicit step in a business workflow, and the journal when the application's design calls for pulling its subscribed event history.

Keep [submissions sent through the Forms API](https://www.martechdevs.com/blog/hubspot-forms-api) distinct from the CRM changes your webhook subscription observes.

| Mechanism | Configuration owner | Delivery shape | What to verify first |
| --- | --- | --- | --- |
| Project-based app subscriptions | App project | HTTP POST to the configured endpoint | Webhook feature metadata and deployed subscriptions |
| Legacy public app subscriptions | Legacy app settings or supported management API | HTTP POST batches | App installation, scopes and active subscriptions |
| Legacy standalone private app subscriptions | Account's private-app settings | HTTP POST batches | Correct app, secret and committed settings |
| Workflow webhook action | Workflow owner | Configured GET or POST action | Edition, enrolment, action and authentication settings |
| Webhooks journal | Application integration | App retrieves journal entries | Install-specific subscriptions, credentials and cursor handling |

Project apps define a webhook component in `src/app/webhooks/` using a `*-hsmeta.json` file. The current reference separates `object.*` subscriptions from classic CRM and hub-event arrays. Use the schema for the project you are deploying. [Project webhook configuration](https://developers.hubspot.com/docs/apps/developer-platform/add-features/configure-webhooks).

Legacy private subscriptions are managed through the app's settings, not the webhook management API. Legacy private apps created with projects do not support that subscription route. The private app's client secret is the signing secret; it is not the access token used to call CRM APIs. [Private-app webhook scope](https://developers.hubspot.com/docs/apps/legacy-apps/private-apps/create-and-edit-webhook-subscriptions-in-private-apps).

The current app-management API guide still applies to legacy public apps even though its endpoint paths use a dated API version. An endpoint's version number does not turn it into the configuration method for every app model. [Webhook management scope](https://developers.hubspot.com/docs/api-reference/latest/webhooks/guide).

For the wider ownership decision, use the [CRM property contract](https://www.martechdevs.com/blog/crm-property-ownership). Webhook delivery should enforce that contract, not give a new worker permission to write every property it receives.

## What should you configure before testing delivery?

Configure one meaningful event, one receiving route and one expected outcome before expanding the subscription set.

1. **Identify the app and account.** Record the app model, app ID, account ID, required object scopes and intended environment.
2. **Define the event contract.** Choose the object and event type. For a property change, record its internal property name rather than the display label.
3. **Configure the receiver.** Use a stable HTTPS route, its expected signing scheme and the correct secret. Keep authentication ahead of JSON parsing or application actions.
4. **Activate and deploy the subscription.** Check the actual saved state after changing settings; creating configuration is not the same as activating it.
5. **Make a controlled source change.** Retain its object ID and expected value, then find its delivery and processing record.
6. **Repeat with a failure.** Make durable receipt temporarily unavailable in the test environment and verify that the receiver does not return success before it can preserve the event.

For legacy app subscriptions, configuration changes can take up to five minutes to propagate. A property subscription also cannot report a property that does not exist in the account. [Subscription configuration behaviour](https://developers.hubspot.com/docs/api-reference/latest/webhooks/guide).

Record the expected change alongside the test. “A request arrived” is weaker evidence than “the change to this controlled deal arrived with the right account, property and value, then produced the intended update.”

## Why must the receiver pin the signature version?

The receiver must use the authentication contract selected for its route, rather than accepting whichever weaker signature an incoming request offers.

HubSpot's validation methods differ. Legacy v1 hashes the app secret followed by the unparsed body. Legacy v2 also includes the method and URI. V3 uses HMAC-SHA256 over method, URI, body and the request timestamp, then Base64 encoding; its timestamp must not be older than five minutes. [Legacy signatures](https://developers.hubspot.com/docs/apps/legacy-apps/authentication/validating-requests), [current request validation](https://developers.hubspot.com/docs/apps/developer-platform/build-apps/authentication/request-validation).

Do not confuse the webhook management API version with the signature version. Capture a controlled request from the configured source and verify its expected headers against that source's contract. If a route requires v3, missing v3 must fail; silently falling back to another header creates a different authentication policy.

Preserve the exact body bytes. Parsing and serializing JSON can change whitespace, escaping or key order even when the resulting object looks identical. The received signature authenticates the transmitted representation.

Also preserve the public request URI under a trusted deployment configuration. An internal proxy address is not the external address that was signed. Do not trust an arbitrary `Host` or forwarded header to decide the URI without a controlled proxy policy.

### A small verifier for a fixed v3 POST route

The Python helper below deliberately supports a fixed HTTPS POST URL with an ASCII path and no query, percent-encoded path or fragment. That keeps URI reconstruction out of this example. The router must reject other methods, paths and query strings before calling it. A route using encoded characters or a query needs HubSpot's specified URI normalization and its own fixtures.

Pass the raw body bytes, the `X-HubSpot-Request-Timestamp` string and the `X-HubSpot-Signature-v3` string. Load the app secret and trusted public URL from deployment configuration. The additional 30-second future-clock allowance is this example's local policy, not a stated HubSpot requirement.

```python
import base64
import binascii
import hashlib
import hmac
import re


def verify_hubspot_v3(
    raw_body, timestamp, signature, *, secret, public_url, now_ms
):
    if not isinstance(secret, bytes) or not secret:
        raise ValueError("Configure a nonempty secret as bytes")
    if not isinstance(public_url, str) or not re.fullmatch(
        r"https://[A-Za-z0-9.-]+(?::[0-9]+)?/[A-Za-z0-9/_~.-]*",
        public_url,
    ):
        raise ValueError("Use a fixed HTTPS URL without query or encoding")
    if type(now_ms) is not int or now_ms < 0:
        raise ValueError("now_ms must be a nonnegative integer")
    if not isinstance(raw_body, bytes):
        raise ValueError("Pass the original body bytes")
    if not isinstance(timestamp, str) or not re.fullmatch(
        r"[0-9]{10,16}", timestamp
    ):
        return False
    age_ms = now_ms - int(timestamp)
    if age_ms > 300_000 or age_ms < -30_000:
        return False
    if not isinstance(signature, str):
        return False
    try:
        supplied = base64.b64decode(signature, validate=True)
    except (ValueError, binascii.Error):
        return False
    if len(supplied) != 32:
        return False
    message = (
        b"POST" + public_url.encode("ascii")
        + raw_body + timestamp.encode("ascii")
    )
    expected = hmac.new(secret, message, hashlib.sha256).digest()
    return hmac.compare_digest(expected, supplied)
```

The helper verifies authenticity and its timestamp policy only. It does not validate the account, parse the batch, prevent duplicate processing or store anything. Those checks follow a successful result. Do not put a `200 OK` response before this function just to keep the endpoint fast.

For legacy v1 or v2 routes, implement their pinned contract separately and retain business-level replay protection. They do not gain v3's signed timestamp merely because the payload contains a field resembling a date.

## When should the endpoint acknowledge a batch?

Acknowledge an authenticated, accepted batch after it is durably recorded for processing. Keep slow CRM reads and downstream writes out of the receipt path.

Use a transaction or durable queue operation that preserves the raw request, parsed events, app/account context and processing status. Commit before returning success. If the commit fails, return an appropriate failure so the sender can retry under its policy.

After signature verification, validate the app and account against the installation or account registry used by your integration. Check the expected event schema and subscription scope before assigning a processing destination. A valid signature must not let a payload select another customer's credentials or database partition. Keep account identity in receipt, object and business-action keys.

There are two different crashes to test. A crash before the durable commit must leave no success acknowledgement. A crash after the commit but before the response can cause a repeated delivery, which your receiver must recognise without losing the already stored work.

For a batch containing several events, avoid acknowledging after storing only the first event. Either commit the whole validated batch or durably record which items were accepted or quarantined under an explicit policy. An authenticated but unsupported event can be preserved for investigation; silently dropping it makes later recovery harder.

![Original request bytes pass authentication and route checks, then require durable receipt commitment before acknowledgement. Failed authentication rejects the request; failed commit returns a failure. A queued worker separately checks business eligibility before a controlled effect and reconciliation.](https://www.martechdevs.com/assets/blog/hubspot-webhooks.svg)

Diagram connections:

- Receive original request bytes to Authentication and route checks pass?. 
- Authentication and route checks pass? to Reject without business action: No. 
- Authentication and route checks pass? to Durable receipt committed?: Yes. 
- Durable receipt committed? to Return failure and preserve diagnostics: No. 
- Durable receipt committed? to Acknowledge and queue processing: Yes. 
- Acknowledge and queue processing to Business action still eligible?. 
- Business action still eligible? to Record duplicate or ineligible outcome: No. 
- Business action still eligible? to Apply controlled effect and reconcile: Yes

Queue age deserves its own alert. A receiver can return successful HTTP responses all day while a stalled worker leaves every customer update waiting. Measure durable receipts, processing failures and completed outcomes separately.

## Which retry rules apply to app subscriptions and workflow actions?

App-subscription retries and workflow-action retries follow different policies, so configure response handling for the actual source.

| Source | Published retry boundary | Receiver consequence |
| --- | --- | --- |
| App subscription delivery | A response taking over five seconds, connection failure or 4xx/5xx can trigger retries; up to ten retries over the next 24 hours | Commit quickly and expect repeated batches |
| Workflow webhook action | Failed actions retry for up to three days; ordinary 4xx responses are not retried, while 429 is retried | Distinguish permanent rejection from temporary backpressure |
| Your worker after acknowledgement | Your own policy | Persist retry state and prevent duplicate downstream actions |

Sources: [app delivery retries](https://developers.hubspot.com/docs/api-reference/latest/webhooks/guide), [workflow retry behaviour](https://knowledge.hubspot.com/workflows/how-do-i-use-webhooks-with-hubspot-workflows).

The workflow action is a Data Hub Professional or Enterprise feature. It can use a request signature or configured API-key authentication and supports GET or POST. A GET action retrieving data is not the same as a subscribed CRM-event POST receiver. Its test action can send data to an external service, so use controlled records and the intended test endpoint. [Workflow webhook scope](https://knowledge.hubspot.com/workflows/how-do-i-use-webhooks-with-hubspot-workflows).

If you use workflow 429 responses, follow that action's particular `Retry-After` convention: the current help page specifies milliseconds. Do not transplant that unit into unrelated HTTP clients or app-subscription receivers.

A legitimate repeated delivery must not become a security workaround. Fix a bad signature configuration rather than acknowledging unverified requests. A sender exhausting its retries is an operational incident, not proof that the missing event never happened.

## How should duplicates and business actions be separated?

Use one mechanism to recognise delivery repetition and another to prevent repeated business effects. They solve different problems.

HubSpot does not guarantee delivery order or a single notification per event. Its classic payload's `eventId` is not guaranteed unique. Batches may contain up to 100 events. [Webhook payload and delivery contract](https://developers.hubspot.com/docs/api-reference/latest/webhooks/guide).

A raw-body hash can identify an identical request, but it is not a universal event identity: retry metadata or batching can change while the underlying update stays the same. Conversely, a single object ID cannot be a deduplication key because one contact legitimately changes many times.

For a current-state sync, store the received event and schedule a refresh keyed by account and object. Coalescing several pending refreshes can be reasonable when the worker only needs the latest state. Keep the receipt history separately so that coalescing does not erase evidence.

For a business action, define the episode. A hypothetical task might be keyed to `(account, deal, approved-handoff-version)`. Two notifications about that handoff should resolve to one task. A later genuinely new handoff version can create another. The key must follow the business process rather than an arbitrary payload field.

| Received situation | Receipt treatment | Business-effect rule |
| --- | --- | --- |
| Same authenticated request arrives twice | Preserve or link the repeated attempt | Do not create another effect for the same episode |
| Two different events describe one handoff | Keep both events | Resolve both to the same approved episode |
| Same deal enters a new approved episode | Keep the new event | Permit a new effect under the new key |
| Same object ID appears in another account | Keep account identity distinct | Never share that account's deduplication state |

Use an atomic uniqueness constraint or equivalent transactional guarantee for the action claim. An in-memory set is lost on restart and does not coordinate several workers.

An action claim alone does not solve an uncertain downstream write. If a remote API accepts a task but the response is lost, marking the local action incomplete and blindly retrying can create a duplicate. Use the destination's supported idempotency mechanism or reconcile by a stable external reference before retrying an ambiguous outcome. The [integration recovery guide](https://www.martechdevs.com/blog/martech-data-integration) develops that boundary.

## How do you avoid older events overwriting newer state?

Treat arrival order as delivery history, not business authority. For current-state synchronization, retrieve the authoritative object and serialize or version writes for that account/object so two workers cannot race an older result over a newer one.

A source timestamp helps explain when the event happened, but timestamps can tie and do not automatically create a total ordering across objects. Preserve the event's occurrence time and your receipt time separately. Do not invent a sequence guarantee from an integer-looking ID.

If the destination needs every historical transition, a latest-state fetch is insufficient on its own. Store the transition evidence and define how to handle gaps or uncertain ordering. A CRM snapshot showing a deal won today does not reconstruct every stage the deal passed through.

Deletion and merge events also need explicit treatment. For example, a normal deletion followed by a privacy deletion must not cause a refresh worker to recreate the contact from an older cache. Maintain the appropriate tombstone and erasure process across the systems involved. Keep the [field ownership rules](https://www.martechdevs.com/blog/crm-property-ownership) in force during recovery as well as ordinary delivery.

## Can the journal or CRM search recover missing changes?

The webhooks journal can support a deliberately configured pull-based recovery design; CRM search can reconcile selected current-state populations. Neither should be assumed to reconstruct every event your existing push receiver missed.

The current journal uses install-specific subscriptions, client-credentials OAuth and dedicated scopes. It exposes subscribed historical changes for the past three days and is separate from project webhook feature components. [Current journal contract](https://developers.hubspot.com/docs/api-reference/latest/webhooks-journal/guide).

Persist the journal cursor only after the corresponding entries are durable. Monitor cursor age against the available history window. Repeating a page after a crash should not repeat its business effects. Confirm that the necessary subscription existed during the period you intend to recover.

For current-state reconciliation, define an overlapping modified-time window, paginate to completion and retain evidence of coverage. HubSpot search can lag recent updates, excludes archived objects and caps a query at 10,000 results. Split an oversized population into bounded windows rather than treating the first result set as complete. [CRM search limits](https://developers.hubspot.com/docs/api-reference/latest/crm/search-the-crm).

A separate deletion strategy is required because an ordinary search of live records cannot prove which previously known records were removed. Compare that scope with the failure period and the downstream action affected. Do not advertise a complete recovery when only current active contacts were inspected.

## Where should you look when an update is missing?

Trace the failure through configuration, receipt, processing and business eligibility before changing unrelated settings.

| Symptom | Evidence to inspect | Corrective action |
| --- | --- | --- |
| No delivery attempt exists | App installation, scope, active subscription and exact event | Repair configuration and repeat the controlled change |
| Delivery fails authentication | Original bytes, pinned signature version, secret and public URI | Correct the contract without weakening verification |
| Sender retries despite stored data | Receipt commit and response timing | Make repeated receipt safe and reduce acknowledgement latency |
| Delivery succeeds but destination is stale | Queue age, worker errors and object mapping | Repair processing and replay durable work |
| Destination contains duplicate tasks | Episode key and uncertain remote write outcomes | Reconcile existing effects before resuming writes |
| Reconciliation finds unexplained gaps | Source population, pagination, archived records and cursor coverage | Expand the recovery plan to the missing scope |

Legacy private apps expose webhook request logs with subscription, status, batch or log identifiers and payload details. Use those records to connect a specific source change to your own receipt history. [Private-app webhook logs](https://developers.hubspot.com/docs/apps/legacy-apps/private-apps/create-and-edit-webhook-subscriptions-in-private-apps).

Log identifiers and outcomes rather than dumping every customer field into application logs. Retain sensitive payloads only in the controlled store required for processing and recovery, with an explicit retention policy.

## What should pass before release?

Release the receiver after authentication, durability, duplicate handling and recovery pass as separate checks.

## Checklist

- [ ] The app model and webhook mechanism are explicit.
- [ ] Active subscriptions match the actual object scopes and properties.
- [ ] The receiver pins the expected signature contract and preserves raw bytes.
- [ ] An altered body, wrong secret and invalid timestamp fail.
- [ ] Success is returned only after durable receipt.
- [ ] A crash before and after the commit has a known recovery path.
- [ ] Repeated delivery cannot repeat the same business action.
- [ ] An uncertain remote write is reconciled before retry.
- [ ] Older work cannot overwrite newer authoritative state.
- [ ] Deletion, merge and missed-window recovery are covered.
- [ ] Queue age and failed outcomes have an operational owner.

If HubSpot events drive sales routing or customer communication, [Martechdevs' integration work](https://www.martechdevs.com/martech-integration) can define the receiver, field authority and recovery path together. Fit that release into the [HubSpot rollout checklist](https://www.martechdevs.com/blog/hubspot-implementation-checklist).

## Frequently asked questions

### Does a webhook API version tell me which signature verifier to use?

No. The management API version and request-signature version describe different contracts. Identify the configured source, inspect a controlled delivery and pin the appropriate verifier for that route. Do not select a weaker method merely because an incoming request supplies an older header. Keep any intentional legacy receiver separate and give it the replay protections its business actions require.

### Can a webhook receipt and the resulting CRM task share one status field?

Use separate states because receipt and task creation can fail independently. A delivery may be safely stored while its worker is waiting, rejected by a business rule or reconciling an uncertain remote response. Record the receipt ID, processing status and final effect reference. This makes a successful HTTP acknowledgement compatible with honest visibility into unfinished downstream work.

### Should changing a contact's email move its integration identity?

Keep the stable account and object identity separate from the mutable email field. An email correction should normally update the mapped person's information rather than create a new integration entity. Recheck any external system that matches only by email, and preserve the crosswalk and change evidence. A valid webhook signature does not decide which destination person is the correct match.

### How can a test subscription succeed while the real business change fails?

A test payload can prove endpoint reachability and basic parsing without reproducing the actual source event or permission scope. Test the real controlled property change, its account and object association, and the worker's final effect separately. Also check configuration activation and propagation. Keep the sample test and the source-change test as different pieces of release evidence.

### Should a replay run send all the customer messages it would have sent originally?

Re-evaluate each message against current eligibility and the purpose of the replay. Recovering a missing CRM state is different from restarting an old customer journey. Preserve original event times, suppress completed episodes and route ambiguous effects for review. Approve intentional historical outreach separately so a technical recovery cannot silently generate stale reminders or duplicate sales contact.

## Sources

1. [HubSpot: project webhook configuration](https://developers.hubspot.com/docs/apps/developer-platform/add-features/configure-webhooks).

2. [HubSpot: legacy private webhook subscriptions](https://developers.hubspot.com/docs/apps/legacy-apps/private-apps/create-and-edit-webhook-subscriptions-in-private-apps).

3. [HubSpot: webhook API management, payloads and retries](https://developers.hubspot.com/docs/api-reference/latest/webhooks/guide).

4. [HubSpot: legacy request signatures](https://developers.hubspot.com/docs/apps/legacy-apps/authentication/validating-requests).

5. [HubSpot: current request validation](https://developers.hubspot.com/docs/apps/developer-platform/build-apps/authentication/request-validation).

6. [HubSpot: workflow webhooks](https://knowledge.hubspot.com/workflows/how-do-i-use-webhooks-with-hubspot-workflows).

7. [HubSpot: current webhooks journal](https://developers.hubspot.com/docs/api-reference/latest/webhooks-journal/guide).

8. [HubSpot: CRM search and limits](https://developers.hubspot.com/docs/api-reference/latest/crm/search-the-crm).
