HubSpot API skill for Codex
CRM syncs that upsert, associate and never duplicate. Codex syncs data into HubSpot by upserting on a unique property, so reruns update instead of duplicating, and batches 100 records per call to stay inside rate limits. It creates properties with the right type and fieldType pairing, slices searches by timestamp to get past the 10,000-result cap, wires v4 associations, and validates webhook signature v3 with a replay window. It knows the 2026 date-based API versions.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- hubspot-api Installs to ~/.agents/skills/hubspot-api/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install hubspot-api The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project hubspot-api Installs to .agents/skills/hubspot-api/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/hubspot-api.zipunzip -o hubspot-api.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) hubspot-api PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $hubspot-api, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Integrates with the HubSpot CRM API - contacts, companies, deals, tickets and custom objects via the v3 and date-versioned (2026-03) object endpoints, batch upserts by unique property, the search API and its limits, custom properties, v4 associations, webhook signature v3 validation and rate-limit handling with @hubspot/api-client or plain HTTP. Use when asked to sync records into HubSpot, create or migrate CRM properties, associate contacts with companies and deals, process HubSpot webhooks, or debug 429 and validation errors.
Use it when
- Contacts, companies, deals or tickets must be written to or read from HubSpot
- You need custom properties or associations created reproducibly
- A HubSpot webhook receiver needs signature validation and deduplication
- An integration hits 429s or the search API's 10,000 result limit
Use something else when
- The CRM is Salesforce: use salesforce-api
- You need general webhook receiver infrastructure (queues, replay): use webhook-receivers
How to check the result
Dry-run counts of creates versus updates, then compare ten synced records field by field via search on the external ID, and unit-test webhook validation with a stale timestamp.
The mistake it prevents
Creating companies without a unique custom property, because HubSpot does not enforce domain uniqueness and reruns quietly create duplicates.
Done well means: records are matched on a unique property (email, domain, or a custom unique ID) so reruns never duplicate, properties are created deliberately with the right type, every list or search is paged to the end within the 10,000-result search cap, and webhooks are verified and deduplicated.
Versions and auth
- HubSpot moved to date-based API versions in 2026 (
/crm/objects/2026-03/contacts), released each March and September with at least 18 months of support. These mirror the latest v3/v4 behaviour, so/crm/v3/objects/contactscode migrates by changing the path. New code: use the newest date version your account documents; existing v3 code keeps working for now. Keep the version string in one constant. - Single-account integrations: a static access token from an app installed in that account
(private or legacy app, or a project-based app with static auth). Send
Authorization: Bearer <token>. Multi-account/marketplace apps: OAuth with refresh tokens. - Request only needed scopes (
crm.objects.contacts.read,crm.objects.contacts.write,crm.schemas.contacts.writefor properties, and so on). - Tokens from env (
HUBSPOT_TOKEN); readAGENTS.md. Calls need network; in an offline Codex sandbox write the code and ask before running.
Client and retry wrapper
const BASE = "https://api.hubapi.com";
const V = "2026-03"; // or use "v3" paths for older code
async function hs(method: string, path: string, body?: unknown, attempt = 0): Promise<any> {
const res = await fetch(BASE + path, {
method,
headers: { Authorization: `Bearer ${process.env.HUBSPOT_TOKEN}`, "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if ((res.status === 429 || res.status >= 500) && attempt < 6) {
const wait = Number(res.headers.get("Retry-After") ?? 0) * 1000 || 2 ** attempt * 500;
await new Promise((r) => setTimeout(r, wait));
return hs(method, path, body, attempt + 1);
}
if (!res.ok) throw new Error(`${method} ${path} ${res.status}: ${await res.text()}`);
return res.status === 204 ? null : res.json();
}
@hubspot/api-client (Node) and hubspot-api-client (Python) wrap the v3 endpoints and have
built-in retry options; plain HTTP is simpler when you target date-versioned paths.
Objects: batch upsert, read, page
// Upsert up to 100 contacts in one call, matched on email
await hs("POST", `/crm/objects/${V}/contacts/batch/upsert`, {
inputs: rows.slice(0, 100).map((r) => ({
id: r.email.toLowerCase(),
idProperty: "email",
properties: { firstname: r.first, lastname: r.last, erp_customer_id: r.erpId },
})),
});
// List all companies with selected properties
async function* listAll(objectType: string, properties: string[]) {
let after: string | undefined;
do {
const qs = new URLSearchParams({ limit: "100", properties: properties.join(",") });
if (after) qs.set("after", after);
const page = await hs("GET", `/crm/objects/${V}/${objectType}?${qs}`);
yield* page.results;
after = page.paging?.next?.after;
} while (after);
}
| Object | Natural unique key | Note |
|---|---|---|
| contacts | email | Enforced unique; secondary emails also match |
| companies | domain | Not enforced; create a custom unique property for upserts |
| deals, tickets | none | Create a custom property with “unique values” for your external ID |
| custom objects | defined in schema | Reference by objectTypeId such as 2-1234567 |
Batch responses can be 207 Multi-Status: some inputs succeeded, some failed. Read errors
and retry only the failed inputs.
Search
const body = {
filterGroups: [
{ filters: [
{ propertyName: "lifecyclestage", operator: "EQ", value: "customer" },
{ propertyName: "hs_lastmodifieddate", operator: "GTE", value: "2026-10-01T00:00:00Z" },
] },
],
properties: ["email", "firstname", "hs_lastmodifieddate"],
sorts: [{ propertyName: "hs_lastmodifieddate", direction: "ASCENDING" }],
limit: 200,
};
const page = await hs("POST", `/crm/objects/${V}/contacts/search`, body);
- Filters within a group are AND; groups are OR. Up to 5 groups and 6 filters per group (18 filters total).
- Search returns at most 10,000 results per query. For more, slice by a sorted timestamp:
remember the last
hs_lastmodifieddateand run the next query withGTthat value. - Search has its own stricter rate limit (a handful of requests per second per account) and an index lag of a few seconds after writes. Do not search for a record you just created; use the ID from the write response.
Properties
Create properties in code (or a migration script) rather than by hand, so environments match.
await hs("POST", `/crm/v3/properties/deals`, { // or the date-versioned path if documented
name: "erp_order_id", label: "ERP order ID", groupName: "dealinformation",
type: "string", fieldType: "text", hasUniqueValue: true,
});
type | fieldType | Value format |
|---|---|---|
| string | text, textarea | string |
| number | number | numeric string or number |
| enumeration | select, radio, checkbox (multi uses ;) | option internal value |
| bool | booleancheckbox | "true" / "false" |
| date | date | midnight UTC in ms or YYYY-MM-DD |
| datetime | date | ms since epoch or ISO 8601 |
Use the internal name, never the label. Enumeration writes must match an existing option
value; a label fails validation. Deleting or archiving properties loses data on every record:
confirm with the user first.
Associations (v4)
// Default association contact -> company
await hs("PUT", `/crm/v4/objects/contacts/${contactId}/associations/default/companies/${companyId}`);
// Labelled association in batch
await hs("POST", `/crm/v4/associations/deals/contacts/batch/create`, {
inputs: [{ from: { id: dealId }, to: { id: contactId },
types: [{ associationCategory: "USER_DEFINED", associationTypeId: 7 }] }],
});
Look up label type IDs with GET /crm/v4/associations/{from}/{to}/labels; they differ per
account for user-defined labels. Associations can be passed inline on create to save calls.
Webhooks
App webhook subscriptions deliver batches of up to 100 events per POST. Validate signature v3 before parsing:
import crypto from "node:crypto";
function validHubSpot(req: { method: string; url: string; headers: any; rawBody: string }) {
const ts = Number(req.headers["x-hubspot-request-timestamp"]);
if (!ts || Date.now() - ts > 5 * 60 * 1000) return false; // replay window
const uri = `https://${req.headers.host}${req.url}`; // full URL as HubSpot called it
const base = req.method + uri + req.rawBody + ts;
const expected = crypto.createHmac("sha256", process.env.HUBSPOT_CLIENT_SECRET!).update(base).digest("base64");
const got = Buffer.from(String(req.headers["x-hubspot-signature-v3"] ?? ""));
return got.length === Buffer.from(expected).length && crypto.timingSafeEqual(got, Buffer.from(expected));
}
- Respond 2xx fast and process async; HubSpot retries failures for a limited window.
- Deduplicate by
eventId; events are not ordered, so re-read the object before acting on apropertyChange. - Workflow “Send a webhook” actions are configured per workflow and signed differently; check which source you are receiving.
Limits
- Per-app burst limits are on the order of 100-190 requests per 10 seconds depending on
subscription tier, plus a daily cap; read
X-HubSpot-RateLimit-Remainingand slow down before 429s. - Batch endpoints count as one request for up to 100 records: always batch bulk work.
Gotchas
- Email matching is case-insensitive on HubSpot’s side but your local map may not be: lowercase before comparing.
lifecyclestagecannot move backwards unless you clear it first.- Archived records (
DELETE) sit in the recycle bin for 90 days; the GDPR delete endpoint is permanent and needs explicit user confirmation. - Owners are referenced by
hubspot_owner_id, not user ID or email. - Property history is available with
propertiesWithHistory; it is heavy, request it sparingly.
Verification
- Dry run first: print counts of creates vs updates (match on the unique key locally).
- After the sync, search on your external ID property for a sample of 10 records and compare every mapped field.
- Webhooks: send a signed test event with a fixed timestamp to the handler’s unit test, plus one with a stale timestamp that must be rejected.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "HubSpot API"
short_description: "CRM syncs that upsert, associate and never duplicate"
default_prompt: "$hubspot-api Sync our Postgres customers into HubSpot contacts and companies without duplicates" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.