System Landscape
contact.creation webhook. Chert dispatches the send through its
delivery infrastructure and posts replies back into HubSpot as
Communication engagements on the contact timeline.
There is no shared infrastructure between HubSpot accounts. Every
request is verified against the per-account signing secret before
any work runs.
Components
Server routes
| Route | Method | Surface | Auth |
|---|---|---|---|
/api/hubspot/oauth/install | GET | Browser entry | Signed state cookie |
/api/hubspot/oauth/callback | GET | OAuth redirect target | Signed state cookie + code exchange |
/api/hubspot/uninstall | POST | app.uninstalled webhook | HubSpot Signature v3 |
/api/hubspot/webhook | POST | contact.creation webhook | HubSpot Signature v3 |
/api/hubspot/send | POST | Workflow custom action callback | HubSpot Signature v3 |
/api/hubspot/widget/context | GET | Sidebar card load | HubSpot Signature v3 |
/api/hubspot/widget/send | POST | Sidebar card send | HubSpot Signature v3 |
/api/hubspot/widget/campaign-status | GET | Sidebar card campaign tab | HubSpot Signature v3 |
Server modules
| Module | Responsibility |
|---|---|
| OAuth | Build the install URL, exchange the authorization code, refresh the access token with a 60-second leeway. |
| Tenants | Lookup of per-account install state by HubSpot account ID. |
| Verify | HubSpot Signature v3 validation with a 5-minute timestamp skew bound. |
| Contact resolve | Match a HubSpot contact to a Chert lead by normalized phone. Auto-create on workflow paths when the account opts in. |
| Push inbound | Create a Communication engagement on the contact when a reply arrives. Per-account circuit breaker after five consecutive failures. |
Data model
| Table | Purpose |
|---|---|
hubspot_tenants | One row per connected HubSpot account. Holds OAuth tokens, the bound Chert project, the phone line override, auto-create and auto-send flags, and the inbound circuit-breaker counter. |
hubspot_send_events | Append-only delivery log keyed by tenant and idempotency key. Powers dedup, retry-safe responses, and the admin dashboard. |
leads, convo, campaigns, phone_lines | Existing Chert tables. The integration is a thin adapter on top. |
Outbound Send Sequence
- A workflow, the sidebar card, or the contact-creation webhook hits a Chert ingress route.
- The route reconstructs the full request URL and verifies the HubSpot Signature v3 header against the timestamp and the raw body.
- The route loads the tenant by HubSpot account ID. Inactive tenants short-circuit with
unknown or inactive tenant. - The route checks the idempotency key in
hubspot_send_events. A prior row returns its stored response verbatim. - The route resolves a Chert lead. Sidebar sends always auto-create; workflow sends auto-create only when the per-account flag is on.
- The route renders the message. The sidebar passes the operator’s text through unchanged. The workflow action passes a token-rendered string from HubSpot’s workflow editor. The contact-creation webhook renders the project’s CRM template against the new contact’s name and company.
- The route hands off to Chert’s internal send pipeline, which selects a phone line, enforces the 10-minute minimum gap, deduplicates within a 6-hour rolling window, and dispatches through Chert’s delivery infrastructure.
- The route writes the outcome to
hubspot_send_eventsand returns the response shape required by the caller.
Inbound Reply Sequence
- A recipient replies. Chert’s internal reply pipeline resolves the lead and stamps the inbound message on its conversation log.
- The reply-notification cron picks up the new inbound and fans out to email, Slack, the customer webhook (if configured), and the HubSpot push.
- The HubSpot push loads the tenant and confirms the lead carries a
hubspot_tenant_idandhubspot_contact_id. Without both, the push is a no-op. - The push obtains a valid access token, refreshing if the cached token is within 60 seconds of expiry.
- The push POSTs a Communication engagement to
/crm/v3/objects/communicationswithhs_communication_channel_type = SMS. - The push associates the new Communication to the contact via
/crm/v4/objects/communications/{id}/associations/default/contacts/{contactId}. The default association resolves the correct association type ID server-side. - On success, the per-tenant
inbound_consecutive_failurescounter resets andinbound_last_success_atis stamped. On failure, the counter increments. Five consecutive failures trip the breaker and silence the push until the next successful send through the tenant.
OAuth Token Refresh
HubSpot access tokens expire after roughly 30 minutes. Chert never refreshes ahead of time on a schedule; instead, every code path that calls HubSpot acquires a token through the same helper.| Step | Detail |
|---|---|
| 1 | Read the cached access token and expiry from the tenant row. |
| 2 | If expiry is more than 60 seconds in the future, return the cached token. |
| 3 | Otherwise, exchange the refresh token at HubSpot’s OAuth token endpoint. |
| 4 | Persist the new access token, refresh token, and expiry in hubspot_tenants. HubSpot rotates the refresh token on every refresh; storing the new value is required for subsequent calls to succeed. |
| 5 | Return the new access token. |
Synchronous versus Asynchronous Boundaries
| Action | Where it runs |
|---|---|
Sidebar card load (/widget/context) | Synchronous. Returns within one HTTP roundtrip. |
Sidebar card send (/widget/send) | Synchronous up to the dispatch handoff. |
Workflow custom action (/send) | Synchronous. HubSpot waits for the response and reads outputFields. |
Contact creation webhook (/webhook) | Synchronous up to dispatch. Always returns 200 to HubSpot to avoid retries on per-event errors. |
| Reply push to HubSpot | Asynchronous. Triggered by the reply-notify cron, never blocks operator notifications. |
| OAuth token refresh | Lazy. Triggered on the first call that finds the cached token within 60 seconds of expiry. |
Idempotency
| Path | Idempotency key | Window |
|---|---|---|
| Workflow custom action | HubSpot’s callbackId | Forever — same callbackId always returns the prior response. |
| Sidebar card send | widget:{tenantId}:{contactId}:{secondBucket} | One second. Collapses double-clicks; allows operator retries the next second. |
| Contact creation webhook | webhook:contact_creation:{objectId} | Forever — same contact creation event never re-fires. |
(phone_line, recipient, message).
See Also
- Configuration — settings exposed in the Chert console.
- Security — signature verification, token storage, scope rationale.
- Limits — rate limits, daily caps, retry semantics.

