HubSpot's native AI features are a starter kit. They are fine for "summarize this email" and "draft a follow-up". The moment you want production AI behaviour inside a HubSpot workflow — multi-step reasoning, custom retrieval, write-back to structured properties, with proper retries and audit logging — you are off the marketing brochure and into engineering territory. This post is what I wish I had read before we shipped our first HubSpot + OpenAI integration for a B2B SaaS RevOps team.
If you want the broader business case, AltheraCode (the studio I work with) has a published case study on this. I'm going to skip the business case here. This is engineer-to-engineer.
The four surfaces where AI plugs into a HubSpot stack
You have exactly four real integration surfaces. Everything you read about HubSpot AI patterns reduces to one of these.
- Workflow custom code actions. Node.js, runs serverless inside HubSpot, 20-second hard timeout, 100 MB memory cap. The most common path. Good for synchronous enrichment that fits in 20 seconds.
- Webhooks out of HubSpot. Workflow → POST to your service → your service does whatever → writes back via HubSpot CRM API. The right choice when you need more than 20 seconds, more than 100 MB, or anything that needs queueing.
- The CRM API directly, polled or event-driven via app subscriptions. You operate outside HubSpot entirely and treat HubSpot as a database with REST endpoints. Best for high-volume bulk operations.
- Conversations API and timeline events. Underused. Lets you write AI-generated content into the contact or deal timeline without touching structured properties. Excellent for "summaries" that should be auditable but not searchable.
Pick the surface that fits the SLA you need, not the one that feels familiar.
Pattern 1: lead-intent enrichment as a workflow custom code action
Most common pattern. Contact enters a workflow on some engagement threshold; you call OpenAI; you write a summary property back. Looks like this:
exports.main = async (event, callback) => {
const hubspot = require('@hubspot/api-client');
const OpenAI = require('openai');
const client = new hubspot.Client({ accessToken: process.env.HS_PRIVATE_APP_TOKEN });
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const contactId = event.object.objectId;
const contact = await client.crm.contacts.basicApi.getById(
contactId,
['email', 'company', 'jobtitle', 'linkedin_url', 'last_seen_url']
);
const prompt = `You write a single 4-sentence pre-call summary for an account executive.
Rules:
- Do NOT describe the company in marketing language.
- Reference exactly one specific, dated fact from the company's public footprint.
- Note the prospect's likely role in a buying committee.
- End with one concrete question the AE should ask first.
Inputs:
${JSON.stringify(contact.properties)}`;
const completion = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: prompt }],
temperature: 0.2,
max_tokens: 200,
});
const summary = completion.choices[0].message.content;
await client.crm.contacts.basicApi.update(contactId, {
properties: { ai_pre_call_summary: summary }
});
callback({ outputFields: { summary } });
};
Three notes that cost me time.
You must add the @hubspot/api-client and openai packages explicitly in the custom code action's package list. The HubSpot UI does this for you only if you discover the right dropdown. I missed it for two hours.
event.object.objectId is reliable. event.inputFields is not — workflow inputs that look populated in the UI sometimes arrive as undefined, especially after a property rename. Always re-fetch from the CRM API, do not trust the inputs.
Use temperature: 0.2 or lower for any structured writeback. At 0.7 you will get cute summaries with one outlier sentence per week that breaks downstream parsing.
Pattern 2: AI-summarized deal notes with property writeback
A call lands in Gong (or Fathom, or Granola — same shape). You want the prospect's stated objection, timeline, and decision criteria written into structured properties on the HubSpot deal.
The naive version writes a free-text summary into a single Note property. The production version uses three separate custom properties and a function-calling response so you can actually run pipeline analytics on them later.
const tool = {
type: 'function',
function: {
name: 'record_deal_signals',
description: 'Record structured signals from a sales call.',
parameters: {
type: 'object',
properties: {
stated_objection: { type: 'string' },
stated_timeline: {
type: 'string',
enum: ['no_timeline', '0_30_days', '30_90_days', '90_plus_days']
},
decision_criteria: {
type: 'array',
items: { type: 'string' }
}
},
required: ['stated_timeline', 'decision_criteria']
}
}
};
const completion = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{ role: 'system', content: 'You extract structured signals from sales call transcripts. Do not paraphrase. Quote.' },
{ role: 'user', content: transcript }
],
tools: [tool],
tool_choice: { type: 'function', function: { name: 'record_deal_signals' } },
temperature: 0.1
});
const signals = JSON.parse(completion.choices[0].message.tool_calls[0].function.arguments);
await client.crm.deals.basicApi.update(dealId, {
properties: {
stated_objection: signals.stated_objection || '',
stated_timeline: signals.stated_timeline,
decision_criteria: (signals.decision_criteria || []).join(' | ')
}
});
Two production lessons here.
Force tool_choice to a specific function rather than auto. Auto will occasionally return a chat message with no tool call when the model decides the input does not warrant one. You can not tolerate that flakiness inside a workflow.
For the decision_criteria array, store as a pipe-delimited string in a single HubSpot property rather than trying to model a multi-select. HubSpot multi-select properties are limited and brittle for free-text values.
Pattern 3: timeline events instead of properties
People reach for properties because they are the obvious unit. For AI-generated content that is interesting context but not data you'll filter pipeline reports on, write to the timeline instead.
await client.crm.timeline.eventsApi.create({
eventTemplateId: 'YOUR_TEMPLATE_ID',
email: contact.email,
tokens: {
summary: aiGeneratedNote,
source: 'gong_call_2026_05_28',
confidence: 0.92
}
});
Timeline events are searchable, sortable by time, and don't pollute your property list. We use timeline events for "research summaries", "previous-call recap on next-call open", and "agent-flagged risk signals". Anything that's "context for a human" goes here. Anything you'll run a report on goes into a property.
Failure modes, by frequency
In rough order of how often each one bit me:
Workflow custom code 20-second timeout. If your AI call plus enrichment plus writeback can not finish in 20 seconds, you must move to pattern 2 (webhook out). I have seen people split a call across two custom code actions to "fit". Do not do this. The state management between them is a nightmare. Bite the bullet and externalise.
HubSpot rate limits. 100 requests per 10 seconds per portal for the v3 API. If your AI agent is enriching new contacts in bulk, you will hit this. Implement a leaky-bucket queue in front of your writes.
Property rename detection. A marketing ops person renames Lead_Score_v2 to Lead Score (V2). Half your code breaks. Build a nightly job that fetches property metadata and diff-checks against your code's expected schema.
Custom code action package versions. HubSpot pins package versions on the runtime side. openai@4.x works; openai@5.x does not at the time of writing. Pin explicitly.
Idempotency on retries. Workflows retry on failure. Your AI action must be idempotent — same input, same output property update — or you will have duplicate timeline events and AE confusion. Use a request-ID derived from the contact ID and a hash of the prompt inputs.
Notes that turn out to be portal-visible. HubSpot has a "private note" UI affordance and a "shared portal note" property — they are not the same thing. Read the docs carefully, and default to draft-mode for any AI writeback that touches a customer-shared object.
Logging and observability
The unfun part. Production AI inside HubSpot needs three things you will not have on day one:
- Every model call logged to a side store (we use a simple Cloudflare D1 instance with
request_id, contact_id, model, prompt_hash, response, latency_ms, cost_cents). - A
prompt_versionproperty written alongside every AI-generated value so you can A/B prompts in production without losing history. - A weekly cost-and-quality report. Cost is easy. Quality is a small spreadsheet where the sales ops lead grades 20 sampled outputs each week. You do not skip this.
Closing
Real HubSpot AI engineering is more workflow-mechanics than model-engineering. The model is the easy part. The retries, the property hygiene, the 20-second budget, the rate limits, the portal-visibility surprises — that is where the work lives.
If you ship a HubSpot AI integration this quarter, the thing that will save you a sprint is reading the workflow custom code action docs cover to cover before you write your first prompt.
I'd be curious how others are handling property-rename detection. We brute-force it with a daily diff. There must be a smarter way.
Top comments (0)