Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
Authenticate a service to HubSpot and operate the auth layer in production. This is not a setup walkthrough — it is the auth code your integration runs at 3am when an OAuth token expires mid-batch, when a portal admin removes a scope, when an agency credential router sends a request to the wrong portal, and when on-call needs to rotate a leaked private-app token without dropping in-flight requests.
The six production failures this skill prevents:
Token expiry storms — OAuth access tokens expire in 1800 seconds. Every concurrent request notices expiry simultaneously, races to refresh, the token endpoint rate-limits at 10 auth calls/10s, the integration cascades to red.
Daily rate-limit burnout — retry storms on auth failures burn through the 500K daily API call quota before noon. Exponential backoff with jitter is non-optional.
Scope drift — a portal admin edits the private app's scopes or a connected OAuth app loses authorization. Cached tokens start returning 403. Retrying does not help.
Token leakage in commits — pat-na1-* private-app tokens are wide-scope and not auto-expiring. A single leaked commit exposes the entire portal.
Multi-portal credential routing — agencies managing 50+ portals cannot use a single HUBSPOT_ACCESS_TOKEN. Requests sent to the wrong portal silently operate on the wrong data.
OAuth refresh-token decay — HubSpot refresh tokens expire after one year of non-use. Integrations that go idle (seasonal products, paused automations) silently lose access and require user reconnection.
Prerequisites
Node.js 18+ (examples) or Python 3.10+
HubSpot account with a private app or a connected OAuth public app
For private apps: token from Settings → Integrations → Private Apps → your app → Auth tab
For OAuth: client ID + secret from developer portal, redirect URI registered
A secret store the runtime can read at startup and on rotation signal (env var, AWS Secrets Manager, GCP Secret Manager, or equivalent)
Instructions
Build in this order. Each section neutralizes one production failure mode.
Reactive refresh on 401 is wrong. It doubles latency on the failing request and creates a thundering herd when all concurrent requests notice expiry at the same millisecond. Cache the token in-process and refresh proactively at 80% of TTL, behind a single-flight gate so concurrent callers serialize on one refresh.
This pattern applies to OAuth access tokens only — private-app tokens do not expire.
3. Scope validation on refresh (neutralizes scope drift)
When a portal admin edits scopes, your next token refresh silently returns a token with the new (reduced) scope set. Requests fail with 403 and no retry will help. Validate scopes immediately after each refresh:
Agencies and ISVs managing multiple HubSpot portals need per-portal token caches, not a single env var. Requests sent to the wrong portal silently operate on the wrong data with no error.
// credentials.json (in secret store, NOT in git)// { "portals": { "acme-corp": "pat-na1-...", "beta-inc": "pat-na1-..." } }classHubSpotRouter {
private caches = newMap<string, { value: string; expiresAt: number }>();
privatecredentials: Record<string, string>;
constructor(credentials: Record<string, string>) {
this.credentials = credentials;
}
asyncgetClient(portalSlug: string): Promise<{ token: string }> {
const cached = this.caches.get(portalSlug);
// Private-app tokens don't expire — still cache to avoid repeated lookupsif (cached) return { token: cached.value };
const token = this.credentials[portalSlug];
if (!token) thrownewError(`No credential for portal: ${portalSlug}`);
// Verify the token is live before cachingconst ok = awaitverifyToken(token);
if (!ok) thrownewError(`Token for portal ${portalSlug} is invalid or revoked`);
this.caches.set(portalSlug, { value: token, expiresAt: Infinity });
return { token };
}
}
// Load credentials from secret store at startupconst creds = JSON.parse(awaitreadSecret("hubspot/portal-credentials"));
const router = newHubSpotRouter(creds.portals);
// Usageconst { token } = await router.getClient("acme-corp");
HubSpot refresh tokens expire after 525,600 minutes (1 year) of non-use. Integrations with seasonal usage patterns or paused automations will silently lose access.
// Store last-used timestamp alongside the refresh tokeninterfaceRefreshTokenRecord {
token: string;
lastUsed: number; // Unix ms
}
constREFRESH_TOKEN_WARN_DAYS = 300; // warn at 300d, expire at 365dasyncfunctionloadRefreshToken(): Promise<string> {
constrecord: RefreshTokenRecord = JSON.parse(
awaitreadSecret("hubspot/refresh-token")
);
const ageDays = (Date.now() - record.lastUsed) / 86_400_000;
if (ageDays > REFRESH_TOKEN_WARN_DAYS) {
console.warn(
`HubSpot refresh token unused for ${ageDays.toFixed(0)} days — ` +
"reconnect the OAuth app before day 365 or access will be lost."
);
}
// Update last-used timestampawaitwriteSecret("hubspot/refresh-token", JSON.stringify({
...record,
lastUsed: Date.now(),
}));
return record.token;
}
Error Handling
HTTP Status
HubSpot Error
Root Cause
Action
401 UNAUTHORIZED
INVALID_AUTHENTICATION
Token expired, revoked, or malformed
Refresh or re-rotate token
403 FORBIDDEN
MISSING_SCOPES
Scope removed from private app
Portal admin re-grants scopes
429 TOO_MANY_REQUESTS
RATE_LIMIT
Auth or API quota exhausted
Back off with Retry-After header
400 BAD_REQUEST
INVALID_CLIENT
Wrong client ID/secret on token request
Verify credentials in dev portal
400 BAD_REQUEST
REFRESH_TOKEN_NOT_FOUND
Refresh token expired or revoked
User must reconnect OAuth app
Output
Token-cache module with proactive refresh at 80% TTL
Exponential backoff with jitter wired to every auth and API call