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

  1. Read the status and text already embedded in the message -- ZoomInfo states the exact problem (e.g. 'Invalid username or password').
  2. For 401/403, regenerate the username and private key in ZoomInfo and ensure the calling IP is allowlisted.
  3. 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

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

Related errors


AI-assisted analysis of coreyhaines31/marketingskills@7868cb9251 (2026-08-13). Data as JSON: /api/errors/3306d6d5a27dc66e. Report an issue: GitHub.