OAuth authorization_code exchange fails with "Not Authorized" — reproducible across 2 apps, 3 test accounts, and production

## Summary

Every attempt to exchange a valid OAuth authorization_code for an access token via POST /oauth2/token fails with:

{ “message”: “Not Authorized”, “type”: “service.not_authorized” }

This happens 100% of the time, regardless of which application, which seller account, or which environment (Sandbox or Production) is used. I’ve isolated this specifically to the authorization_code grant type — directly using an already-issued access token, or using the Developer Console’s own “Renew token” feature, both work fine. So this doesn’t look like a general account restriction — it looks like something specifically blocking the OAuth code-exchange step for my developer account.

## Reproduction steps

1. Send user to /oauth2/authorize with valid client_id, scope, state.

2. User approves on the real Square consent screen (confirmed working correctly — correct app name, correct scopes shown).

3. Square redirects back with a valid, unused, unexpired code.

4. POST to /oauth2/token with client_id, client_secret, code, grant_type: authorization_code (JSON body, Content-Type: application/json) — fails every time with the error above.

## What I’ve ruled out

- Wrong/malformed secret: Regenerated the Sandbox Application secret 3 times from the correct location (OAuth tab, not Credentials tab), confirmed correct prefix (sandbox-sq0csb-), correct length, no whitespace/newlines each time.

- App-specific corruption: Reproduced on a completely brand-new application (sq0idp-c8q8A9cboJ1FxTYZ5pjpJg, “Shears and Beards Site”) created specifically to rule this out, in addition to the original app (sq0idp-wFcpcPpH6pGphYd46BsDQg, “Shears and Beards Booking”). Identical failure on both.

- Seller-account-specific issue: Reproduced with 3 different Sandbox test accounts (“Default Test Account”, “Shears & Beards (Shop)”, “Marcus Stand-in (Contractor)”). Identical failure with all three.

- Sandbox-only bug: Reproduced in Production as well, with my own real Square merchant account logging in and approving through the real consent screen. Identical “Not Authorized” error.

- Client-side code bug: Reproduced the exact same failure using Square’s own official API Explorer tool (Square API Explorer, Obtain Token endpoint) with the same client_id/client_secret/code — so this isn’t a bug in my server code.

- Minimal scope: Reproduced even when requesting just MERCHANT_PROFILE_READ alone (no Appointments/Items scopes), so it’s not a permissions/feature-gating issue on a specific scope.

## What does work (for contrast)

- Using the Developer Console’s “Renew token” button for an existing test-account token, then calling GET /v2/merchants with that token: returns 200 OK with real merchant data. So Sandbox itself, and token-based API auth in general, are healthy for my account.

- This confirms the failure is specific to the authorization_code to /oauth2/token exchange step itself, not a broader account or API-access problem.

## Apps/IDs involved

- Sandbox: sandbox-sq0idb-VAd1FszISeUaXu4E3Wj3uA (app: Shears and Beards Booking, sq0idp-wFcpcPpH6pGphYd46BsDQg)

- Sandbox: sandbox-sq0idb-1p5m6AjpcScGaz5FtcL17g (app: Shears and Beards Site, sq0idp-c8q8A9cboJ1FxTYZ5pjpJg)

- Production: sq0idp-c8q8A9cboJ1FxTYZ5pjpJg (same app, Production credentials)

## Ask

Could someone look into whether there’s an account-level restriction or flag blocking the authorization_code OAuth grant type specifically for my developer account? Happy to provide timestamps of specific failed requests if that helps you find them in logs.

Thanks!

Hi! “Not Authorized” here is just a generic “credentials rejected” error. It’s not an account restriction, and there’s no flag that blocks the authorization_code grant.

Two things to check:

  1. redirect_uri. If your /oauth2/authorize URL included a redirect_uri, you
    also have to send the exact same redirect_uri in the /oauth2/token request.
    Yours isn’t in the token body. This is the most likely culprit.

  2. Sandbox host + matching credentials. For Sandbox, both /oauth2/authorize and
    /oauth2/token need to be on connect.squareupsandbox.com, using the sandbox
    app ID (sandbox-sq0idb-) and sandbox secret (sandbox-sq0csb-). Heads up that
    the authorize step accepts a mismatched app ID without erroring, so you get a
    normal-looking consent screen and code and only see the failure at exchange.
    Also double-check the Sandbox/Production toggle in API Explorer; if that was
    set to Production with a sandbox code, it’d fail the same way your own code
    does.

The fact that it happens on every app and account actually suggests one shared mistake in the request rather than something wrong with your account. Let me know if this helps!

Hi Ashley — thanks for looking into this, and sorry for the slow follow-up on my end.

Good news: we did get to the bottom of it. It turned out to be a request-side issue like you suspected, though not quite either of the two things you flagged — redirect_uri and the sandbox/production host and credential pairing were both already double-checked and consistent.

The actual cause: a stale/mismatched Application Secret sitting in our local environment config. “Not Authorized” turned out to be Square’s generic response whenever the App ID + secret pair itself isn’t valid — it wasn’t specific to the authorization_code grant at all, which is why it hit every app, account, and environment identically. It also explains why nothing showed up in the API Logs: rejected-credential attempts apparently aren’t logged there.

For anyone else who lands on this thread with the same symptom (an existing/renewed token works fine, but it fails only at the authorization_code exchange step): a quick way to test your secret is to send a deliberately fake refresh_token. “Invalid refresh token” back means your secret is good. “Not Authorized” means the secret itself is the problem — regenerate it and update it everywhere it’s stored.

Replacing the secret fixed it immediately for us, confirmed end-to-end in Sandbox. Thanks again for the nudge to look at the request itself rather than assume it was account-side — that was the right instinct even if the specific culprit was a bit different.

Great to hear! Thank you so much for replying back with the solution to help other developers.