maesn
For developers

How to integrate with FreshBooks: Two identifiers, two date filters

FreshBooks shifted from an account concept to a business concept and kept both. Which identifier a resource takes decides more than the URL: it also decides whether you can ask for changes since a moment or only since a day.

Lennart Svensson, CTO and Co-Founder at Maesn
Lennart Svensson
CTO and Co-Founder · · Updated
Illustration for How to Integrate with FreshBooks: Two Identifiers
The context

Two identifiers, and the reason is in the changelog

Every FreshBooks call needs one of two identifiers, and which one is not a matter of taste. Accounting resources are reached at /accounting/account/{accountId}/, while time tracking and projects are reached at /timetracking/business/{businessId}/.

FreshBooks explains why rather than leaving you to infer it: “For historical reasons, FreshBooks has shifted from an account concept to a business concept, but many resources still make use of accountIds.” The migration happened, the old addressing stayed, and both are live.

Both values come back from a single call to /me, inside business_memberships. Each membership has an id of its own and a nested business object, and the business object carries the account id and the business id you actually want.

That nesting is the first trap, and the documentation flags it: “Be careful not to mistake the business’s id for the id of the business_membership object itself.” Two of the three ids in that fragment are plausible and one is correct.

One call, both identifiers, and the id that is neither of themHTTP
GET /auth/api/v1/users/me HTTP/1.1
Host: api.freshbooks.com
Authorization: Bearer {access-token}
 
# The shape that matters in the response:
# "business_memberships": [
# {
# "id": 111, <- the MEMBERSHIP id, not what you want
# "role": "owner",
# "business": {
# "id": 240340, <- the businessId
# "account_id": "ABC123" <- the accountId
# }
# }
# ]
 
GET /accounting/account/ABC123/invoices/invoices
GET /timetracking/business/240340/time_entries

Response shape and both path forms from FreshBooks’ identity model, checked 13 August 2026. Reading /me needs user:profile:read, which is added to new applications by default.

Not every user has an account id

The two identifiers are not equally universal, and this is the case that reaches production before anyone has planned for it. FreshBooks: “All FreshBooks users have an Identity and a Business resource and thus a business_id. Most users have accounts … but not all.”

The example given is a client who receives an invoice, views it and saves it. That person exists with a role on someone else’s business and has no account of their own. The instruction is explicit: “you must gracefully handle the case where you cannot find an account for a user.”

If that split still sounds like a distinction someone invented, FreshBooks settles it from a different direction. Every webhook it delivers carries both values, and the reason it gives is the model itself: “Because some FreshBooks resources use account and others business (See Identity Model), both the account_id and business_id are provided.” A vendor that shipped one identifier under two names would have no reason to put both in every notification.

Which objects a connection reaches, field by field, is on the FreshBooks API page. What the rest of this piece is about is what the identifier split decides after you have resolved it, because the answer turns out not to be limited to routing.

The problem

The same split decides how finely you can filter by time

Ask both halves of FreshBooks what changed since a moment and you get two different answers, because they accept two different kinds of moment. The accounting side takes a date. The project side takes a timestamp.

On accounting resources the between-style filters carry a caveat in FreshBooks’ own words: “A special note about Betweens applied to date-time columns like ‘updated’: these generally only have day-level granularity, and take the input form YYYY-MM-DD.”

On the project side the same idea is a full timestamp. FreshBooks documents updated_since on time entries as a value that “must be in ISO 8601 format”, and its own example carries seconds.

The practical difference is the size of the smallest question you can ask. A sync that runs every fifteen minutes can ask the project side for the last fifteen minutes. On the accounting side the narrowest window available is today, and today gets larger all day.

The narrowest question each half will answerHTTP
# Accounting: a date, and the day is the unit
GET /accounting/account/{accountId}/invoices/invoices
?search[date_min]=2026-08-13
&search[date_max]=2026-08-13
 
# Time tracking: a timestamp, and the second is the unit
GET /timetracking/business/{businessId}/time_entries
?updated_since=2026-08-13T15:00:53Z

Both filter forms and the granularity note from the search, paging and includes reference, checked 13 August 2026. The line breaks are for reading; the parameters go on one line.

This is the part of the seam that no layer removes. Resolving the identifiers is genuine work and it can be done once, behind an interface. Inventing a timestamp that the accounting side never recorded is a different kind of request, and nobody can serve it.

So the design decision lands on your side either way. Either your accounting pass reaches back a whole day and you accept re-reading records you already have, or it reaches back further and you accept more of them.

