Install
openclaw skills install @chrischall/freshbooks-mcpQuery FreshBooks (invoices, clients, estimates, payments, expenses, projects, time tracking) from a shell with curl and a rotating OAuth token. Use when you want FreshBooks data without running the freshbooks-mcp server, in a script, or on a machine where the MCP isn't installed.
openclaw skills install @chrischall/freshbooks-mcpFreshBooks has a real REST API reachable server-side — no browser bridge, no signed-in
tab, no fpx. Authentication is OAuth2 only: there is no API key and no personal
access token, so a one-time browser authorize flow is unavoidable.
1. Refresh tokens are single-use and rotate. Every refresh returns a new refresh
token and kills the one you used. If the replacement is not written to disk before the
process ends, the account is locked out and the only recovery is re-running the browser
flow. Never hand-roll the refresh — use fb_access_token from
references/fb-token.sh, which persists the rotation before returning, and never point
two tools at the same state file.
2. Three identifiers that are not interchangeable. accountId (alphanumeric, e.g.
xZNQ1X), businessId (integer), businessUuid. Each URL family takes a different one
and answers a mismatch with a bare 404 that reads like a missing record. Always
resolve first with fb_ids.
| Family | Path | Identifier |
|---|---|---|
| Accounting | /accounting/account/{accountId}/… | accountId |
| Payments | /payments/account/{accountId}/… | accountId |
| Accounting (business) | /accounting/businesses/{businessUuid}/… | businessUuid |
| Projects | /projects/business/{businessId}/… | businessId |
| Time tracking | /timetracking/business/{businessId}/… | businessId |
Register an app at https://my.freshbooks.com/#/developer. The redirect URI must be
HTTPS with no query string — https://localhost works and never needs to
resolve. Put FRESHBOOKS_CLIENT_ID and FRESHBOOKS_CLIENT_SECRET somewhere loadable
(e.g. ~/.secrets).
Then run the authorize flow once:
set -a; . ~/.secrets; set +a
# 1. Print the authorize URL, open it, click Allow.
node references/fb-bootstrap.mjs url "$FRESHBOOKS_CLIENT_ID"
# 2. The browser fails to load https://localhost?code=… — that is expected.
# Copy the whole URL from the address bar and exchange it (the code is single-use
# and expires within minutes).
node references/fb-bootstrap.mjs exchange "$FRESHBOOKS_CLIENT_ID" "$FRESHBOOKS_CLIENT_SECRET" \
https://localhost 'https://localhost?code=PASTE_HERE'
Save the printed refresh_token as FRESHBOOKS_REFRESH_TOKEN. It seeds the state file
on first use; after that the state file is the source of truth and the env value is
ignored until you re-bootstrap.
set -a; . ~/.secrets; set +a
. references/fb-token.sh
fb_ids # resolve accountId / businessId / businessUuid
ACCT=$(fb_account_id)
fb_curl "/accounting/account/$ACCT/invoices/invoices?per_page=5" \
| jq '.response.result.invoices[] | {id, invoice_number, amount, outstanding, status}'
fb_curl <path> [curl args…] attaches the bearer token and Api-Version: alpha;
everything after the path is passed to curl, so writes work too.
.response.result.<name>, errors at .response.errors[].message.data, errors at .errors.message + .errors.details[].reason.error.errors.details[].fieldA single jq path will not work across families — see references/recipes.md.
Accounting writes wrap the payload in the singular resource name:
fb_curl "/accounting/account/$ACCT/invoices/invoices" \
-X POST -H 'Content-Type: application/json' \
-d '{"invoice":{"customerid":123,"create_date":"2026-08-12","lines":[]}}'
Money is {"amount":"150.00","code":"USD"} — the amount is a string, not a number.
Deletes on the accounting family are frequently soft deletes via vis_state on an
update rather than HTTP DELETE. Confirm per resource before assuming.
A 200 is not proof a write persisted — re-read the record to confirm.
| Symptom | Cause |
|---|---|
invalid_client on refresh | The refresh grant needs client_secret and redirect_uri, form-encoded — not JSON |
invalid_grant on refresh | The token was already spent. Re-run the bootstrap |
| 404 on a valid-looking record | Wrong identifier for that URL family — run fb_ids |
| 401 on every call | Access token stale and refresh failing; check the state file |
See references/recipes.md for ready-to-run request bodies and jq recipes.