Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 37 additions & 16 deletions packages/v1-ready/hubspot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,18 @@ for the full Tier 3 contract.
| Portal → integration routing | `findIntegrationByEntityExternalId` via friggCommands | Inbound `portalId` from the payload |
| Per-account business logic | Your bound `HUBSPOT_WEBHOOK` handler | Per-portal OAuth credentials (loaded by the worker) |

At request time HubSpot POSTs the whole app's events to the receiver. The receiver
verifies the v3 signature, then resolves each event's `portalId` to the owning Frigg
integration and enqueues a per-event `HUBSPOT_WEBHOOK` job. The queue worker hydrates
that integration with its own per-portal credentials and runs your handler.
At request time HubSpot POSTs the whole app's events to the receiver. The receiver is
**DB-free** (`useDatabase: false`): it verifies the v3 signature and enqueues one
`HUBSPOT_WEBHOOK_RESOLVE` job per event — no database access. In the queue worker (which
has the database), the resolve step looks up each event's `portalId` → owning integration
and re-enqueues a `HUBSPOT_WEBHOOK` job bound to that integration id. The worker hydrates
that integration with its own per-portal credentials and runs your handler. Keeping the
lookup in the worker is what lets the public receiver endpoint stay DB-free.

> **Make your `HUBSPOT_WEBHOOK` handler idempotent.** Delivery is at-least-once: the
> two SQS hops (resolve → webhook) and HubSpot's own retry-on-non-2xx mean a given
> event can be delivered more than once. Key your processing on a stable identifier
> (e.g. `objectId` + `subscriptionType` + `occurredAt`) so duplicates are no-ops.

### Enabling it on an integration

