The Embedded Finance Index is live: 6,292 companies and 1,383 providers, mapped across the stack. Explore the Index →

FreshBooks API guide: OAuth 2.0, IDs, webhooks and reports

FreshBooks API guide: OAuth 2.0 with single-use refresh tokens, account_id vs business_uuid, the silent 100-per-page cap, signed webhooks, and Classic vs new.

Kateryna PoryvayKateryna Poryvay

Kateryna Poryvay · Growth Marketer, Apideck

14 min readView as .md
FreshBooks API guide: OAuth 2.0, IDs, webhooks and reports

One call to the FreshBooks API's identity endpoint returns three different identifiers for the same business: an account_id like ABC123, a numeric business id like 240340, and a business_uuid. Invoices want the first, time entries want the second, and the profit and loss report wants the third. That split is a common way for a first integration to break. Two others are a refresh token that works exactly once and a search filter that doesn't exist. Every FreshBooks request below is based on FreshBooks' own examples.

Map of the three FreshBooks identifiers returned by /users/me and the URL families that use each: account_id for accounting and webhooks, business id for projects and time tracking, business_uuid for reports, journal entries and chart of accounts

FreshBooks Classic vs the new FreshBooks API

FreshBooks has run two different APIs, so the distinction comes first. The Classic API sent every call to one fixed URL, https://yourcompanyname.freshbooks.com/api/2.1/xml-in, named the method inside an XML payload, and accepted an API token or OAuth 1.0. The current API uses a separate JSON URL per resource under https://api.freshbooks.com and only supports OAuth 2.0 (FreshBooks: How to migrate from the Classic XML API). The old freshbooks.com/classic-api reference now redirects to the current API introduction.

FreshBooks publishes a migration table showing which Classic methods map to the new platform. Contractor and currency methods are marked as not working, and a client created through the migrated method loses Classic-only fields such as fax and notes. Webhooks registered under Classic "require no changes", per the migration guide. If your integration still targets the Classic API, plan a rewrite of auth, URLs and payloads together, because none of the three carries over.

Registering an app

Create a FreshBooks account (a free trial works), then open the developer page and create an app with a name and a redirect URI. The name has to be unique because FreshBooks shows it on its App Store and on each user's integrations page (FreshBooks: Introduction). The same page notes that FreshBooks support can move your account to the partner plan.

Redirect URIs must use HTTPS and cannot contain query strings. Pass any state you need through the state parameter instead, and list several redirect URIs on separate lines if you have staging and production hosts (FreshBooks: Authentication).

Scopes are configured on the app; FreshBooks' authorization URL carries no scope parameter. They follow the pattern user:<object>:read or user:<object>:write, and user:profile:read is added to every new app by default. You can't remove a scope from an existing token: changing the consented set means revoking the token and sending the user through authorization again (FreshBooks: Scopes). A call with a missing scope returns HTTP 403 with the scopes you sent and the ones the endpoint needs (FreshBooks: How to find the right scopes), which makes a throwaway test app with only user:profile:read a quick way to discover your real list. Our post on getting your FreshBooks API key walks through the developer portal screens step by step.

OAuth 2.0 authorization code flow

FreshBooks supports the authorization code grant only. The token endpoint's own sample carries the comment "we do not support client_credentials grant type", so every connection needs a FreshBooks user who signs in and approves your app (FreshBooks: Authentication). Send that user to the authorization URL from your app settings:

https://auth.freshbooks.com/oauth/authorize/?response_type=code&redirect_uri=<REDIRECT_URL>&client_id=<CLIENT_ID>

FreshBooks redirects back with a code that expires five minutes after it is issued (FreshBooks: Get authenticated on the FreshBooks API). Exchange it from your server:

curl -L -X POST 'https://api.freshbooks.com/auth/oauth/token' \
  -H 'Content-Type: application/json' \
  --data-raw '{
    "grant_type": "authorization_code",
    "client_id": "YOUR_APP_CLIENT_ID",
    "code": "YOUR_AUTHORIZATION_CODE",
    "client_secret": "YOUR_APP_CLIENT_SECRET",
    "redirect_uri": "YOUR_APP_HTTPS_REDIRECT_URI"
  }'

FreshBooks' sample response shows "token_type": "Bearer" and "expires_in": 43200, which is 12 hours, but the same tutorial says "Access token expiry may vary. Check the expiry in the token." Read expires_in on every response instead of hard-coding 12 hours. Access tokens are variable-length JWTs (new apps since April 2021, all apps since May 2023), and FreshBooks recommends a TEXT column to store them (FreshBooks: Bearer tokens replaced with JWT tokens).

