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.


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.
GET /auth/api/v1/users/me HTTP/1.1Host: api.freshbooks.comAuthorization: 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/invoicesGET /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.
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 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.
# Accounting: a date, and the day is the unitGET /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 unitGET /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.
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.
account/{accountId}
Under /accounting
business/{businessId}
Under /timetracking
search[date_min]=2026-08-13
Resolves to a day
updated_since=…T15:00:53Z
Resolves to a second
?sort=due_date_asc
A suffix sets direction
?sort=-due_date
A minus sets direction
?search[key]=value
Bracketed and encoded
?complete=true
A plain parameter
Path shapes from the identity model, the other three rows from the search, paging and includes reference, both checked 13 August 2026.
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.
- 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.
- createCREATED
- updateUPDATED
- deleteDELETED
- sendByEmailNo counterpart
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.
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.
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.
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.
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.
| You send | What happens |
|---|---|
| per_page above 100 | Capped to 100, silently |
| page=0 | Returns page 1 |
| page beyond the last | Returns an empty list of items |
| A negative page | Returns 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.
# Returns 100, not 2000, and says nothing about itGET /accounting/account/{accountId}/invoices/invoices?per_page=2000# Without include[], the line items are simply absentGET /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.
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.
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”.
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.
Frequently asked questions
Why does FreshBooks need two different identifiers?
How do I read both identifiers without mixing them up?
How far back can I filter FreshBooks records by date?
How many results does a FreshBooks page return?
Why does my FreshBooks webhook signature never match?
What happens if two processes refresh a FreshBooks token at once?

QuickBooks Online Webhooks: Events, Retries and Recovery
QuickBooks Online webhooks cover 29 entity types and expect HTTP 200 in three seconds. Why Intuit still asks you to poll change data capture.
Lennart Svensson · 25 Aug 2026
Lexware Office Pagination: The 406 and One Page Size
Lexware Office validates the page size and rejects a bad one with 406, the same code it uses for an unsupported media type. What that means for your read loop.
Lennart Svensson · 20 Aug 2026
How to Integrate with DATEV Rechnungswesen: One Connection
One connection carries reading and writing in DATEV Rechnungswesen, on a two-year token. Which objects travel in which direction is the real decision.
Lennart Svensson · 17 Aug 2026Build 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.