The problem

Sorting and searching change form at the same line

The seam shows up twice more on the same documentation page, and both times it is a change of syntax rather than a change of capability. Sorting is written one way for accounting endpoints and another for project-like ones.

FreshBooks states the split directly: “The form of the sort differs slightly between /accounting endpoints and project-like endpoints.” Accounting takes a suffix, ?sort=field_name_asc or ?sort=field_name_desc. Project-like endpoints take a leading minus, ?sort=field_name against ?sort=-field_name.

Searching splits the same way. Accounting resources take bracketed parameters that need url-encoding, in the form ?search[key]=value. Project-like resources take plain query parameters, as in ?complete=true or ?billed=true.

None of this is difficult on its own. It is difficult to hold, because a helper that builds a sort or a filter is exactly the kind of code you write once and reuse everywhere, and here the same helper is wrong on half the surface.

One product, two sets of rulesBoth sides documented
Path
Accounting

account/{accountId}

Under /accounting

Time tracking

business/{businessId}

Under /timetracking

Date filter
Accounting

search[date_min]=2026-08-13

Resolves to a day

Time tracking

updated_since=…T15:00:53Z

Resolves to a second

Sort
Accounting

?sort=due_date_asc

A suffix sets direction

Time tracking

?sort=-due_date

A minus sets direction

Search
Accounting

?search[key]=value

Bracketed and encoded

Time tracking

?complete=true

A plain parameter

The same line, four times. Only the first of the four is visible while you are reading a URL, and the second is the one that changes what a sync can ask for. Every cell is taken from FreshBooks' identity model and its search reference.

Path shapes from the identity model, the other three rows from the search, paging and includes reference, both checked 13 August 2026.

How it works

Eight resources send events, and all eight sit on one side

FreshBooks does send native events, which puts it in a minority of the systems we support. Eight resources are wired through us, and they carry three verbs: created, updated and deleted.

A subscription is narrow and it is per customer. Our create call takes one resource and one eventType per subscription, so the inventory you operate is a grid of resources against verbs, multiplied by every connected account.

FreshBooks’ own event table is wider than the routed set. It lists fifteen nouns, including project and time_entry, so events exist on both sides of the seam even though the routed eight are all accounting-side resources.

It also has a fourth verb. sendByEmail exists on invoices and estimates, and an invoice being emailed to a client is a real moment in a receivable. It has no counterpart in a vocabulary of created, updated and deleted.

The 15 nouns FreshBooks publishesCounted in the table
  • bill
  • bill_vendor
  • category
  • client
  • credit_note
  • estimate
  • expense
  • invoice
  • item
  • payment
  • project
  • recurring
  • service
  • tax
  • time_entry

Seven of them reach you through a mapped resource name. The rest send events at FreshBooks without a route through the unified interface today.

Four verbs at FreshBooks, three in the envelope
  • createCREATED
  • updateUPDATED
  • deleteDELETED
  • sendByEmailNo counterpart
Fifteen nouns in FreshBooks' event table against the resources a subscription can name through us, and four verbs against three. The eighth routed resource is documented as ACCOUNT, which matches no noun in FreshBooks' published table, so it is not drawn as a mapped pair here.

Nouns and verbs from FreshBooks’ webhook callbacks reference, the routed resources and the three-verb envelope from our own FreshBooks documentation, both checked 13 August 2026.

Delivery has three edges worth encoding before the first customer, and FreshBooks documents all three. There is no speed commitment at all: “delivery could range from a few seconds to several minutes.”

The second edge is the one that catches people. Anything other than a 2xx counts as a failure, and FreshBooks names the case you would not think of: “including 3xx HTTP redirection codes”. An endpoint that answers a redirect to its canonical host has been failing silently the whole time.

The third is a ten second timeout, with the remedy stated in the same paragraph: “consider deferring app processing until after a response to the webhook has been sent.” Repeated failures drop the message, and a long run of them can have the webhook disabled until you resend the verification token.

How it works

The signature is computed over a string you rebuild yourself

Every callback carries an X-FreshBooks-Hmac-SHA256 header, and verifying it is where a correct implementation still fails. The digest is not taken over the bytes you received.

The payload arrives form-urlencoded, as query-style parameters. The signature is described as an HMAC over “a UTF-8 encoded json string of the parameters using the verifier key”. So you re-encode the parameters as JSON, and the exact text of that JSON is the input.

