The real problem
Why this is harder than it looks
HarvestAPI is not a single product — it's a bridge to two distinct services under one API key: Harvest time tracking and LinkedIn data scraping. That dual nature is the first thing that trips teams up. The setup seems simple (just an API key), but the operational complexity emerges quickly once you move beyond a single-user prototype.
The auth model is API key rather than OAuth. That sounds simpler until you're building a multi-tenant product. Each user in your system needs their own HarvestAPI key stored securely, isolated from every other user's key, and injected into the right request at the right time. Leaking one user's key to another's request isn't just a bug — it means one customer's Harvest account gets billed for another's activity, and one user's LinkedIn session is used to scrape on another's behalf. Building that isolation correctly — with encrypted storage, per-user key lookup, and clean separation at the infrastructure level — is the same problem OAuth connectors have, just with a different credential shape.
Then there's the credit model. HarvestAPI uses pay-as-you-go credits, and different operations cost different amounts — a full profile scrape costs more than a simplified one, and email enrichment costs extra on top of that. An agent that calls scrape_profile without checking credit balance, or calls bulk_scrape_profiles without knowing it routes through a separate Apify account with its own billing, will silently fail mid-workflow or run up unexpected costs. Your agent needs to be credit-aware before calling any scraping operation.
Finally, the connection management tools — sending connection requests, messaging LinkedIn contacts — require a separate credential entirely: the user's li_at LinkedIn session cookie, not the HarvestAPI key. Managing two different credential types per user, keeping the session cookie fresh, and handling the failure mode when a LinkedIn session expires mid-workflow is a distinct engineering problem from the API key management above.
Scalekit handles API key storage, per-user isolation, and credential injection on every call. Your agent names a tool and passes parameters. The plumbing is invisible.
Capabilities
What your agent can do with HarvestAPI
Once connected, your agent has 36 pre-built tools spanning time tracking and LinkedIn data:
- Log and query Harvest time entries: create duration-based or timer-based entries against any project and task; list and filter by user, client, date range, or billing status
- Discover Harvest projects and users: list active projects with budget and client details; look up users and their weekly capacity — both needed before logging time
- Scrape LinkedIn profiles, companies, and jobs: full profile data including employment history, education, and skills; company overviews with headcount, funding, and specialties; complete job listing details with salary and requirements
- Search LinkedIn at scale: find people by title, company, location, and industry via Lead Search; search companies, jobs, posts, groups, and service providers with rich filter options
- Track LinkedIn engagement: retrieve post comments, reactions, and replies; pull profile-level activity including recent comments and reactions made by any user
- Manage LinkedIn connections and messages: send connection requests with personalized notes, accept pending invitations, and send direct messages — all on behalf of a specific LinkedIn account
Setup context
What we're building
This guide connects a productivity assistant agent to HarvestAPI — helping team members log time against Harvest projects and pull LinkedIn data without leaving your product.
🤖
Example agent
Productivity assistant logging Harvest time entries and enriching contacts with LinkedIn data on behalf of each user
🔐
Auth model
API Key auth — each user provides their own HarvestAPI key. identifier = your user ID
Setup
1 Setup: One SDK, One credential
Install the Scalekit SDK. The only credential your application manages is the Scalekit API key — no HarvestAPI secrets, no user tokens, nothing belonging to your users.
pip install scalekit-sdk-python
npm install @scalekit-sdk/node
import scalekit.client
import os
from dotenv import load_dotenv
load_dotenv()
scalekit = scalekit.client.ScalekitClient(
client_id=os.getenv("SCALEKIT_CLIENT_ID"),
client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"),
env_url=os.getenv("SCALEKIT_ENV_URL"),
)
actions = scalekit.actions
import { ScalekitClient } from '@scalekit-sdk/node';
import 'dotenv/config';
const scalekit = new ScalekitClient(
process.env.SCALEKIT_ENV_URL,
process.env.SCALEKIT_CLIENT_ID,
process.env.SCALEKIT_CLIENT_SECRET
);
const actions = scalekit.actions;
Already have credentials?
Connected Accounts
2 Per-User Auth: Creating connected accounts
HarvestAPI uses API key auth — there is no OAuth redirect flow. Each user's HarvestAPI key is registered directly in the Scalekit dashboard under the connection's Connected Accounts tab. Scalekit stores it encrypted and injects it into every request for that user automatically.
In your application code, create a connected account reference for each user. The identifier is any unique string from your system — a UUID, email, whatever you use internally.
response = actions.get_or_create_connected_account(
connection_name="harvestapi",
identifier="user_ha_456" # your internal user ID
)
connected_account = response.connected_account
print(f"Status: {connected_account.status}")
# Status: ACTIVE once the API key has been saved in the Scalekit dashboard
const response = await actions.getOrCreateConnectedAccount({
connectionName: "harvestapi",
identifier: "user_ha_456" // your internal user ID
});
const connectedAccount = response.connectedAccount;
console.log(`Status: ${connectedAccount.status}`);
// Status: ACTIVE once the API key has been saved in the Scalekit dashboard
This call is idempotent — safe to call on every session start. Returns the existing account if one already exists.
Calling HarvestAPI
3 Calling HarvestAPI: What your agent writes
With a connected account active, your agent calls HarvestAPI tools using execute_tool. Name the tool, pass parameters. Scalekit handles key retrieval, request construction, and response parsing.
Log a time entry
Log time against a Harvest project and task. Use list_projects first to resolve project_id and task_id — they differ per account. Duration-based entries take hours; timer-based entries take started_time and ended_time.
result = actions.execute_tool(
identifier="user_ha_456",
tool_name="log_time_entry",
tool_input={
"project_id": 12345678,
"task_id": 87654321,
"spent_date": "2026-03-31",
"hours": 1.5,
"notes": "Reviewed Q1 pipeline and updated deal stages"
}
)
# Returns: { "id": ..., "hours": 1.5, "billable": true, "project": { "name": "Acme Corp" }, ... }
const result = await actions.executeTool({
identifier: "user_ha_456",
toolName: "log_time_entry",
toolInput: {
"project_id": 12345678,
"task_id": 87654321,
"spent_date": "2026-03-31",
"hours": 1.5,
"notes": "Reviewed Q1 pipeline and updated deal stages"
}
});
// Returns: { "id": ..., "hours": 1.5, "billable": true, "project": { "name": "Acme Corp" }, ... }
List time entries with filters
Query time entries across projects, users, and date ranges. Useful for weekly summaries or billing reports. is_running: true returns only active timers.
result = actions.execute_tool(
identifier="user_ha_456",
tool_name="list_time_entries",
tool_input={
"from": "2026-03-01",
"to": "2026-03-31",
"is_billed": False,
"per_page": 100
}
)
# Returns paginated list of time entries with project, task, user, and hours details
const result = await actions.executeTool({
identifier: "user_ha_456",
toolName: "list_time_entries",
toolInput: {
"from": "2026-03-01",
"to": "2026-03-31",
"is_billed": false,
"per_page": 100
}
});
// Returns paginated list of time entries with project, task, user, and hours details
Scrape a LinkedIn profile
Retrieve full profile data by URL or handle. Set main: true for a faster, lower-credit simplified profile. Only enable find_email when you specifically need it — it costs additional credits per successful match.
result = actions.execute_tool(
identifier="user_ha_456",
tool_name="scrape_profile",
tool_input={
"profile_url": "https://www.linkedin.com/in/jeffweiner08",
"main": False,
"find_email": False
}
)
# Returns: employment history, education, skills, location, connections count
const result = await actions.executeTool({
identifier: "user_ha_456",
toolName: "scrape_profile",
toolInput: {
"profile_url": "https://www.linkedin.com/in/jeffweiner08",
"main": false,
"find_email": false
}
});
// Returns: employment history, education, skills, location, connections count
Search LinkedIn for people
Find prospects using LinkedIn Lead Search. Returns unmasked results with name, title, and LinkedIn URL. All filters support comma-separated multi-values — pass multiple titles or companies in one call.
result = actions.execute_tool(
identifier="user_ha_456",
tool_name="search_people",
tool_input={
"title": "VP of Engineering,Head of Engineering",
"company": "Stripe,Airbnb",
"location": "San Francisco, CA",
"page": 1
}
)
# Returns paginated profiles with name, headline, location, and LinkedIn URL
const result = await actions.executeTool({
identifier: "user_ha_456",
toolName: "search_people",
toolInput: {
"title": "VP of Engineering,Head of Engineering",
"company": "Stripe,Airbnb",
"location": "San Francisco, CA",
"page": 1
}
});
// Returns paginated profiles with name, headline, location, and LinkedIn URL
Framework wiring
4 Wiring into your agent framework
Scalekit integrates directly with LangChain. The agent decides what to call; Scalekit handles credential injection on every invocation. No key plumbing in your agent logic.
from langchain_anthropic import ChatAnthropic
from langchain.agents import AgentExecutor, create_tool_calling_agent
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from scalekit.langchain import get_tools
ha_tools = get_tools(
connection_name="harvestapi",
identifier="user_ha_456"
)
prompt = ChatPromptTemplate.from_messages([
("system", "You are a productivity assistant. Use the available tools to log time in Harvest and look up LinkedIn data."),
MessagesPlaceholder("chat_history", optional=True),
("human", "{input}"),
MessagesPlaceholder("agent_scratchpad"),
])
agent = create_tool_calling_agent(ChatAnthropic(model="claude-sonnet-4-6"), ha_tools, prompt)
result = AgentExecutor(agent=agent, tools=ha_tools).invoke({
"input": "Log 2 hours against the Acme Corp project for today and find the LinkedIn profile of their VP of Engineering"
})
import { ChatAnthropic } from "@langchain/anthropic";
import { AgentExecutor, createToolCallingAgent } from "langchain/agents";
import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
import { getTools } from "@scalekit-sdk/langchain";
const haTools = getTools({
connectionName: "harvestapi",
identifier: "user_ha_456"
});
const prompt = ChatPromptTemplate.fromMessages([
["system", "You are a productivity assistant. Use the available tools to log time in Harvest and look up LinkedIn data."],
new MessagesPlaceholder("chat_history", true),
["human", "{input}"],
new MessagesPlaceholder("agent_scratchpad"),
]);
const agent = await createToolCallingAgent({
llm: new ChatAnthropic({ model: "claude-sonnet-4-6" }),
tools: haTools,
prompt
});
const result = await AgentExecutor.fromAgentAndTools({
agent,
tools: haTools
}).invoke({
input: "Log 2 hours against the Acme Corp project for today and find the LinkedIn profile of their VP of Engineering"
});
Other frameworks supported
Tool reference
All 36 HarvestAPI tools
Grouped by capability. Your agent calls tools by name — no API wrappers to write.
Create a time entry in Harvest. Supports duration-based (hours) or timer-based (start/end time) modes. Returns entry ID, billable status, and invoice details
List time entries with filters by project, user, client, task, date range, billed status, or active timer state. Paginated
List all Harvest projects with name, client, budget, billing method, dates, and active status. Use to resolve project_id before logging time
List all users in the Harvest account with name, email, roles, and weekly capacity. Use to resolve user_id for filtering or cross-user logging
Retrieve a specific Harvest user by ID, including name, email, roles, capacity, and avatar
Retrieve the Harvest account's company information — name, plan type, clock format, currency, and capacity settings. Takes no parameters
Scrape a LinkedIn profile by URL or handle. Returns employment history, education, skills, and contact metadata. Supports simplified (main=true) and email enrichment modes
Scrape a LinkedIn company page by URL, universal name, or search query. Returns headcount, follower count, locations, specialties, and funding data
Batch scrape up to 50 LinkedIn profiles in one request via Apify. Requires a separate Apify API token. Billed at $4 per 1,000 profiles through Apify — separate from HarvestAPI credits
Retrieve full job listing details by URL or job ID — title, company, description, salary, location, workplace type, and applicant count
Search LinkedIn via Lead Search with title, company, location, and industry filters. Returns unmasked results. All filters support comma-separated multi-values
Quick profile lookup by name or keywords with title, company, school, and location filters. Use for simple name/title lookups; use search_people for advanced Lead Search
Search companies by keyword with industry, location, and headcount range filters. Returns name, domain, industry, and LinkedIn URL
Search job listings by keyword, location, company, workplace type, employment type, experience level, and salary range
Find freelancers and service providers by service keyword and location. Supports LinkedIn Geo ID override for precise location filtering
Search LinkedIn groups by keyword. Returns group name, member count, and description
Search LinkedIn posts by keyword, author profile, company, content type, and recency. Sortable by relevance or date
Look up LinkedIn geographic IDs by location name. Use returned geoId values as precise location overrides in search_jobs and search_profile_services
Retrieve full post details including content, author, likes, comments, and shares by URL or activity ID
Retrieve recent posts from a person's LinkedIn profile with engagement stats and timestamps
Retrieve recent posts from a company page with likes, comments, and shares. Filterable by recency: 24h, week, or month
Retrieve posts from a LinkedIn group with author info and engagement stats
Retrieve comments on a post with commenter profiles, text, and timestamps. Sortable by relevance or date
Retrieve users who reacted to a post with name, title, and reaction type (like, celebrate, support, insightful, funny, love)
Retrieve replies to a specific comment with author info and engagement stats
Retrieve users who reacted to a specific comment with name, title, and reaction type
Retrieve recent comments made by a profile, including post context and timestamps
Retrieve recent reactions (likes, celebrates, etc.) made by a profile on posts and articles
Retrieve group details including name, description, and member count by group URL or ID
Search LinkedIn Ad Library by keyword, advertiser, country, and date range. Supports Ad Library search URL input or individual filter parameters — mutually exclusive
Retrieve details of a specific LinkedIn ad from the Ad Library by URL or ad ID — content, creative, advertiser, and targeting
Send a connection request to a LinkedIn profile on behalf of an account. Requires the li_at session cookie. Optional personalized message up to 300 characters
get_sent_connection_requests
Retrieve pending outbound connection requests for an account. Requires li_at cookie
get_received_connection_requests
Retrieve pending inbound connection requests. Returns invitation_id and shared_secret needed to accept. Requires li_at cookie
accept_connection_request
Accept a pending connection request using invitation_id and shared_secret from get_received_connection_requests. Requires li_at cookie
Send a direct message to a 1st-degree LinkedIn connection on behalf of an account. Requires li_at cookie. Cannot message non-connections
Retrieve HarvestAPI account info including current credit balance, credits used, plan name, and rate limit. Call before high-volume scraping workflows to verify sufficient credits
get_private_account_pools
List private LinkedIn account pools configured on the HarvestAPI account. Private pools route requests through dedicated accounts for better rate limit isolation in high-volume workflows
Connector notes
HarvestAPI-specific behavior
Resolve project_id and task_id before logging time
Harvest uses numeric IDs for projects and tasks — not names. Call list_projects first to get project_id values, then inspect the project's tasks to get the correct task_id. An agent that hardcodes IDs will silently log against the wrong project across different Harvest accounts.
Connection management tools use li_at, not the API key
send_connection_request, get_sent_connection_requests, get_received_connection_requests, accept_connection_request, and send_linkedin_message require the user's LinkedIn session cookie (li_at), not their HarvestAPI key. li_at expires when the LinkedIn session ends. Handle the failure mode explicitly — a missing or expired cookie returns an auth error, not a data error.
bulk_scrape_profiles requires a separate Apify account
This tool routes through Apify and is billed separately from your HarvestAPI credits at $4 per 1,000 profiles. You must supply an apify_token from console.apify.com/settings/integrations. Failing to provide the token returns an error — the HarvestAPI key alone is not sufficient for this tool.
Check credit balance before high-volume workflows
HarvestAPI uses a pay-as-you-go credit model. Different operations have different costs — full profile scrapes cost more than simplified ones, and email enrichment costs extra per match. Call get_my_api_user before any high-volume scraping sequence to verify your balance. Requests that exceed your credit balance fail mid-workflow with no partial results.
Infrastructure decision
Why not build this yourself
The HarvestAPI setup looks simple — just store an API key. But here's what you're actually signing up for:
PROBLEM 01
Per-user API key isolation in a multi-tenant system — one user's HarvestAPI key must never be injected into another user's request, even under the same account
PROBLEM 02
Managing two credential types per user — a HarvestAPI key for scraping and a li_at session cookie for connection management — with different expiry behavior and failure modes
PROBLEM 03
Credit-aware request handling — bulk operations and email enrichment have separate billing, and exhausted credits fail silently mid-workflow without proper pre-flight checks
PROBLEM 04
Encrypted credential storage for API keys that belong to your customers — storing them in plaintext or a general config system is a security liability, not just an engineering shortcut
That's one connector. Your agent product will eventually need Salesforce, GitHub, Gmail, Slack, and whatever else your customers ask for. Each has its own auth quirks and failure modes.
Scalekit maintains every connector. You maintain none of them.