Refresh tokens are single use

Refresh tokens never expire, but each one works once, and only one can be alive per user per application. Every refresh returns a new access token and a new refresh token, and the old refresh token is invalid from that moment (FreshBooks: Authentication). The refresh call uses the same endpoint:

curl -L -X POST 'https://api.freshbooks.com/auth/oauth/token' \
  -H 'Content-Type: application/json' \
  --data-raw '{
    "grant_type": "refresh_token",
    "client_id": "YOUR_APP_CLIENT_ID",
    "refresh_token": "YOUR_REFRESH_TOKEN",
    "client_secret": "YOUR_APP_CLIENT_SECRET",
    "redirect_uri": "YOUR_APP_HTTPS_REDIRECT_URI"
  }'

Persist the new refresh token before you use the new access token. If two workers refresh the same connection at once, one of them fails with a spent token, and if the winner's new token isn't the one you store, the user has to authorize again. A lock per connection around the refresh avoids that. FreshBooks' docs disagree on whether the previous access token survives a refresh: the authentication page says several bearer tokens can be valid at once, while the tutorial says a refresh "invalidates the previous access token immediately". Assume the stricter version and switch every in-flight request to the new token. Tokens are revoked with a POST to https://api.freshbooks.com/auth/oauth/revoke.

Finding the account, business and business_uuid

Most requests carry two headers, Content-Type: application/json and Authorization: Bearer <TOKEN> (FreshBooks: Introduction). The first call after authorization goes to the identity endpoint:

curl -X GET \
  -H 'Authorization: Bearer <insert your bearer here>' \
  -H 'Api-Version: alpha' \
  -H 'Content-Type: application/json' \
  "https://api.freshbooks.com/auth/api/v1/users/me"

The part you need is business_memberships. FreshBooks' own example looks like this (FreshBooks: The Identity Model):

"business_memberships": [
  {
    "id": 111,
    "role": "owner",
    "business": {
      "id": 240340,
      "business_uuid": "046cc001-0002-e93e-1db1-1186b2983879",
      "name": "Awesome Business Inc.",
      "account_id": "ABC123",
      "date_format": "dd/mm/yyyy",
      "active": true
    }
  }
]

The membership's id (111) is not the business id (240340), and FreshBooks warns against mixing them up. Store all three business values per connection:

  • account_id goes into /accounting/account/{account_id}/... for clients, invoices, payments, expenses and bills, and into the webhook callbacks URL.
  • The business id goes into /projects/business/{business_id}/projects and /timetracking/business/{business_id}/time_entries. FreshBooks asks you to leave the Content-Type header out of GET calls to projects and time tracking (FreshBooks: Time Entries).
  • business_uuid goes into /accounting/businesses/{business_uuid}/... for reports, journal entries and the chart of accounts (FreshBooks: Journal Entries).

One email can belong to several businesses with different roles. The API role names differ from the UI: business_partner is an Admin, business_manager a Manager, business_employee an Employee and business_accountant an Accountant, next to owner, contractor and client. An identity that only exists as someone's client has a business but no account of its own, so your code has to handle a membership without a usable account_id (FreshBooks: The Identity Model). Let the user pick which business to connect, and treat each business as its own connection.

Reading data: pagination, search and includes

List endpoints page with page and per_page, and the response carries page, pages, per_page and total. FreshBooks' parameters page puts the main trap in one line: "per_page is silently capped at 100". Ask for 2,000 and you get 100 with no error. Requesting page 0 returns page 1, and a page past the end returns an empty list (FreshBooks: Search, Paging and Includes). Loop until page equals pages.

Search filters go in search[...] query parameters, and the names differ per resource. On invoices, the documented filters include customerid, statusids, paid, outstanding, date_min, date_max, updated_min and updated_max (FreshBooks: Invoices). There is no payment_status filter and no status list filter, two names that show up in older guides. FreshBooks' own examples:

# search for invoices 123 and 124
/invoices/invoices?search[invoiceids][]=123&search[invoiceids][]=124
# search for invoice created between 2016-08-01 and 2016-09-01
/invoices/invoices?search[date_min]=2016-08-01&search[date_max]=2016-09-01
# search for clients with outstanding amounts
/users/clients?search[has_overdue]=true

Date "between" filters on accounting lists have day-level granularity and take YYYY-MM-DD. For incremental sync of invoices that means search[updated_min] can only say "changed on or after this day", so expect to re-read a day's worth of records on each run and dedupe by id. Time entries are different: updated_since takes a full ISO 8601 timestamp, and FreshBooks' example pairs it with include_deleted=1 (FreshBooks: Time Entries).

