CRM webhooks look like the easy ones. No money moves, no five-second budget, no subscription gets deleted if you are slow. A contact changed, go update your copy.
Then a customer tells you their lifecycle stage in your app says Lead and in HubSpot it says Customer, and it has been wrong for two weeks, and every event involved was processed exactly once.
Business-record webhooks fail differently from payment webhooks. The volume is update-heavy rather than event-heavy, the same object changes many times in a short window, and the ordering guarantees are weaker than anyone reading the payload would assume.
TL;DR
- "HubSpot does not guarantee that you'll receive these notifications in the order they occurred"
- Notifications arrive as arrays, not single objects. Batches can hold up to 100
- The response budget is 5 seconds, for the whole batch
- Failed notifications retry up to 10 times over 24 hours, which widens the reordering window
eventIdis documented as "not guaranteed to be unique," so it is the wrong idempotency key- Order on
occurredAt. Dedupe on a composite. Never order on arrival time
The field that looks like the answer
Coming from Stripe, the reflex is to find the event ID, put a unique index on it, and move on. HubSpot's payload has eventId right there.
The documentation says: eventId is "The ID of the event that triggered this notification. This value is not guaranteed to be unique."
That is an unusually direct warning and it is easy to skim past. A unique index on eventId will look correct in testing and will, at some point in production, either reject an event you needed or accept one you already handled. Neither failure announces itself.
The two fields you can rely on are occurredAt, "When this event occurred as a millisecond timestamp," and attemptNumber, "Starting at 0, which number attempt this is to notify your service of this event."
attemptNumber is the useful diagnostic. Anything above 0 means this is a retry, which means your endpoint failed earlier, which means events are now arriving out of the order they happened in.
Batching changes the shape of the handler
HubSpot does not send one notification per request: "you should expect to receive an array of objects in a single request. The batch size can vary, but will be under 100 notifications."
Three consequences.
Your handler is a loop, not a function. The 5-second budget covers the entire array, so a per-item database round trip at 30ms crosses the budget at around 160 items even before the array reaches 100 with any other work attached.
Partial failure has no vocabulary in HTTP. If item 40 of 90 throws, you cannot tell HubSpot "the first 39 landed." You return one status for the batch. Return non-2xx and all 90 come back on the retry; return 200 and the 50 you did not reach are gone.
That forces the same conclusion as everywhere else: persist the whole array first, return 200, process afterwards. The batch write is one insert rather than 90, which also fixes the timing problem.
export async function POST(req: Request) {
const raw = await req.text();
if (!verifyHubSpotSignature(raw, req.headers)) {
return new Response("unauthorized", { status: 401 });
}
const notifications = JSON.parse(raw); // always an array
await db.insertBatch(notifications); // one durable write
return new Response(null, { status: 200 }); // inside 5s regardless of size
}
Why ordering breaks, and why dedupe cannot fix it
A sales rep edits a deal four times in ninety seconds. Four notifications. Your endpoint is briefly unhealthy and rejects the second one. HubSpot retries it, "up to 10 times" with "retries spread out over the next 24 hours, with varying delays between requests."
So notification two lands after notifications three and four. Your handler applies it. The deal's stage is now whatever it was at the second edit, and it will stay that way until somebody touches the record again.
Every notification was processed exactly once. Deduplication was working perfectly. The data is wrong anyway.
This is the distinction that matters: idempotency protects against processing the same event twice. Nothing about it protects against applying an older state on top of a newer one. They are different failures with different fixes, and CRM streams produce the second one far more often than payment streams do, because the same object gets updated repeatedly.
The fix is a monotonic guard in the write itself.
UPDATE contacts
SET lifecycle_stage = $1, hs_updated_at = $2
WHERE hubspot_id = $3
AND hs_updated_at < $2; -- refuse to apply stale state
Use occurredAt for $2, not your own NOW(). Your clock records when you received it, and reception order is exactly the thing that is unreliable. The wider treatment, including what to do for state machines with real invariants, is in You Received the Same Webhook Twice.
A dedupe key that actually holds
Since eventId is off the table, build a composite from fields that together identify one change:
CREATE TABLE processed_hubspot (
object_id BIGINT NOT NULL,
subscription_type TEXT NOT NULL,
occurred_at BIGINT NOT NULL,
PRIMARY KEY (object_id, subscription_type, occurred_at)
);
One object, one kind of change, one millisecond. Two genuinely distinct edits to the same field in the same millisecond would collide, which is a real but negligible risk for human-driven CRM edits and a real one for bulk imports. If you run large imports, add propertyName to the key.
This is worth stating plainly because it is a step down from what Stripe gives you. With a provider-issued unique event ID, deduplication is exact. With a composite, it is a very good heuristic. Write the choice down next to the table definition so the next person knows it was deliberate.
Bulk imports are the stress test
A customer importing 50,000 contacts generates 50,000 notifications in a short window, batched into arrays. This is when everything above stops being theoretical at the same time: the batches are at their largest, your database is at its busiest, the 5-second budget is at its tightest, and any failure produces retries that land hours later interleaved with live edits.
If you only ever test with hand-made single events, you have not tested the case that breaks. Send yourself a batch of 100 with timestamps deliberately shuffled and check that the final state is the one with the newest occurredAt, not the one that arrived last.
Where AnyHook fits
None of the ordering problem is transport. If HubSpot sends notification two after notification four, no relay can un-send it, and any product claiming otherwise is overselling.
What a relay changes is everything around it.
Point HubSpot at in.anyhook.net/you/hubspot and AnyHook returns 200 in under 50ms regardless of batch size, so the 5-second budget stops constraining how you process. Delivery to your endpoint retries on its own schedule rather than HubSpot's, so a brief outage does not widen the reordering window by 24 hours.
Beyond that:
- Every batch is stored with headers and body before delivery is attempted, so "what did we actually receive, and in what order" is answerable from a log rather than reconstructed from your database's current state
- Replay over a time range lets you re-run an import window against a fixed handler instead of asking the customer to import again
attemptNumberabove 0 stops being your only clue that something went wrong, because delivery failures are counted and alerted at 1, 5, and 20
Your handler still needs the version guard. That part is yours.
Takeaway
The CRM category rewards different reflexes than the payments category. Read the field documentation before you index anything, because HubSpot is unusually honest that its event ID will not carry the weight you want to put on it. Order on occurredAt, dedupe on a composite you chose deliberately, and guard every write against stale state.
If your handler currently does UPDATE ... WHERE id = $1 with no timestamp condition, it is not a matter of whether it has already written stale data. It is a matter of whether anyone has noticed.