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.

  • Integrations
  • $hubspot-api
  • 202 lines
  • 2 files
  • ~2,150 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- hubspot-api

Installs to ~/.agents/skills/hubspot-api/ for every project on this machine.

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

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.

~/.agents/skills/hubspot-api/SKILL.md

--- name: hubspot-api description: Integrates with the HubSpot CRM API - contacts, companies, deals, tickets and custom objects via the v3 and date-versioned (2026-03) object… license: MIT ---

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/contacts code 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.write for properties, and so on).
  • Tokens from env (HUBSPOT_TOKEN); read AGENTS.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);
}
ObjectNatural unique keyNote
contactsemailEnforced unique; secondary emails also match
companiesdomainNot enforced; create a custom unique property for upserts
deals, ticketsnoneCreate a custom property with “unique values” for your external ID
custom objectsdefined in schemaReference 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.

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_lastmodifieddate and run the next query with GT that 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,
});
typefieldTypeValue format
stringtext, textareastring
numbernumbernumeric string or number
enumerationselect, radio, checkbox (multi uses ;)option internal value
boolbooleancheckbox"true" / "false"
datedatemidnight UTC in ms or YYYY-MM-DD
datetimedatems 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 a propertyChange.
  • 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-Remaining and 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.
  • lifecyclestage cannot 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.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem