Build and harden HubSpot v3 webhook handlers that survive production: HMAC-SHA256
signature verification, Redis SET NX deduplication, async batch processing with
immediate 200 ACK, dead-letter queuing for permanent failures, and event-ordering
guards for property-change streams. Use when implementing HubSpot webhooks for the
first time, hardening an existing handler against retry storms or duplicate
processing, debugging signature verification failures, or designing a reliable
event pipeline for contact, company, or deal change events. Trigger with "hubspot
webhook", "hubspot signature verification", "hubspot webhook dedup", "hubspot
webhook retry storm", "hubspot event handler", "hubspot property change webhook",
"hubspot list membership webhook", "hubspot dead letter queue".
Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.
Quelldateien prüfen
Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.
Mit Codex oder Claude installieren Kopieren Sie diesen Prompt, fügen Sie ihn in Codex, Claude oder einen anderen Assistant ein und lassen Sie die Skill-Seite prüfen und installieren.
Ein direkter Befehl überspringt den Prüf-Prompt. Prüfen Sie die Quelle, bevor Sie ihn ausführen.
Build and harden HubSpot v3 webhook handlers that survive production: HMAC-SHA256
signature verification, Redis SET NX deduplication, async batch processing with
immediate 200 ACK, dead-letter queuing for permanent failures, and event-ordering
guards for property-change streams. Use when implementing HubSpot webhooks for the
first time, hardening an existing handler against retry storms or duplicate
processing, debugging signature verification failures, or designing a reliable
event pipeline for contact, company, or deal change events. Trigger with "hubspot
webhook", "hubspot signature verification", "hubspot webhook dedup", "hubspot
webhook retry storm", "hubspot event handler", "hubspot property change webhook",
"hubspot list membership webhook", "hubspot dead letter queue".
Receive and process HubSpot webhook events reliably at production scale. This is not a walkthrough for getting your first event — it is the handler code your integration runs when HubSpot delivers 100 events in a single payload at 2am, when a misconfigured proxy silently strips your signature header, when a 3-day outage causes events to arrive in a burst after recovery, and when a rapid sequence of property updates arrives reversed because HubSpot sends in delivery order rather than chronological order.
The six production failures this skill prevents:
Signature verification bypass — skipping or misconfiguring the HMAC-SHA256 check on X-HubSpot-Signature-v3 allows any party to send spoofed webhook payloads to your endpoint. One misconfigured load balancer or proxy that strips the X-HubSpot-Signature-v3 header silently disables all security — your handler returns 200 to unauthenticated requests without knowing it.
Duplicate delivery — HubSpot retries unacknowledged webhooks (non-200 response or timeout) for up to 3 days with exponential backoff. If your handler crashes after processing but before responding, the same contact-update event creates duplicate CRM mutations downstream. Redis SET NX on the event ID is the reliable guard.
No replay API — events are permanently lost after the 3-day retry window — if your handler is down for more than 3 days, HubSpot drops those events permanently. There is no replay endpoint. Dead-letter queues and recovery runbooks are your only mitigation.
Batch event explosion — a single webhook delivery contains up to 100 events. Synchronous processing of all 100 within the HTTP request context times out (HubSpot timeout: 5 seconds) and returns 5xx, which triggers a retry storm. The correct pattern is to ACK immediately with 200, enqueue the batch, and process asynchronously.
Property change ordering — HubSpot sends contact.propertyChange events in delivery order, not chronological order. A fast property update followed by a slow one can arrive reversed: your handler sees the newer value first, then overwrites it with the older value. Sequence guards on occurredAt are required.
List-membership scope mismatch — subscribing to contact.propertyChange for lifecyclestage does not automatically deliver list-membership changes. Those require a separate subscription to the list-membership event type and a separate oauth scope or crm.lists.read scope on the app.
Prerequisites
Node.js 18+ (TypeScript examples) or Python 3.10+
Express 4.x (or any HTTP server that can expose a raw body buffer for HMAC verification)
Redis 6+ (for SET NX deduplication)
A message queue: BullMQ (Redis-backed), RabbitMQ, or SQS (for async batch processing)
HubSpot app client secret (Settings → App → Client Secret) — not the access token
HUBSPOT_CLIENT_SECRET environment variable set in your runtime
For list-membership events: crm.lists.read scope granted to your app
Instructions
Build in this order. Each section neutralizes one production failure mode.
1. Signature verification (neutralizes spoofing and proxy bypass)
HubSpot v3 signatures use HMAC-SHA256 over the concatenation of your client secret, HTTP method, full request URI (including query string), raw request body, and the timestamp from X-HubSpot-Request-Timestamp. You must compute this over the raw body bytes, not a parsed JSON string. Any body middleware that re-serializes JSON will produce a signature mismatch.
The resulting hex digest must match the value in X-HubSpot-Signature-v3.
Timestamp tolerance: reject any request where abs(now - X-HubSpot-Request-Timestamp) > 300 seconds (5 minutes). This prevents replay attacks.
import { createHmac } from"crypto";
importtype { Request, Response, NextFunction } from"express";
constSIGNATURE_TOLERANCE_MS = 5 * 60 * 1000; // 5 minutesexportfunctionverifyHubSpotSignature(req: Request,
res: Response,
next: NextFunction,
): void {
const signature = req.headers["x-hubspot-signature-v3"] asstring | undefined;
const timestamp = req.headers["x-hubspot-request-timestamp"] asstring | undefined;
// Reject if either header is missing — do NOT silently passif (!signature || !timestamp) {
res.status(403).json({
error: "missing_signature",
detail: "X-HubSpot-Signature-v3 or X-HubSpot-Request-Timestamp header absent",
});
return;
}
// Reject stale requests — prevents replay attacksconst requestAge = Math.abs(Date.now() - parseInt(timestamp, 10));
if (requestAge > SIGNATURE_TOLERANCE_MS) {
res.status(403).json({
error: "timestamp_out_of_window",
detail: `Request is ${Math.round(requestAge / 1000)}s old; max is 300s`,
});
return;
}
// Build the signature input string// rawBody must be set by express.raw() middleware — NOT express.json()constrawBody: Buffer = (req asany).rawBody;
if (!rawBody) {
console.error("rawBody not available — check express.raw() middleware ordering");
res.status(500).json({ error: "misconfigured_middleware" });
return;
}
const method = req.method.toUpperCase();
// Full URI including query stringconst uri = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
const signingInput = `${method}${uri}${rawBody.toString("utf8")}${timestamp}`;
const expected = createHmac("sha256", process.env.HUBSPOT_CLIENT_SECRET!)
.update(signingInput, "utf8")
.digest("hex");
// Constant-time comparison to prevent timing attacksconst receivedBuf = Buffer.from(signature, "hex");
const expectedBuf = Buffer.from(expected, "hex");
if (
receivedBuf.length !== expectedBuf.length ||
!crypto.timingSafeEqual(receivedBuf, expectedBuf)
) {
console.warn("HubSpot signature mismatch", {
expected: expected.slice(0, 8) + "...",
received: signature.slice(0, 8) + "...",
uri,
timestamp,
});
res.status(403).json({ error: "invalid_signature" });
return;
}
// Attach parsed body for the route handler
(req asany).hubspotEvents = JSON.parse(rawBody.toString("utf8"));
next();
}
Critical: configure Express to capture the raw body. Body middleware that calls JSON.stringify(JSON.parse(...)) re-serializes the body and breaks signature verification:
import express from"express";
const app = express();
// Use raw() for the webhook route ONLY — not json()
app.use(
"/webhooks/hubspot",
express.raw({ type: "application/json", limit: "1mb" }),
(req, _res, next) => {
// Preserve the raw buffer before any parsing
(req asany).rawBody = req.bodyasBuffer;
next();
},
);
2. Redis SET NX deduplication (neutralizes duplicate delivery)
HubSpot guarantees at-least-once delivery. Every event object carries a unique eventId. Use Redis SET NX (set if not exists) with a 24-hour TTL as an idempotency gate. Process the event only if the SET NX succeeds; skip it if the key already exists.
importtype { Redis } from"ioredis";
constDEDUP_TTL_SECONDS = 86_400; // 24 hours — HubSpot retries for up to 3 daysasyncfunctionisNewEvent(redis: Redis, eventId: number): Promise<boolean> {
const key = `hubspot:event:${eventId}`;
// SET key 1 EX 86400 NX — returns "OK" if set, null if already existsconst result = await redis.set(key, "1", "EX", DEDUP_TTL_SECONDS, "NX");
return result === "OK";
}
asyncfunctionprocessEventIfNew(redis: Redis,
event: HubSpotEvent,
handler: (event: HubSpotEvent) => Promise<void>,
): Promise<void> {
const isNew = awaitisNewEvent(redis, event.eventId);
if (!isNew) {
console.debug("Skipping duplicate event", { eventId: event.eventId, type: event.subscriptionType });
return;
}
try {
awaithandler(event);
} catch (err) {
// If processing fails, delete the dedup key so retries can reprocess// Only do this for transient failures — not for permanent business errorsawait redis.del(`hubspot:event:${event.eventId}`);
throw err;
}
}
3. Immediate ACK with async batch processing (neutralizes retry storms)
HubSpot expects a 200 response within 5 seconds. For a batch of 100 events, synchronous processing will exceed this window under any real load. The correct pattern is to return 200 immediately, enqueue the batch, and process in a background worker.
4. Dead-letter queue for permanent failures (neutralizes silent event loss)
Events that exhaust all retry attempts must land in a dead-letter structure where they can be inspected, replayed, or alerted on. BullMQ's failed job store provides this out of the box, but you need explicit monitoring.
import { Worker, typeJob } from"bullmq";
// Dead-letter handler — called when a job exhausts all attemptsasyncfunctiononJobFailed(job: Job | undefined, err: Error): Promise<void> {
if (!job) return;
if (job.attemptsMade < (job.opts.attempts ?? 1)) return; // still has retries remaining// This job is permanently dead — route to DLQconst dlqKey = `hubspot:dlq:${job.name}`;
await redis.lpush(
dlqKey,
JSON.stringify({
jobId: job.id,
eventId: job.data.eventId,
portalId: job.data.portalId,
objectId: job.data.objectId,
subscriptionType: job.data.subscriptionType,
occurredAt: job.data.occurredAt,
failedAt: Date.now(),
error: err.message,
attempts: job.attemptsMade,
}),
);
// Alert — use your notification channelconsole.error("HubSpot event permanently failed", {
jobId: job.id,
eventId: job.data.eventId,
type: job.data.subscriptionType,
error: err.message,
});
}
const worker = newWorker(
"hubspot-events",
async (job) => {
awaitprocessEventIfNew(redis, job.data, routeEvent);
},
{
connection: { host: process.env.REDIS_HOST, port: parseInt(process.env.REDIS_PORT ?? "6379") },
concurrency: 10, // process up to 10 events in parallel
},
);
worker.on("failed", onJobFailed);
DLQ replay pattern — when your handler is recovered and you need to reprocess failed events:
asyncfunctionreplayDeadLetterQueue(redis: Redis,
subscriptionType: string,
limit = 100,
): Promise<number> {
const dlqKey = `hubspot:dlq:${subscriptionType}`;
let replayed = 0;
for (let i = 0; i < limit; i++) {
const raw = await redis.rpop(dlqKey);
if (!raw) break;
const dead = JSON.parse(raw) as { eventId: number; [key: string]: unknown };
// Delete the dedup key so the replayed event is treated as newawait redis.del(`hubspot:event:${dead.eventId}`);
await eventQueue.add(subscriptionType, dead, {
jobId: `hubspot-replay-${dead.eventId}-${Date.now()}`,
});
replayed++;
}
console.info(`Replayed ${replayed} dead-letter events for ${subscriptionType}`);
return replayed;
}
HubSpot delivers contact.propertyChange events in delivery order, not chronological order. When a contact's property changes twice in rapid succession, the second change can arrive before the first. If you apply them in delivery order, you overwrite a newer value with an older one.
Guard every write operation with an occurredAt comparison:
typePropertyVersion = { value: string; occurredAt: number };
asyncfunctionapplyPropertyChange(redis: Redis,
objectId: number,
propertyName: string,
newValue: string,
occurredAt: number,
): Promise<void> {
const versionKey = `hubspot:prop:${objectId}:${propertyName}`;
// Read the last-applied versionconst existingRaw = await redis.get(versionKey);
constexisting: PropertyVersion | null = existingRaw ? JSON.parse(existingRaw) : null;
if (existing && existing.occurredAt >= occurredAt) {
console.debug("Ignoring stale property change", {
objectId,
propertyName,
existingTimestamp: existing.occurredAt,
incomingTimestamp: occurredAt,
});
return;
}
// Safe to apply — this is the newest value seen for this propertyawait redis.set(
versionKey,
JSON.stringify({ value: newValue, occurredAt }),
"EX",
7 * 86_400, // 7-day TTL — keep long enough to guard burst redeliveries
);
// Now apply the change to your downstream systemawaitupdateContactProperty(objectId, propertyName, newValue);
}
contact.propertyChange for lifecyclestage does NOT deliver list-membership changes. These require a separate subscription. Use the webhook subscription API to register:
# Register a contact.propertyChange subscription
curl -X POST "https://api.hubapi.com/webhooks/v3/{appId}/subscriptions" \
-H "Authorization: Bearer $HUBSPOT_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"eventType": "contact.propertyChange",
"propertyName": "lifecyclestage",
"active": true
}'# Register a list-membership subscription (separate subscription required)# Note: HubSpot does not expose a direct list.membershipChange event type.# Use contact.propertyChange for hs_all_contact_vids or workflow enrollment events,# or poll GET /crm/v3/lists/{listId}/memberships for list-driven automation.
Verify active subscriptions before debugging delivery: