coreyhaines31/marketingskills · error · Error
Authentication failed (${res.status}): ${text}
Error message
Authentication failed (${res.status}): ${text} What it means
The POST to https://api.zoominfo.com/authenticate returned a non-2xx HTTP status. The error embeds both the status code and the raw response body, so ZoomInfo's reason is visible in the message. 401 means bad credentials, 400 a malformed request body, 403 an IP/permission issue, and 5xx a ZoomInfo-side fault.
Source
Thrown at tools/clis/zoominfo.js:26
console.error(JSON.stringify({ error: 'ZOOMINFO_ACCESS_TOKEN or ZOOMINFO_USERNAME + ZOOMINFO_PRIVATE_KEY environment variables required' }))
process.exit(1)
}
async function authenticate() {
if (ACCESS_TOKEN) return ACCESS_TOKEN
const username = process.env.ZOOMINFO_USERNAME
const password = process.env.ZOOMINFO_PRIVATE_KEY
if (!username || !password) {
throw new Error('ZOOMINFO_USERNAME and ZOOMINFO_PRIVATE_KEY required for authentication')
}
const res = await fetch(`${BASE_URL}/authenticate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
const text = await res.text()
if (!res.ok) {
throw new Error(`Authentication failed (${res.status}): ${text}`)
}
try {
const data = JSON.parse(text)
if (!data.jwt) throw new Error('No JWT in response')
ACCESS_TOKEN = data.jwt
return ACCESS_TOKEN
} catch (e) {
if (e.message === 'No JWT in response') throw e
throw new Error(`Authentication failed: ${text}`)
}
}
async function api(method, path, body) {
if (args['dry-run']) {
return { _dry_run: true, method, url: `${BASE_URL}${path}`, headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ***' }, body }
}
const token = await authenticate()
const res = await fetch(`${BASE_URL}${path}`, {View on GitHub (pinned to 7868cb9251)
Solutions
- Read the status and text already embedded in the message -- ZoomInfo states the exact problem (e.g. 'Invalid username or password').
- For 401/403, regenerate the username and private key in ZoomInfo and ensure the calling IP is allowlisted.
- Preserve real newlines in the private key by exporting it single-quoted in the shell, or load it from a file to avoid shell mangling.
Defensive patterns
Strategy: retry
Try / catch
async function authenticateWithRetry(maxAttempts = 3) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await authenticate()
} catch (e) {
const status = /\((\d{3})\)/.exec(e.message)?.[1]
const transient = status && Number(status) >= 500
if (!transient || attempt === maxAttempts) throw e
await new Promise(r => setTimeout(r, 500 * 2 ** attempt))
}
}
} Prevention
- Parse the status code from the message to distinguish 4xx (fix credentials) from 5xx (retry).
- Keep the private key's real newlines intact to avoid 400-level body rejections.
- Ensure the calling IP is allowlisted in ZoomInfo to prevent intermittent 403s.
When it happens
Trigger: Wrong username/private-key pair, expired or locked credentials, an IP not allowlisted by the account, a malformed JSON body (e.g. a private key with literal backslash-n instead of real newlines), or ZoomInfo rate limiting / outage.
Common situations: Private key pasted with escaped backslash-n instead of real line breaks, credentials from a different ZoomInfo environment/region, account throttled after repeated automated calls, or a stale key after rotation.
Understand the failure class
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
Related errors
- Authentication failed: ${text}
- Failed to obtain access token
- ZOOMINFO_USERNAME and ZOOMINFO_PRIVATE_KEY required for auth
- No JWT in response
- Failed to obtain access token
AI-assisted analysis of coreyhaines31/marketingskills@7868cb9251 (2026-08-13).
Data as JSON: /api/errors/3306d6d5a27dc66e.
Report an issue: GitHub.