If you build on HubSpot long enough, you eventually hit the same trio of problems: retries, duplicates, and visibility. HubSpot webhooks are powerful, but the difference between a fragile integration and a bulletproof one comes down to how you design for idempotency, backoff, queues/DLQ, and observability.
This guide focuses on HubSpot webhooks in two directions:
-
Outbound (HubSpot ➜ your systems)
- via Webhooks API (subscriptions in a public/private app)
-
- via Workflows “Send a webhook” action
- via Workflows “Send a webhook” action
-
Inbound (your systems ➜ HubSpot)
- via “When a webhook is received” workflow triggers (newer capability)
- via “When a webhook is received” workflow triggers (newer capability)
We’ll cover the moving parts, give you practical code samples, and show patterns that “survive failures” without leaking duplicates or losing events.
The three flavors of HubSpot webhooks (and when to use each)
1) Webhooks API (app subscriptions)
Create a public or private app and subscribe to CRM object events (e.g., contact.creation, deal.propertyChange). HubSpot sends batches of JSON events to your target URL whenever subscribed events occur. Great for CRM-wide streaming to your data plane or middleware.
Key delivery behavior. HubSpot retries failed deliveries for the Webhooks API up to 10 times spread across ~24 hours, with randomized delays to reduce thundering herds. That change was introduced to improve reliability and smooth retry spikes.
Payload characteristics. Messages include identifiers such as eventId, subscriptionId, portalId, objectId, and an attemptNumber counter—handy for ensuring idempotency and diagnostics. (HubSpot’s docs reference eventId and attemptNumber in the webhook payload tables.)
2) Workflows “Send a webhook” (no app required)
Within a HubSpot workflow, add 'Send a webhook' and select either POST or GET. You can include all properties or customize the body with specific fields (plus static values), and you can choose authentication (request signature header using an App ID, or an API key/header). There’s also a rate limit setting (BETA) for the action to throttle executions.
Workflow retry logic. If your endpoint fails, HubSpot will retry for up to three days, starting ~one minute after the first failure, with increasing intervals capped at eight hours between attempts. Workflows don’t retry on 4xx, except 429 (they respect the Retry-After header, in milliseconds). This is critical for your error handling plan.
3) When a webhook is received (trigger workflows from external apps)
You can now enroll records when HubSpot receives a webhook from a third-party system. You define a unique matching property and map incoming JSON fields; content type must be application/json. This closes the loop for inbound webhooks driving HubSpot automation.
Security first: verifying HubSpot signatures (v3)
For outgoing requests (HubSpot ➜ you), HubSpot signs requests with v3 signatures that include X-HubSpot-Signature-v3 and X-HubSpot-Request-Timestamp. Verification is straightforward:
-
Reject the request if the timestamp is older than ~5 minutes.
-
Build the string: requestMethod + requestUri + requestBody + timestamp.
-
Compute HMAC-SHA256 with your app secret and Base64-encode the result.
-
Compare with X-HubSpot-Signature-v3.
Workflows’ “Send a webhook” can also attach a request signature header (you provide the App ID, then verify with your app secret on your server).
Idempotency and deduplication with HubSpot webhooks
The golden rule: process each event exactly once—even when HubSpot resends or your code runs twice.
-
eventId is the canonical unique ID for the triggering event. Use it as your idempotency key.
-
attemptNumber (starts at 0) increases on retries; it should not create new work—log it for telemetry.
Storage pattern. Keep a fast store (Redis, DynamoDB, Postgres with a unique index) of processed eventIds. Insert on first successful process; on duplicates, ack and skip.
Downstream safety. If your handler creates/updates records elsewhere, make those calls idempotent too (e.g., upserts by a natural key like email or an external ID) to avoid duplicate side effects in your own systems.
Retry & backoff policies (HubSpot and you)
HubSpot Workflows ➜ your endpoint
- Retries for up to 3 days, starting in ~1 minute, with a max gap of 8 hours.
- No retry on 4xx, except 429 (honors Retry-After).
Design your endpoint to return 2xx quickly if you’ve accepted the job into your queue, and use 429 when you want HubSpot to back off according to your own rate budget.
HubSpot Webhooks API (app subscriptions) ➜ your endpoint
- Up to 10 retries within 24 hours, with randomized delay to spread load. If you can’t process now, return a non-2xx so HubSpot retries; when you do accept the job, respond 200 fast.
Your retries ➜ HubSpot (or other APIs)
- If your receiver calls HubSpot APIs or other services, adopt exponential backoff with jitter on your side as well, and avoid feedback loops. Respect HubSpot’s HTTP 429 semantics if you re-call HubSpot from your handler.
Architecture that survives failures: queues and a DLQ
Pattern: ack fast ➜ queue ➜ worker ➜ DLQ (dead-letter queue)
- Receive webhook, verify signature, perform light validation.
- Persist the event (e.g., S3) and enqueue a small job referencing eventId + payload pointer.
- Ack 200 immediately to HubSpot.
- Process asynchronously in worker(s).
- On repeated worker failures beyond a threshold, dead-letter the job for investigation and manual replay.
This buys you:
- Throughput & backpressure control (you set concurrency and limits).
- Natural idempotency (workers check your idempotency store before work).
- Reprocessing (you can replay from DLQ or archived payloads without asking HubSpot to resend).
Practical example: Express (Node.js) receiver with v3 signature + Redis idempotency
You can adapt this to any stack; the point is the shape, not the specific library.
<p>import crypto from "crypto";</p>
<p>import express from "express";</p>
<p>import bodyParser from "body-parser";</p>
<p>import { createClient } from "redis";<br><br>const app = express();</p>
<p><br>// Preserve raw body for signature verification<br>app.use(<br> bodyParser.json({<br> verify: (req, res, buf) => {<br> req.rawBody = buf;<br> },<br> })<br>);<br><br>const APP_SECRET = process.env.HUBSPOT_APP_SECRET;<br>const redis = createClient({ url: process.env.REDIS_URL });<br>await redis.connect();<br><br>function verifyV3(req) {<br> const sig = req.get("X-HubSpot-Signature-v3");<br> const ts = req.get("X-HubSpot-Request-Timestamp"); // ms since epoch<br> if (!sig || !ts) return false;<br><br> // Reject stale<br> const ageMs = Math.abs(Date.now() - Number(ts));<br> if (ageMs > 5 * 60 * 1000) return false;<br><br> const base =<br> req.method +<br> req.originalUrl +<br> (req.rawBody?.toString() || "") +<br> ts;<br><br> const hmac = crypto<br> .createHmac("sha256", APP_SECRET)<br> .update(base)<br> .digest("base64");<br><br> return crypto.timingSafeEqual(Buffer.from(hmac), Buffer.from(sig));<br>}<br><br>app.post("/webhooks/hubspot", async (req, res) => {<br> if (!verifyV3(req)) return res.sendStatus(401);<br><br> // HubSpot sends batches (array of events)<br> const events = Array.isArray(req.body) ? req.body : req.body?.data || [];<br><br> for (const evt of events) {<br> const key = `hubspot:event:${evt.eventId}`;<br> const inserted = await redis.set(key, "1", {<br> NX: true,<br> EX: 7 * 24 * 3600, // 7-day TTL<br> });<br><br> if (!inserted) continue; // duplicate; already processed<br><br> // enqueue lightweight job (pseudo)<br> await enqueue({<br> id: evt.eventId,<br> type: evt.subscriptionType,<br> objectId: evt.objectId,<br> attempt: evt.attemptNumber,<br> payload: evt,<br> });<br> }<br><br> // Ack fast so HubSpot uses its success path<br> res.sendStatus(200);<br>});<br><br>app.listen(3000);</p>
Why this works: eventId is your idempotency key; attemptNumber is telemetry. HubSpot signs requests with v3, so stale or tampered traffic gets dropped. Batching is supported by iterating the array of events before enqueuing.
Workflow “Send a webhook”: payloads, auth, rate limit & retries
When sending outbound webhooks from a workflow:
- Method & URL: Support for POST and GET; URL must be HTTPS.
- Body options: Include all properties or customize the body with selected properties and static fields. (Pick only stable fields if later actions depend on the response.)
- Auth options:
- Request signature (header) using your App ID (verify with your app secret).
- API key into query or header; or use an OAuth token stored as a secret if you’re calling HubSpot APIs.
- Rate limit (BETA): Configure executions per time window to protect downstream systems. Paused actions resume automatically once within limits.
- Testing: Use Test action to inspect request/response, or send to webhook.site for quick external inspection.
Retries: Up to 3 days, no retry on 4xx (except 429 with Retry-After honored). Design your receiver accordingly.
Example body (customized) for a Deal webhook:
<p>{</p>
<p><span style="color: #000000;"> </span>"dealId":<span style="color: #000000;"> </span>"",</p>
<p><span style="color: #000000;"> </span>"dealName":<span style="color: #000000;"> </span>"",</p>
<p><span style="color: #000000;"> </span>"amount":<span style="color: #000000;"> </span>"",</p>
<p><span style="color: #000000;"> </span>"stage":<span style="color: #000000;"> </span>"",</p>
<p><span style="color: #000000;"> </span>"companyDomain":<span style="color: #000000;"> </span>"",</p>
<p><span style="color: #000000;"> </span>"staticSource":<span style="color: #000000;"> </span>"hubspot-workflow"</p>
<p>}</p>
Inbound: trigger workflows when a webhook is received
- Send application/json to the unique URL.
- Map incoming fields to HubSpot properties (string/enum/number/boolean). Datetime must be mapped as string (you can convert later via Custom Code).
-
Enrollment requires a unique matching property present in HubSpot (e.g., external user ID).
*Tip: use a dedicated unique property (e.g., external_id) and store it consistently when you create contacts/companies—this keeps triggers precise and avoids accidental enrollments.
Observability: knowing what happened (and being able to fix it)
-
Workflow action logs show if an action was paused due to the rate limit, and the request/response for webhook tests—useful when debugging authentication/payload issues.
-
For apps, HubSpot provides request monitoring for webhooks and OAuth calls so you can view attempts and failures associated with subscriptions and attempts. (The public apps overview references webhook/OAuth request logs and attempt identifiers composed of subscriptionId + eventId + attemptNumber.)
In your platform:
-
Structured logs (JSON) with correlation IDs: portalId, subscriptionId, eventId, attemptNumber.
-
Metrics: count deliveries, success rate, error rate, P50/P95 processing time.
-
Traceability: store raw payloads (e.g., S3) with a retention policy; index by eventId for deterministic replay.
-
Reprocessing UI: a simple “replay” action that re-enqueues DLQ items after you deploy a fix.
Data types & payload surprises
A common gotcha: type differences between values sent by a workflow webhook vs values fetched later by API. For example, some numeric property values arrive as numbers in webhooks, while other API reads may present them differently. Audit your parsing and cast deliberately to avoid subtle bugs. (Developers have noted these differences when mixing workflow webhooks and API reads.)
End-to-end example: syncing a deal stage to an external OMS, resiliently
Scenario
- When a Deal moves to “Closed Won,” we notify an Order Management System (OMS).
- We protect against retries, duplicates, and OMS downtime.
Steps
1. Trigger: Deal-based workflow with enrollment “Deal stage is any of Closed Won”.
2. Action: Send a webhook (POST) to https://oms.example.com/hooks/deal.
-
- Custom JSON body with dealId, amount, companyDomain, closedDate.
-
- Authentication: Request signature header (verify with your app secret).
-
- Rate limit: 60 executions / minute (BETA), to shield OMS.
- 3. OMS receiver:
-
- Verify v3 signature (reject stale >5 min).
- Put the payload into deals queue; respond 200 immediately.
4. Worker:
-
- Idempotency by externalId = "hubspot:" + dealId + ":" + closedDate (or if using Webhooks API, use eventId).
-
- Call OMS idempotent upsert (/orders/upsert).
-
- On transient failures, retry with exponential backoff inside your worker.
-
- On max failures, move to DLQ.
5. Observe:
- Dashboard shows “webhook accepted” vs “OMS processed.”
-
- When OMS goes down, DLQ grows, but HubSpot continues to receive 200 quickly; you don’t burn HubSpot’s retry budget.
-
- After OMS recovers, use “redrive from DLQ”.
Another direction: external → HubSpot using inbound webhook triggers
If your payment processor posts a subscription_paid event, you can POST that JSON to the “When a webhook is received” URL mapped to contact/company by an external unique ID. The workflow can:
- Set lifecycle stage to Customer
- Add a “Last Paid At” property (convert to datetime in Custom Code)
- Enrich a Deal or create a new one via a follow-up action
This removes polling and lets you push state changes into HubSpot in near real time.
Implementation checklist (copy/paste)
HubSpot configuration
- Choose the right mechanism: Webhooks API (app level), Workflow webhooks, or Inbound webhook triggers.
- For workflow webhooks, configure auth (request signature or API key), body, and (optionally) rate limit (BETA).
- Document retry expectations for stakeholders (3-day workflow retries vs 24-hour subscriptions).
Receiver
- Verify v3 signature and reject stale timestamps.
- Ack fast (2xx) after enqueue—do work asynchronously.
- Idempotency store keyed by eventId (subscriptions) or a natural key (workflow webhook).
- Queue + DLQ with controlled concurrency and redrive tools.
- Structured logs & metrics tagged by portalId, subscriptionId, eventId, attemptNumber.
- Backoff with jitter when calling external APIs; honor 429/Retry-After when appropriate.
Observability & reprocessing
- Use HubSpot monitoring/logs (workflow action logs; app request monitoring) to see attempts, responses, and signature issues.
- Archive raw payloads in object storage for evidence and replay.
Frequently asked “gotchas”
Python (Flask) sketch: verify v3 + enqueue
<p><span style="color: #1967d2;">import</span><span style="color: #000000;"> </span><span style="color: #37474f;">base64,</span><span style="color: #000000;"> </span><span style="color: #37474f;">hmac,</span><span style="color: #000000;"> </span><span style="color: #37474f;">time</span></p>
<p><span style="color: #1967d2;">from</span><span style="color: #000000;"> </span><span style="color: #37474f;">hashlib</span><span style="color: #000000;"> </span><span style="color: #1967d2;">import</span><span style="color: #000000;"> </span><span style="color: #37474f;">sha256</span></p>
<p><span style="color: #1967d2;">from</span><span style="color: #000000;"> </span><span style="color: #37474f;">flask</span><span style="color: #000000;"> </span><span style="color: #1967d2;">import</span><span style="color: #000000;"> </span><span style="color: #37474f;">Flask,</span><span style="color: #000000;"> </span><span style="color: #37474f;">request,</span><span style="color: #000000;"> </span><span style="color: #37474f;">Response</span></p>
<p><span style="color: #1967d2;">from</span><span style="color: #000000;"> </span><span style="color: #37474f;">yourqueue</span><span style="color: #000000;"> </span><span style="color: #1967d2;">import</span><span style="color: #000000;"> </span><span style="color: #37474f;">enqueue</span><span style="color: #000000;"> </span><span style="color: #b80672;"># placeholder</span></p>
<br>
<p><span style="color: #37474f;">APP_SECRET</span><span style="color: #000000;"> </span><span style="color: #37474f;">=</span><span style="color: #000000;"> </span><span style="color: #37474f;">b</span><span style="color: #188038;">"</span><span style="color: #37474f;">...</span><span style="color: #188038;">"</span></p>
<p><span style="color: #188038;">MAX_SKEW_MS = 5 * 60 * 1000</span></p>
<br>
<p><span style="color: #188038;">app = Flask(__name__)</span></p>
<br>
<p><span style="color: #188038;">def verify_v3(req):</span></p>
<p><span style="color: #188038;"> sig = req.headers.get("</span><span style="color: #37474f;">X-HubSpot-Signature-v3</span><span style="color: #188038;">"</span><span style="color: #37474f;">)</span></p>
<p><span style="color: #000000;"> </span><span style="color: #37474f;">ts</span><span style="color: #000000;"> </span><span style="color: #37474f;">=</span><span style="color: #000000;"> </span><span style="color: #37474f;">req.headers.get(</span><span style="color: #188038;">"X-HubSpot-Request-Timestamp"</span><span style="color: #37474f;">)</span></p>
<p><span style="color: #000000;"> </span><span style="color: #1967d2;">if</span><span style="color: #000000;"> </span><span style="color: #37474f;">not</span><span style="color: #000000;"> </span><span style="color: #37474f;">sig</span><span style="color: #000000;"> </span><span style="color: #37474f;">or</span><span style="color: #000000;"> </span><span style="color: #37474f;">not</span><span style="color: #000000;"> </span><span style="color: #37474f;">ts:</span><span style="color: #000000;"> </span><span style="color: #1967d2;">return</span><span style="color: #000000;"> </span><span style="color: #37474f;">False</span></p>
<p><span style="color: #000000;"> </span><span style="color: #1967d2;">if</span><span style="color: #000000;"> </span><span style="color: #1967d2;">abs</span><span style="color: #37474f;">(</span><span style="color: #9334e6;">int</span><span style="color: #37474f;">(time.time()*</span><span style="color: #c5221f;">1000</span><span style="color: #37474f;">)</span><span style="color: #000000;"> </span><span style="color: #37474f;">-</span><span style="color: #000000;"> </span><span style="color: #9334e6;">int</span><span style="color: #37474f;">(ts))</span><span style="color: #000000;"> </span><span style="color: #37474f;">></span><span style="color: #000000;"> </span><span style="color: #37474f;">MAX_SKEW_MS:</span><span style="color: #000000;"> </span><span style="color: #1967d2;">return</span><span style="color: #000000;"> </span><span style="color: #37474f;">False</span></p>
<p><span style="color: #000000;"> </span><span style="color: #37474f;">base</span><span style="color: #000000;"> </span><span style="color: #37474f;">=</span><span style="color: #000000;"> </span><span style="color: #37474f;">req.method</span><span style="color: #000000;"> </span><span style="color: #37474f;">+</span><span style="color: #000000;"> </span><span style="color: #37474f;">req.full_path</span><span style="color: #000000;"> </span><span style="color: #37474f;">+</span><span style="color: #000000;"> </span><span style="color: #37474f;">(req.get_data(as_text=True)</span><span style="color: #000000;"> </span><span style="color: #37474f;">or</span><span style="color: #000000;"> </span><span style="color: #188038;">""</span><span style="color: #37474f;">)</span><span style="color: #000000;"> </span><span style="color: #37474f;">+</span><span style="color: #000000;"> </span><span style="color: #37474f;">ts</span></p>
<p><span style="color: #000000;"> </span><span style="color: #37474f;">digest</span><span style="color: #000000;"> </span><span style="color: #37474f;">=</span><span style="color: #000000;"> </span><span style="color: #37474f;">hmac.new(APP_SECRET,</span><span style="color: #000000;"> </span><span style="color: #37474f;">base.encode(</span><span style="color: #188038;">"utf-8"</span><span style="color: #37474f;">),</span><span style="color: #000000;"> </span><span style="color: #37474f;">sha256).digest()</span></p>
<p><span style="color: #000000;"> </span><span style="color: #37474f;">calc</span><span style="color: #000000;"> </span><span style="color: #37474f;">=</span><span style="color: #000000;"> </span><span style="color: #37474f;">base64.b64encode(digest).decode()</span></p>
<p><span style="color: #000000;"> </span><span style="color: #b80672;"># constant-time compare</span></p>
<p><span style="color: #000000;"> </span><span style="color: #1967d2;">return</span><span style="color: #000000;"> </span><span style="color: #37474f;">hmac.compare_digest(calc,</span><span style="color: #000000;"> </span><span style="color: #37474f;">sig)</span></p>
<br>
<p><span style="color: #37474f;">@app.post(</span><span style="color: #188038;">"/hubspot/webhooks"</span><span style="color: #37474f;">)</span></p>
<p><span style="color: #1967d2;">def</span><span style="color: #000000;"> </span><span style="color: #37474f;">hubspot_webhooks():</span></p>
<p><span style="color: #000000;"> </span><span style="color: #1967d2;">if</span><span style="color: #000000;"> </span><span style="color: #37474f;">not</span><span style="color: #000000;"> </span><span style="color: #37474f;">verify_v3(request):</span><span style="color: #000000;"> </span><span style="color: #1967d2;">return</span><span style="color: #000000;"> </span><span style="color: #37474f;">Response(status=</span><span style="color: #c5221f;">401</span><span style="color: #37474f;">)</span></p>
<p><span style="color: #000000;"> </span><span style="color: #37474f;">events</span><span style="color: #000000;"> </span><span style="color: #37474f;">=</span><span style="color: #000000;"> </span><span style="color: #37474f;">request.get_json(silent=True)</span><span style="color: #000000;"> </span><span style="color: #37474f;">or</span><span style="color: #000000;"> </span><span style="color: #37474f;">[]</span></p>
<p><span style="color: #000000;"> </span><span style="color: #1967d2;">if</span><span style="color: #000000;"> </span><span style="color: #1967d2;">isinstance</span><span style="color: #37474f;">(events,</span><span style="color: #000000;"> </span><span style="color: #1967d2;">dict</span><span style="color: #37474f;">)</span><span style="color: #000000;"> </span><span style="color: #37474f;">and</span><span style="color: #000000;"> </span><span style="color: #188038;">"data"</span><span style="color: #000000;"> </span><span style="color: #1967d2;">in</span><span style="color: #000000;"> </span><span style="color: #37474f;">events:</span></p>
<p><span style="color: #000000;"> </span><span style="color: #37474f;">events</span><span style="color: #000000;"> </span><span style="color: #37474f;">=</span><span style="color: #000000;"> </span><span style="color: #37474f;">events[</span><span style="color: #188038;">"data"</span><span style="color: #37474f;">]</span></p>
<p><span style="color: #000000;"> </span><span style="color: #1967d2;">for</span><span style="color: #000000;"> </span><span style="color: #37474f;">evt</span><span style="color: #000000;"> </span><span style="color: #1967d2;">in</span><span style="color: #000000;"> </span><span style="color: #37474f;">events:</span></p>
<p><span style="color: #000000;"> </span><span style="color: #37474f;">enqueue(evt)</span><span style="color: #000000;"> </span><span style="color: #b80672;"># your idempotent worker will dedupe by eventId</span></p>
<p><span style="color: #000000;"> </span><span style="color: #1967d2;">return</span><span style="color: #000000;"> </span><span style="color: #37474f;">Response(status=</span><span style="color: #c5221f;">200</span><span style="color: #37474f;">)</span></p>
Use a unique index or SETNX to prevent double-processing on eventId. Keep raw payloads for replay. See the v3 signature steps for the exact concatenation and HMAC method.
Bringing it all together
Designing reliable HubSpot webhooks isn’t “set a URL and hope.” It’s:
- Idempotent by default (eventId/unique keys).
- Queue-first with a DLQ and replay path.
- Backoff-aware of HubSpot’s retry windows and your own rate budgets.
- Observable end-to-end (HubSpot logs + your telemetry).
- Secure with v3 signature verification and tight time windows.
Do this, and your integrations will survive failures—without duplicates, dropped events, or mystery outages.
References (key behavior and setup)
- Workflows “Send a webhook” — setup, auth, rate limit (BETA), testing & 3-day retry policy with 4xx/429 nuances.
- Inbound “When a webhook is received” — enrollment, mapping, JSON content type.
- Webhooks API — subscriptions and event payload fields (incl. eventId, attemptNumber).
- Webhooks API retry logic — 10 retries over 24 hours with delay randomization.
- Signature v3 — X-HubSpot-Signature-v3, X-HubSpot-Request-Timestamp, HMAC-SHA256 + Base64.
Pro tip 1: keep “HubSpot webhooks” in your internal docs and commit messages the same way you’d tag an API—clear names, explicit contracts, and a plan for the day something goes sideways. Your future self will thank you.
Pro tip 2: With HubSpot webhooks, it is also possible to trigger actions based on property values. Each webhook event delivered by HubSpot includes two key attributes: propertyName and propertyValue. By validating these values against specific requirements, you can initiate the processing of your business rule chain and ensure that downstream systems react only when the right conditions are met.