Two details decide whether your digest matches. FreshBooks casts every value to a string first, so the numeric ids are quoted. And the serialisation carries whitespace, which the documentation spells out because it has to.

The whitespace is part of the signature

FreshBooks, verbatim: “The python json.dumps method yields json in the form of {"key": "value", "key2": "value"}. Note the spaces after each ‘:’ and ‘,’. If you’re json stringify does not include those, it will result in a different signature.”

JSON.stringify emits no such spaces. The failure is a valid event, a correct secret, correct code and a digest that never matches, and nothing in the response tells you which of the four is wrong.

Rebuilding the signed string, spaces includedJavaScript
import { createHmac, timingSafeEqual } from "node:crypto";
 
// The body arrives form-urlencoded, not as JSON.
const params = Object.fromEntries(new URLSearchParams(rawBody));
 
// Every value is signed as a string, and the separators
// carry the spaces that json.dumps writes by default.
const signed = JSON.stringify(
Object.fromEntries(
Object.entries(params).map(([k, v]) => [k, String(v)])
)
).replace(/":/g, '": ').replace(/,"/g, ', "');
 
const digest = createHmac("sha256", verifier)
.update(signed, "utf8")
.digest("base64");
 
const sent = Buffer.from(header, "base64");
const ours = Buffer.from(digest, "base64");
const ok = sent.length === ours.length &&
timingSafeEqual(sent, ours);

The serialisation rule, the string casting and the header name from FreshBooks’ webhook callbacks reference, checked 13 August 2026. Key order follows the parameters as received, so preserve it rather than sorting.

The signing secret is never issued as one. When you register a callback, FreshBooks posts a verification code to the URL and you send it back to confirm ownership. That same code is then the signing key: “it will be used as the secret to calculate signatures used to verify webhooks are from FreshBooks”.

So the value you might treat as a one-time handshake token is the long-lived secret for every event that follows. Storing it is not optional, and it belongs wherever your other signing keys live rather than in a log line about onboarding. How failures of this kind surface once they are normalised is on the unified error handling page.

Key facts

Paging stops at 100 without saying so

FreshBooks caps a page at 100 records and does not tell you when it has done so. The documentation uses the word itself: “per_page is silently capped at 100”.

Its own example is unambiguous. A request written as ?per_page=2000 is annotated “will only return 100 invoices”. There is no error, no warning and no header saying the request was modified.

That makes the failure a correctness problem rather than a performance one. A sync that asks for everything and reads what comes back is complete in testing, where the customer has forty invoices, and quietly wrong in production, where they have four thousand.

What FreshBooks does with a page request it will not honour
You sendWhat happens
per_page above 100Capped to 100, silently
page=0Returns page 1
page beyond the lastReturns an empty list of items
A negative pageReturns an error naming the parameter

All four behaviours from the paging section, checked 13 August 2026. FreshBooks also advises setting the limit yourself, so that “you will know how many results to expect”. Read pages and total from the response envelope and keep going until page equals pages.

The second thing a collection call will not give you by default is the related data. In FreshBooks’ words, some relations “require additional calls for us to full out”, and a request has to name them explicitly.

An invoice read without ?include[]=lines returns the header and no line items, which is the second version of the same silent gap: a response that is shaped correctly and incomplete. Multiple includes can be combined in one call.

Two silent gaps in one requestHTTP
# Returns 100, not 2000, and says nothing about it
GET /accounting/account/{accountId}/invoices/invoices
?per_page=2000
 
# Without include[], the line items are simply absent
GET /accounting/account/{accountId}/invoices/invoices
?include[]=lines&include[]=allowed_gateways
 
# The envelope that tells you how far you actually got
# "pages": { "page": 1, "pages": 14, "per_page": 100,
# "total": 1389 }

Both parameters and the response envelope from the search, paging and includes reference, checked 13 August 2026.

Key facts

One refresh token is alive at a time

FreshBooks runs ordinary OAuth 2.0 with the authorization code flow, and one rule inside it is not ordinary. Refresh tokens rotate, and only one of them exists per user per application at any moment.

The documentation is precise about all three parts: “Refresh Tokens live forever, but are one-time-use, and only one Refresh Token can be alive at any time per user per application … all old Refresh Tokens immediately become invalid.”

The asymmetry underneath it is what makes this a concurrency problem rather than a storage problem. Bearer tokens do not behave the same way: “you could have several valid Bearer Tokens at any given point in time.”

So two workers refreshing the same customer at the same time both succeed at getting a bearer token, and only one of them holds a usable refresh token afterwards. The other holds a dead one, and the recovery path is the customer authorising your application again by hand. Refreshes belong behind a per-customer lock.

Do not hard-code the access token lifetime

FreshBooks publishes no fixed lifetime for a bearer token. It asks you to read the value instead: “Bearer Tokens are not long lived (ensure you check the expiry in the token)”.

A number written into your configuration is a number that can drift away from the truth without anything failing loudly, and the instruction here is to take it from the token on every issue.

Scopes have a related property that costs more the later you meet it. They cannot be widened in place: “It’s not possible to remove scopes from an existing access token. The only way to reduce or add consented scopes is to revoke the token and start with the app authorization flow again.”

A scope you did not request at onboarding is a scope you obtain by sending an existing customer back through consent. The scope grammar is entity:object:action across 22 objects, with two action classes, where write covers creating, editing, archiving and deleting together.

Token rotation, the bearer asymmetry and the expiry instruction from FreshBooks’ authentication page; the scope grammar and the consent rule from its scopes page; both checked 13 August 2026. Concurrent modification surfaces as a 409 Conflict, described in the error reference as a resource “being modified by another request”.

Where our part ends

What stays on your side

Most of the seam is absorbable, and the parts that are not divide cleanly. Four things stop being yours:

  • The identifier routing. Both values are resolved during the connection and stored per tenant, so no call site has to know whether the resource it wants is addressed by account or by business.
  • The two query grammars. One sort form and one filter form reach your code, rather than a suffix on one half of the surface and a minus sign on the other.
  • Paging and includes. The silent cap and the missing line items are the same class of bug, and both are handled before a response is normalised into the common data model.
  • Token rotation and its race. A single-use refresh token per user per application is a locking problem, and it is solved once here instead of in every integration.

Three things do not move, and two of them are decisions rather than work:

  • The resolution of the accounting delta. A day is the smallest window FreshBooks records on that side. No interface can turn a date into a change timestamp, so how far each pass reaches back, and how much re-reading you accept, stays a choice you make.
  • Your receiver. The ten second budget, the queue behind it and the signature check are yours, because they run on your infrastructure. Answer first and process afterwards, and remember that a redirect counts as a failure.
  • Which permissions you ask for. Scopes cannot be widened without sending a customer back through consent, so the list you request at onboarding is a decision about the product you intend to build, not a setting.

One note on the objects themselves. Customers and suppliers are supported for reading and writing today, and most of the remaining catalogue is marked on demand, which means available on request rather than unavailable. If bills or credit notes matter to your product, ask for the read at the same time as the event, because the two are separate requests.

Object states from our own coverage data, which records what is enabled today rather than what FreshBooks can technically reach.

FAQ

Frequently asked questions

Why does FreshBooks need two different identifiers?

FreshBooks moved from an account concept to a business concept and kept both. Accounting endpoints are addressed as /accounting/account/{accountId}/, while time tracking and projects use /timetracking/business/{businessId}/. Both values come back from one call to /me, inside the business_memberships array.

How do I read both identifiers without mixing them up?

Call /me and walk business_memberships. Each entry has an id of its own and a nested business object, and the business object carries both the account_id and the id you need. FreshBooks warns explicitly not to mistake the membership id for the business id.

How far back can I filter FreshBooks records by date?

On accounting resources the between-style date filters have day-level granularity and take the form YYYY-MM-DD, so the smallest window you can request is a whole day. Time tracking resources accept updated_since as a full ISO 8601 timestamp, which resolves to the second.

How many results does a FreshBooks page return?

At most 100. FreshBooks documents that per_page is silently capped, so a request for 2.000 invoices returns 100 without an error or a warning. Read the pages and total fields in the response envelope and keep requesting until page equals pages.

Why does my FreshBooks webhook signature never match?

The signature is an HMAC over a JSON serialisation of the parameters, and FreshBooks builds that string the way Python's json.dumps does, with a space after every colon and comma. A default JSON.stringify omits those spaces and produces a different digest. All values are cast to strings first.

What happens if two processes refresh a FreshBooks token at once?

One of them wins. Only one refresh token can be alive per user per application, and issuing a new bearer token invalidates every older refresh token immediately. The loser holds a dead token and the customer has to authorise the application again, so refreshes belong behind a lock.

Build once on the Unified API.

FreshBooks splits its query grammar in two and resolves an accounting date to the day. The next system will draw both lines somewhere else, or not draw them at all. Build against one interface and each of those differences turns into a field you read instead of a branch you maintain.