# 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.

Here is the whole path one `email-changed` delivery travels — the lanes are the same as the [architecture map](/docs/guides/getting-started), with Cloudflare split into the edge that owns your endpoint and the Worker that runs your handler. Each step below has a section of its own.

## 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:

```ts
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

```sh
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 subscription
```

In `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](/docs/guides/dev-mode) covers the loop; the [monitoring guide](/docs/guides/monitoring) covers tracing an event from delivery to handler to HubSpot API call in production.

**Where next**

- [Syncs: scheduled and push ingestion](/docs/guides/syncs)
- [Local dev: replay events against your handler](/docs/guides/dev-mode)
- [Rate limits: how the budget is shared](/docs/guides/rate-limits-in-production)