Related data needs an explicit include. Invoice lines only come back with include[]=lines, and you can stack includes:

/invoices/invoices?include[]=lines&include[]=allowed_gateways
/payments/payments?include[]=invoice

Sorting uses ?sort=invoice_date_asc on accounting endpoints and ?sort=-due_date style on project endpoints.

Writing data

A client needs very little. FreshBooks' migration guide creates one with nothing but an email address:

curl -L -X POST 'https://api.freshbooks.com/accounting/account/<accountid>/users/clients' \
  -H 'Authorization: Bearer <insert-bearer-token>' \
  -H 'Content-Type: application/json' \
  --data-raw '{"client": { "email": "john.doe@abcorp.com"}}'

Invoices are created in Draft, and FreshBooks' accounting reports ignore drafts until the invoice is marked as sent or emailed (FreshBooks: Invoices). If your integration creates invoices that should count in the books, follow the create with the mark-as-sent action:

curl -L -X PUT 'https://api.freshbooks.com/accounting/account/<accountId>/invoices/invoices/<invoiceId>' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer <insert-bearer-token>' \
  --data-raw '{
    "invoice": {
      "action_mark_as_sent": true
    }
  }'

Two more invoice rules from the same page cause bugs that look like data loss. A PUT that touches lines must contain every line you want to keep, because lines left out of the payload are removed. And concurrent creates without an explicit invoice_number can collide on the next number and return HTTP 409; either set invoice_number yourself or create invoices one at a time.

Deleting in the accounting endpoints usually means setting vis_state to 1 with a PUT. Records carry vis_state: 0 active, 1 deleted, 2 archived, and lists return active and archived records unless you filter with search[vis_state] (FreshBooks: Active, Archived, and Deleted). The same page documents a sharp edge: "If you have a vis_state field in a PUT call, even with the same value it used to have, its phase activates and we throw away the rest of the data silently." Never round-trip a fetched object back into a PUT with vis_state still in it. Resources outside /accounting, such as projects and time entries, often use an active field instead, and where there is none, a DELETE may remove the record for good.

Reports and the Bills API

Reports split by identifier. Some hang off account_id at /accounting/account/{account_id}/reports/accounting/...: payments collected, tax summary and invoice details (FreshBooks: Other Reports), plus accounts aging (FreshBooks: Account Aging Report). The aging report covers receivables only, "overdue invoices from clients" in FreshBooks' words, and the docs list no payables aging report. The ledger-based reports use business_uuid: profit and loss, balance sheet, trial balance, cash flow, general ledger and the manual journal entry report. FreshBooks' profit and loss example:

curl --location --request GET 'https://api.freshbooks.com/accounting/businesses/{business_uuid}/reports/profit_and_loss?start_date=2023-01-01&end_date=2023-12-31&use_ledger_entries=true' \
  --header 'Authorization: Bearer <SET BEARER TOKEN>'

The report pages that list a scope require user:reports:read.

Bills live at /accounting/account/{account_id}/bills/bills with user:bills:read and user:bills:write, and every bill needs a vendor first (FreshBooks: Bills). FreshBooks labels the companion Bill Vendors and Bill Payments APIs as beta, so pin your tests around the payables flow and watch those pages for changes.

Webhooks

FreshBooks has webhooks, with a verification handshake before anything is delivered. Register a callback with a POST to /events/account/<account_id>/events/callbacks:

POST https://api.freshbooks.com/events/account/<account_id>/events/callbacks
{
 "callback": {
 "event": "invoice.create",
 "uri": "http://your_server.com/webhooks/ready"
 }
}

FreshBooks then POSTs a verification code to that URI. Send it back to activate the callback (FreshBooks: Webhook Callbacks):

PUT https://api.freshbooks.com/events/account/<account_id>/events/callbacks/<callback_id>
{
 "callback": {
 "verifier": "scADVVi5QuKuj5qTjVkbJNYQe7V7USpGd"
 }
}

Keep that verifier. It is the secret behind the X-FreshBooks-Hmac-SHA256 header on every later delivery. If you lose it, a PUT with "resend": true makes FreshBooks send the verification code again.

Five-step FreshBooks webhook flow: register the callback, receive a verification code, PUT it back as verifier, receive form-urlencoded events, verify the HMAC header and answer 2xx within ten seconds

Events arrive as form-urlencoded POSTs with name, object_id, account_id, business_id and identity_id; user_id is a deprecated Classic field. The payload names the object but doesn't contain it, so fetch the record by id. FreshBooks computes the signature over a JSON string of those parameters with every value cast to a string, and its sample relies on Python's json.dumps spacing. Their Flask example:

