How to Integrate with the FreshBooks API (2026 Engineering Guide)
A technical deep-dive into FreshBooks API integration: 12-hour OAuth tokens with single-use rotation, silent pagination caps, webhook verification, and the build-vs-buy decision.
If your product touches financial data, expense management, or invoicing, your customers will eventually ask for a FreshBooks integration. At first glance the API appears standard — a JSON-based REST API with well-documented endpoints for invoicing, expenses, clients, and time tracking. The real engineering cost is not in reading the docs. It is in handling OAuth token rotation that silently invalidates your previous refresh token, a pagination system that ignores your page size requests, and a webhook verification flow that requires you to echo back a code via a separate PUT request. This is where engineering teams burn weeks of unbudgeted time.
FreshBooks is cloud-based accounting software designed for owners of small client-service businesses that send invoices and get paid for their time and expertise. Founded in 2003, it holds a massive share of the small business and freelancer market — 68% of FreshBooks customers are businesses with fewer than 50 employees. If your product serves freelancers, agencies, or service-based SMBs, you will encounter FreshBooks accounts in the wild. This guide covers the actual technical constraints you will hit, the architectural decisions required to keep the integration stable, and the trade-offs between building natively versus using a unified accounting API. For a broader perspective on the accounting integration landscape, see our guide on the best unified accounting API for B2B SaaS.
Understanding the FreshBooks API Architecture
The FreshBooks API is a RESTful JSON API with a dual-identity model that requires two different identifiers depending on which resource you are accessing.
FreshBooks API Quick Reference:
- Protocol: REST over HTTPS
- Data format: JSON
- Base URL:
https://api.freshbooks.com - Authentication: OAuth 2.0 (authorization code grant)
- SDKs: Official SDKs for Node.js and Python; community libraries for PHP and Java
This dual-identity model is the first place most teams trip up. All FreshBooks users have an Identity and a Business resource and thus a business_id. Most users also have accounts, which represent their own FreshBooks account, but not all. You retrieve both identifiers from a single endpoint after authentication:
curl -X GET 'https://api.freshbooks.com/auth/api/v1/users/me' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN'The response includes a business_memberships array:
{
"response": {
"id": 123456,
"business_memberships": [
{
"business": {
"id": 987654,
"account_id": "xY1z2"
}
}
]
}
}A single user can have multiple business memberships with different roles — owner, admin, manager, employee, contractor, client. Your integration needs to let the user pick which business they want to connect, or iterate over all available accounts.
Here is the critical detail most developers miss: some API resources are scoped to a FreshBooks accountId while others are scoped to a businessId. In general these fall along the lines of accounting resources versus projects and time tracking resources, but that is not a precise rule.
| Endpoint group | Identifier used | Example path |
|---|---|---|
| Invoices, Clients, Expenses, Payments | accountId |
/accounting/account/{accountId}/invoices/invoices |
| Time Tracking, Projects | businessId |
/timetracking/business/{businessId}/time_entries |
| Journal Entries (newer API) | business_uuid |
/accounting/businesses/{business_uuid}/journal_entries |
Using the wrong identifier for the wrong endpoint gives you a confusing 404 with no explanation. Store both values at connection time and route requests accordingly.
FreshBooks API scopes you actually need
FreshBooks uses granular, resource-level scopes that must be declared when initiating the OAuth flow. If you fail to include a required scope in the initial authorization request, the API returns an authorization error for that resource. Correcting this requires your customer to revoke the existing access and go through the full OAuth flow again.
This means you need to get your scope list right before launch. Requesting too few scopes is painful to fix in production because it forces every connected customer back through the auth flow. Common scopes for accounting integrations:
user:profile:read— required to access the identity modeluser:invoices:read,user:invoices:writeuser:clients:read,user:clients:writeuser:expenses:read,user:expenses:writeuser:payments:read,user:payments:write
How FreshBooks OAuth 2.0 Token Rotation Works
FreshBooks access tokens expire after 12 hours, and refresh tokens are single-use — each refresh gives you a new pair, immediately invalidating the old refresh token.
This 12-hour lifespan is unusually short compared to other accounting platforms that often issue tokens valid for 24 hours or longer. The combination of a short-lived access token and a single-use refresh token creates a high-risk environment for production failures that you will not catch in local development.
Here is the complete auth flow:
sequenceDiagram
participant App as Your App
participant FB as FreshBooks
participant User as User Browser
User->>FB: Visit auth URL with scopes
FB->>User: Login + consent screen
User->>App: Redirect with auth code
Note over App: Code valid for 5 minutes
App->>FB: POST /auth/oauth/token<br>(code + client credentials)
FB->>App: access_token (12h) +<br>refresh_token (single-use)
Note over App: Store both tokens securely
App->>FB: API calls with Bearer token
Note over App: Before token expires...
App->>FB: POST /auth/oauth/token<br>(refresh_token + client credentials)
FB->>App: NEW access_token +<br>NEW refresh_token
Note over App: Old refresh token is<br>now permanently invalidThe token exchange request:
curl -X POST 'https://api.freshbooks.com/auth/oauth/token' \
-H 'Content-Type: application/json' \
-d '{
"grant_type": "refresh_token",
"client_id": "YOUR_CLIENT_ID",
"refresh_token": "YOUR_CURRENT_REFRESH_TOKEN",
"client_secret": "YOUR_CLIENT_SECRET",
"redirect_uri": "YOUR_REDIRECT_URI"
}'Why single-use rotation is dangerous
Every time you use a refresh token to obtain a new access token, FreshBooks issues a brand new refresh token alongside it. The old refresh token is immediately invalidated. There is no recovery path other than asking the customer to go through the OAuth flow again.
This creates a class of bugs that are invisible in development and catastrophic in production:
- Race conditions: Two concurrent processes — say, an invoice sync and an expense sync — both detect an expired token simultaneously. Both attempt to use the same refresh token. The first succeeds and gets a new token pair. The second sends the now-invalidated old refresh token and gets an
invalid_granterror. If your error handling interprets this as a hard authentication failure, it marks the integration as disconnected, forcing the user to re-authenticate manually — even though valid credentials were just retrieved by the first process.
sequenceDiagram
participant Job A
participant Job B
participant FreshBooks API
Job A->>FreshBooks API: POST /oauth/token (use refresh_token_1)
Job B->>FreshBooks API: POST /oauth/token (use refresh_token_1)<br>Race condition occurs
FreshBooks API-->>Job A: 200 OK (access_token_2, refresh_token_2)
FreshBooks API-->>Job B: 400 Bad Request (invalid_grant)-
Partial write failures: You refresh the token, get the new pair, but your database write fails before persisting the new refresh token. The old one is gone. The new one is lost. The customer has to re-authorize.
-
Retry storms: A failed API call triggers a retry, which triggers another refresh attempt with a stale token, which triggers another failure, and so on.
The fix is a concurrency lock around your refresh logic. As we cover in our guide on architecting a scalable OAuth token management system, only one process should ever attempt a refresh per connected account at a time. Callers that arrive while a refresh is in-flight should wait for the result rather than issuing their own refresh request.
async function getValidFreshBooksToken(accountId: string): Promise<string> {
const tokenData = await db.tokens.get(accountId);
if (isExpired(tokenData.expiresAt)) {
// Acquire a distributed lock specific to this account
const lock = await mutex.acquire(`freshbooks_refresh_${accountId}`);
try {
// Double-check inside the lock — another process may have just refreshed
const freshTokenData = await db.tokens.get(accountId);
if (!isExpired(freshTokenData.expiresAt)) {
return freshTokenData.accessToken;
}
const response = await performOAuthRefresh(freshTokenData.refreshToken);
await db.tokens.update(accountId, response);
return response.accessToken;
} finally {
await lock.release();
}
}
return tokenData.accessToken;
}Do not underestimate this. The combination of 12-hour token lifespan and single-use rotation means your token management code is the single most critical piece of your FreshBooks integration. A bug here does not produce a visible error — it silently disconnects your customer's account.
For teams managing hundreds of integrations, building and maintaining this locking infrastructure is a serious drain on resources. We covered the broader architectural patterns in our guide on handling OAuth token refresh failures in production.
Truto handles this by refreshing OAuth tokens proactively, 60 to 180 seconds before expiry with randomized jitter. If multiple requests attempt a refresh concurrently, only one actually executes while the others wait for the result — eliminating the race condition that plagues single-use rotation. If a refresh fails, the connected account is flagged and a webhook event notifies your application immediately so you can prompt re-authorization.
Handling FreshBooks API Pagination and the Silent 100-Item Cap
FreshBooks paginates list endpoints using page and per_page query parameters, but silently caps results at 100 items per page regardless of what you request.
There is no error, no warning header, no obvious documentation caveat. If you request per_page=500, you get 100 results back and the response metadata says per_page: 100.
The Silent Cap Trap
If your pagination logic relies on checking if (response.data.length < requested_per_page) to determine the final page, a requested per_page of 500 will cause your loop to terminate immediately after the first page (because 100 is less than 500). You will silently drop the majority of your customer's data with no error to alert you.
A correct pagination loop uses the metadata FreshBooks returns in the response:
async function fetchAllInvoices(accountId, accessToken) {
let page = 1;
const perPage = 100; // Max enforced by API — hardcode this
let allInvoices = [];
while (true) {
const response = await fetch(
`https://api.freshbooks.com/accounting/account/${accountId}/invoices/invoices?page=${page}&per_page=${perPage}`,
{ headers: { 'Authorization': `Bearer ${accessToken}` } }
);
const data = await response.json();
const invoices = data.response.result.invoices;
allInvoices = allInvoices.concat(invoices);
const { pages: totalPages } = data.response.result;
if (page >= totalPages) break;
page++;
}
return allInvoices;
}The response payload includes page, pages (total pages), per_page, and total fields:
{
"response": {
"result": {
"invoices": [],
"page": 1,
"pages": 15,
"per_page": 100,
"total": 1450
}
}
}Always use these values to drive your loop rather than assuming your requested page size was honored.
Another quirk worth flagging: the form of the sort parameter differs between endpoint groups. For /accounting endpoints, ascending sort uses ?sort=field_name_asc. Project-like endpoints (/projects, /timetracking) use ?sort=field_name for ascending and ?sort=-field_name for descending. Mixing these up gives you unsorted results with no error.
For more on handling pagination inconsistencies at scale, see our article on normalizing pagination across 50+ APIs.
Handling FreshBooks 429 rate limit errors
There is no limit on the number of API requests per day. However, requests will be rate-limited if too many calls are made within a short period. The exact numeric limits are not published, and FreshBooks reserves the right to disable an app that is hitting their API aggressively.
The lack of published numeric limits is the real problem. You cannot pre-calculate a safe request rate. Your only option is reactive handling:
- Catch 429 responses and respect the
Retry-Afterheader if present. - Implement exponential backoff with jitter — start at 1 second, double on each retry, add randomness to avoid synchronized retries across workers.
- Cap your concurrency — there are no dedicated bulk endpoints, so paginating large datasets means many sequential requests. Keep parallelism low.
- Log aggressively — without published limits, your observability is your only debugging tool.
The absence of bulk endpoints makes rate limits especially painful for initial data syncs when you are pulling thousands of invoices or clients one page at a time.
Implementing and Verifying FreshBooks Webhooks
FreshBooks webhooks require a multi-step verification handshake: register the callback, receive a verification code via POST, then echo it back via a PUT request before any events are delivered.
Polling the API for changes is highly inefficient and will quickly exhaust your rate limits. Webhooks are the only viable path for real-time data sync. However, the setup process is unusually involved.
When you register a webhook callback URL via the FreshBooks API, the webhook is initially created in an unverified state. FreshBooks immediately sends an HTTP POST request to your callback URL containing a unique verification code and a callback ID.
sequenceDiagram
participant App as Your App
participant FB as FreshBooks API
App->>FB: POST /events/account/{id}/events/callbacks<br>{event: "invoice.create", uri: "https://..."}
FB->>App: callback created (verified: false)
FB->>App: POST to your URI with<br>verification code
Note over App: Store the verification code
App->>FB: PUT /events/account/{id}/events/callbacks/{cb_id}<br>{verifier: "code_from_POST"}
FB->>App: callback updated (verified: true)
Note over FB,App: Events now flow to your URIYour application must parse the incoming POST, extract the verifier string, and immediately send a PUT request back to the FreshBooks API to prove ownership of the endpoint:
app.post('/webhooks/freshbooks', async (req, res) => {
const { verifier, callback_id } = req.body;
// Respond to the initial POST quickly
res.status(200).send();
if (verifier) {
// Execute the verification handshake
await axios.put(
`https://api.freshbooks.com/events/account/${accountId}/events/callbacks/${callback_id}`,
{ callback: { verifier: verifier } },
{ headers: { Authorization: `Bearer ${token}` } }
);
}
});Store the verification code permanently. It is reused as the secret to calculate HMAC signatures for validating all future webhook payloads from FreshBooks.
Validating webhook signatures with HMAC-SHA256
Each webhook sent by FreshBooks includes an X-FreshBooks-Hmac-SHA256 header with a base64-encoded signature generated using the request body and the verification code from the handshake as the secret.
const crypto = require('crypto');
function verifyFreshBooksWebhook(payload, signatureHeader, verifierSecret) {
const jsonPayload = JSON.stringify(payload);
const hmac = crypto.createHmac('sha256', verifierSecret);
const digest = hmac.update(jsonPayload, 'utf8').digest('base64');
return digest === signatureHeader;
}Watch out for a common gotcha here: FreshBooks signs the JSON payload with specific formatting — spaces after colons and commas (e.g., {"key": "value", "key2": "value"}). Python's json.dumps produces this format by default, but many other languages and serializers use compact formatting without those spaces. If your JSON serializer strips the spaces, your HMAC will never match and every webhook will fail verification silently.
FreshBooks will retry failed webhook deliveries periodically, but delivery is not guaranteed to be immediate and can range from seconds to minutes. If your registered URL returns only failures over a sustained period, FreshBooks may disable the webhook entirely. There is no dead-letter queue or webhook event replay. If your endpoint is down for too long, you lose events permanently and must fall back to polling to backfill the gap.
If your infrastructure drops the initial verification POST request, or your worker fails to execute the PUT request in time, the webhook remains unverified and no data will ever flow. Managing this asynchronous handshake state requires dedicated database tables and retry logic. For deeper patterns on webhook reliability, see our article on designing reliable webhooks in production.
Build vs. Buy: Using a Unified Accounting API
Let's tally the engineering work for a production-grade FreshBooks integration:
| Concern | Engineering effort |
|---|---|
| OAuth 2.0 with single-use token rotation + concurrency lock | 2–3 days |
| Dual identity model resolution (accountId/businessId) | 0.5 days |
| Pagination with silent 100-item cap handling | 1 day |
| Rate limiting with exponential backoff (no published limits) | 1 day |
| Webhook registration, verification handshake, HMAC validation | 1–2 days |
| Data normalization to your internal schema | 2–3 days |
| Error handling, monitoring, reauth flows | 2–3 days |
| Total | 10–15 engineering days |
That is for FreshBooks alone. If your product also needs to support QuickBooks, Xero, or NetSuite, multiply that investment. Each platform has entirely different auth flows, pagination schemes, rate limit policies, and data models. The U.S. accounting software market was estimated at USD 6.09 billion in 2024 and is projected to grow at a CAGR of 6.3% through 2030. The market is expanding, and so is the list of accounting platforms your customers will ask you to support.
A unified accounting API abstracts these provider-specific differences behind a single interface. You call one endpoint for invoices, one for clients, one for expenses — and the normalization layer handles the FreshBooks-specific identity model, token rotation, pagination caps, and webhook verification under the hood.
Truto's approach is architecturally distinct. Instead of writing custom code for each integration, the platform uses declarative configuration to define how each provider's API maps to a unified accounting schema. FreshBooks' accountId/businessId resolution, its scope requirements, its offset-based pagination, and its specific HMAC-SHA256 webhook format are all handled by configuration rather than bespoke code. This means when FreshBooks changes their API behavior — and they will, since some endpoints like bills and bill_vendors are still in beta — the fix is a configuration update, not a code deploy.
The platform proactively refreshes FreshBooks tokens before they expire, normalizes the offset pagination into a standard format while handling the silent 100-item cap automatically, and manages the webhook verification handshake and signature validation without developer intervention. You interact with standard accounting models — Invoices, Contacts, Expenses — while the underlying configuration translates your requests into FreshBooks' native API format. To understand the architectural advantages of this approach, read The Best Unified Accounting API for B2B SaaS and AI Agents (2026).
A trade-off to be honest about: A unified API gives you breadth at the cost of some depth. If your product needs access to FreshBooks-specific features that do not map to a common accounting model — say, FreshBooks' retainer invoicing or project profitability tracking — you will need a pass-through layer to access provider-native endpoints alongside the unified API. Truto provides a Proxy API for exactly this: direct, unmapped access to any FreshBooks endpoint when the unified model is not enough.
What to Ship Next
If you are building a direct FreshBooks integration:
- Implement a mutex around token refresh. This is non-negotiable with single-use rotation. One refresh in flight per connected account, callers wait for the result.
- Always read pagination metadata from the response. Never assume your
per_pagewas honored. - Store the webhook verification code permanently. You need it to validate every future event via HMAC.
- Request all scopes upfront. Getting this wrong means forcing every connected customer back through OAuth.
- Monitor for 429s and disabled webhooks. Neither is well-documented, and both can silently break your integration.
If you are evaluating whether to build this yourself or use a unified API, the question is not whether you can build it — of course you can. The question is whether maintaining it across FreshBooks API changes, plus doing the same for QuickBooks, Xero, and however many other platforms your sales team promises, is the best use of your engineering team's time.
FAQ
- How long do FreshBooks API access tokens last?
- FreshBooks access tokens expire after 12 hours. Each refresh gives you a new access token and a new single-use refresh token, immediately invalidating the old refresh token. There is no recovery path if you lose a refresh token — the customer must re-authorize.
- What is the maximum page size for the FreshBooks API?
- FreshBooks silently caps pagination at 100 results per page. If you request a higher per_page value, the API returns 100 results without any error or warning. Always check the pagination metadata in the response rather than assuming your page size was honored.
- How do you verify FreshBooks webhooks?
- FreshBooks sends a verification code via POST when you register a webhook. You must echo that code back via a PUT request to activate the webhook. Store the verification code permanently — it is used as the HMAC-SHA256 secret for validating all future webhook signatures via the X-FreshBooks-Hmac-SHA256 header.
- Does the FreshBooks API have rate limits?
- FreshBooks has no daily request limit but enforces short-term rate limiting. The exact numeric limits are not published. Exceeding them returns a 429 error, and aggressive usage may result in your app being disabled by FreshBooks.
- What is the FreshBooks dual identity model?
- FreshBooks uses both an accountId and a businessId. Accounting endpoints like invoices, clients, and expenses use accountId, while project and time tracking endpoints use businessId. Both are retrieved from the /auth/api/v1/users/me endpoint after authentication.