Auto-enrich new HubSpot contacts with Apollo using a Claude Code skill
Install this skill
Download the skill archive and extract it into your .claude/skills/ directory.
contact-enrichment.skill.zipPrerequisites
This skill works with any agent that supports the Claude Code skills standard, including Claude Code, Claude Cowork, OpenAI Codex, and Google Antigravity.
- One of the agents listed above
- HubSpot private app with
crm.objects.contacts.readandcrm.objects.contacts.writescopes - Apollo API key with enrichment credits (Settings → Integrations → API)
Why a Claude Code skill?
The other approaches in this guide are deterministic: they run the same logic every time, the same way. An Claude Code skill is different. You tell Claude what you want in plain language, and the skill gives it enough context to do it reliably.
That means you can say:
- "Enrich the contacts created today"
- "Enrich contacts missing job titles from this week"
- "Enrich the 50 contacts from today's webinar, skip personal emails"
- "How many contacts from last month are still missing company data?"
The skill contains workflow guidelines, API reference materials, and a field mapping template that the agent reads on demand. When you invoke the skill, Claude reads these files, writes a script on the fly, runs it, and reports results. If you ask for something different next time — a different time window, an ICP filter, a dry-run mode — the agent adapts without you touching any code.
How it works
The skill directory has three parts:
SKILL.md— workflow guidelines telling the agent what steps to follow, which env vars to use, and what pitfalls to avoidreferences/— Apollo and HubSpot API patterns (endpoints, request shapes, response formats) so the agent calls the right APIs with the right parameterstemplates/— a field mapping template so enriched data is written to the correct HubSpot properties consistently
When invoked, the agent reads SKILL.md, consults the reference and template files as needed, writes a Python script, executes it, and reports what it enriched. The reference files act as guardrails — the agent knows exactly which endpoints to hit and what the responses look like, so it doesn't have to guess.
What is a Claude Code skill?
An Claude Code skill is a reusable command you add to your project that Claude Code can run on demand. Skills live in a .claude/skills/ directory and are defined by a SKILL.md file that tells the agent what the skill does, when to run it, and what tools it's allowed to use.
In this skill, the agent doesn't run a pre-written script. Instead, SKILL.md provides workflow guidelines and points to reference files — API documentation, field mappings — that the agent reads to generate and execute code itself. This is the key difference from a traditional script: the agent can adapt its approach based on what you ask for while still using the right APIs and field formats.
Once installed, you can invoke a skill as a slash command (e.g., /enrich-contacts), or the agent will use it automatically when you give it a task where the skill is relevant. Skills are portable — anyone who clones your repo gets the same commands.
Step 1: Create the skill directory
mkdir -p .claude/skills/enrich-contacts/{templates,references}This creates the layout:
.claude/skills/enrich-contacts/
├── SKILL.md # workflow guidelines + config
├── templates/
│ └── field-mapping.md # HubSpot field mapping for enriched data
└── references/
├── apollo-people-api.md # Apollo People Match API patterns
└── hubspot-contacts-api.md # HubSpot CRM Search + Update patternsStep 2: Write the SKILL.md
Create .claude/skills/enrich-contacts/SKILL.md:
---
name: enrich-contacts
description: Enrich HubSpot contacts with Apollo data — fill in job title, company, phone, LinkedIn, and industry for contacts missing those fields
disable-model-invocation: true
allowed-tools: Bash, Read
---
## Goal
Find HubSpot contacts that are missing key fields (job title, company, phone) and enrich them using Apollo's People Match API. Write enriched data back to HubSpot, preserving any manually entered values.
## Configuration
Read these environment variables:
- `HUBSPOT_ACCESS_TOKEN` — HubSpot private app token (required)
- `APOLLO_API_KEY` — Apollo API key (required)
Default behavior: enrich contacts created in the last 24 hours that are missing a job title. The user may request a different time window, different missing field, or a specific set of contacts.
## Workflow
1. Validate that all required env vars are set. If any are missing, print which ones and exit.
2. Search HubSpot for contacts matching the criteria. See `references/hubspot-contacts-api.md` for the Search API endpoint and request format.
3. Filter out contacts with personal email domains (gmail.com, yahoo.com, hotmail.com, outlook.com, aol.com) to conserve Apollo credits.
4. For each contact, call Apollo's People Match endpoint with the contact's email. See `references/apollo-people-api.md` for the endpoint and response format.
5. Map Apollo fields to HubSpot properties using the mapping in `templates/field-mapping.md`. Only write fields that are currently empty on the contact — never overwrite existing data.
6. Update each contact in HubSpot with the enriched fields. See `references/hubspot-contacts-api.md` for the PATCH endpoint.
7. Print a summary: how many contacts were found, enriched, skipped (no match), and skipped (personal email).
## Important notes
- Apollo's People Match returns `person: null` for unmatched emails — this is expected, not an error. Skip silently and count as "not found."
- Apollo rate limit is 5 requests/second on the Basic plan. Add a 0.5-second delay between calls.
- HubSpot's Search API returns a maximum of 10,000 results. If the user's search would exceed this, suggest adding additional filters.
- Personal email domains (gmail.com, yahoo.com, etc.) rarely return useful B2B data from Apollo. Always skip them unless the user explicitly asks to include them.
- Use the `requests` library for HTTP calls. Install with pip if needed.Understanding the SKILL.md
Unlike a script-based approach, this SKILL.md doesn't contain a Run: command pointing to a script. Instead, it provides:
| Section | Purpose |
|---|---|
| Goal | Tells the agent what outcome to produce |
| Configuration | Which env vars to read and what defaults to use |
| Workflow | Numbered steps with pointers to reference files |
| Important notes | Non-obvious context that prevents common mistakes |
The allowed-tools: Bash, Read setting lets the agent both read reference files and execute code. The agent writes its own script based on the workflow steps and reference materials.
Step 3: Add reference files
references/apollo-people-api.md
Create .claude/skills/enrich-contacts/references/apollo-people-api.md:
# Apollo People Match API Reference
## Enrich a single person by email
**Request:**
```
POST https://api.apollo.io/api/v1/people/match
x-api-key: <APOLLO_API_KEY>
Content-Type: application/json
```
**Body:**
```json
{
"email": "jdoe@acme.com"
}
```
**Response shape (matched):**
```json
{
"person": {
"title": "VP of Engineering",
"seniority": "vp",
"departments": ["engineering"],
"organization": {
"name": "Acme Corp",
"estimated_num_employees": 500,
"industry": "computer software",
"short_description": "B2B SaaS platform"
},
"phone_numbers": [
{
"sanitized_number": "+14155550142",
"type": "work_direct"
}
],
"linkedin_url": "https://www.linkedin.com/in/jdoe",
"email": "jdoe@acme.com"
}
}
```
**Response shape (no match):**
```json
{
"person": null
}
```
## Notes
- Rate limit: 5 requests/second on Basic plan, 10/sec on Professional.
- Each call costs 1 Apollo credit regardless of whether a match is found.
- The `phone_numbers` array may be empty. Always check length before accessing index 0.
- `organization` may be null if Apollo has person data but no company data.references/hubspot-contacts-api.md
Create .claude/skills/enrich-contacts/references/hubspot-contacts-api.md:
# HubSpot Contacts API Reference
## Search for contacts
Find contacts matching specific criteria using the CRM Search API.
**Request:**
```
POST https://api.hubapi.com/crm/v3/objects/contacts/search
Authorization: Bearer <HUBSPOT_ACCESS_TOKEN>
Content-Type: application/json
```
**Body (contacts created recently, missing job title):**
```json
{
"filterGroups": [
{
"filters": [
{
"propertyName": "createdate",
"operator": "GTE",
"value": "<cutoff_timestamp_ms>"
},
{
"propertyName": "jobtitle",
"operator": "NOT_HAS_PROPERTY"
}
]
}
],
"properties": ["email", "firstname", "lastname", "jobtitle", "company", "phone", "linkedin_url", "industry"],
"limit": 100
}
```
- `value` for date filters is a Unix timestamp in **milliseconds**.
- Filters within the same `filterGroup` are ANDed together.
- `limit` max is 100. Use `paging.next.after` to paginate.
- `NOT_HAS_PROPERTY` matches contacts where the property was never set (not empty strings).
**Response shape:**
```json
{
"total": 42,
"results": [
{
"id": "12345",
"properties": {
"email": "jdoe@acme.com",
"firstname": "Jane",
"lastname": "Doe",
"jobtitle": null,
"company": null
}
}
],
"paging": {
"next": {
"after": "100"
}
}
}
```
## Update a contact
Write enriched fields back to a contact record.
**Request:**
```
PATCH https://api.hubapi.com/crm/v3/objects/contacts/<CONTACT_ID>
Authorization: Bearer <HUBSPOT_ACCESS_TOKEN>
Content-Type: application/json
```
**Body:**
```json
{
"properties": {
"jobtitle": "VP of Engineering",
"company": "Acme Corp",
"phone": "+14155550142",
"linkedin_url": "https://www.linkedin.com/in/jdoe",
"industry": "computer software"
}
}
```
- Only include properties you want to update. Omitted properties are left unchanged.
- Unknown property names are silently ignored (no error).
- Rate limit: 150 requests per 10 seconds.templates/field-mapping.md
Create .claude/skills/enrich-contacts/templates/field-mapping.md:
# Apollo → HubSpot Field Mapping
Use this mapping when writing Apollo enrichment data to HubSpot contacts.
| Apollo field | HubSpot property | Notes |
|-------------|-----------------|-------|
| `person.title` | `jobtitle` | Job title |
| `person.organization.name` | `company` | Company name |
| `person.phone_numbers[0].sanitized_number` | `phone` | Check array is non-empty first |
| `person.linkedin_url` | `linkedin_url` | Custom property — must exist in HubSpot |
| `person.organization.industry` | `industry` | Industry name |
| `person.seniority` | `seniority` | Custom property — must exist in HubSpot |
| `person.organization.estimated_num_employees` | `numemployees` | Employee count range |
## Rules
- **Only write fields that are currently empty** on the HubSpot contact. Never overwrite existing data.
- If Apollo returns `null` or an empty value for a field, skip it.
- If the `phone_numbers` array is empty, do not write to the `phone` property.
- Custom properties (`linkedin_url`, `seniority`) must be created in HubSpot before the script runs, or the update will silently ignore them.Step 4: Test the skill
Invoke the skill conversationally:
/enrich-contactsClaude will read the SKILL.md, check the reference files, write a script, install any missing dependencies, run it, and report the results. A typical run looks like:
Searching for contacts created in the last 24 hours missing job title...
Found 18 contacts
Filtered out 4 personal emails
Enriching 14 business contacts via Apollo...
Enriched: jdoe@acme.com → VP of Engineering, Acme Corp
Enriched: mchen@widgetco.io → Director of Sales, WidgetCo
No match: info@smallshop.com
...
Done. Enriched 11/14 contacts (3 not found in Apollo). Skipped 4 personal emails.Because the agent generates code on the fly, you can also make ad hoc requests:
- "Enrich contacts from the last week" — the agent adjusts the time window
- "Enrich contacts missing phone numbers" — the agent changes the search filter
- "Do a dry run — show me what would be enriched but don't update HubSpot" — the agent skips the PATCH calls
- "How many contacts from this month are still missing company data?" — the agent queries and reports without enriching
Create a test contact in HubSpot with just an email address (use a business domain, not gmail.com), then run the skill. The contact should be enriched within seconds.
Step 5: Schedule it (optional)
Option A: Cron + Claude CLI
# Run every hour on the hour
0 * * * * cd /path/to/your/project && claude -p "Run /enrich-contacts" --allowedTools 'Bash(*)' 'Read(*)'Option B: GitHub Actions + Claude
name: Enrich HubSpot Contacts
on:
schedule:
- cron: '0 * * * *' # Every hour
workflow_dispatch: {} # Manual trigger for testing
jobs:
enrich:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: anthropics/claude-code-action@v1
with:
prompt: "Run /enrich-contacts"
allowed_tools: "Bash(*),Read(*)"
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
HUBSPOT_ACCESS_TOKEN: ${{ secrets.HUBSPOT_ACCESS_TOKEN }}
APOLLO_API_KEY: ${{ secrets.APOLLO_API_KEY }}0 * * * * runs at the top of every hour UTC. GitHub Actions cron may also have up to 15 minutes of delay. For time-sensitive enrichment, use cron on your own server or a dedicated scheduler instead.
Option C: Cowork Scheduled Tasks
Claude Desktop's Cowork supports built-in scheduled tasks. Open a Cowork session, type /schedule, and configure the cadence — hourly, daily, weekly, or weekdays only. Each scheduled run has full access to your connected tools, plugins, and MCP servers.
Scheduled tasks only run while your computer is awake and Claude Desktop is open. If a run is missed, Cowork executes it automatically when the app reopens. For always-on scheduling, use GitHub Actions (Option B) instead. Available on all paid plans (Pro, Max, Team, Enterprise).
Troubleshooting
When to use this approach
- You want conversational flexibility — "enrich contacts from the webinar" or "how many are still missing company data?" alongside scheduled runs
- You want on-demand enrichment before pipeline reviews or outreach campaigns
- You're already using Claude Code and want skills that integrate with your workflow
- You want to run tasks in the background via Claude Cowork while focusing on other work
- You prefer guided references over rigid scripts — the agent adapts while staying reliable
When to switch approaches
- You need real-time enrichment the instant a contact is created → use n8n with the HubSpot trigger
- You want a no-code setup with a visual builder → use Zapier or Make
- You need enrichment running 24/7 with zero LLM cost → use the Code + Cron approach
Common questions
Does this use Claude API credits?
Yes. The agent reads skill files and generates code each time. Typical cost is $0.01-0.05 per invocation depending on how many contacts are returned and how much the agent needs to read. The Apollo and HubSpot APIs themselves have their own pricing (Apollo: 1 credit per enrichment; HubSpot: free within rate limits).
How is this different from the Code + Cron approach?
The Code + Cron approach runs a fixed script that does the same thing every time. The Claude Code skill adapts to what you ask — different time windows, different missing fields, dry-run mode, summary reports. The reference files ensure it calls the right APIs even when improvising.
Can I run this skill on a schedule without a server?
Yes. GitHub Actions (Option B in Step 5) runs Claude on a cron schedule using GitHub's infrastructure. The free tier includes 2,000 minutes/month.
What if I want to enrich thousands of contacts at once?
Ask the agent to use Apollo's bulk_match endpoint (10 contacts per request) instead of individual calls. The reference file documents the single-person endpoint, but the agent can adapt to the bulk endpoint if you describe what you need. For very large batches (10,000+), the Code + Cron approach with explicit pagination is more predictable.
Cost
- Claude API — $0.01-0.05 per invocation (the agent reads files and generates code)
- Apollo — 1 credit per enrichment, included in all paid plans. Basic plan ($49/mo) = 900 credits.
- HubSpot API — included in all plans, no per-call cost
- GitHub Actions (if scheduled) — free tier includes 2,000 minutes/month
Looking to scale your AI operations?
We build and optimize automation systems for mid-market businesses. Let's discuss the right approach for your team.