Expand Down Expand Up @@ -71,9 +79,19 @@ class HubSpotIntegration extends IntegrationBase {
}
```

The framework auto-mounts the receiver route at the integration's base path
(`POST /<integration-base>/webhooks`) — register that URL as the app's webhook target
in your HubSpot app settings.
The framework mounts the receiver route **under your binding key**, on its own
DB-free Lambda function:

```
POST /api/<integration-name>-integration/<bindingKey>/webhooks
```

So the binding `hubspotWebhooks` above yields
`POST /api/hubspot-integration/hubspotWebhooks/webhooks`. Pick a clean binding key
(e.g. `hubspot` → `.../hubspot/webhooks`) and register that URL as the app's webhook
target in your HubSpot app settings. Namespacing by the binding key means a second
module's webhooks extension (e.g. Clockwork) can live on the same integration without
colliding.

### Configuration

Expand All @@ -83,12 +101,15 @@ in your HubSpot app settings.

### What the bundle contributes

- **Route:** `POST /webhooks` → `HUBSPOT_WEBHOOK_RECEIVED`
- **`HUBSPOT_WEBHOOK_RECEIVED`** — default receiver: verifies the signature, resolves
each event's `portalId`, and queues matched events. Events whose portal does not map
to any integration are skipped (HubSpot broadcasts every portal's events to the app).
Events are resolved and enqueued in parallel; if a `portalId` resolves ambiguously
(one external ID owned by multiple integrations) the whole batch is rejected rather
than risk cross-tenant routing.
- **`HUBSPOT_WEBHOOK`** — default no-op; override it via `binding.handlers.HUBSPOT_WEBHOOK`
to run your per-event logic.
- `useDatabase: false` — the receiver route runs without a DB connection.
- **Route:** `POST /webhooks` (namespaced to `/{bindingKey}/webhooks`) → `HUBSPOT_WEBHOOK_RECEIVED`.
- **`HUBSPOT_WEBHOOK_RECEIVED`** — DB-free receiver: verifies the v3 signature and
enqueues one `HUBSPOT_WEBHOOK_RESOLVE` per event (in parallel). Events missing a
`portalId` are skipped. No database access.
- **`HUBSPOT_WEBHOOK_RESOLVE`** — queue event (worker, DB): reverse-looks up
`portalId` → integration id and re-enqueues a `HUBSPOT_WEBHOOK` bound to it. Events
whose portal maps to no integration are skipped; an ambiguous resolution (one external
id owned by multiple integrations) throws rather than risk cross-tenant routing.
- **`HUBSPOT_WEBHOOK`** — queue event (worker, hydrated integration): default no-op;
override via `binding.handlers.HUBSPOT_WEBHOOK` to run your per-event logic with the
correct per-account context.
112 changes: 52 additions & 60 deletions packages/v1-ready/hubspot/extensions/webhooks/handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,11 @@ const { verifyHubSpotSignature } = require('./signature-verifier');
const { findIntegrationByPortalId } = require('./lookup');

/**
* Receiver handler for `POST /webhooks`.
*
* Verifies HubSpot's v3 signature using `process.env.HUBSPOT_CLIENT_SECRET`
* (the same env var the api-module already reads for OAuth), iterates the
* inbound batch, resolves each event's `portalId` to a Frigg integration via
* the platform-neutral reverse lookup, and enqueues a per-event
* `HUBSPOT_WEBHOOK` job for the matched integration. Events whose portal
* does not map to any integration are silently skipped (HubSpot sends events
* for the whole app, not per-account).
*
* Per the Tier 3 contract, this function is bound as a plain function on the
* integration instance — `this` is the IntegrationBase instance and exposes
* `commands.findIntegrationByEntityExternalId` and `queueWebhook`.
*
* @this {import('@friggframework/core').IntegrationBase}
* @param {Object} args
* @param {import('express').Request} args.req
* @param {import('express').Response} args.res
* @returns {Promise<void>}
* Receiver for `POST /webhooks` — DB-free. Verifies the v3 signature, then
* enqueues one HUBSPOT_WEBHOOK_RESOLVE per event (carrying the raw payload incl.
* portalId). It does NOT resolve the owning integration — that needs the DB and
* happens in the worker, which is what lets the extension declare
* useDatabase: false. Events missing a portalId are skipped.
*/
async function onHubSpotWebhookReceived({ req, res }) {
const verification = verifyHubSpotSignature({
Expand All @@ -37,69 +23,75 @@ async function onHubSpotWebhookReceived({ req, res }) {

const events = Array.isArray(req.body) ? req.body : [];

// Phase 1 — resolve every portalId in parallel before queueing anything.
// Two reasons for two-phase: (a) HubSpot batches can be large and each
// lookup is an independent DB round-trip; running them serially blows
// the HubSpot response budget; (b) if any lookup is ambiguous, the core
// command throws by design — we want that throw to land BEFORE any
// queueWebhook fires so we never leave the batch in a partial-enqueue
// state that HubSpot's retry would then duplicate.
const resolutions = await Promise.all(
// Parallel: each is an independent SQS send and HubSpot batches can be large.
const outcomes = await Promise.all(
events.map(async (evt, i) => {
const portalId = evt && evt.portalId;
if (portalId === undefined || portalId === null) {
console.warn(
`[hubspot-webhooks] event[${i}] missing portalId ` +
`(subscriptionType=${evt && evt.subscriptionType}); skipping`
);
return null;
return 'skipped';
}
const integrationId = await findIntegrationByPortalId(
this,
portalId
);
if (!integrationId) return null;
return { integrationId, evt };
})
);

// Phase 2 — enqueue matched events in parallel. Every match resolved
// cleanly above, so any failure here is genuinely SQS-side and should
// surface to HubSpot for retry.
const matches = resolutions.filter(Boolean);
await Promise.all(
matches.map(({ integrationId, evt }) =>
this.queueWebhook({
integrationId,
await this.queueWebhook({
event: 'HUBSPOT_WEBHOOK_RESOLVE',
body: evt,
event: 'HUBSPOT_WEBHOOK',
})
)
});
return 'queued';
})
);

const queued = outcomes.filter((o) => o === 'queued').length;
res.status(200).json({
received: events.length,
queued: matches.length,
skipped: events.length - matches.length,
queued,
skipped: events.length - queued,
});
}

/**
* HUBSPOT_WEBHOOK_RESOLVE — runs in the queue worker (DB available) on a dry
* integration instance. Reverse-looks up portalId → owning integration, then
* re-enqueues HUBSPOT_WEBHOOK bound to that id (the worker hydrates the real
* record on that hop). Keeping the lookup here is what lets the receiver stay
* DB-free. Ambiguous resolution propagates rather than risk cross-tenant misrouting.
*/
async function onHubSpotWebhookResolve({ data }) {
const body = data && data.body;
const portalId = body && body.portalId;
if (portalId === undefined || portalId === null) {
console.warn(
'[hubspot-webhooks] resolve: event missing portalId; skipping'
);
return;
}

const integrationId = await findIntegrationByPortalId(this, portalId);
if (!integrationId) {
console.warn(
`[hubspot-webhooks] resolve: no integration for portalId=${portalId}; skipping`
);
return;
}

await this.queueWebhook({
event: 'HUBSPOT_WEBHOOK',
integrationId,
body,
});
}

/**
* Default per-event handler. Integration consumers override this via
* `binding.handlers.HUBSPOT_WEBHOOK = 'methodName'`; the default is a no-op
* so a misconfigured binding fails predictably rather than crashing the
* queue worker.
*
* @this {import('@friggframework/core').IntegrationBase}
* @param {Object} args
* @param {Object} args.data
* @returns {Promise<void>}
* HUBSPOT_WEBHOOK — runs in the worker with the owning integration hydrated.
* Default no-op; consumers override via binding.handlers.HUBSPOT_WEBHOOK.
*/
async function onHubSpotWebhook({ data }) {
// intentional no-op — override via binding.handlers.HUBSPOT_WEBHOOK
// no-op — override via binding.handlers.HUBSPOT_WEBHOOK
}

module.exports = {
onHubSpotWebhookReceived,
onHubSpotWebhookResolve,
onHubSpotWebhook,
};
21 changes: 12 additions & 9 deletions packages/v1-ready/hubspot/extensions/webhooks/index.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
const {
onHubSpotWebhookReceived,
onHubSpotWebhookResolve,
onHubSpotWebhook,
} = require('./handlers');

/**
* HubSpot Webhooks — Tier 3 Integration Extension bundle.
*
* Contributes a single receiver route (`POST /webhooks`) plus two events:
* DB-free receiver (`useDatabase: false`): HUBSPOT_WEBHOOK_RECEIVED verifies the
* v3 signature and enqueues a resolve job. The portal→integration lookup needs
* the DB, so it runs in the worker (HUBSPOT_WEBHOOK_RESOLVE), which re-enqueues
* HUBSPOT_WEBHOOK bound to the resolved integration. Consumers override
* HUBSPOT_WEBHOOK via binding.handlers.
*
* HUBSPOT_WEBHOOK_RECEIVED — bound to the route; verifies signature,
* resolves portalId → integrationId, queues
* one HUBSPOT_WEBHOOK per matched event.
* HUBSPOT_WEBHOOK — default no-op; integrations override via
* `binding.handlers.HUBSPOT_WEBHOOK`.
*
* See the binding contract at:
* https://github.com/friggframework/frigg/blob/next/packages/core/integrations/EXTENSIONS.md
* Contract: https://github.com/friggframework/frigg/blob/next/packages/core/integrations/EXTENSIONS.md
*/
module.exports = {
name: 'hubspot-webhooks',
useDatabase: false,
Comment thread
d-klotz marked this conversation as resolved.
routes: [
{
path: '/webhooks',
Expand All @@ -31,6 +30,10 @@ module.exports = {
type: 'LIFE_CYCLE_EVENT',
handler: onHubSpotWebhookReceived,
},
HUBSPOT_WEBHOOK_RESOLVE: {
type: 'LIFE_CYCLE_EVENT',
handler: onHubSpotWebhookResolve,
},
HUBSPOT_WEBHOOK: {
type: 'LIFE_CYCLE_EVENT',
handler: onHubSpotWebhook,
Expand Down
2 changes: 1 addition & 1 deletion packages/v1-ready/hubspot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@
"prettier": "^2.7.1"
},
"dependencies": {
"@friggframework/core": "^2.0.0-next.88"
"@friggframework/core": "^2.0.0-next.89"
}
}
Loading
Loading