import base64
import hmac
import hashlib
import json

from flask import Flask, request

def signature_match(verifier, request):
   signature = request.headers.get('X-FreshBooks-Hmac-SHA256')
   data = json.dumps(request.form)

   dig = hmac.new(
      verifier.encode('utf-8'),
      msg=data.encode('utf-8'),
      digestmod=hashlib.sha256
   ).digest()
   calculated_sig = base64.b64encode(dig).decode()

   return signature == calculated_sig

In another language, reproduce the separators exactly (", " and ": "), or the hashes won't match.

Event names are a noun plus a verb, for example invoice.create, payment.update or time_entry.delete. Subscribing to the noun alone (invoice) covers all of its events. Nouns include bill, bill_vendor, category, client, credit_note, estimate, expense, invoice, item, payment, project, recurring, service, tax and time_entry, and each needs the matching read scope.

Delivery is not instant: FreshBooks says it can take "from a few seconds to several minutes". Anything other than a 2xx, including a redirect, counts as a failure, as does no response within ten seconds. Failed deliveries are retried and then dropped, and a callback that fails for a long time can be disabled until you resend the verification token. Acknowledge fast and queue the work; a periodic updated_min sweep catches what webhooks drop.

Rate limits and errors

FreshBooks publishes no numeric rate limit. The limits page says there is no daily cap, but "requests will be rate-limited if too many calls are made within a short period of time" (FreshBooks: Request Limits). The App Store requirements add the response you'll see, HTTP 429 HTTP_RATE_LIMITED, the instruction to back off, and a warning that FreshBooks reserves the right to disable an app "that is hitting us aggressively" (FreshBooks: Requirement for public apps).

FreshBooks' own Python SDK shows one reasonable policy: up to three retries with a 0.3 backoff factor on 400, 408, 429 and the 500/502/503/504 responses, for GET, PUT and DELETE but not POST. Retrying a POST can create a duplicate invoice, so that exclusion is worth copying.

Accounting errors come back with FreshBooks error numbers next to the HTTP status. The error reference lists codes such as 1012 UnknownResource and 1013 InvalidPerPage. Log the number with the request, because the HTTP status alone rarely tells you which field was wrong.

Testing and App Store review

FreshBooks has no separate sandbox environment. Its migration guide suggests creating test accounts by signing up for a free trial. Apideck's connector notes put the consequence plainly: any account you connect holds live business data. Use your own email address on test clients before you send an invoice by email.

Connecting your own OAuth app to customer accounts needs no partnership, and the App Store listing is optional. A public listing on the FreshBooks App Store goes through review from the developer portal's AppStore Listing tab (FreshBooks: How to create your app listing). The requirements page asks for an install link that goes straight to the OAuth screen and scopes limited to what the listing describes. Apps that add their own payment gateway are not eligible.

Building on Apideck instead

A direct build means storing three identifiers per connection and serializing refresh-token rotation before you write any business logic. Apideck's FreshBooks API integration handles the OAuth flow and token refresh, and asks the user to choose the business during connection: one connection covers one FreshBooks business, and only businesses where the user is owner or admin are offered (Apideck: FreshBooks connector). Receivables and payables objects, plus the profit and loss and balance sheet reports, come back in the same shape as the other ledgers behind Apideck's Accounting API, which covers 55+ accounting systems.

A list call looks the same for every connector:

curl --globoff "https://unify.apideck.com/accounting/invoices?filter[updated_since]=2026-10-01T00:00:00Z&limit=100" \
  -H "Authorization: Bearer <APIDECK_API_KEY>" \
  -H "x-apideck-app-id: <APP_ID>" \
  -H "x-apideck-consumer-id: <CONSUMER_ID>" \
  -H "x-apideck-service-id: freshbooks"

For FreshBooks, Apideck passes updated_since to search[updated_min] at day precision, the same limit described above, so keep deduplication by id on your side.

If FreshBooks is the only accounting system your customers use and you need its time tracking, projects or webhooks, build directly on the API with the identity, refresh and vis_state rules above. If FreshBooks is one of several ledgers on your roadmap, start with Apideck for the accounting objects and add direct FreshBooks calls only for what the unified model doesn't cover. Our list of the top accounting APIs compares FreshBooks with the others.

Ready to get started?

Scale your integration strategy and deliver the integrations your customers need in record time.

Ready to get started?
Talk to an expert

Frequently asked questions

Trusted by fast-moving product & engineering teams

Bonsai by Zoom
JobNimbus
Blue Zinc
Exact
Drata