Home › Guides › QuickBooks refresh token invalid_grant

QuickBooks Online refresh token returns invalid_grant: why, and how to fix it

Matching pack: API Integrator for QuickBooks Online (US$24), an Agent Skill for Claude Code and Cursor with a dependency-free TypeScript client that does rotation-safe token refresh. Read its test report (19 tests against mocked Intuit HTTP, not a live sandbox company).

Your app calls Intuit's token endpoint with grant_type=refresh_token and gets back HTTP 400 with {"error":"invalid_grant"}. Every QuickBooks Online API call then fails, because you can't get a new access token. Here is what that error means, what to do when you see it, and how to stop it coming back.

The short answer

invalid_grant on a refresh means Intuit will no longer accept that refresh token. Retrying the same request will not help. Either you sent an old token that has since been replaced, or the token has expired or been revoked. If the latest token you have stored also fails, the only fix is to send the user through the OAuth 2.0 connect flow again and store the new tokens.

How QuickBooks Online tokens behave

Sources: Intuit Developer help, "Refresh Token Expiration and Validity Policy" (help.developer.intuit.com/s/article/Validity-of-Refresh-Token); Intuit Developer blog, "Important changes to refresh token policy", 12 Nov 2025 (blogs.intuit.com/2025/11/12/important-changes-to-refresh-token-policy); intuit/oauth-jsclient CHANGELOG, version 4.2.4 (github.com/intuit/oauth-jsclient). Checked 22 Oct 2026.

Common causes of invalid_grant on refresh

  1. You refreshed with an old token. The app saved the refresh token from the first connection and never saved the new one from a later refresh response. It works for about a day, then fails. This is the most common cause.
  2. Two processes refreshed at once. Two workers, or two serverless instances, both read token A and both call refresh. One gets token B and saves it; the other can end up with an error, or can overwrite B in your database with a value that is no longer current. The next refresh then fails.
  3. The new token was never persisted. The refresh succeeded but the database write failed, ran in a transaction that rolled back, or the process crashed before saving. You are left holding the old, now-invalid token.
  4. The token wasn't used for 100 days. A company connection that nothing touched for 100 days has an expired refresh token.
  5. The connection was revoked. The user disconnected your app in QuickBooks Online, or your code called Intuit's revoke endpoint (for example in a "disconnect" button or a cleanup script).
  6. The wrong keys or environment. A token is tied to the app keys it was issued for. A token from a connection made with your Development keys won't refresh with your Production keys, or with a different app's keys.
  7. The token was changed in storage. A column that is too short truncates it, or the value is URL-encoded twice, or whitespace is added. Store it exactly as received and send it form-encoded.

Sources: Intuit Developer help, "Refresh Token Expiration and Validity Policy" (help.developer.intuit.com/s/article/Validity-of-Refresh-Token); Intuit Developer blog, "OAuth token management done the right way", 3 Jun 2024 (blogs.intuit.com/2024/06/03/oauth-token-management-done-the-right-way). Checked 22 Oct 2026.

The refresh request, done right

The endpoint is POST https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer, with HTTP Basic auth (client_id:client_secret) and a form-encoded body. To test one connection by hand:

curl -X POST https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer \
  -u "$INTUIT_CLIENT_ID:$INTUIT_CLIENT_SECRET" \
  -H "Accept: application/json" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "refresh_token=$LATEST_REFRESH_TOKEN"

In code, handle invalid_grant as "reconnect needed", not as a temporary error, and save both tokens from every successful response:

// Node 20+ (global fetch). One refresh per realmId at a time; see "Prevent it" below.
const TOKEN_URL = "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer";

export async function refreshQbo(conn: StoredConnection): Promise<StoredConnection> {
  const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");
  const res = await fetch(TOKEN_URL, {
    method: "POST",
    headers: {
      Authorization: `Basic ${basic}`,
      Accept: "application/json",
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: conn.refreshToken }),
  });
  const body = await res.json().catch(() => ({}));

  if (!res.ok) {
    if (body.error === "invalid_grant") {
      // Not retryable: this refresh token is dead. Ask the user to reconnect.
      await markDisconnected(conn.realmId, "invalid_grant");
      throw new ReconnectRequired(conn.realmId);
    }
    throw new Error(`QuickBooks token refresh failed: HTTP ${res.status}`);
  }

  const now = Date.now();
  const next: StoredConnection = {
    realmId: conn.realmId,
    accessToken: body.access_token,
    refreshToken: body.refresh_token,                          // save it EVERY time, changed or not
    accessExpiresAt: now + body.expires_in * 1000,              // 3600 s
    refreshExpiresAt: now + body.x_refresh_token_expires_in * 1000,
  };
  await saveConnection(next);                                   // persist before using the new access token
  return next;
}

StoredConnection, saveConnection, markDisconnected and ReconnectRequired stand for your own storage and error types. Keep the secret on your server only.

What to do when you see it

  1. Check that the token you sent is the latest one you stored, for that realmId. If your logs show a newer refresh token was returned and then lost, fix the save path first (causes 1 to 3).
  2. Check that the client ID matches the environment the connection was made in (Development or Production).
  3. If the latest stored token still fails, stop retrying. Mark the connection as disconnected, stop scheduled syncs for that company, and show the user a "Reconnect to QuickBooks" button that starts the OAuth flow again. Store the new tokens and the realmId from the callback.
  4. Intuit's developer portal has a "Reconnect URL" field in app settings (available since 24 Feb 2026). Point it at that reconnect page, so customers who see an expiring-connection notice in QuickBooks Online land in the right place.

Source: Intuit Developer blog, "Important changes to refresh token policy", 12 Nov 2025 (blogs.intuit.com/2025/11/12/important-changes-to-refresh-token-policy). Checked 22 Oct 2026.

Prevent it

Let your agent build it the safe way

API Integrator for QuickBooks Online (US$24) is an Agent Skill for Claude Code and Cursor. It handles OAuth 2.0 with state checks and realmId, refresh tokens that rotate (kept safe with single-flight refresh), the 5-year refresh-token cap (x_refresh_token_hard_expires_in), minorversion 75, duplicate-safe creates with requestid, find-or-create customers, invoices, emailing invoices, and errors (Fault parsing, 401, 429). It was written from Intuit's 2025–26 developer docs. Its 19 automated tests use mocked Intuit HTTP, not a real sandbox company, and the multi-instance refresh lock is described in the skill but not implemented.

See API Integrator for QuickBooks Online · Read its test report