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
- Access token: valid for 1 hour (
expires_in: 3600). - Refresh token: valid for up to 100 days, on a rolling basis (
x_refresh_token_expires_in, in seconds). Each successful refresh resets the 100 days. - Rotation: the refresh token can change about once a day. When a refresh response contains a new refresh token, the previous one stops working, and using it returns
invalid_grant. Intuit's advice is to always store and use the latest refresh token returned. - 5-year cap: since Intuit's November 2025 policy change, refresh tokens also have a maximum life of five years. For apps on the Accounting or Payments scopes, tokens issued from October 2023 are covered, so the first ones expire in October 2028. Intuit's official OAuth clients can opt in to an
x-include-refresh-token-hard-expires-in: truerequest header, which makes the token endpoint returnx_refresh_token_hard_expires_in(seconds left in that 5-year life). Treat that field as optional; it may be absent.
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
- 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.
- 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.
- 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.
- The token wasn't used for 100 days. A company connection that nothing touched for 100 days has an expired refresh token.
- 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).
- 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.
- 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
- 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). - Check that the client ID matches the environment the connection was made in (Development or Production).
- 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
realmIdfrom the callback. - 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
- Save the access token and the refresh token from every refresh response, even when the refresh token looks unchanged, and save them before you use the new access token.
- Allow only one refresh per
realmIdat a time: share one in-flight refresh inside a process, and take a per-realm lock (a database advisory lock or a Redis lock) when you run more than one instance. - Refresh shortly before the access token expires (for example 5 minutes early) instead of waiting for a 401.
- Make sure every connected company is refreshed at least once within 100 days, even if nothing else uses it, for example with a weekly job.
- Store tokens per
realmId, encrypted at rest, in columns long enough for the full value. - Log the time and outcome of each refresh per realm (never the token itself), so the next
invalid_grantis easy to trace. - If your client receives
x_refresh_token_hard_expires_in, use it to ask customers to reconnect before the 5-year cap.
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