Triggers & webhooks
Hand-rolled HubSpot webhook endpoints all fail the same three ways: the signature check gets skipped, duplicates get processed twice, and one event burst takes down everything else sharing the server. An HS-X trigger is the same idea with the failure modes owned by the runtime — declared in one block, typed end to end, running in your own Cloudflare account.
TL;DR — Declare a trigger with worker.trigger: an event type like contact.propertyChange.email, a dedup mode, and a typed handler. The runtime exposes the webhook endpoint on your Worker, verifies HubSpot's v3 signature with your app secret, drops duplicate delivery ids, queues bursts, and hands your handler the event and install context. The worked example is Email Guard's email-changed trigger, which re-scores deliverability whenever an address changes. Point your app's webhook subscriptions at the trigger's endpoint.
Events are capabilities, not endpoints
A HubSpot webhook subscription has two halves. HubSpot's half is configuration: which event types your app wants, delivered where. Your half is an HTTPS endpoint that has to verify signatures, survive duplicates, absorb bursts, and respond fast enough that HubSpot does not mark you unhealthy.
HS-X owns your half completely and names the other half for you. The handler, the endpoint at /webhooks/hubspot/<trigger-id>, the signature check, dedup, and queueing all belong to the runtime. The HubSpot half — the webhook subscription in your app's configuration — you still declare in the app's webhooks settings today, pointing it at the trigger's endpoint with the matching eventType. Generating that subscription config automatically from your trigger declarations is on the roadmap; until it lands, the two halves are one string apart.
The handler receives the same HandlerContext every HS-X capability gets: typed input (the event), the install for the portal that fired it, an authenticated ctx.hubspot client scoped to that install, and a structured logger.
A real trigger
Email Guard validates addresses through a workflow today: a contact enrolls, validate-email runs, the verdict lands on the record. What that path misses is staleness. A contact edits their email six months later, and the stored verdict now describes an address that no longer exists. The email-changed trigger closes the gap by re-running validation the moment the property changes, no workflow enrollment required.
A complete declaration — hs-x check validates it before anything deploys:
worker.trigger("email-changed", {
eventType: "contact.propertyChange.email",
dedup: "best-effort",
async handler({ input, env, hubspot, logger }) {
const contactId = String(input.objectId);
const contact = await hubspot.crm.objects.contacts.get(contactId, {
properties: ["email"],
});
const email = contact.properties.email ?? "";
if (email === "") return { accepted: true };
const res = await fetch("https://api.emailcheck.example/v1/verify", {
method: "POST",
headers: {
authorization: `Bearer ${String(env.EMAILCHECK_API_KEY)}`,
"content-type": "application/json",
},
body: JSON.stringify({ email }),
});
const verdict = (await res.json()) as { status: string; score: number };
await hubspot.crm.objects.contacts.update(contactId, {
properties: {
email_health_status: verdict.status,
email_health_score: String(verdict.score),
},
});
logger.info("re-verified", { contactId, status: verdict.status });
return { accepted: true };
},
});The eventType follows HubSpot's subscription naming: <object>.<change> with an optional property suffix, so contact.propertyChange.email fires when any contact's email property changes, and contact.creation fires on new contacts. HubSpot delivers events in batches; the runtime unpacks the batch and invokes your handler per event, each carrying fields like objectId, portalId, subscriptionType, and occurredAt.
The handler writes the same email_health_status and email_health_score properties the validate-email action writes, so the email-health card renders the fresh verdict no matter which path produced it. And because the properties it writes are different from the one it subscribes to, it cannot re-trigger itself: a loop worth checking for on any propertyChange trigger that writes back to the same object.
Signatures are verified before your code runs
Every request to the webhook endpoint is checked against HubSpot's v3 request signature using your app's client secret. Wrong signature, stale timestamp, or no signature at all: the request is rejected before your handler is invoked. You write zero verification code, and more importantly, you cannot forget to.
This matters because the skipped signature check is the classic webhook vulnerability. An unverified endpoint lets anyone on the internet inject fake CRM events into your system with a single curl command.
Duplicates, bursts, and ordering
HubSpot's delivery contract is at-least-once. Duplicates are not a bug to be surprised by; they are the normal case to design for. On a deploy with the tenant data plane (any linked deploy), delivery ids are claimed in your own tenant D1, and the dedup setting decides how hard that guarantee is:
"best-effort"records delivery ids and drops repeats, accepting a small window where a duplicate can slip through under concurrency. Lowest latency; right for handlers that are cheap to run twice."strict"claims the delivery id atomically before invoking, trading a write on the hot path for a hard guarantee — a duplicate cannot double-fire, even across isolates. Right for handlers with side effects you never want repeated.
On an unlinked deploy there is no dedup store at all, so every signed delivery dispatches — design those handlers to tolerate redelivery. And in either mode, deliveries dispatch as they arrive rather than through an ordered queue, so events are not a strictly ordered log. Write handlers that treat each event as an independent fact, reading current state through ctx.hubspot when the decision needs it, rather than assuming event N arrived before event N+1. The email-changed handler above already does this: it reads the address back from the record instead of trusting the event payload, so a late or re-ordered delivery still validates whatever the contact's email is now.
Test the loop
hs-x check # validates the trigger declaration
hs-x dev # local endpoint + live logs; fire test events at it
hs-x deploy # serves the real endpoint for your webhook subscriptionIn hs-x dev the trigger's endpoint runs locally with the same verification path, so you can replay a real contact.propertyChange.email payload and watch the handler's structured logs before anything ships. The local dev guide covers the loop; the monitoring guide covers tracing an event from delivery to handler to HubSpot API call in production.
