Triggers & webhooks
Hand-rolled HubSpot webhook endpoints all fail the same two ways: the signature check gets skipped, and duplicates get processed twice. An HS-X trigger is the same idea with those 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 eventType label, an optional dedup mode, and a typed handler. The runtime exposes /webhooks/hubspot/<trigger-id> on your Worker, verifies HubSpot’s v3 signature with your app secret, drops duplicate delivery ids, and calls your handler once per delivery with input.events, the batch HubSpot sent. The handler runs inline; the 200 goes back to HubSpot after it returns. The worked example is Email Guard’s email-changed trigger. The HubSpot half, the webhooks component that points HubSpot at that URL, you still write by hand today.
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, and respond fast enough that HubSpot does not mark you unhealthy.
HS-X owns your half. The handler, the endpoint at /webhooks/hubspot/<trigger-id>, the signature check, and dedup all belong to the runtime. The HubSpot half is the webhooks component in your generated project, src/app/webhooks/webhooks-hsmeta.json, and you write it yourself today: settings.targetUrl is your Worker’s /webhooks/hubspot/<trigger-id>, and each subscription is an object like { "subscriptionType": "object.propertyChange", "objectType": "contact", "propertyName": "email" } in the crmObjects array (or the classic contact.propertyChange form in legacyCrmObjects). HubSpot allows one targetUrl per app, so an app with several triggers points HubSpot at one trigger endpoint and branches on subscriptionType inside that handler, or splits triggers across apps. Generating this file from worker.trigger declarations is on the roadmap; hs-x migrate already maps an existing subscription set onto triggers.
The handler receives the same HandlerContext every HS-X capability gets, with two differences worth knowing before you write one. input is the delivery, not a single event: { triggerId, deliveryId, events }, where events is the array HubSpot posted. And on this path the runtime derives only the portal id from the delivery; it does not bind the install, so ctx.hubspot cannot resolve an install token yet. Use ctx.logger, ctx.env, and your own HTTP calls inside a trigger, and hand any CRM write-back to a workflow action or a sync (the pattern below does exactly that).
Here is the whole path one email-changed delivery travels, with Cloudflare split into the edge that owns your endpoint and the Worker that runs your handler.
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-scoring the moment the property changes.
A complete declaration:
worker.trigger("email-changed", {
eventType: "contact.propertyChange.email",
dedup: "best-effort",
async handler({ input, env, logger }) {
const events = (
input.events as Array<{
objectId: number | string;
subscriptionType: string;
propertyName?: string;
propertyValue?: string;
}>
).filter((e) => e.subscriptionType.endsWith("propertyChange") && e.propertyName === "email");
for (const event of events) {
const email = event.propertyValue ?? "";
if (email === "") continue;
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 };
logger.info("re-verified", { contactId: String(event.objectId), status: verdict.status });
}
return { accepted: events.length, deliveryId: input.deliveryId };
},
});eventType is a label HS-X records in the manifest; it is not validated and is not sent to HubSpot. The subscription itself lives in the webhooks component described above. HubSpot delivers events in batches, an array per POST, and the runtime hands the whole batch to your handler once: input.events is the array of HubSpot event objects (objectId, portalId, subscriptionType, propertyName, propertyValue, occurredAt, eventId, attemptNumber, changeSource), and input.deliveryId is the id the dedup store tracks. Loop over input.events and skip the subscription types you do not care about, because one targetUrl receives everything the app subscribes to.
The handler above scores the new address and logs the verdict; it does not write it back, because ctx.hubspot is not bound to an install on the webhook path. To get the fresh verdict onto the record, keep the contact workflow from the workflow actions guide enrolled on “Contact property changed: Email”: validate-email runs with an install-bound client and writes email_health_status and email_health_score. Once install binding lands on the webhook path this handler can do that write itself; until then a trigger is the right place for side effects that live outside HubSpot (a queue, an audit log, your own database) and for anything a workflow cannot subscribe to.
A loop worth checking for on any propertyChange trigger that does write back: write to properties other than the one you subscribe to, or the trigger re-fires itself.
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 with 401 before your handler is invoked. You write zero verification code, and more importantly, you cannot forget to.
A deployed Worker that has no HSX_HUBSPOT_CLIENT_SECRET binding answers 503 to every delivery rather than accepting unsigned ones; hs-x deploy pushes that secret for OAuth apps. 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 linked deploy the runtime claims every delivery id in your tenant D1 with a 24-hour window and drops repeats for every trigger, whatever its dedup setting. The setting only changes the race window around that claim:
"best-effort"(the default) reads and writes the store and accepts a small window where two concurrent duplicates on the same isolate can both pass. Lowest contention; right for handlers that are cheap to run twice."strict"claims the delivery id synchronously in the isolate before the store read, so a concurrent duplicate cannot slip between the read and the write. 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. The delivery id is derived from the first event’s subscriptionId, objectId, and occurredAt when present, and from a hash of the body otherwise.
There is no queue between HubSpot and your handler: the runtime verifies, dedups, runs the handler, and returns 200 only after it finishes. A throw becomes a 500, which HubSpot retries. Two consequences. Keep handlers fast, since a slow endpoint is one HubSpot will eventually back off from. And treat each event as an independent fact rather than an ordered log; the email-changed handler above already does this by reading the new value from the event and never assuming event N arrived before event N+1. A trigger declared with a billing binding is gated before the handler runs: a denied delivery is acknowledged with 200 and dropped, so HubSpot never retries it into an unpaid install.
Test the loop
hs-x check # validates the project and app config
hs-x dev invoke email-changed --input '{"events":[{"objectId":3301452,"portalId":46993937,"subscriptionType":"contact.propertyChange","propertyName":"email","propertyValue":"mia@example.com","occurredAt":1756252800000}]}'
hs-x deploy # serves the real endpoint for your webhook subscriptionThe local dev server does not host /webhooks/*. hs-x dev invoke email-changed --input '<json>' runs the handler in-process with that batch (and signs the request with HSX_HUBSPOT_CLIENT_SECRET from .dev.vars when one is set, so the production verifier runs too). To see real deliveries, deploy once; while hs-x dev is running with a dev override, the deployed Worker verifies each HubSpot delivery and forwards it to your local handler through the tunnel. The local dev guide covers the loop; the monitoring guide covers tracing a delivery from the edge to your handler in production.
