Appearance
Authentication
TenkeyBridge clones the QuickBooks Online OAuth 2.0 authorization-code flow. If your app already connects to QBO, you keep your OAuth code and swap two URLs.
| QuickBooks Online | TenkeyBridge | |
|---|---|---|
| Authorize URL | https://appcenter.intuit.com/connect/oauth2 | https://api.tenkeybridge.com/oauth2/v1/authorize |
| Token URL | https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer | https://api.tenkeybridge.com/oauth2/v1/tokens |
Getting credentials
Credentials are self-serve through the Admin API:
- Register an OAuth client —
POST /admin/v1/clientswith your app's name and redirect URIs. Returns aclient_idand aclient_secret, shown once. - Provision a realm per customer —
POST /admin/v1/realms, one per QuickBooks company you connect. Returns arealmId— you already hold this by the time you send anyone to the authorize URL, because you're the one who created it.
Both live under one organization; see Organizations and roles for who on your team can do what.
Early access
The self-serve portal UI in front of this API (#91 P4) isn't live yet — for now, access to /admin/v1 itself is arranged by email (hello@tenkeybridge.com). Once you have that access, provisioning clients and realms works exactly as described above and won't change when the portal ships.
1. Send the user to the authorize URL
text
https://api.tenkeybridge.com/oauth2/v1/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.example.com/callback
&state=RANDOM_OPAQUE_STRING
&scope=com.intuit.quickbooks.accounting
&realm_id=THE_REALM_IDresponse_typemust becode(anything else is rejected withunsupported_response_type, HTTP 400).redirect_urimust exactly match one of your registered redirect URIs.scopeis accepted for QBO compatibility and defaults tocom.intuit.quickbooks.accounting.realm_idis required: therealmIdof the QuickBooks company being connected. You already hold it — you're the one who created the realm through the Admin API — so this is the one parameter Intuit's authorize URL doesn't have.
The consent page names your app and the company (realm_id resolves server-side to a company name) and has one button. On success the browser is redirected to your redirect_uri with code, your state, and realmId in the query string — the same shape Intuit sends.
The server enforces that realm_id belongs to the same organization that owns client_id, on both the GET that renders this page and the POST it submits to — independently, so a value that only made it through a browser round trip is never trusted on its own. With that ownership check server-side, this page is honest about what it is: it preserves the QBO consent-screen shape so your existing OAuth code keeps working unmodified, but the click itself isn't the access control — the same-org check is. A realm_id that doesn't exist, or belongs to a different organization than the client, answers with the identical error either way (see below), so the page can't be used to probe which realm ids exist.
2. Exchange the code for tokens
POST /oauth2/v1/tokens with HTTP Basic auth (client_id:client_secret) and a form body:
bash
curl -s https://api.tenkeybridge.com/oauth2/v1/tokens \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=authorization_code \
-d code="$CODE" \
-d redirect_uri=https://yourapp.example.com/callbackredirect_uri must match the one the code was issued against. A mismatch returns invalid_grant and consumes the code — restart from step 1.
Response:
json
{
"token_type": "bearer",
"access_token": "…",
"expires_in": 3600,
"refresh_token": "…",
"x_refresh_token_expires_in": 8640000,
"realmId": "1234567890"
}Access tokens last 1 hour; refresh tokens last 100 days.
3. Refresh
Same endpoint, same Basic auth:
bash
curl -s https://api.tenkeybridge.com/oauth2/v1/tokens \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=refresh_token \
-d refresh_token="$REFRESH_TOKEN"Refresh tokens rotate: each refresh returns a new pair. Persist the new access_token and refresh_token together before using either.
Rotation is soft, matching Intuit: the previous refresh token stays usable for about 5 minutes after rotation. This exists for apps that refresh concurrently (a scheduled sync job racing a user-triggered one, say) without a refresh mutex — the race's "loser" isn't punished. Presenting the previous token within that window returns a fresh, independently valid pair (not the same one the winner got); both the winner's and the loser's pairs keep working. Presenting it again beyond the window returns invalid_grant. Revoking any token in the chain (see below) kills refresh for the whole connection immediately, window or not.
4. Call the API
bash
curl -s "https://api.tenkeybridge.com/v3/company/$REALM_ID/query" \
--get --data-urlencode "query=select * from Customer maxresults 5" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/json"Same paths and JSON as QBO — see the Quickstart and the compatibility contract.
Revoking
POST /oauth2/v1/revoke with Basic auth and token=<access-or-refresh-token>. Idempotent — always returns 200 (RFC 7009). Revoking any token for a connection kills refresh for that whole connection right away: a still-in-window previous refresh token (see rotation, above) stops working too. A disconnect means disconnected.
Errors
| Symptom | Meaning |
|---|---|
400 unsupported_response_type on authorize | response_type wasn't code |
| 400 "invalid client_id or redirect_uri" | Unknown client, or redirect_uri not registered |
| 400 "realm_id is required" on authorize | realm_id missing from the authorize URL or consent form |
| 400 "invalid realm_id" on authorize | The realm doesn't exist, or belongs to a different organization than the client — same error either way |
401 invalid_client on /tokens | Basic auth header wrong (client ID/secret) |
400 invalid_grant on /tokens | Code expired/consumed, redirect_uri mismatch, or a refresh token reused past its ~5-minute grace window (or after revocation) |
API-level faults use QBO's Fault shape — see Error codes.