{"id":"a300167f-0018-43e5-a73c-5be36f70865b","entityType":"agent","slug":"clawhub-chrischall-freshbooks-mcp","name":"freshbooks-mcp","canonicalUrl":"https://www.xpersona.co/agent/clawhub-chrischall-freshbooks-mcp","canonicalPath":"/agent/clawhub-chrischall-freshbooks-mcp","generatedAt":"2026-10-10T21:55:53.005Z","source":"CLAWHUB","claimStatus":"UNCLAIMED","verificationTier":"NONE","summary":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-10T16:23:36.063Z","emptyReason":null},"description":"Query 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.","descriptionLabel":"Source description","evidenceSummary":"Capability contract not published. No trust telemetry is available yet. 1.3K downloads reported by the source. Last updated 10/10/2026.","installCommand":"clawhub skill install s17cjx1a349nz5apaqp02vgz4h85728z:freshbooks-mcp","sourceUrl":"https://clawhub.ai/chrischall/freshbooks-mcp","homepage":"https://clawhub.ai/chrischall/skills/freshbooks-mcp","primaryLinks":[{"label":"View on ClawHub","url":"https://clawhub.ai/chrischall/freshbooks-mcp","kind":"source"},{"label":"Homepage","url":"https://clawhub.ai/chrischall/skills/freshbooks-mcp","kind":"homepage"}],"safetyScore":84,"overallRank":62,"popularityScore":63,"trustScore":null,"claimedByName":null,"isOwner":false,"seoDescription":"freshbooks-mcp technical dossier on Xpersona with agent coverage, OPENCLEW support, and live trust metadata."},"coverage":{"evidence":{"source":"public-profile","verified":false,"confidence":"medium","updatedAt":"2026-10-10T16:23:36.063Z","emptyReason":null},"protocols":[{"protocol":"OPENCLEW","label":"OpenClaw","status":"self-declared","notes":"Declared in the public agent profile."}],"capabilities":[],"verifiedCount":0,"selfDeclaredCount":1,"capabilityMatrix":{"rows":[{"key":"OPENCLEW","type":"protocol","support":"unknown","confidenceSource":"profile","notes":"Listed on profile"}],"flattenedTokens":"protocol:OPENCLEW|unknown|profile"}},"adoption":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-10T16:23:36.063Z","emptyReason":null},"stars":null,"forks":null,"downloads":1340,"packageName":null,"latestVersion":"1.2.1","tractionLabel":"1.3K downloads"},"release":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-10T16:23:36.063Z","emptyReason":null},"lastUpdatedAt":"2026-10-10T16:23:36.063Z","lastCrawledAt":"2026-10-10T16:23:36.063Z","lastIndexedAt":null,"nextCrawlAt":"2026-10-11T16:23:36.063Z","lastVerifiedAt":null,"highlights":[{"version":"1.2.1","createdAt":"2026-10-09T23:25:17.058Z","changelog":"freshbooks-mcp 1.2.1 - Removed skill-card.md, streamlining documentation. - Updated references/fb-token.sh. - No functional or user-facing changes; minor maintenance release.","fileCount":6,"zipByteSize":11154},{"version":"1.2.0","createdAt":"2026-10-07T13:35:09.349Z","changelog":"- Removed the file skill-card.md. - No user-facing functionality changes. All core usage, setup, and documentation remain unchanged.","fileCount":6,"zipByteSize":10875},{"version":"1.1.5","createdAt":"2026-10-05T02:52:37.498Z","changelog":"- Removed the file: skill-card.md - No user-facing feature or documentation changes.","fileCount":6,"zipByteSize":10920},{"version":"1.1.4","createdAt":"2026-10-03T01:40:57.415Z","changelog":"- Removed the file skill-card.md. - No changes to functionality or documentation in SKILL.md.","fileCount":6,"zipByteSize":10886},{"version":"1.1.3","createdAt":"2026-09-30T16:58:19.899Z","changelog":"- Removed the sample file skill-card.md. - No functional changes to the skill itself.","fileCount":6,"zipByteSize":10892},{"version":"1.1.2","createdAt":"2026-09-30T00:54:38.141Z","changelog":"- Removed the file skill-card.md. - No changes to functionality or user-facing documentation. - Maintenance cleanup: documentation file removal only.","fileCount":6,"zipByteSize":10796},{"version":"1.1.1","createdAt":"2026-09-25T15:55:18.731Z","changelog":"- Removed the file: skill-card.md - No feature or documentation changes; this update is a minor cleanup.","fileCount":6,"zipByteSize":10856},{"version":"1.1.0","createdAt":"2026-09-24T15:12:16.389Z","changelog":"- Removed the sample file skill-card.md. - No user-facing changes to functionality.","fileCount":6,"zipByteSize":10896}]},"execution":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No published capability contract is available yet."},"installCommand":"clawhub skill install s17cjx1a349nz5apaqp02vgz4h85728z:freshbooks-mcp","setupComplexity":"low","setupSteps":["Install using `clawhub skill install s17cjx1a349nz5apaqp02vgz4h85728z:freshbooks-mcp` in an isolated environment before connecting it to live workloads.","No published capability contract is available yet, so validate auth and request/response behavior manually.","Review the upstream CLAWHUB listing at https://clawhub.ai/chrischall/freshbooks-mcp before using production credentials."],"contract":{"contractStatus":"missing","authModes":[],"requires":[],"forbidden":[],"supportsMcp":false,"supportsA2a":false,"supportsStreaming":false,"inputSchemaRef":null,"outputSchemaRef":null,"dataRegion":null,"contractUpdatedAt":null,"sourceUpdatedAt":null,"freshnessSeconds":null},"invocationGuide":{"preferredApi":{"snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-freshbooks-mcp/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-freshbooks-mcp/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-freshbooks-mcp/trust"},"curlExamples":["curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-freshbooks-mcp/snapshot\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-freshbooks-mcp/contract\"","curl -s \"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-freshbooks-mcp/trust\""],"jsonRequestTemplate":{"query":"summarize this repo","constraints":{"maxLatencyMs":2000,"protocolPreference":["OPENCLEW"]}},"jsonResponseTemplate":{"ok":true,"result":{"summary":"...","confidence":0.9},"meta":{"source":"CLAWHUB","generatedAt":"2026-10-10T21:55:53.001Z"}},"retryPolicy":{"maxAttempts":3,"backoffMs":[500,1500,3500],"retryableConditions":["HTTP_429","HTTP_503","NETWORK_TIMEOUT"]}},"endpoints":{"dossierUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-freshbooks-mcp/dossier","snapshotUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-freshbooks-mcp/snapshot","contractUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-freshbooks-mcp/contract","trustUrl":"https://www.xpersona.co/api/v1/agents/clawhub-chrischall-freshbooks-mcp/trust"}},"reliability":{"evidence":{"source":"runtime-metrics","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No trust, reliability, or runtime telemetry is available."},"trust":{"status":"unavailable","handshakeStatus":"UNKNOWN","verificationFreshnessHours":null,"reputationScore":null,"p95LatencyMs":null,"successRate30d":null,"fallbackRate":null,"attempts30d":null,"trustUpdatedAt":null,"trustConfidence":"unknown","sourceUpdatedAt":null,"freshnessSeconds":null},"decisionGuardrails":{"doNotUseIf":["Contract metadata is missing or unavailable for deterministic execution."],"safeUseWhen":[],"riskFlags":["missing_or_unavailable_contract","trust_data_unavailable","schema_references_missing"],"operationalConfidence":"low"},"executionMetrics":{"observedLatencyMsP50":null,"observedLatencyMsP95":null,"estimatedCostUsd":null,"uptime30d":null,"rateLimitRpm":null,"rateLimitBurst":null,"lastVerifiedAt":null,"verificationSource":null},"runtimeMetrics":{"successRate":null,"avgLatencyMs":null,"avgCostUsd":null,"hallucinationRate":null,"retryRate":null,"disputeRate":null,"p50Latency":null,"p95Latency":null,"lastUpdated":null}},"benchmarks":{"evidence":{"source":"no-benchmark-data","verified":false,"confidence":"low","updatedAt":null,"emptyReason":"No benchmark suites or observed failure patterns are available."},"suites":[],"failurePatterns":[]},"artifacts":{"evidence":{"source":"CLAWHUB","verified":false,"confidence":"medium","updatedAt":"2026-10-10T16:23:36.063Z","emptyReason":null},"readme":"Skill: freshbooks-mcp\n\nOwner: chrischall\n\nSummary: Query 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.\n\nTags: latest:1.2.1\n\nVersion history:\n\nv1.2.1 | 2026-10-09T23:25:17.058Z | auto\n\nfreshbooks-mcp 1.2.1\n\n- Removed skill-card.md, streamlining documentation.\n- Updated references/fb-token.sh.\n- No functional or user-facing changes; minor maintenance release.\n\nv1.2.0 | 2026-10-07T13:35:09.349Z | auto\n\n- Removed the file skill-card.md.\n- No user-facing functionality changes. All core usage, setup, and documentation remain unchanged.\n\nv1.1.5 | 2026-10-05T02:52:37.498Z | auto\n\n- Removed the file: skill-card.md\n- No user-facing feature or documentation changes.\n\nv1.1.4 | 2026-10-03T01:40:57.415Z | auto\n\n- Removed the file skill-card.md.\n- No changes to functionality or documentation in SKILL.md.\n\nv1.1.3 | 2026-09-30T16:58:19.899Z | auto\n\n- Removed the sample file skill-card.md.\n- No functional changes to the skill itself.\n\nv1.1.2 | 2026-09-30T00:54:38.141Z | auto\n\n- Removed the file skill-card.md.\n- No changes to functionality or user-facing documentation.\n- Maintenance cleanup: documentation file removal only.\n\nv1.1.1 | 2026-09-25T15:55:18.731Z | auto\n\n- Removed the file: skill-card.md\n- No feature or documentation changes; this update is a minor cleanup.\n\nv1.1.0 | 2026-09-24T15:12:16.389Z | auto\n\n- Removed the sample file skill-card.md.\n- No user-facing changes to functionality.\n\nv1.0.2 | 2026-09-23T21:38:17.972Z | auto\n\n- Removed the file skill-card.md.\n- No changes to features or functionality.\n- No changes to documentation other than file cleanup.\n\nv1.0.1 | 2026-09-23T15:42:05.027Z | auto\n\n- Removed the file: skill-card.md\n- No changes to core functionality or usage.\n- Documentation and core command patterns remain unchanged.\n\nv1.0.0 | 2026-09-20T08:22:41.217Z | auto\n\n- Removed the skill-card.md file.\n- No user-visible feature changes; only documentation and metadata cleanup.\n\nv0.7.0 | 2026-09-17T23:36:07.958Z | auto\n\n- Removed the file skill-card.md.\n- No user-facing changes to functionality or documentation in SKILL.md.\n\nv0.6.1 | 2026-09-10T17:49:25.159Z | auto\n\n- Removed the file skill-card.md.\n- No functional or documentation changes to code or user-facing documentation.\n\nv0.6.0 | 2026-09-04T22:21:07.781Z | auto\n\n- Removed the skill-card.md file.\n- No user-facing changes to documentation or features.\n\nv0.5.2 | 2026-08-31T16:38:03.438Z | auto\n\n- Removed the file skill-card.md.\n- No changes made to code or documentation content.\n\nv0.5.1 | 2026-08-31T13:31:53.617Z | auto\n\n- Removed the sample file skill-card.md.\n- No changes to functionality or documentation content.\n\nv0.5.0 | 2026-08-31T12:40:38.434Z | auto\n\n- Removed the skill-card.md file.\n- No changes to core functionality or documentation.\n\nv0.4.0 | 2026-08-31T00:22:14.731Z | auto\n\n- Removed the file: skill-card.md.\n- No other functionality or documentation changes in this release.\n\nv0.3.1 | 2026-08-28T11:34:29.591Z | auto\n\n- Removed the file: skill-card.md.\n- No changes to functionality or documentation content.\n- Minor cleanup of repository files.\n\nv0.3.0 | 2026-08-13T18:04:44.413Z | auto\n\n- Updated documentation in references/recipes.md.\n- Removed the skill-card.md file.\n\nv0.2.0 | 2026-08-13T00:44:20.094Z | auto\n\n- Removed the skill card file (skill-card.md).\n- Updated references/recipes.md (details not listed).\n- No changes to main functionality or usage.\n- Documentation is now maintained via references/recipes.md.\n\nv0.1.0 | 2026-08-12T22:30:42.548Z | auto\n\nInitial release of freshbooks-curl skill.\n\n- Query FreshBooks data (invoices, clients, estimates, payments, expenses, projects, time tracking) from a shell via cURL and rotating OAuth token.\n- No need to run the freshbooks-mcp server; use directly in scripts or on machines without MCP.\n- Handles OAuth2 token rotation and multiple FreshBooks resource identifiers.\n- Includes setup and troubleshooting instructions in SKILL.md.\n- Provides command examples for listing, reading, and writing FreshBooks resources from the shell.\n\nArchive index:\n\nArchive v1.2.1: 6 files, 11154 bytes\n\nFiles: references/fb-bootstrap.mjs (4220b), references/fb-token.sh (5373b), references/recipes.md (8476b), skill-card.md (1788b), SKILL.md (4894b), _meta.json (133b)\n\nFile v1.2.1:SKILL.md\n\n---\nname: freshbooks-curl\ndescription: Query 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.\n---\n\n# FreshBooks from the shell\n\nFreshBooks has a real REST API reachable server-side — no browser bridge, no signed-in\ntab, no `fpx`. Authentication is **OAuth2 only**: there is no API key and no personal\naccess token, so a one-time browser authorize flow is unavoidable.\n\n## The two things that break naive clients\n\n**1. Refresh tokens are single-use and rotate.** Every refresh returns a *new* refresh\ntoken and kills the one you used. If the replacement is not written to disk before the\nprocess ends, the account is locked out and the only recovery is re-running the browser\nflow. Never hand-roll the refresh — use `fb_access_token` from\n`references/fb-token.sh`, which persists the rotation before returning, and never point\ntwo tools at the same state file.\n\n**2. Three identifiers that are not interchangeable.** `accountId` (alphanumeric, e.g.\n`xZNQ1X`), `businessId` (integer), `businessUuid`. Each URL family takes a different one\nand answers a mismatch with a bare **404** that reads like a missing record. Always\nresolve first with `fb_ids`.\n\n| Family | Path | Identifier |\n| --- | --- | --- |\n| Accounting | `/accounting/account/{accountId}/…` | `accountId` |\n| Payments | `/payments/account/{accountId}/…` | `accountId` |\n| Accounting (business) | `/accounting/businesses/{businessUuid}/…` | `businessUuid` |\n| Projects | `/projects/business/{businessId}/…` | `businessId` |\n| Time tracking | `/timetracking/business/{businessId}/…` | `businessId` |\n\n## One-time setup\n\nRegister an app at <https://my.freshbooks.com/#/developer>. The redirect URI must be\n**HTTPS with no query string** — `https://localhost` works and never needs to\nresolve. Put `FRESHBOOKS_CLIENT_ID` and `FRESHBOOKS_CLIENT_SECRET` somewhere loadable\n(e.g. `~/.secrets`).\n\nThen run the authorize flow once:\n\n```sh\nset -a; . ~/.secrets; set +a\n\n# 1. Print the authorize URL, open it, click Allow.\nnode references/fb-bootstrap.mjs url \"$FRESHBOOKS_CLIENT_ID\"\n\n# 2. The browser fails to load https://localhost?code=… — that is expected.\n#    Copy the whole URL from the address bar and exchange it (the code is single-use\n#    and expires within minutes).\nnode references/fb-bootstrap.mjs exchange \"$FRESHBOOKS_CLIENT_ID\" \"$FRESHBOOKS_CLIENT_SECRET\" \\\n  https://localhost 'https://localhost?code=PASTE_HERE'\n```\n\nSave the printed `refresh_token` as `FRESHBOOKS_REFRESH_TOKEN`. It seeds the state file\non first use; after that the state file is the source of truth and the env value is\nignored until you re-bootstrap.\n\n## Core call pattern\n\n```sh\nset -a; . ~/.secrets; set +a\n. references/fb-token.sh\n\nfb_ids                                    # resolve accountId / businessId / businessUuid\nACCT=$(fb_account_id)\n\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=5\" \\\n  | jq '.response.result.invoices[] | {id, invoice_number, amount, outstanding, status}'\n```\n\n`fb_curl <path> [curl args…]` attaches the bearer token and `Api-Version: alpha`;\neverything after the path is passed to `curl`, so writes work too.\n\n## Response envelopes differ per family\n\n- Accounting / events → `.response.result.<name>`, errors at `.response.errors[].message`\n- Accounting-business → `.data`, errors at `.errors.message` + `.errors.details[].reason`\n- Projects / time tracking / comments → bare object, errors at `.error`\n- Payments → errors at `.errors.details[].field`\n\nA single `jq` path will not work across families — see `references/recipes.md`.\n\n## Writes\n\nAccounting writes wrap the payload in the **singular** resource name:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/invoices/invoices\" \\\n  -X POST -H 'Content-Type: application/json' \\\n  -d '{\"invoice\":{\"customerid\":123,\"create_date\":\"2026-08-12\",\"lines\":[]}}'\n```\n\nMoney is `{\"amount\":\"150.00\",\"code\":\"USD\"}` — the amount is a **string**, not a number.\n\nDeletes on the accounting family are frequently soft deletes via `vis_state` on an\nupdate rather than HTTP `DELETE`. Confirm per resource before assuming.\n\n**A 200 is not proof a write persisted — re-read the record to confirm.**\n\n## Troubleshooting\n\n| Symptom | Cause |\n| --- | --- |\n| `invalid_client` on refresh | The refresh grant needs `client_secret` **and** `redirect_uri`, form-encoded — not JSON |\n| `invalid_grant` on refresh | The token was already spent. Re-run the bootstrap |\n| 404 on a valid-looking record | Wrong identifier for that URL family — run `fb_ids` |\n| 401 on every call | Access token stale and refresh failing; check the state file |\n\nSee `references/recipes.md` for ready-to-run request bodies and `jq` recipes.\n\nFile v1.2.1:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"freshbooks-mcp\",\n  \"version\": \"1.2.1\",\n  \"publishedAt\": 1791588317058\n}\n\nFile v1.2.1:references/recipes.md\n\n# FreshBooks curl recipes\n\nAll examples assume:\n\n```sh\nset -a; . ~/.secrets; set +a\n. references/fb-token.sh\nACCT=$(fb_account_id)\nBIZ=$(fb_ids | jq -r .businessId)\n```\n\nPaths marked **(unverified)** are transcribed from the official\n`freshbooks-python-sdk` source but have not been exercised against a live account.\n\n## Identity\n\n```sh\nfb_curl /auth/api/v1/users/me | jq '.response | {identity_id, email}'\nfb_ids   # accountId / businessId / businessUuid — run before anything else\n```\n\n## Invoices\n\n```sh\n# List (newest first via FreshBooks' own sort param)\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=25&page=1\" \\\n  | jq '.response.result | {page, pages, total,\n        invoices: [.invoices[] | {id, invoice_number, organization, create_date,\n                                  amount: .amount.amount, outstanding: .outstanding.amount, status}]}'\n\n# One invoice, with line items expanded\nfb_curl \"/accounting/account/$ACCT/invoices/invoices/INVOICE_ID?include[]=lines\" \\\n  | jq '.response.result.invoice'\n\n# Outstanding only — v3_status is the useful status field\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?search[v3_status]=overdue\" \\\n  | jq '[.response.result.invoices[] | {id, invoice_number, outstanding: .outstanding.amount}]'\n\n# Total outstanding across a page\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=100\" \\\n  | jq '[.response.result.invoices[].outstanding.amount | tonumber] | add'\n```\n\nCreate **(unverified)** — money amounts are strings:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/invoices/invoices\" -X POST \\\n  -H 'Content-Type: application/json' -d '{\n  \"invoice\": {\n    \"customerid\": 123,\n    \"create_date\": \"2026-08-12\",\n    \"lines\": [\n      { \"name\": \"Consulting\", \"description\": \"August retainer\",\n        \"qty\": 1, \"unit_cost\": { \"amount\": \"1500.00\", \"code\": \"USD\" } }\n    ]\n  }\n}' | jq '.response.result.invoice | {id, invoice_number, amount}'\n```\n\n## Clients\n\n```sh\nfb_curl \"/accounting/account/$ACCT/users/clients?per_page=100\" \\\n  | jq '[.response.result.clients[] | {id: .userid, organization, fname, lname, email}]'\n\n# Find a client by name\nfb_curl \"/accounting/account/$ACCT/users/clients?search[organization_like]=acme\" \\\n  | jq '.response.result.clients'\n```\n\nCreate **(unverified)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/users/clients\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"client\":{\"email\":\"ap@example.com\",\"organization\":\"Example Co\",\"fname\":\"Pat\",\"lname\":\"Doe\"}}' \\\n  | jq '.response.result.client | {userid, organization}'\n```\n\n## Payments\n\n```sh\nfb_curl \"/accounting/account/$ACCT/payments/payments?per_page=50\" \\\n  | jq '[.response.result.payments[] | {id, invoiceid, date, amount: .amount.amount, type}]'\n```\n\nRecord a payment **(unverified)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/payments/payments\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"payment\":{\"invoiceid\":456,\"amount\":{\"amount\":\"1500.00\",\"code\":\"USD\"},\"date\":\"2026-08-12\",\"type\":\"Check\"}}'\n```\n\n## Estimates — accepting is an action, not a status write\n\n`status`, `display_status` and `ui_status` are computed read-only fields and disagree with\neach other (`status: 3` / `\"viewed\"` / `\"open\"` are the same estimate). Writing them does\nnothing. Acceptance is an action on the estimate **(unverified — collection-derived)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/estimates/estimates/279405\" -X PUT \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"estimate\":{\"action_accept\":true}}' \\\n  | jq '.response.result.estimate | {id, accepted, status, display_status, ui_status}'\n```\n\nEmail it to the client — note `estimate_customized_email`, not the invoice endpoint's\n`invoice_customized_email`:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/estimates/estimates/279405\" -X PUT \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"estimate\":{\"action_email\":true,\"email_recipients\":[\"client@example.com\"]}}'\n```\n\nThere is **no decline**: no declined status, no `action_deny`, no `estimate.decline`\nwebhook. The nearest expressible thing is the soft delete, `{\"estimate\":{\"vis_state\":1}}`,\nwhich removes it rather than declining it.\n\n## Full accounting resource map\n\nSame family and envelope; only the path suffix and list key change. Four of these are not\nwhat you would guess — they are marked ⚠ and each cost a 404 before the SDK settled it.\n\n| Resource | Path suffix | List key |\n| --- | --- | --- |\n| Invoices | `invoices/invoices` | `invoices` |\n| Clients | `users/clients` | `clients` |\n| Estimates | `estimates/estimates` | `estimates` |\n| Payments | `payments/payments` | `payments` |\n| Credit notes | `credit_notes/credit_notes` | `credit_notes` |\n| Invoice profiles | `invoice_profiles/invoice_profiles` | `invoice_profiles` |\n| Items | `items/items` | `items` |\n| Taxes | `taxes/taxes` | `taxes` |\n| Expenses | `expenses/expenses` | `expenses` |\n| Expense categories ⚠ | `expenses/categories` | `categories` |\n| Staff ⚠ | `users/staffs` | `staff` |\n| Gateways ⚠ | `systems/gateways` | `gateways` |\n| Other income ⚠ | `other_incomes/other_incomes` | `other_income` |\n| Tasks (billable catalogue) | `projects/tasks` | `tasks` |\n| Bills | `bills/bills` | `bills` |\n| Bill vendors | `bill_vendors/bill_vendors` | `bill_vendors` |\n| Bill payments | `bill_payments/bill_payments` | `bill_payments` |\n\n`projects/tasks` is in the **accounting** family (accountId) despite the prefix.\n\n### `total` can exceed the rows you get back\n\nVerified live: expenses returned `total: 16` with an empty array — the count includes\nrecords the identity cannot read, and paging never surfaces them. Always check both:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=100\" \\\n  | jq '{total: .response.result.total, rows: (.response.result.expenses|length)}'\n```\n\nIf `rows` is 0 while `total` is not **and you are on a page within range**, that is a\npermission boundary rather than an empty account. Check the page first — an empty page with\na non-zero total is also just what paging past the end looks like:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=100\" \\\n  | jq '.response.result | {total, pages, page, rows: (.expenses|length),\n         verdict: (if (.expenses|length) > 0 then \"ok\"\n                   elif .page > .pages then \"past the last page\"\n                   else \"rows withheld by permissions\" end)}'\n```\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=50\" \\\n  | jq '[.response.result.expenses[] | {id, date, amount: .amount.amount, vendor, notes}]'\n```\n\n## Projects and time tracking — different id, different envelope\n\nThese take `businessId` and return **bare** objects, not `.response.result`. Pagination\nlives under `meta`, so `.response.result.total` reads `undefined` here and looks like an\nempty account:\n\n```sh\nfb_curl \"/projects/business/$BIZ/projects\" \\\n  | jq '{meta, projects: [.projects[] | {id, title, client_id, active}]}'\n\n# time_entries' meta also carries total_logged and total_unbilled\nfb_curl \"/timetracking/business/$BIZ/time_entries\" \\\n  | jq '{logged: .meta.total_logged, unbilled: .meta.total_unbilled,\n         entries: [.time_entries[] | {id, project_id, duration, started_at, note}]}'\n\nfb_curl \"/comments/business/$BIZ/services\" | jq '[.services[] | {id, name, billable}]'\n```\n\nErrors here are `.error`, a plain string — not `.response.errors[]`.\n\nThese endpoints work on a business with **no accounting account**, so they can succeed\nwhen every `/accounting/account/...` call fails.\n\nLog time (duration is in **seconds**):\n\n```sh\nfb_curl \"/timetracking/business/$BIZ/time_entries\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"time_entry\":{\"duration\":3600,\"started_at\":\"2026-08-12T09:00:00Z\",\"note\":\"Design review\"}}'\n```\n\n## Pagination\n\nAccounting lists carry `page`, `pages`, `per_page`, `total` on the result envelope.\nWalk them:\n\n```sh\np=1\nwhile :; do\n  body=$(fb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=100&page=$p\")\n  printf '%s' \"$body\" | jq -c '.response.result.invoices[]'\n  pages=$(printf '%s' \"$body\" | jq -r '.response.result.pages')\n  [ \"$p\" -ge \"$pages\" ] && break\n  p=$((p + 1))\ndone\n```\n\n## Error shapes by family\n\n```sh\n# Accounting / events\njq -r '.response.errors[]? | \"\\(.errno): \\(.message)\"'\n# Accounting-business\njq -r '.errors? | \"\\(.message) \\(.details[]?.reason // \"\")\"'\n# Payments\njq -r '.errors?.details[]? | \"\\(.field): \\(.message)\"'\n# Projects / time tracking / comments / uploads\njq -r '.error? // empty'\n```\n\nFile v1.2.1:skill-card.md\n\n## Description:\n\nHelps agents access FreshBooks accounting, project, and time-tracking data from a shell using OAuth-authenticated requests.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nDevelopers and finance teams use this skill to query FreshBooks records from a shell without running an MCP server, and to prepare accounting updates when authorized.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: Commands can create or change live accounting records and send estimates to customers.\n\nMitigation: Test with a FreshBooks test account first; review and explicitly authorize every POST or PUT command before execution.\n\nRisk: OAuth tokens stored on disk or exposed in shell logs could allow unauthorized account access.\n\nMitigation: Keep the token state file private and avoid shell tracing or logging around token commands.\n\n## Reference(s):\n\n- [FreshBooks curl recipes](artifact/references/recipes.md)\n- [FreshBooks developer portal](https://my.freshbooks.com/#/developer)\n- [ClawHub skill release](https://clawhub.ai/chrischall/skills/freshbooks-mcp)\n\n## Skill Output:\n\n**Output Type(s):** [Shell commands, Guidance]\n\n**Output Format:** [Markdown with shell examples]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Examples can include live FreshBooks write requests.]\n\n## Skill Version(s):\n\n1.2.1 (source: ClawHub release metadata)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.2.0: 6 files, 10875 bytes\n\nFiles: references/fb-bootstrap.mjs (4220b), references/fb-token.sh (4351b), references/recipes.md (8476b), skill-card.md (2102b), SKILL.md (4894b), _meta.json (133b)\n\nFile v1.2.0:SKILL.md\n\n---\nname: freshbooks-curl\ndescription: Query 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.\n---\n\n# FreshBooks from the shell\n\nFreshBooks has a real REST API reachable server-side — no browser bridge, no signed-in\ntab, no `fpx`. Authentication is **OAuth2 only**: there is no API key and no personal\naccess token, so a one-time browser authorize flow is unavoidable.\n\n## The two things that break naive clients\n\n**1. Refresh tokens are single-use and rotate.** Every refresh returns a *new* refresh\ntoken and kills the one you used. If the replacement is not written to disk before the\nprocess ends, the account is locked out and the only recovery is re-running the browser\nflow. Never hand-roll the refresh — use `fb_access_token` from\n`references/fb-token.sh`, which persists the rotation before returning, and never point\ntwo tools at the same state file.\n\n**2. Three identifiers that are not interchangeable.** `accountId` (alphanumeric, e.g.\n`xZNQ1X`), `businessId` (integer), `businessUuid`. Each URL family takes a different one\nand answers a mismatch with a bare **404** that reads like a missing record. Always\nresolve first with `fb_ids`.\n\n| Family | Path | Identifier |\n| --- | --- | --- |\n| Accounting | `/accounting/account/{accountId}/…` | `accountId` |\n| Payments | `/payments/account/{accountId}/…` | `accountId` |\n| Accounting (business) | `/accounting/businesses/{businessUuid}/…` | `businessUuid` |\n| Projects | `/projects/business/{businessId}/…` | `businessId` |\n| Time tracking | `/timetracking/business/{businessId}/…` | `businessId` |\n\n## One-time setup\n\nRegister an app at <https://my.freshbooks.com/#/developer>. The redirect URI must be\n**HTTPS with no query string** — `https://localhost` works and never needs to\nresolve. Put `FRESHBOOKS_CLIENT_ID` and `FRESHBOOKS_CLIENT_SECRET` somewhere loadable\n(e.g. `~/.secrets`).\n\nThen run the authorize flow once:\n\n```sh\nset -a; . ~/.secrets; set +a\n\n# 1. Print the authorize URL, open it, click Allow.\nnode references/fb-bootstrap.mjs url \"$FRESHBOOKS_CLIENT_ID\"\n\n# 2. The browser fails to load https://localhost?code=… — that is expected.\n#    Copy the whole URL from the address bar and exchange it (the code is single-use\n#    and expires within minutes).\nnode references/fb-bootstrap.mjs exchange \"$FRESHBOOKS_CLIENT_ID\" \"$FRESHBOOKS_CLIENT_SECRET\" \\\n  https://localhost 'https://localhost?code=PASTE_HERE'\n```\n\nSave the printed `refresh_token` as `FRESHBOOKS_REFRESH_TOKEN`. It seeds the state file\non first use; after that the state file is the source of truth and the env value is\nignored until you re-bootstrap.\n\n## Core call pattern\n\n```sh\nset -a; . ~/.secrets; set +a\n. references/fb-token.sh\n\nfb_ids                                    # resolve accountId / businessId / businessUuid\nACCT=$(fb_account_id)\n\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=5\" \\\n  | jq '.response.result.invoices[] | {id, invoice_number, amount, outstanding, status}'\n```\n\n`fb_curl <path> [curl args…]` attaches the bearer token and `Api-Version: alpha`;\neverything after the path is passed to `curl`, so writes work too.\n\n## Response envelopes differ per family\n\n- Accounting / events → `.response.result.<name>`, errors at `.response.errors[].message`\n- Accounting-business → `.data`, errors at `.errors.message` + `.errors.details[].reason`\n- Projects / time tracking / comments → bare object, errors at `.error`\n- Payments → errors at `.errors.details[].field`\n\nA single `jq` path will not work across families — see `references/recipes.md`.\n\n## Writes\n\nAccounting writes wrap the payload in the **singular** resource name:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/invoices/invoices\" \\\n  -X POST -H 'Content-Type: application/json' \\\n  -d '{\"invoice\":{\"customerid\":123,\"create_date\":\"2026-08-12\",\"lines\":[]}}'\n```\n\nMoney is `{\"amount\":\"150.00\",\"code\":\"USD\"}` — the amount is a **string**, not a number.\n\nDeletes on the accounting family are frequently soft deletes via `vis_state` on an\nupdate rather than HTTP `DELETE`. Confirm per resource before assuming.\n\n**A 200 is not proof a write persisted — re-read the record to confirm.**\n\n## Troubleshooting\n\n| Symptom | Cause |\n| --- | --- |\n| `invalid_client` on refresh | The refresh grant needs `client_secret` **and** `redirect_uri`, form-encoded — not JSON |\n| `invalid_grant` on refresh | The token was already spent. Re-run the bootstrap |\n| 404 on a valid-looking record | Wrong identifier for that URL family — run `fb_ids` |\n| 401 on every call | Access token stale and refresh failing; check the state file |\n\nSee `references/recipes.md` for ready-to-run request bodies and `jq` recipes.\n\nFile v1.2.0:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"freshbooks-mcp\",\n  \"version\": \"1.2.0\",\n  \"publishedAt\": 1791380109349\n}\n\nFile v1.2.0:references/recipes.md\n\n# FreshBooks curl recipes\n\nAll examples assume:\n\n```sh\nset -a; . ~/.secrets; set +a\n. references/fb-token.sh\nACCT=$(fb_account_id)\nBIZ=$(fb_ids | jq -r .businessId)\n```\n\nPaths marked **(unverified)** are transcribed from the official\n`freshbooks-python-sdk` source but have not been exercised against a live account.\n\n## Identity\n\n```sh\nfb_curl /auth/api/v1/users/me | jq '.response | {identity_id, email}'\nfb_ids   # accountId / businessId / businessUuid — run before anything else\n```\n\n## Invoices\n\n```sh\n# List (newest first via FreshBooks' own sort param)\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=25&page=1\" \\\n  | jq '.response.result | {page, pages, total,\n        invoices: [.invoices[] | {id, invoice_number, organization, create_date,\n                                  amount: .amount.amount, outstanding: .outstanding.amount, status}]}'\n\n# One invoice, with line items expanded\nfb_curl \"/accounting/account/$ACCT/invoices/invoices/INVOICE_ID?include[]=lines\" \\\n  | jq '.response.result.invoice'\n\n# Outstanding only — v3_status is the useful status field\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?search[v3_status]=overdue\" \\\n  | jq '[.response.result.invoices[] | {id, invoice_number, outstanding: .outstanding.amount}]'\n\n# Total outstanding across a page\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=100\" \\\n  | jq '[.response.result.invoices[].outstanding.amount | tonumber] | add'\n```\n\nCreate **(unverified)** — money amounts are strings:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/invoices/invoices\" -X POST \\\n  -H 'Content-Type: application/json' -d '{\n  \"invoice\": {\n    \"customerid\": 123,\n    \"create_date\": \"2026-08-12\",\n    \"lines\": [\n      { \"name\": \"Consulting\", \"description\": \"August retainer\",\n        \"qty\": 1, \"unit_cost\": { \"amount\": \"1500.00\", \"code\": \"USD\" } }\n    ]\n  }\n}' | jq '.response.result.invoice | {id, invoice_number, amount}'\n```\n\n## Clients\n\n```sh\nfb_curl \"/accounting/account/$ACCT/users/clients?per_page=100\" \\\n  | jq '[.response.result.clients[] | {id: .userid, organization, fname, lname, email}]'\n\n# Find a client by name\nfb_curl \"/accounting/account/$ACCT/users/clients?search[organization_like]=acme\" \\\n  | jq '.response.result.clients'\n```\n\nCreate **(unverified)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/users/clients\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"client\":{\"email\":\"ap@example.com\",\"organization\":\"Example Co\",\"fname\":\"Pat\",\"lname\":\"Doe\"}}' \\\n  | jq '.response.result.client | {userid, organization}'\n```\n\n## Payments\n\n```sh\nfb_curl \"/accounting/account/$ACCT/payments/payments?per_page=50\" \\\n  | jq '[.response.result.payments[] | {id, invoiceid, date, amount: .amount.amount, type}]'\n```\n\nRecord a payment **(unverified)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/payments/payments\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"payment\":{\"invoiceid\":456,\"amount\":{\"amount\":\"1500.00\",\"code\":\"USD\"},\"date\":\"2026-08-12\",\"type\":\"Check\"}}'\n```\n\n## Estimates — accepting is an action, not a status write\n\n`status`, `display_status` and `ui_status` are computed read-only fields and disagree with\neach other (`status: 3` / `\"viewed\"` / `\"open\"` are the same estimate). Writing them does\nnothing. Acceptance is an action on the estimate **(unverified — collection-derived)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/estimates/estimates/279405\" -X PUT \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"estimate\":{\"action_accept\":true}}' \\\n  | jq '.response.result.estimate | {id, accepted, status, display_status, ui_status}'\n```\n\nEmail it to the client — note `estimate_customized_email`, not the invoice endpoint's\n`invoice_customized_email`:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/estimates/estimates/279405\" -X PUT \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"estimate\":{\"action_email\":true,\"email_recipients\":[\"client@example.com\"]}}'\n```\n\nThere is **no decline**: no declined status, no `action_deny`, no `estimate.decline`\nwebhook. The nearest expressible thing is the soft delete, `{\"estimate\":{\"vis_state\":1}}`,\nwhich removes it rather than declining it.\n\n## Full accounting resource map\n\nSame family and envelope; only the path suffix and list key change. Four of these are not\nwhat you would guess — they are marked ⚠ and each cost a 404 before the SDK settled it.\n\n| Resource | Path suffix | List key |\n| --- | --- | --- |\n| Invoices | `invoices/invoices` | `invoices` |\n| Clients | `users/clients` | `clients` |\n| Estimates | `estimates/estimates` | `estimates` |\n| Payments | `payments/payments` | `payments` |\n| Credit notes | `credit_notes/credit_notes` | `credit_notes` |\n| Invoice profiles | `invoice_profiles/invoice_profiles` | `invoice_profiles` |\n| Items | `items/items` | `items` |\n| Taxes | `taxes/taxes` | `taxes` |\n| Expenses | `expenses/expenses` | `expenses` |\n| Expense categories ⚠ | `expenses/categories` | `categories` |\n| Staff ⚠ | `users/staffs` | `staff` |\n| Gateways ⚠ | `systems/gateways` | `gateways` |\n| Other income ⚠ | `other_incomes/other_incomes` | `other_income` |\n| Tasks (billable catalogue) | `projects/tasks` | `tasks` |\n| Bills | `bills/bills` | `bills` |\n| Bill vendors | `bill_vendors/bill_vendors` | `bill_vendors` |\n| Bill payments | `bill_payments/bill_payments` | `bill_payments` |\n\n`projects/tasks` is in the **accounting** family (accountId) despite the prefix.\n\n### `total` can exceed the rows you get back\n\nVerified live: expenses returned `total: 16` with an empty array — the count includes\nrecords the identity cannot read, and paging never surfaces them. Always check both:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=100\" \\\n  | jq '{total: .response.result.total, rows: (.response.result.expenses|length)}'\n```\n\nIf `rows` is 0 while `total` is not **and you are on a page within range**, that is a\npermission boundary rather than an empty account. Check the page first — an empty page with\na non-zero total is also just what paging past the end looks like:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=100\" \\\n  | jq '.response.result | {total, pages, page, rows: (.expenses|length),\n         verdict: (if (.expenses|length) > 0 then \"ok\"\n                   elif .page > .pages then \"past the last page\"\n                   else \"rows withheld by permissions\" end)}'\n```\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=50\" \\\n  | jq '[.response.result.expenses[] | {id, date, amount: .amount.amount, vendor, notes}]'\n```\n\n## Projects and time tracking — different id, different envelope\n\nThese take `businessId` and return **bare** objects, not `.response.result`. Pagination\nlives under `meta`, so `.response.result.total` reads `undefined` here and looks like an\nempty account:\n\n```sh\nfb_curl \"/projects/business/$BIZ/projects\" \\\n  | jq '{meta, projects: [.projects[] | {id, title, client_id, active}]}'\n\n# time_entries' meta also carries total_logged and total_unbilled\nfb_curl \"/timetracking/business/$BIZ/time_entries\" \\\n  | jq '{logged: .meta.total_logged, unbilled: .meta.total_unbilled,\n         entries: [.time_entries[] | {id, project_id, duration, started_at, note}]}'\n\nfb_curl \"/comments/business/$BIZ/services\" | jq '[.services[] | {id, name, billable}]'\n```\n\nErrors here are `.error`, a plain string — not `.response.errors[]`.\n\nThese endpoints work on a business with **no accounting account**, so they can succeed\nwhen every `/accounting/account/...` call fails.\n\nLog time (duration is in **seconds**):\n\n```sh\nfb_curl \"/timetracking/business/$BIZ/time_entries\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"time_entry\":{\"duration\":3600,\"started_at\":\"2026-08-12T09:00:00Z\",\"note\":\"Design review\"}}'\n```\n\n## Pagination\n\nAccounting lists carry `page`, `pages`, `per_page`, `total` on the result envelope.\nWalk them:\n\n```sh\np=1\nwhile :; do\n  body=$(fb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=100&page=$p\")\n  printf '%s' \"$body\" | jq -c '.response.result.invoices[]'\n  pages=$(printf '%s' \"$body\" | jq -r '.response.result.pages')\n  [ \"$p\" -ge \"$pages\" ] && break\n  p=$((p + 1))\ndone\n```\n\n## Error shapes by family\n\n```sh\n# Accounting / events\njq -r '.response.errors[]? | \"\\(.errno): \\(.message)\"'\n# Accounting-business\njq -r '.errors? | \"\\(.message) \\(.details[]?.reason // \"\")\"'\n# Payments\njq -r '.errors?.details[]? | \"\\(.field): \\(.message)\"'\n# Projects / time tracking / comments / uploads\njq -r '.error? // empty'\n```\n\nFile v1.2.0:skill-card.md\n\n## Description:\n\nHelps agents query FreshBooks invoices, clients, estimates, payments, expenses, projects, and time entries from a shell using curl and rotating OAuth tokens.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nDevelopers and finance operators use this skill to retrieve FreshBooks records from shell scripts without running an MCP server. Its request examples also support creating or updating records when the user approves those actions.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: POST and PUT requests can alter live accounting records, record payments, or trigger customer-facing actions.\n\nMitigation: Require explicit approval before non-GET requests; try write recipes in a sandbox or test account first, then re-read records to confirm changes.\n\nRisk: Persistent OAuth token state can expose account access or become unusable if rotating refresh tokens are mishandled.\n\nMitigation: Keep the token state file private, use the bundled rotation helper, and do not share one state file between tools.\n\n## Reference(s):\n\n- [FreshBooks curl recipes](references/recipes.md)\n- [FreshBooks token helper](references/fb-token.sh)\n- [FreshBooks OAuth bootstrap](references/fb-bootstrap.mjs)\n- [FreshBooks developer portal](https://my.freshbooks.com/#/developer)\n- [ClawHub skill release](https://clawhub.ai/chrischall/skills/freshbooks-mcp)\n\n## Skill Output:\n\n**Output Type(s):** [Shell commands, Configuration instructions, Guidance]\n\n**Output Format:** [Markdown with shell and JSON examples]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [FreshBooks API responses are JSON.]\n\n## Skill Version(s):\n\n1.2.0 (source: server-resolved release)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.1.5: 6 files, 10920 bytes\n\nFiles: references/fb-bootstrap.mjs (4220b), references/fb-token.sh (4351b), references/recipes.md (8476b), skill-card.md (2226b), SKILL.md (4894b), _meta.json (133b)\n\nFile v1.1.5:SKILL.md\n\n---\nname: freshbooks-curl\ndescription: Query 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.\n---\n\n# FreshBooks from the shell\n\nFreshBooks has a real REST API reachable server-side — no browser bridge, no signed-in\ntab, no `fpx`. Authentication is **OAuth2 only**: there is no API key and no personal\naccess token, so a one-time browser authorize flow is unavoidable.\n\n## The two things that break naive clients\n\n**1. Refresh tokens are single-use and rotate.** Every refresh returns a *new* refresh\ntoken and kills the one you used. If the replacement is not written to disk before the\nprocess ends, the account is locked out and the only recovery is re-running the browser\nflow. Never hand-roll the refresh — use `fb_access_token` from\n`references/fb-token.sh`, which persists the rotation before returning, and never point\ntwo tools at the same state file.\n\n**2. Three identifiers that are not interchangeable.** `accountId` (alphanumeric, e.g.\n`xZNQ1X`), `businessId` (integer), `businessUuid`. Each URL family takes a different one\nand answers a mismatch with a bare **404** that reads like a missing record. Always\nresolve first with `fb_ids`.\n\n| Family | Path | Identifier |\n| --- | --- | --- |\n| Accounting | `/accounting/account/{accountId}/…` | `accountId` |\n| Payments | `/payments/account/{accountId}/…` | `accountId` |\n| Accounting (business) | `/accounting/businesses/{businessUuid}/…` | `businessUuid` |\n| Projects | `/projects/business/{businessId}/…` | `businessId` |\n| Time tracking | `/timetracking/business/{businessId}/…` | `businessId` |\n\n## One-time setup\n\nRegister an app at <https://my.freshbooks.com/#/developer>. The redirect URI must be\n**HTTPS with no query string** — `https://localhost` works and never needs to\nresolve. Put `FRESHBOOKS_CLIENT_ID` and `FRESHBOOKS_CLIENT_SECRET` somewhere loadable\n(e.g. `~/.secrets`).\n\nThen run the authorize flow once:\n\n```sh\nset -a; . ~/.secrets; set +a\n\n# 1. Print the authorize URL, open it, click Allow.\nnode references/fb-bootstrap.mjs url \"$FRESHBOOKS_CLIENT_ID\"\n\n# 2. The browser fails to load https://localhost?code=… — that is expected.\n#    Copy the whole URL from the address bar and exchange it (the code is single-use\n#    and expires within minutes).\nnode references/fb-bootstrap.mjs exchange \"$FRESHBOOKS_CLIENT_ID\" \"$FRESHBOOKS_CLIENT_SECRET\" \\\n  https://localhost 'https://localhost?code=PASTE_HERE'\n```\n\nSave the printed `refresh_token` as `FRESHBOOKS_REFRESH_TOKEN`. It seeds the state file\non first use; after that the state file is the source of truth and the env value is\nignored until you re-bootstrap.\n\n## Core call pattern\n\n```sh\nset -a; . ~/.secrets; set +a\n. references/fb-token.sh\n\nfb_ids                                    # resolve accountId / businessId / businessUuid\nACCT=$(fb_account_id)\n\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=5\" \\\n  | jq '.response.result.invoices[] | {id, invoice_number, amount, outstanding, status}'\n```\n\n`fb_curl <path> [curl args…]` attaches the bearer token and `Api-Version: alpha`;\neverything after the path is passed to `curl`, so writes work too.\n\n## Response envelopes differ per family\n\n- Accounting / events → `.response.result.<name>`, errors at `.response.errors[].message`\n- Accounting-business → `.data`, errors at `.errors.message` + `.errors.details[].reason`\n- Projects / time tracking / comments → bare object, errors at `.error`\n- Payments → errors at `.errors.details[].field`\n\nA single `jq` path will not work across families — see `references/recipes.md`.\n\n## Writes\n\nAccounting writes wrap the payload in the **singular** resource name:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/invoices/invoices\" \\\n  -X POST -H 'Content-Type: application/json' \\\n  -d '{\"invoice\":{\"customerid\":123,\"create_date\":\"2026-08-12\",\"lines\":[]}}'\n```\n\nMoney is `{\"amount\":\"150.00\",\"code\":\"USD\"}` — the amount is a **string**, not a number.\n\nDeletes on the accounting family are frequently soft deletes via `vis_state` on an\nupdate rather than HTTP `DELETE`. Confirm per resource before assuming.\n\n**A 200 is not proof a write persisted — re-read the record to confirm.**\n\n## Troubleshooting\n\n| Symptom | Cause |\n| --- | --- |\n| `invalid_client` on refresh | The refresh grant needs `client_secret` **and** `redirect_uri`, form-encoded — not JSON |\n| `invalid_grant` on refresh | The token was already spent. Re-run the bootstrap |\n| 404 on a valid-looking record | Wrong identifier for that URL family — run `fb_ids` |\n| 401 on every call | Access token stale and refresh failing; check the state file |\n\nSee `references/recipes.md` for ready-to-run request bodies and `jq` recipes.\n\nFile v1.1.5:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"freshbooks-mcp\",\n  \"version\": \"1.1.5\",\n  \"publishedAt\": 1791168757498\n}\n\nFile v1.1.5:references/recipes.md\n\n# FreshBooks curl recipes\n\nAll examples assume:\n\n```sh\nset -a; . ~/.secrets; set +a\n. references/fb-token.sh\nACCT=$(fb_account_id)\nBIZ=$(fb_ids | jq -r .businessId)\n```\n\nPaths marked **(unverified)** are transcribed from the official\n`freshbooks-python-sdk` source but have not been exercised against a live account.\n\n## Identity\n\n```sh\nfb_curl /auth/api/v1/users/me | jq '.response | {identity_id, email}'\nfb_ids   # accountId / businessId / businessUuid — run before anything else\n```\n\n## Invoices\n\n```sh\n# List (newest first via FreshBooks' own sort param)\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=25&page=1\" \\\n  | jq '.response.result | {page, pages, total,\n        invoices: [.invoices[] | {id, invoice_number, organization, create_date,\n                                  amount: .amount.amount, outstanding: .outstanding.amount, status}]}'\n\n# One invoice, with line items expanded\nfb_curl \"/accounting/account/$ACCT/invoices/invoices/INVOICE_ID?include[]=lines\" \\\n  | jq '.response.result.invoice'\n\n# Outstanding only — v3_status is the useful status field\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?search[v3_status]=overdue\" \\\n  | jq '[.response.result.invoices[] | {id, invoice_number, outstanding: .outstanding.amount}]'\n\n# Total outstanding across a page\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=100\" \\\n  | jq '[.response.result.invoices[].outstanding.amount | tonumber] | add'\n```\n\nCreate **(unverified)** — money amounts are strings:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/invoices/invoices\" -X POST \\\n  -H 'Content-Type: application/json' -d '{\n  \"invoice\": {\n    \"customerid\": 123,\n    \"create_date\": \"2026-08-12\",\n    \"lines\": [\n      { \"name\": \"Consulting\", \"description\": \"August retainer\",\n        \"qty\": 1, \"unit_cost\": { \"amount\": \"1500.00\", \"code\": \"USD\" } }\n    ]\n  }\n}' | jq '.response.result.invoice | {id, invoice_number, amount}'\n```\n\n## Clients\n\n```sh\nfb_curl \"/accounting/account/$ACCT/users/clients?per_page=100\" \\\n  | jq '[.response.result.clients[] | {id: .userid, organization, fname, lname, email}]'\n\n# Find a client by name\nfb_curl \"/accounting/account/$ACCT/users/clients?search[organization_like]=acme\" \\\n  | jq '.response.result.clients'\n```\n\nCreate **(unverified)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/users/clients\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"client\":{\"email\":\"ap@example.com\",\"organization\":\"Example Co\",\"fname\":\"Pat\",\"lname\":\"Doe\"}}' \\\n  | jq '.response.result.client | {userid, organization}'\n```\n\n## Payments\n\n```sh\nfb_curl \"/accounting/account/$ACCT/payments/payments?per_page=50\" \\\n  | jq '[.response.result.payments[] | {id, invoiceid, date, amount: .amount.amount, type}]'\n```\n\nRecord a payment **(unverified)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/payments/payments\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"payment\":{\"invoiceid\":456,\"amount\":{\"amount\":\"1500.00\",\"code\":\"USD\"},\"date\":\"2026-08-12\",\"type\":\"Check\"}}'\n```\n\n## Estimates — accepting is an action, not a status write\n\n`status`, `display_status` and `ui_status` are computed read-only fields and disagree with\neach other (`status: 3` / `\"viewed\"` / `\"open\"` are the same estimate). Writing them does\nnothing. Acceptance is an action on the estimate **(unverified — collection-derived)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/estimates/estimates/279405\" -X PUT \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"estimate\":{\"action_accept\":true}}' \\\n  | jq '.response.result.estimate | {id, accepted, status, display_status, ui_status}'\n```\n\nEmail it to the client — note `estimate_customized_email`, not the invoice endpoint's\n`invoice_customized_email`:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/estimates/estimates/279405\" -X PUT \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"estimate\":{\"action_email\":true,\"email_recipients\":[\"client@example.com\"]}}'\n```\n\nThere is **no decline**: no declined status, no `action_deny`, no `estimate.decline`\nwebhook. The nearest expressible thing is the soft delete, `{\"estimate\":{\"vis_state\":1}}`,\nwhich removes it rather than declining it.\n\n## Full accounting resource map\n\nSame family and envelope; only the path suffix and list key change. Four of these are not\nwhat you would guess — they are marked ⚠ and each cost a 404 before the SDK settled it.\n\n| Resource | Path suffix | List key |\n| --- | --- | --- |\n| Invoices | `invoices/invoices` | `invoices` |\n| Clients | `users/clients` | `clients` |\n| Estimates | `estimates/estimates` | `estimates` |\n| Payments | `payments/payments` | `payments` |\n| Credit notes | `credit_notes/credit_notes` | `credit_notes` |\n| Invoice profiles | `invoice_profiles/invoice_profiles` | `invoice_profiles` |\n| Items | `items/items` | `items` |\n| Taxes | `taxes/taxes` | `taxes` |\n| Expenses | `expenses/expenses` | `expenses` |\n| Expense categories ⚠ | `expenses/categories` | `categories` |\n| Staff ⚠ | `users/staffs` | `staff` |\n| Gateways ⚠ | `systems/gateways` | `gateways` |\n| Other income ⚠ | `other_incomes/other_incomes` | `other_income` |\n| Tasks (billable catalogue) | `projects/tasks` | `tasks` |\n| Bills | `bills/bills` | `bills` |\n| Bill vendors | `bill_vendors/bill_vendors` | `bill_vendors` |\n| Bill payments | `bill_payments/bill_payments` | `bill_payments` |\n\n`projects/tasks` is in the **accounting** family (accountId) despite the prefix.\n\n### `total` can exceed the rows you get back\n\nVerified live: expenses returned `total: 16` with an empty array — the count includes\nrecords the identity cannot read, and paging never surfaces them. Always check both:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=100\" \\\n  | jq '{total: .response.result.total, rows: (.response.result.expenses|length)}'\n```\n\nIf `rows` is 0 while `total` is not **and you are on a page within range**, that is a\npermission boundary rather than an empty account. Check the page first — an empty page with\na non-zero total is also just what paging past the end looks like:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=100\" \\\n  | jq '.response.result | {total, pages, page, rows: (.expenses|length),\n         verdict: (if (.expenses|length) > 0 then \"ok\"\n                   elif .page > .pages then \"past the last page\"\n                   else \"rows withheld by permissions\" end)}'\n```\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=50\" \\\n  | jq '[.response.result.expenses[] | {id, date, amount: .amount.amount, vendor, notes}]'\n```\n\n## Projects and time tracking — different id, different envelope\n\nThese take `businessId` and return **bare** objects, not `.response.result`. Pagination\nlives under `meta`, so `.response.result.total` reads `undefined` here and looks like an\nempty account:\n\n```sh\nfb_curl \"/projects/business/$BIZ/projects\" \\\n  | jq '{meta, projects: [.projects[] | {id, title, client_id, active}]}'\n\n# time_entries' meta also carries total_logged and total_unbilled\nfb_curl \"/timetracking/business/$BIZ/time_entries\" \\\n  | jq '{logged: .meta.total_logged, unbilled: .meta.total_unbilled,\n         entries: [.time_entries[] | {id, project_id, duration, started_at, note}]}'\n\nfb_curl \"/comments/business/$BIZ/services\" | jq '[.services[] | {id, name, billable}]'\n```\n\nErrors here are `.error`, a plain string — not `.response.errors[]`.\n\nThese endpoints work on a business with **no accounting account**, so they can succeed\nwhen every `/accounting/account/...` call fails.\n\nLog time (duration is in **seconds**):\n\n```sh\nfb_curl \"/timetracking/business/$BIZ/time_entries\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"time_entry\":{\"duration\":3600,\"started_at\":\"2026-08-12T09:00:00Z\",\"note\":\"Design review\"}}'\n```\n\n## Pagination\n\nAccounting lists carry `page`, `pages`, `per_page`, `total` on the result envelope.\nWalk them:\n\n```sh\np=1\nwhile :; do\n  body=$(fb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=100&page=$p\")\n  printf '%s' \"$body\" | jq -c '.response.result.invoices[]'\n  pages=$(printf '%s' \"$body\" | jq -r '.response.result.pages')\n  [ \"$p\" -ge \"$pages\" ] && break\n  p=$((p + 1))\ndone\n```\n\n## Error shapes by family\n\n```sh\n# Accounting / events\njq -r '.response.errors[]? | \"\\(.errno): \\(.message)\"'\n# Accounting-business\njq -r '.errors? | \"\\(.message) \\(.details[]?.reason // \"\")\"'\n# Payments\njq -r '.errors?.details[]? | \"\\(.field): \\(.message)\"'\n# Projects / time tracking / comments / uploads\njq -r '.error? // empty'\n```\n\nFile v1.1.5:skill-card.md\n\n## Description:\n\nHelps agents query FreshBooks invoices, clients, estimates, payments, expenses, projects, and time entries from a shell using curl and rotating OAuth credentials, without running an MCP server.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nDevelopers and operators use this skill to retrieve FreshBooks accounting and project data in shell workflows without installing the FreshBooks MCP server. Its commands can also change account data when given write options.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: OAuth credentials and rotating refresh tokens may be exposed or lost if setup is logged, the token state file is shared, or concurrent tools reuse it.\n\nMitigation: Keep credentials and the token state file private, avoid setup in logged CI or shared terminals, and use a separate state file per tool.\n\nRisk: The shell helper can perform live financial and accounting writes, including updates and deletes, despite its query-oriented description.\n\nMitigation: Use an app and account with only the necessary permissions; require explicit user approval before POST, PUT, DELETE, or vis_state changes, and re-read records to confirm writes.\n\n## Reference(s):\n\n- [FreshBooks developer portal](https://my.freshbooks.com/#/developer)\n- [FreshBooks curl recipes](references/recipes.md)\n- [FreshBooks token helper](references/fb-token.sh)\n- [FreshBooks bootstrap helper](references/fb-bootstrap.mjs)\n\n## Skill Output:\n\n**Output Type(s):** [Shell commands, Configuration instructions, Guidance]\n\n**Output Format:** [Markdown with shell commands and JSON query examples]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Uses FreshBooks OAuth credentials and requires a writable private token state file.]\n\n## Skill Version(s):\n\n1.1.5 (source: ClawHub release)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.1.4: 6 files, 10886 bytes\n\nFiles: references/fb-bootstrap.mjs (4220b), references/fb-token.sh (4351b), references/recipes.md (8476b), skill-card.md (2160b), SKILL.md (4894b), _meta.json (133b)\n\nFile v1.1.4:SKILL.md\n\n---\nname: freshbooks-curl\ndescription: Query 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.\n---\n\n# FreshBooks from the shell\n\nFreshBooks has a real REST API reachable server-side — no browser bridge, no signed-in\ntab, no `fpx`. Authentication is **OAuth2 only**: there is no API key and no personal\naccess token, so a one-time browser authorize flow is unavoidable.\n\n## The two things that break naive clients\n\n**1. Refresh tokens are single-use and rotate.** Every refresh returns a *new* refresh\ntoken and kills the one you used. If the replacement is not written to disk before the\nprocess ends, the account is locked out and the only recovery is re-running the browser\nflow. Never hand-roll the refresh — use `fb_access_token` from\n`references/fb-token.sh`, which persists the rotation before returning, and never point\ntwo tools at the same state file.\n\n**2. Three identifiers that are not interchangeable.** `accountId` (alphanumeric, e.g.\n`xZNQ1X`), `businessId` (integer), `businessUuid`. Each URL family takes a different one\nand answers a mismatch with a bare **404** that reads like a missing record. Always\nresolve first with `fb_ids`.\n\n| Family | Path | Identifier |\n| --- | --- | --- |\n| Accounting | `/accounting/account/{accountId}/…` | `accountId` |\n| Payments | `/payments/account/{accountId}/…` | `accountId` |\n| Accounting (business) | `/accounting/businesses/{businessUuid}/…` | `businessUuid` |\n| Projects | `/projects/business/{businessId}/…` | `businessId` |\n| Time tracking | `/timetracking/business/{businessId}/…` | `businessId` |\n\n## One-time setup\n\nRegister an app at <https://my.freshbooks.com/#/developer>. The redirect URI must be\n**HTTPS with no query string** — `https://localhost` works and never needs to\nresolve. Put `FRESHBOOKS_CLIENT_ID` and `FRESHBOOKS_CLIENT_SECRET` somewhere loadable\n(e.g. `~/.secrets`).\n\nThen run the authorize flow once:\n\n```sh\nset -a; . ~/.secrets; set +a\n\n# 1. Print the authorize URL, open it, click Allow.\nnode references/fb-bootstrap.mjs url \"$FRESHBOOKS_CLIENT_ID\"\n\n# 2. The browser fails to load https://localhost?code=… — that is expected.\n#    Copy the whole URL from the address bar and exchange it (the code is single-use\n#    and expires within minutes).\nnode references/fb-bootstrap.mjs exchange \"$FRESHBOOKS_CLIENT_ID\" \"$FRESHBOOKS_CLIENT_SECRET\" \\\n  https://localhost 'https://localhost?code=PASTE_HERE'\n```\n\nSave the printed `refresh_token` as `FRESHBOOKS_REFRESH_TOKEN`. It seeds the state file\non first use; after that the state file is the source of truth and the env value is\nignored until you re-bootstrap.\n\n## Core call pattern\n\n```sh\nset -a; . ~/.secrets; set +a\n. references/fb-token.sh\n\nfb_ids                                    # resolve accountId / businessId / businessUuid\nACCT=$(fb_account_id)\n\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=5\" \\\n  | jq '.response.result.invoices[] | {id, invoice_number, amount, outstanding, status}'\n```\n\n`fb_curl <path> [curl args…]` attaches the bearer token and `Api-Version: alpha`;\neverything after the path is passed to `curl`, so writes work too.\n\n## Response envelopes differ per family\n\n- Accounting / events → `.response.result.<name>`, errors at `.response.errors[].message`\n- Accounting-business → `.data`, errors at `.errors.message` + `.errors.details[].reason`\n- Projects / time tracking / comments → bare object, errors at `.error`\n- Payments → errors at `.errors.details[].field`\n\nA single `jq` path will not work across families — see `references/recipes.md`.\n\n## Writes\n\nAccounting writes wrap the payload in the **singular** resource name:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/invoices/invoices\" \\\n  -X POST -H 'Content-Type: application/json' \\\n  -d '{\"invoice\":{\"customerid\":123,\"create_date\":\"2026-08-12\",\"lines\":[]}}'\n```\n\nMoney is `{\"amount\":\"150.00\",\"code\":\"USD\"}` — the amount is a **string**, not a number.\n\nDeletes on the accounting family are frequently soft deletes via `vis_state` on an\nupdate rather than HTTP `DELETE`. Confirm per resource before assuming.\n\n**A 200 is not proof a write persisted — re-read the record to confirm.**\n\n## Troubleshooting\n\n| Symptom | Cause |\n| --- | --- |\n| `invalid_client` on refresh | The refresh grant needs `client_secret` **and** `redirect_uri`, form-encoded — not JSON |\n| `invalid_grant` on refresh | The token was already spent. Re-run the bootstrap |\n| 404 on a valid-looking record | Wrong identifier for that URL family — run `fb_ids` |\n| 401 on every call | Access token stale and refresh failing; check the state file |\n\nSee `references/recipes.md` for ready-to-run request bodies and `jq` recipes.\n\nFile v1.1.4:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"freshbooks-mcp\",\n  \"version\": \"1.1.4\",\n  \"publishedAt\": 1790991657415\n}\n\nFile v1.1.4:references/recipes.md\n\n# FreshBooks curl recipes\n\nAll examples assume:\n\n```sh\nset -a; . ~/.secrets; set +a\n. references/fb-token.sh\nACCT=$(fb_account_id)\nBIZ=$(fb_ids | jq -r .businessId)\n```\n\nPaths marked **(unverified)** are transcribed from the official\n`freshbooks-python-sdk` source but have not been exercised against a live account.\n\n## Identity\n\n```sh\nfb_curl /auth/api/v1/users/me | jq '.response | {identity_id, email}'\nfb_ids   # accountId / businessId / businessUuid — run before anything else\n```\n\n## Invoices\n\n```sh\n# List (newest first via FreshBooks' own sort param)\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=25&page=1\" \\\n  | jq '.response.result | {page, pages, total,\n        invoices: [.invoices[] | {id, invoice_number, organization, create_date,\n                                  amount: .amount.amount, outstanding: .outstanding.amount, status}]}'\n\n# One invoice, with line items expanded\nfb_curl \"/accounting/account/$ACCT/invoices/invoices/INVOICE_ID?include[]=lines\" \\\n  | jq '.response.result.invoice'\n\n# Outstanding only — v3_status is the useful status field\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?search[v3_status]=overdue\" \\\n  | jq '[.response.result.invoices[] | {id, invoice_number, outstanding: .outstanding.amount}]'\n\n# Total outstanding across a page\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=100\" \\\n  | jq '[.response.result.invoices[].outstanding.amount | tonumber] | add'\n```\n\nCreate **(unverified)** — money amounts are strings:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/invoices/invoices\" -X POST \\\n  -H 'Content-Type: application/json' -d '{\n  \"invoice\": {\n    \"customerid\": 123,\n    \"create_date\": \"2026-08-12\",\n    \"lines\": [\n      { \"name\": \"Consulting\", \"description\": \"August retainer\",\n        \"qty\": 1, \"unit_cost\": { \"amount\": \"1500.00\", \"code\": \"USD\" } }\n    ]\n  }\n}' | jq '.response.result.invoice | {id, invoice_number, amount}'\n```\n\n## Clients\n\n```sh\nfb_curl \"/accounting/account/$ACCT/users/clients?per_page=100\" \\\n  | jq '[.response.result.clients[] | {id: .userid, organization, fname, lname, email}]'\n\n# Find a client by name\nfb_curl \"/accounting/account/$ACCT/users/clients?search[organization_like]=acme\" \\\n  | jq '.response.result.clients'\n```\n\nCreate **(unverified)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/users/clients\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"client\":{\"email\":\"ap@example.com\",\"organization\":\"Example Co\",\"fname\":\"Pat\",\"lname\":\"Doe\"}}' \\\n  | jq '.response.result.client | {userid, organization}'\n```\n\n## Payments\n\n```sh\nfb_curl \"/accounting/account/$ACCT/payments/payments?per_page=50\" \\\n  | jq '[.response.result.payments[] | {id, invoiceid, date, amount: .amount.amount, type}]'\n```\n\nRecord a payment **(unverified)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/payments/payments\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"payment\":{\"invoiceid\":456,\"amount\":{\"amount\":\"1500.00\",\"code\":\"USD\"},\"date\":\"2026-08-12\",\"type\":\"Check\"}}'\n```\n\n## Estimates — accepting is an action, not a status write\n\n`status`, `display_status` and `ui_status` are computed read-only fields and disagree with\neach other (`status: 3` / `\"viewed\"` / `\"open\"` are the same estimate). Writing them does\nnothing. Acceptance is an action on the estimate **(unverified — collection-derived)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/estimates/estimates/279405\" -X PUT \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"estimate\":{\"action_accept\":true}}' \\\n  | jq '.response.result.estimate | {id, accepted, status, display_status, ui_status}'\n```\n\nEmail it to the client — note `estimate_customized_email`, not the invoice endpoint's\n`invoice_customized_email`:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/estimates/estimates/279405\" -X PUT \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"estimate\":{\"action_email\":true,\"email_recipients\":[\"client@example.com\"]}}'\n```\n\nThere is **no decline**: no declined status, no `action_deny`, no `estimate.decline`\nwebhook. The nearest expressible thing is the soft delete, `{\"estimate\":{\"vis_state\":1}}`,\nwhich removes it rather than declining it.\n\n## Full accounting resource map\n\nSame family and envelope; only the path suffix and list key change. Four of these are not\nwhat you would guess — they are marked ⚠ and each cost a 404 before the SDK settled it.\n\n| Resource | Path suffix | List key |\n| --- | --- | --- |\n| Invoices | `invoices/invoices` | `invoices` |\n| Clients | `users/clients` | `clients` |\n| Estimates | `estimates/estimates` | `estimates` |\n| Payments | `payments/payments` | `payments` |\n| Credit notes | `credit_notes/credit_notes` | `credit_notes` |\n| Invoice profiles | `invoice_profiles/invoice_profiles` | `invoice_profiles` |\n| Items | `items/items` | `items` |\n| Taxes | `taxes/taxes` | `taxes` |\n| Expenses | `expenses/expenses` | `expenses` |\n| Expense categories ⚠ | `expenses/categories` | `categories` |\n| Staff ⚠ | `users/staffs` | `staff` |\n| Gateways ⚠ | `systems/gateways` | `gateways` |\n| Other income ⚠ | `other_incomes/other_incomes` | `other_income` |\n| Tasks (billable catalogue) | `projects/tasks` | `tasks` |\n| Bills | `bills/bills` | `bills` |\n| Bill vendors | `bill_vendors/bill_vendors` | `bill_vendors` |\n| Bill payments | `bill_payments/bill_payments` | `bill_payments` |\n\n`projects/tasks` is in the **accounting** family (accountId) despite the prefix.\n\n### `total` can exceed the rows you get back\n\nVerified live: expenses returned `total: 16` with an empty array — the count includes\nrecords the identity cannot read, and paging never surfaces them. Always check both:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=100\" \\\n  | jq '{total: .response.result.total, rows: (.response.result.expenses|length)}'\n```\n\nIf `rows` is 0 while `total` is not **and you are on a page within range**, that is a\npermission boundary rather than an empty account. Check the page first — an empty page with\na non-zero total is also just what paging past the end looks like:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=100\" \\\n  | jq '.response.result | {total, pages, page, rows: (.expenses|length),\n         verdict: (if (.expenses|length) > 0 then \"ok\"\n                   elif .page > .pages then \"past the last page\"\n                   else \"rows withheld by permissions\" end)}'\n```\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=50\" \\\n  | jq '[.response.result.expenses[] | {id, date, amount: .amount.amount, vendor, notes}]'\n```\n\n## Projects and time tracking — different id, different envelope\n\nThese take `businessId` and return **bare** objects, not `.response.result`. Pagination\nlives under `meta`, so `.response.result.total` reads `undefined` here and looks like an\nempty account:\n\n```sh\nfb_curl \"/projects/business/$BIZ/projects\" \\\n  | jq '{meta, projects: [.projects[] | {id, title, client_id, active}]}'\n\n# time_entries' meta also carries total_logged and total_unbilled\nfb_curl \"/timetracking/business/$BIZ/time_entries\" \\\n  | jq '{logged: .meta.total_logged, unbilled: .meta.total_unbilled,\n         entries: [.time_entries[] | {id, project_id, duration, started_at, note}]}'\n\nfb_curl \"/comments/business/$BIZ/services\" | jq '[.services[] | {id, name, billable}]'\n```\n\nErrors here are `.error`, a plain string — not `.response.errors[]`.\n\nThese endpoints work on a business with **no accounting account**, so they can succeed\nwhen every `/accounting/account/...` call fails.\n\nLog time (duration is in **seconds**):\n\n```sh\nfb_curl \"/timetracking/business/$BIZ/time_entries\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"time_entry\":{\"duration\":3600,\"started_at\":\"2026-08-12T09:00:00Z\",\"note\":\"Design review\"}}'\n```\n\n## Pagination\n\nAccounting lists carry `page`, `pages`, `per_page`, `total` on the result envelope.\nWalk them:\n\n```sh\np=1\nwhile :; do\n  body=$(fb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=100&page=$p\")\n  printf '%s' \"$body\" | jq -c '.response.result.invoices[]'\n  pages=$(printf '%s' \"$body\" | jq -r '.response.result.pages')\n  [ \"$p\" -ge \"$pages\" ] && break\n  p=$((p + 1))\ndone\n```\n\n## Error shapes by family\n\n```sh\n# Accounting / events\njq -r '.response.errors[]? | \"\\(.errno): \\(.message)\"'\n# Accounting-business\njq -r '.errors? | \"\\(.message) \\(.details[]?.reason // \"\")\"'\n# Payments\njq -r '.errors?.details[]? | \"\\(.field): \\(.message)\"'\n# Projects / time tracking / comments / uploads\njq -r '.error? // empty'\n```\n\nFile v1.1.4:skill-card.md\n\n## Description:\n\nGuides shell-based FreshBooks queries and updates using curl and rotating OAuth tokens.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nDevelopers and business users use this skill to retrieve FreshBooks invoices, clients, payments, expenses, projects, and time records from a shell without running an MCP server. It also provides examples for changing business records when explicitly authorized.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: Shell commands can create or modify financial and customer records, despite the query-focused description.\n\nMitigation: Require explicit human confirmation before POST, PUT, email, payment, invoice, or client-change commands; re-read changed records to verify the result.\n\nRisk: OAuth credentials and rotating refresh tokens can expose business data or interrupt access if mishandled.\n\nMitigation: Use a dedicated FreshBooks app and least-privileged user, keep token state private, avoid logging tokens, and do not share a token state file between tools.\n\n## Reference(s):\n\n- [FreshBooks MCP release on ClawHub](https://clawhub.ai/chrischall/skills/freshbooks-mcp)\n- [FreshBooks developer portal](https://my.freshbooks.com/#/developer)\n- [FreshBooks shell recipes](references/recipes.md)\n- [OAuth bootstrap helper](references/fb-bootstrap.mjs)\n- [OAuth token helper](references/fb-token.sh)\n\n## Skill Output:\n\n**Output Type(s):** [Guidance, Shell commands, Configuration instructions]\n\n**Output Format:** [Markdown with shell commands and JSON examples]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Commands require FreshBooks OAuth credentials and appropriate account permissions.]\n\n## Skill Version(s):\n\n1.1.4 (source: ClawHub release metadata)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.1.3: 6 files, 10892 bytes\n\nFiles: references/fb-bootstrap.mjs (4220b), references/fb-token.sh (4351b), references/recipes.md (8476b), skill-card.md (2263b), SKILL.md (4894b), _meta.json (133b)\n\nFile v1.1.3:SKILL.md\n\n---\nname: freshbooks-curl\ndescription: Query 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.\n---\n\n# FreshBooks from the shell\n\nFreshBooks has a real REST API reachable server-side — no browser bridge, no signed-in\ntab, no `fpx`. Authentication is **OAuth2 only**: there is no API key and no personal\naccess token, so a one-time browser authorize flow is unavoidable.\n\n## The two things that break naive clients\n\n**1. Refresh tokens are single-use and rotate.** Every refresh returns a *new* refresh\ntoken and kills the one you used. If the replacement is not written to disk before the\nprocess ends, the account is locked out and the only recovery is re-running the browser\nflow. Never hand-roll the refresh — use `fb_access_token` from\n`references/fb-token.sh`, which persists the rotation before returning, and never point\ntwo tools at the same state file.\n\n**2. Three identifiers that are not interchangeable.** `accountId` (alphanumeric, e.g.\n`xZNQ1X`), `businessId` (integer), `businessUuid`. Each URL family takes a different one\nand answers a mismatch with a bare **404** that reads like a missing record. Always\nresolve first with `fb_ids`.\n\n| Family | Path | Identifier |\n| --- | --- | --- |\n| Accounting | `/accounting/account/{accountId}/…` | `accountId` |\n| Payments | `/payments/account/{accountId}/…` | `accountId` |\n| Accounting (business) | `/accounting/businesses/{businessUuid}/…` | `businessUuid` |\n| Projects | `/projects/business/{businessId}/…` | `businessId` |\n| Time tracking | `/timetracking/business/{businessId}/…` | `businessId` |\n\n## One-time setup\n\nRegister an app at <https://my.freshbooks.com/#/developer>. The redirect URI must be\n**HTTPS with no query string** — `https://localhost` works and never needs to\nresolve. Put `FRESHBOOKS_CLIENT_ID` and `FRESHBOOKS_CLIENT_SECRET` somewhere loadable\n(e.g. `~/.secrets`).\n\nThen run the authorize flow once:\n\n```sh\nset -a; . ~/.secrets; set +a\n\n# 1. Print the authorize URL, open it, click Allow.\nnode references/fb-bootstrap.mjs url \"$FRESHBOOKS_CLIENT_ID\"\n\n# 2. The browser fails to load https://localhost?code=… — that is expected.\n#    Copy the whole URL from the address bar and exchange it (the code is single-use\n#    and expires within minutes).\nnode references/fb-bootstrap.mjs exchange \"$FRESHBOOKS_CLIENT_ID\" \"$FRESHBOOKS_CLIENT_SECRET\" \\\n  https://localhost 'https://localhost?code=PASTE_HERE'\n```\n\nSave the printed `refresh_token` as `FRESHBOOKS_REFRESH_TOKEN`. It seeds the state file\non first use; after that the state file is the source of truth and the env value is\nignored until you re-bootstrap.\n\n## Core call pattern\n\n```sh\nset -a; . ~/.secrets; set +a\n. references/fb-token.sh\n\nfb_ids                                    # resolve accountId / businessId / businessUuid\nACCT=$(fb_account_id)\n\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=5\" \\\n  | jq '.response.result.invoices[] | {id, invoice_number, amount, outstanding, status}'\n```\n\n`fb_curl <path> [curl args…]` attaches the bearer token and `Api-Version: alpha`;\neverything after the path is passed to `curl`, so writes work too.\n\n## Response envelopes differ per family\n\n- Accounting / events → `.response.result.<name>`, errors at `.response.errors[].message`\n- Accounting-business → `.data`, errors at `.errors.message` + `.errors.details[].reason`\n- Projects / time tracking / comments → bare object, errors at `.error`\n- Payments → errors at `.errors.details[].field`\n\nA single `jq` path will not work across families — see `references/recipes.md`.\n\n## Writes\n\nAccounting writes wrap the payload in the **singular** resource name:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/invoices/invoices\" \\\n  -X POST -H 'Content-Type: application/json' \\\n  -d '{\"invoice\":{\"customerid\":123,\"create_date\":\"2026-08-12\",\"lines\":[]}}'\n```\n\nMoney is `{\"amount\":\"150.00\",\"code\":\"USD\"}` — the amount is a **string**, not a number.\n\nDeletes on the accounting family are frequently soft deletes via `vis_state` on an\nupdate rather than HTTP `DELETE`. Confirm per resource before assuming.\n\n**A 200 is not proof a write persisted — re-read the record to confirm.**\n\n## Troubleshooting\n\n| Symptom | Cause |\n| --- | --- |\n| `invalid_client` on refresh | The refresh grant needs `client_secret` **and** `redirect_uri`, form-encoded — not JSON |\n| `invalid_grant` on refresh | The token was already spent. Re-run the bootstrap |\n| 404 on a valid-looking record | Wrong identifier for that URL family — run `fb_ids` |\n| 401 on every call | Access token stale and refresh failing; check the state file |\n\nSee `references/recipes.md` for ready-to-run request bodies and `jq` recipes.\n\nFile v1.1.3:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"freshbooks-mcp\",\n  \"version\": \"1.1.3\",\n  \"publishedAt\": 1790787499899\n}\n\nFile v1.1.3:references/recipes.md\n\n# FreshBooks curl recipes\n\nAll examples assume:\n\n```sh\nset -a; . ~/.secrets; set +a\n. references/fb-token.sh\nACCT=$(fb_account_id)\nBIZ=$(fb_ids | jq -r .businessId)\n```\n\nPaths marked **(unverified)** are transcribed from the official\n`freshbooks-python-sdk` source but have not been exercised against a live account.\n\n## Identity\n\n```sh\nfb_curl /auth/api/v1/users/me | jq '.response | {identity_id, email}'\nfb_ids   # accountId / businessId / businessUuid — run before anything else\n```\n\n## Invoices\n\n```sh\n# List (newest first via FreshBooks' own sort param)\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=25&page=1\" \\\n  | jq '.response.result | {page, pages, total,\n        invoices: [.invoices[] | {id, invoice_number, organization, create_date,\n                                  amount: .amount.amount, outstanding: .outstanding.amount, status}]}'\n\n# One invoice, with line items expanded\nfb_curl \"/accounting/account/$ACCT/invoices/invoices/INVOICE_ID?include[]=lines\" \\\n  | jq '.response.result.invoice'\n\n# Outstanding only — v3_status is the useful status field\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?search[v3_status]=overdue\" \\\n  | jq '[.response.result.invoices[] | {id, invoice_number, outstanding: .outstanding.amount}]'\n\n# Total outstanding across a page\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=100\" \\\n  | jq '[.response.result.invoices[].outstanding.amount | tonumber] | add'\n```\n\nCreate **(unverified)** — money amounts are strings:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/invoices/invoices\" -X POST \\\n  -H 'Content-Type: application/json' -d '{\n  \"invoice\": {\n    \"customerid\": 123,\n    \"create_date\": \"2026-08-12\",\n    \"lines\": [\n      { \"name\": \"Consulting\", \"description\": \"August retainer\",\n        \"qty\": 1, \"unit_cost\": { \"amount\": \"1500.00\", \"code\": \"USD\" } }\n    ]\n  }\n}' | jq '.response.result.invoice | {id, invoice_number, amount}'\n```\n\n## Clients\n\n```sh\nfb_curl \"/accounting/account/$ACCT/users/clients?per_page=100\" \\\n  | jq '[.response.result.clients[] | {id: .userid, organization, fname, lname, email}]'\n\n# Find a client by name\nfb_curl \"/accounting/account/$ACCT/users/clients?search[organization_like]=acme\" \\\n  | jq '.response.result.clients'\n```\n\nCreate **(unverified)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/users/clients\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"client\":{\"email\":\"ap@example.com\",\"organization\":\"Example Co\",\"fname\":\"Pat\",\"lname\":\"Doe\"}}' \\\n  | jq '.response.result.client | {userid, organization}'\n```\n\n## Payments\n\n```sh\nfb_curl \"/accounting/account/$ACCT/payments/payments?per_page=50\" \\\n  | jq '[.response.result.payments[] | {id, invoiceid, date, amount: .amount.amount, type}]'\n```\n\nRecord a payment **(unverified)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/payments/payments\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"payment\":{\"invoiceid\":456,\"amount\":{\"amount\":\"1500.00\",\"code\":\"USD\"},\"date\":\"2026-08-12\",\"type\":\"Check\"}}'\n```\n\n## Estimates — accepting is an action, not a status write\n\n`status`, `display_status` and `ui_status` are computed read-only fields and disagree with\neach other (`status: 3` / `\"viewed\"` / `\"open\"` are the same estimate). Writing them does\nnothing. Acceptance is an action on the estimate **(unverified — collection-derived)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/estimates/estimates/279405\" -X PUT \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"estimate\":{\"action_accept\":true}}' \\\n  | jq '.response.result.estimate | {id, accepted, status, display_status, ui_status}'\n```\n\nEmail it to the client — note `estimate_customized_email`, not the invoice endpoint's\n`invoice_customized_email`:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/estimates/estimates/279405\" -X PUT \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"estimate\":{\"action_email\":true,\"email_recipients\":[\"client@example.com\"]}}'\n```\n\nThere is **no decline**: no declined status, no `action_deny`, no `estimate.decline`\nwebhook. The nearest expressible thing is the soft delete, `{\"estimate\":{\"vis_state\":1}}`,\nwhich removes it rather than declining it.\n\n## Full accounting resource map\n\nSame family and envelope; only the path suffix and list key change. Four of these are not\nwhat you would guess — they are marked ⚠ and each cost a 404 before the SDK settled it.\n\n| Resource | Path suffix | List key |\n| --- | --- | --- |\n| Invoices | `invoices/invoices` | `invoices` |\n| Clients | `users/clients` | `clients` |\n| Estimates | `estimates/estimates` | `estimates` |\n| Payments | `payments/payments` | `payments` |\n| Credit notes | `credit_notes/credit_notes` | `credit_notes` |\n| Invoice profiles | `invoice_profiles/invoice_profiles` | `invoice_profiles` |\n| Items | `items/items` | `items` |\n| Taxes | `taxes/taxes` | `taxes` |\n| Expenses | `expenses/expenses` | `expenses` |\n| Expense categories ⚠ | `expenses/categories` | `categories` |\n| Staff ⚠ | `users/staffs` | `staff` |\n| Gateways ⚠ | `systems/gateways` | `gateways` |\n| Other income ⚠ | `other_incomes/other_incomes` | `other_income` |\n| Tasks (billable catalogue) | `projects/tasks` | `tasks` |\n| Bills | `bills/bills` | `bills` |\n| Bill vendors | `bill_vendors/bill_vendors` | `bill_vendors` |\n| Bill payments | `bill_payments/bill_payments` | `bill_payments` |\n\n`projects/tasks` is in the **accounting** family (accountId) despite the prefix.\n\n### `total` can exceed the rows you get back\n\nVerified live: expenses returned `total: 16` with an empty array — the count includes\nrecords the identity cannot read, and paging never surfaces them. Always check both:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=100\" \\\n  | jq '{total: .response.result.total, rows: (.response.result.expenses|length)}'\n```\n\nIf `rows` is 0 while `total` is not **and you are on a page within range**, that is a\npermission boundary rather than an empty account. Check the page first — an empty page with\na non-zero total is also just what paging past the end looks like:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=100\" \\\n  | jq '.response.result | {total, pages, page, rows: (.expenses|length),\n         verdict: (if (.expenses|length) > 0 then \"ok\"\n                   elif .page > .pages then \"past the last page\"\n                   else \"rows withheld by permissions\" end)}'\n```\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=50\" \\\n  | jq '[.response.result.expenses[] | {id, date, amount: .amount.amount, vendor, notes}]'\n```\n\n## Projects and time tracking — different id, different envelope\n\nThese take `businessId` and return **bare** objects, not `.response.result`. Pagination\nlives under `meta`, so `.response.result.total` reads `undefined` here and looks like an\nempty account:\n\n```sh\nfb_curl \"/projects/business/$BIZ/projects\" \\\n  | jq '{meta, projects: [.projects[] | {id, title, client_id, active}]}'\n\n# time_entries' meta also carries total_logged and total_unbilled\nfb_curl \"/timetracking/business/$BIZ/time_entries\" \\\n  | jq '{logged: .meta.total_logged, unbilled: .meta.total_unbilled,\n         entries: [.time_entries[] | {id, project_id, duration, started_at, note}]}'\n\nfb_curl \"/comments/business/$BIZ/services\" | jq '[.services[] | {id, name, billable}]'\n```\n\nErrors here are `.error`, a plain string — not `.response.errors[]`.\n\nThese endpoints work on a business with **no accounting account**, so they can succeed\nwhen every `/accounting/account/...` call fails.\n\nLog time (duration is in **seconds**):\n\n```sh\nfb_curl \"/timetracking/business/$BIZ/time_entries\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"time_entry\":{\"duration\":3600,\"started_at\":\"2026-08-12T09:00:00Z\",\"note\":\"Design review\"}}'\n```\n\n## Pagination\n\nAccounting lists carry `page`, `pages`, `per_page`, `total` on the result envelope.\nWalk them:\n\n```sh\np=1\nwhile :; do\n  body=$(fb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=100&page=$p\")\n  printf '%s' \"$body\" | jq -c '.response.result.invoices[]'\n  pages=$(printf '%s' \"$body\" | jq -r '.response.result.pages')\n  [ \"$p\" -ge \"$pages\" ] && break\n  p=$((p + 1))\ndone\n```\n\n## Error shapes by family\n\n```sh\n# Accounting / events\njq -r '.response.errors[]? | \"\\(.errno): \\(.message)\"'\n# Accounting-business\njq -r '.errors? | \"\\(.message) \\(.details[]?.reason // \"\")\"'\n# Payments\njq -r '.errors?.details[]? | \"\\(.field): \\(.message)\"'\n# Projects / time tracking / comments / uploads\njq -r '.error? // empty'\n```\n\nFile v1.1.3:skill-card.md\n\n## Description:\n\nGuides shell-based FreshBooks API access for invoices, clients, payments, expenses, projects, and time tracking using curl and rotating OAuth credentials.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nDevelopers and FreshBooks users use this skill to query accounting and project data from a shell without running the FreshBooks MCP server, and to prepare API requests when changes are needed.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: API requests can change live accounting records, including invoices, clients, and payments.\n\nMitigation: Review POST and PUT commands before execution, avoid casual use against production data, and re-read records to confirm changes.\n\nRisk: OAuth tokens are printed during setup and grant the configured app's FreshBooks authority.\n\nMitigation: Keep tokens out of logs and shared messages, and restrict access to credential and token-state files.\n\nRisk: FreshBooks refresh tokens rotate after one use; competing users of a token-state file can interrupt access.\n\nMitigation: Use the provided token helper with a dedicated state file and avoid concurrent access to that file.\n\n## Reference(s):\n\n- [FreshBooks MCP ClawHub release](https://clawhub.ai/chrischall/skills/freshbooks-mcp)\n- [FreshBooks developer portal](https://my.freshbooks.com/#/developer)\n- [FreshBooks curl recipes](references/recipes.md)\n- [FreshBooks OAuth bootstrap helper](references/fb-bootstrap.mjs)\n- [FreshBooks token and curl helper](references/fb-token.sh)\n\n## Skill Output:\n\n**Output Type(s):** [Shell commands, Configuration instructions, Guidance]\n\n**Output Format:** [Markdown with shell code blocks]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Commands can query or change live FreshBooks accounting data.]\n\n## Skill Version(s):\n\n1.1.3 (source: ClawHub release metadata)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.1.2: 6 files, 10796 bytes\n\nFiles: references/fb-bootstrap.mjs (4220b), references/fb-token.sh (4351b), references/recipes.md (8476b), skill-card.md (1934b), SKILL.md (4894b), _meta.json (133b)\n\nFile v1.1.2:SKILL.md\n\n---\nname: freshbooks-curl\ndescription: Query 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.\n---\n\n# FreshBooks from the shell\n\nFreshBooks has a real REST API reachable server-side — no browser bridge, no signed-in\ntab, no `fpx`. Authentication is **OAuth2 only**: there is no API key and no personal\naccess token, so a one-time browser authorize flow is unavoidable.\n\n## The two things that break naive clients\n\n**1. Refresh tokens are single-use and rotate.** Every refresh returns a *new* refresh\ntoken and kills the one you used. If the replacement is not written to disk before the\nprocess ends, the account is locked out and the only recovery is re-running the browser\nflow. Never hand-roll the refresh — use `fb_access_token` from\n`references/fb-token.sh`, which persists the rotation before returning, and never point\ntwo tools at the same state file.\n\n**2. Three identifiers that are not interchangeable.** `accountId` (alphanumeric, e.g.\n`xZNQ1X`), `businessId` (integer), `businessUuid`. Each URL family takes a different one\nand answers a mismatch with a bare **404** that reads like a missing record. Always\nresolve first with `fb_ids`.\n\n| Family | Path | Identifier |\n| --- | --- | --- |\n| Accounting | `/accounting/account/{accountId}/…` | `accountId` |\n| Payments | `/payments/account/{accountId}/…` | `accountId` |\n| Accounting (business) | `/accounting/businesses/{businessUuid}/…` | `businessUuid` |\n| Projects | `/projects/business/{businessId}/…` | `businessId` |\n| Time tracking | `/timetracking/business/{businessId}/…` | `businessId` |\n\n## One-time setup\n\nRegister an app at <https://my.freshbooks.com/#/developer>. The redirect URI must be\n**HTTPS with no query string** — `https://localhost` works and never needs to\nresolve. Put `FRESHBOOKS_CLIENT_ID` and `FRESHBOOKS_CLIENT_SECRET` somewhere loadable\n(e.g. `~/.secrets`).\n\nThen run the authorize flow once:\n\n```sh\nset -a; . ~/.secrets; set +a\n\n# 1. Print the authorize URL, open it, click Allow.\nnode references/fb-bootstrap.mjs url \"$FRESHBOOKS_CLIENT_ID\"\n\n# 2. The browser fails to load https://localhost?code=… — that is expected.\n#    Copy the whole URL from the address bar and exchange it (the code is single-use\n#    and expires within minutes).\nnode references/fb-bootstrap.mjs exchange \"$FRESHBOOKS_CLIENT_ID\" \"$FRESHBOOKS_CLIENT_SECRET\" \\\n  https://localhost 'https://localhost?code=PASTE_HERE'\n```\n\nSave the printed `refresh_token` as `FRESHBOOKS_REFRESH_TOKEN`. It seeds the state file\non first use; after that the state file is the source of truth and the env value is\nignored until you re-bootstrap.\n\n## Core call pattern\n\n```sh\nset -a; . ~/.secrets; set +a\n. references/fb-token.sh\n\nfb_ids                                    # resolve accountId / businessId / businessUuid\nACCT=$(fb_account_id)\n\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=5\" \\\n  | jq '.response.result.invoices[] | {id, invoice_number, amount, outstanding, status}'\n```\n\n`fb_curl <path> [curl args…]` attaches the bearer token and `Api-Version: alpha`;\neverything after the path is passed to `curl`, so writes work too.\n\n## Response envelopes differ per family\n\n- Accounting / events → `.response.result.<name>`, errors at `.response.errors[].message`\n- Accounting-business → `.data`, errors at `.errors.message` + `.errors.details[].reason`\n- Projects / time tracking / comments → bare object, errors at `.error`\n- Payments → errors at `.errors.details[].field`\n\nA single `jq` path will not work across families — see `references/recipes.md`.\n\n## Writes\n\nAccounting writes wrap the payload in the **singular** resource name:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/invoices/invoices\" \\\n  -X POST -H 'Content-Type: application/json' \\\n  -d '{\"invoice\":{\"customerid\":123,\"create_date\":\"2026-08-12\",\"lines\":[]}}'\n```\n\nMoney is `{\"amount\":\"150.00\",\"code\":\"USD\"}` — the amount is a **string**, not a number.\n\nDeletes on the accounting family are frequently soft deletes via `vis_state` on an\nupdate rather than HTTP `DELETE`. Confirm per resource before assuming.\n\n**A 200 is not proof a write persisted — re-read the record to confirm.**\n\n## Troubleshooting\n\n| Symptom | Cause |\n| --- | --- |\n| `invalid_client` on refresh | The refresh grant needs `client_secret` **and** `redirect_uri`, form-encoded — not JSON |\n| `invalid_grant` on refresh | The token was already spent. Re-run the bootstrap |\n| 404 on a valid-looking record | Wrong identifier for that URL family — run `fb_ids` |\n| 401 on every call | Access token stale and refresh failing; check the state file |\n\nSee `references/recipes.md` for ready-to-run request bodies and `jq` recipes.\n\nFile v1.1.2:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"freshbooks-mcp\",\n  \"version\": \"1.1.2\",\n  \"publishedAt\": 1790729678141\n}\n\nFile v1.1.2:references/recipes.md\n\n# FreshBooks curl recipes\n\nAll examples assume:\n\n```sh\nset -a; . ~/.secrets; set +a\n. references/fb-token.sh\nACCT=$(fb_account_id)\nBIZ=$(fb_ids | jq -r .businessId)\n```\n\nPaths marked **(unverified)** are transcribed from the official\n`freshbooks-python-sdk` source but have not been exercised against a live account.\n\n## Identity\n\n```sh\nfb_curl /auth/api/v1/users/me | jq '.response | {identity_id, email}'\nfb_ids   # accountId / businessId / businessUuid — run before anything else\n```\n\n## Invoices\n\n```sh\n# List (newest first via FreshBooks' own sort param)\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=25&page=1\" \\\n  | jq '.response.result | {page, pages, total,\n        invoices: [.invoices[] | {id, invoice_number, organization, create_date,\n                                  amount: .amount.amount, outstanding: .outstanding.amount, status}]}'\n\n# One invoice, with line items expanded\nfb_curl \"/accounting/account/$ACCT/invoices/invoices/INVOICE_ID?include[]=lines\" \\\n  | jq '.response.result.invoice'\n\n# Outstanding only — v3_status is the useful status field\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?search[v3_status]=overdue\" \\\n  | jq '[.response.result.invoices[] | {id, invoice_number, outstanding: .outstanding.amount}]'\n\n# Total outstanding across a page\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=100\" \\\n  | jq '[.response.result.invoices[].outstanding.amount | tonumber] | add'\n```\n\nCreate **(unverified)** — money amounts are strings:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/invoices/invoices\" -X POST \\\n  -H 'Content-Type: application/json' -d '{\n  \"invoice\": {\n    \"customerid\": 123,\n    \"create_date\": \"2026-08-12\",\n    \"lines\": [\n      { \"name\": \"Consulting\", \"description\": \"August retainer\",\n        \"qty\": 1, \"unit_cost\": { \"amount\": \"1500.00\", \"code\": \"USD\" } }\n    ]\n  }\n}' | jq '.response.result.invoice | {id, invoice_number, amount}'\n```\n\n## Clients\n\n```sh\nfb_curl \"/accounting/account/$ACCT/users/clients?per_page=100\" \\\n  | jq '[.response.result.clients[] | {id: .userid, organization, fname, lname, email}]'\n\n# Find a client by name\nfb_curl \"/accounting/account/$ACCT/users/clients?search[organization_like]=acme\" \\\n  | jq '.response.result.clients'\n```\n\nCreate **(unverified)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/users/clients\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"client\":{\"email\":\"ap@example.com\",\"organization\":\"Example Co\",\"fname\":\"Pat\",\"lname\":\"Doe\"}}' \\\n  | jq '.response.result.client | {userid, organization}'\n```\n\n## Payments\n\n```sh\nfb_curl \"/accounting/account/$ACCT/payments/payments?per_page=50\" \\\n  | jq '[.response.result.payments[] | {id, invoiceid, date, amount: .amount.amount, type}]'\n```\n\nRecord a payment **(unverified)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/payments/payments\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"payment\":{\"invoiceid\":456,\"amount\":{\"amount\":\"1500.00\",\"code\":\"USD\"},\"date\":\"2026-08-12\",\"type\":\"Check\"}}'\n```\n\n## Estimates — accepting is an action, not a status write\n\n`status`, `display_status` and `ui_status` are computed read-only fields and disagree with\neach other (`status: 3` / `\"viewed\"` / `\"open\"` are the same estimate). Writing them does\nnothing. Acceptance is an action on the estimate **(unverified — collection-derived)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/estimates/estimates/279405\" -X PUT \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"estimate\":{\"action_accept\":true}}' \\\n  | jq '.response.result.estimate | {id, accepted, status, display_status, ui_status}'\n```\n\nEmail it to the client — note `estimate_customized_email`, not the invoice endpoint's\n`invoice_customized_email`:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/estimates/estimates/279405\" -X PUT \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"estimate\":{\"action_email\":true,\"email_recipients\":[\"client@example.com\"]}}'\n```\n\nThere is **no decline**: no declined status, no `action_deny`, no `estimate.decline`\nwebhook. The nearest expressible thing is the soft delete, `{\"estimate\":{\"vis_state\":1}}`,\nwhich removes it rather than declining it.\n\n## Full accounting resource map\n\nSame family and envelope; only the path suffix and list key change. Four of these are not\nwhat you would guess — they are marked ⚠ and each cost a 404 before the SDK settled it.\n\n| Resource | Path suffix | List key |\n| --- | --- | --- |\n| Invoices | `invoices/invoices` | `invoices` |\n| Clients | `users/clients` | `clients` |\n| Estimates | `estimates/estimates` | `estimates` |\n| Payments | `payments/payments` | `payments` |\n| Credit notes | `credit_notes/credit_notes` | `credit_notes` |\n| Invoice profiles | `invoice_profiles/invoice_profiles` | `invoice_profiles` |\n| Items | `items/items` | `items` |\n| Taxes | `taxes/taxes` | `taxes` |\n| Expenses | `expenses/expenses` | `expenses` |\n| Expense categories ⚠ | `expenses/categories` | `categories` |\n| Staff ⚠ | `users/staffs` | `staff` |\n| Gateways ⚠ | `systems/gateways` | `gateways` |\n| Other income ⚠ | `other_incomes/other_incomes` | `other_income` |\n| Tasks (billable catalogue) | `projects/tasks` | `tasks` |\n| Bills | `bills/bills` | `bills` |\n| Bill vendors | `bill_vendors/bill_vendors` | `bill_vendors` |\n| Bill payments | `bill_payments/bill_payments` | `bill_payments` |\n\n`projects/tasks` is in the **accounting** family (accountId) despite the prefix.\n\n### `total` can exceed the rows you get back\n\nVerified live: expenses returned `total: 16` with an empty array — the count includes\nrecords the identity cannot read, and paging never surfaces them. Always check both:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=100\" \\\n  | jq '{total: .response.result.total, rows: (.response.result.expenses|length)}'\n```\n\nIf `rows` is 0 while `total` is not **and you are on a page within range**, that is a\npermission boundary rather than an empty account. Check the page first — an empty page with\na non-zero total is also just what paging past the end looks like:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=100\" \\\n  | jq '.response.result | {total, pages, page, rows: (.expenses|length),\n         verdict: (if (.expenses|length) > 0 then \"ok\"\n                   elif .page > .pages then \"past the last page\"\n                   else \"rows withheld by permissions\" end)}'\n```\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=50\" \\\n  | jq '[.response.result.expenses[] | {id, date, amount: .amount.amount, vendor, notes}]'\n```\n\n## Projects and time tracking — different id, different envelope\n\nThese take `businessId` and return **bare** objects, not `.response.result`. Pagination\nlives under `meta`, so `.response.result.total` reads `undefined` here and looks like an\nempty account:\n\n```sh\nfb_curl \"/projects/business/$BIZ/projects\" \\\n  | jq '{meta, projects: [.projects[] | {id, title, client_id, active}]}'\n\n# time_entries' meta also carries total_logged and total_unbilled\nfb_curl \"/timetracking/business/$BIZ/time_entries\" \\\n  | jq '{logged: .meta.total_logged, unbilled: .meta.total_unbilled,\n         entries: [.time_entries[] | {id, project_id, duration, started_at, note}]}'\n\nfb_curl \"/comments/business/$BIZ/services\" | jq '[.services[] | {id, name, billable}]'\n```\n\nErrors here are `.error`, a plain string — not `.response.errors[]`.\n\nThese endpoints work on a business with **no accounting account**, so they can succeed\nwhen every `/accounting/account/...` call fails.\n\nLog time (duration is in **seconds**):\n\n```sh\nfb_curl \"/timetracking/business/$BIZ/time_entries\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"time_entry\":{\"duration\":3600,\"started_at\":\"2026-08-12T09:00:00Z\",\"note\":\"Design review\"}}'\n```\n\n## Pagination\n\nAccounting lists carry `page`, `pages`, `per_page`, `total` on the result envelope.\nWalk them:\n\n```sh\np=1\nwhile :; do\n  body=$(fb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=100&page=$p\")\n  printf '%s' \"$body\" | jq -c '.response.result.invoices[]'\n  pages=$(printf '%s' \"$body\" | jq -r '.response.result.pages')\n  [ \"$p\" -ge \"$pages\" ] && break\n  p=$((p + 1))\ndone\n```\n\n## Error shapes by family\n\n```sh\n# Accounting / events\njq -r '.response.errors[]? | \"\\(.errno): \\(.message)\"'\n# Accounting-business\njq -r '.errors? | \"\\(.message) \\(.details[]?.reason // \"\")\"'\n# Payments\njq -r '.errors?.details[]? | \"\\(.field): \\(.message)\"'\n# Projects / time tracking / comments / uploads\njq -r '.error? // empty'\n```\n\nFile v1.1.2:skill-card.md\n\n## Description:\n\nHelps agents query and update FreshBooks accounting and time-tracking data through shell commands using OAuth authentication.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nDevelopers and agents use this skill to retrieve FreshBooks invoices, clients, expenses, projects, and time entries or perform authorized accounting updates from a shell without running an MCP server.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: Commands can create or change live invoices, clients, payments, estimates, emails, and time entries despite the query-focused description.\n\nMitigation: Review write commands before execution and use a least-privileged FreshBooks app and account where possible.\n\nRisk: Local OAuth credentials and rotating refresh tokens can be exposed or invalidated by competing access to the same token state.\n\nMitigation: Keep tokens out of logs and command output, protect the local state file, and do not share it between tools.\n\n## Reference(s):\n\n- [FreshBooks developer portal](https://my.freshbooks.com/#/developer)\n- [FreshBooks curl recipes](references/recipes.md)\n- [OAuth bootstrap helper](references/fb-bootstrap.mjs)\n- [ClawHub skill listing](https://clawhub.ai/chrischall/skills/freshbooks-mcp)\n\n## Skill Output:\n\n**Output Type(s):** [Shell commands, Configuration instructions, Guidance]\n\n**Output Format:** [Markdown with shell examples]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [None]\n\n## Skill Version(s):\n\n1.1.2 (source: ClawHub release)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.1.1: 6 files, 10856 bytes\n\nFiles: references/fb-bootstrap.mjs (4220b), references/fb-token.sh (4351b), references/recipes.md (8476b), skill-card.md (2190b), SKILL.md (4894b), _meta.json (133b)\n\nFile v1.1.1:SKILL.md\n\n---\nname: freshbooks-curl\ndescription: Query 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.\n---\n\n# FreshBooks from the shell\n\nFreshBooks has a real REST API reachable server-side — no browser bridge, no signed-in\ntab, no `fpx`. Authentication is **OAuth2 only**: there is no API key and no personal\naccess token, so a one-time browser authorize flow is unavoidable.\n\n## The two things that break naive clients\n\n**1. Refresh tokens are single-use and rotate.** Every refresh returns a *new* refresh\ntoken and kills the one you used. If the replacement is not written to disk before the\nprocess ends, the account is locked out and the only recovery is re-running the browser\nflow. Never hand-roll the refresh — use `fb_access_token` from\n`references/fb-token.sh`, which persists the rotation before returning, and never point\ntwo tools at the same state file.\n\n**2. Three identifiers that are not interchangeable.** `accountId` (alphanumeric, e.g.\n`xZNQ1X`), `businessId` (integer), `businessUuid`. Each URL family takes a different one\nand answers a mismatch with a bare **404** that reads like a missing record. Always\nresolve first with `fb_ids`.\n\n| Family | Path | Identifier |\n| --- | --- | --- |\n| Accounting | `/accounting/account/{accountId}/…` | `accountId` |\n| Payments | `/payments/account/{accountId}/…` | `accountId` |\n| Accounting (business) | `/accounting/businesses/{businessUuid}/…` | `businessUuid` |\n| Projects | `/projects/business/{businessId}/…` | `businessId` |\n| Time tracking | `/timetracking/business/{businessId}/…` | `businessId` |\n\n## One-time setup\n\nRegister an app at <https://my.freshbooks.com/#/developer>. The redirect URI must be\n**HTTPS with no query string** — `https://localhost` works and never needs to\nresolve. Put `FRESHBOOKS_CLIENT_ID` and `FRESHBOOKS_CLIENT_SECRET` somewhere loadable\n(e.g. `~/.secrets`).\n\nThen run the authorize flow once:\n\n```sh\nset -a; . ~/.secrets; set +a\n\n# 1. Print the authorize URL, open it, click Allow.\nnode references/fb-bootstrap.mjs url \"$FRESHBOOKS_CLIENT_ID\"\n\n# 2. The browser fails to load https://localhost?code=… — that is expected.\n#    Copy the whole URL from the address bar and exchange it (the code is single-use\n#    and expires within minutes).\nnode references/fb-bootstrap.mjs exchange \"$FRESHBOOKS_CLIENT_ID\" \"$FRESHBOOKS_CLIENT_SECRET\" \\\n  https://localhost 'https://localhost?code=PASTE_HERE'\n```\n\nSave the printed `refresh_token` as `FRESHBOOKS_REFRESH_TOKEN`. It seeds the state file\non first use; after that the state file is the source of truth and the env value is\nignored until you re-bootstrap.\n\n## Core call pattern\n\n```sh\nset -a; . ~/.secrets; set +a\n. references/fb-token.sh\n\nfb_ids                                    # resolve accountId / businessId / businessUuid\nACCT=$(fb_account_id)\n\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=5\" \\\n  | jq '.response.result.invoices[] | {id, invoice_number, amount, outstanding, status}'\n```\n\n`fb_curl <path> [curl args…]` attaches the bearer token and `Api-Version: alpha`;\neverything after the path is passed to `curl`, so writes work too.\n\n## Response envelopes differ per family\n\n- Accounting / events → `.response.result.<name>`, errors at `.response.errors[].message`\n- Accounting-business → `.data`, errors at `.errors.message` + `.errors.details[].reason`\n- Projects / time tracking / comments → bare object, errors at `.error`\n- Payments → errors at `.errors.details[].field`\n\nA single `jq` path will not work across families — see `references/recipes.md`.\n\n## Writes\n\nAccounting writes wrap the payload in the **singular** resource name:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/invoices/invoices\" \\\n  -X POST -H 'Content-Type: application/json' \\\n  -d '{\"invoice\":{\"customerid\":123,\"create_date\":\"2026-08-12\",\"lines\":[]}}'\n```\n\nMoney is `{\"amount\":\"150.00\",\"code\":\"USD\"}` — the amount is a **string**, not a number.\n\nDeletes on the accounting family are frequently soft deletes via `vis_state` on an\nupdate rather than HTTP `DELETE`. Confirm per resource before assuming.\n\n**A 200 is not proof a write persisted — re-read the record to confirm.**\n\n## Troubleshooting\n\n| Symptom | Cause |\n| --- | --- |\n| `invalid_client` on refresh | The refresh grant needs `client_secret` **and** `redirect_uri`, form-encoded — not JSON |\n| `invalid_grant` on refresh | The token was already spent. Re-run the bootstrap |\n| 404 on a valid-looking record | Wrong identifier for that URL family — run `fb_ids` |\n| 401 on every call | Access token stale and refresh failing; check the state file |\n\nSee `references/recipes.md` for ready-to-run request bodies and `jq` recipes.\n\nFile v1.1.1:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"freshbooks-mcp\",\n  \"version\": \"1.1.1\",\n  \"publishedAt\": 1790351718731\n}\n\nFile v1.1.1:references/recipes.md\n\n# FreshBooks curl recipes\n\nAll examples assume:\n\n```sh\nset -a; . ~/.secrets; set +a\n. references/fb-token.sh\nACCT=$(fb_account_id)\nBIZ=$(fb_ids | jq -r .businessId)\n```\n\nPaths marked **(unverified)** are transcribed from the official\n`freshbooks-python-sdk` source but have not been exercised against a live account.\n\n## Identity\n\n```sh\nfb_curl /auth/api/v1/users/me | jq '.response | {identity_id, email}'\nfb_ids   # accountId / businessId / businessUuid — run before anything else\n```\n\n## Invoices\n\n```sh\n# List (newest first via FreshBooks' own sort param)\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=25&page=1\" \\\n  | jq '.response.result | {page, pages, total,\n        invoices: [.invoices[] | {id, invoice_number, organization, create_date,\n                                  amount: .amount.amount, outstanding: .outstanding.amount, status}]}'\n\n# One invoice, with line items expanded\nfb_curl \"/accounting/account/$ACCT/invoices/invoices/INVOICE_ID?include[]=lines\" \\\n  | jq '.response.result.invoice'\n\n# Outstanding only — v3_status is the useful status field\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?search[v3_status]=overdue\" \\\n  | jq '[.response.result.invoices[] | {id, invoice_number, outstanding: .outstanding.amount}]'\n\n# Total outstanding across a page\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=100\" \\\n  | jq '[.response.result.invoices[].outstanding.amount | tonumber] | add'\n```\n\nCreate **(unverified)** — money amounts are strings:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/invoices/invoices\" -X POST \\\n  -H 'Content-Type: application/json' -d '{\n  \"invoice\": {\n    \"customerid\": 123,\n    \"create_date\": \"2026-08-12\",\n    \"lines\": [\n      { \"name\": \"Consulting\", \"description\": \"August retainer\",\n        \"qty\": 1, \"unit_cost\": { \"amount\": \"1500.00\", \"code\": \"USD\" } }\n    ]\n  }\n}' | jq '.response.result.invoice | {id, invoice_number, amount}'\n```\n\n## Clients\n\n```sh\nfb_curl \"/accounting/account/$ACCT/users/clients?per_page=100\" \\\n  | jq '[.response.result.clients[] | {id: .userid, organization, fname, lname, email}]'\n\n# Find a client by name\nfb_curl \"/accounting/account/$ACCT/users/clients?search[organization_like]=acme\" \\\n  | jq '.response.result.clients'\n```\n\nCreate **(unverified)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/users/clients\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"client\":{\"email\":\"ap@example.com\",\"organization\":\"Example Co\",\"fname\":\"Pat\",\"lname\":\"Doe\"}}' \\\n  | jq '.response.result.client | {userid, organization}'\n```\n\n## Payments\n\n```sh\nfb_curl \"/accounting/account/$ACCT/payments/payments?per_page=50\" \\\n  | jq '[.response.result.payments[] | {id, invoiceid, date, amount: .amount.amount, type}]'\n```\n\nRecord a payment **(unverified)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/payments/payments\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"payment\":{\"invoiceid\":456,\"amount\":{\"amount\":\"1500.00\",\"code\":\"USD\"},\"date\":\"2026-08-12\",\"type\":\"Check\"}}'\n```\n\n## Estimates — accepting is an action, not a status write\n\n`status`, `display_status` and `ui_status` are computed read-only fields and disagree with\neach other (`status: 3` / `\"viewed\"` / `\"open\"` are the same estimate). Writing them does\nnothing. Acceptance is an action on the estimate **(unverified — collection-derived)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/estimates/estimates/279405\" -X PUT \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"estimate\":{\"action_accept\":true}}' \\\n  | jq '.response.result.estimate | {id, accepted, status, display_status, ui_status}'\n```\n\nEmail it to the client — note `estimate_customized_email`, not the invoice endpoint's\n`invoice_customized_email`:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/estimates/estimates/279405\" -X PUT \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"estimate\":{\"action_email\":true,\"email_recipients\":[\"client@example.com\"]}}'\n```\n\nThere is **no decline**: no declined status, no `action_deny`, no `estimate.decline`\nwebhook. The nearest expressible thing is the soft delete, `{\"estimate\":{\"vis_state\":1}}`,\nwhich removes it rather than declining it.\n\n## Full accounting resource map\n\nSame family and envelope; only the path suffix and list key change. Four of these are not\nwhat you would guess — they are marked ⚠ and each cost a 404 before the SDK settled it.\n\n| Resource | Path suffix | List key |\n| --- | --- | --- |\n| Invoices | `invoices/invoices` | `invoices` |\n| Clients | `users/clients` | `clients` |\n| Estimates | `estimates/estimates` | `estimates` |\n| Payments | `payments/payments` | `payments` |\n| Credit notes | `credit_notes/credit_notes` | `credit_notes` |\n| Invoice profiles | `invoice_profiles/invoice_profiles` | `invoice_profiles` |\n| Items | `items/items` | `items` |\n| Taxes | `taxes/taxes` | `taxes` |\n| Expenses | `expenses/expenses` | `expenses` |\n| Expense categories ⚠ | `expenses/categories` | `categories` |\n| Staff ⚠ | `users/staffs` | `staff` |\n| Gateways ⚠ | `systems/gateways` | `gateways` |\n| Other income ⚠ | `other_incomes/other_incomes` | `other_income` |\n| Tasks (billable catalogue) | `projects/tasks` | `tasks` |\n| Bills | `bills/bills` | `bills` |\n| Bill vendors | `bill_vendors/bill_vendors` | `bill_vendors` |\n| Bill payments | `bill_payments/bill_payments` | `bill_payments` |\n\n`projects/tasks` is in the **accounting** family (accountId) despite the prefix.\n\n### `total` can exceed the rows you get back\n\nVerified live: expenses returned `total: 16` with an empty array — the count includes\nrecords the identity cannot read, and paging never surfaces them. Always check both:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=100\" \\\n  | jq '{total: .response.result.total, rows: (.response.result.expenses|length)}'\n```\n\nIf `rows` is 0 while `total` is not **and you are on a page within range**, that is a\npermission boundary rather than an empty account. Check the page first — an empty page with\na non-zero total is also just what paging past the end looks like:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=100\" \\\n  | jq '.response.result | {total, pages, page, rows: (.expenses|length),\n         verdict: (if (.expenses|length) > 0 then \"ok\"\n                   elif .page > .pages then \"past the last page\"\n                   else \"rows withheld by permissions\" end)}'\n```\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=50\" \\\n  | jq '[.response.result.expenses[] | {id, date, amount: .amount.amount, vendor, notes}]'\n```\n\n## Projects and time tracking — different id, different envelope\n\nThese take `businessId` and return **bare** objects, not `.response.result`. Pagination\nlives under `meta`, so `.response.result.total` reads `undefined` here and looks like an\nempty account:\n\n```sh\nfb_curl \"/projects/business/$BIZ/projects\" \\\n  | jq '{meta, projects: [.projects[] | {id, title, client_id, active}]}'\n\n# time_entries' meta also carries total_logged and total_unbilled\nfb_curl \"/timetracking/business/$BIZ/time_entries\" \\\n  | jq '{logged: .meta.total_logged, unbilled: .meta.total_unbilled,\n         entries: [.time_entries[] | {id, project_id, duration, started_at, note}]}'\n\nfb_curl \"/comments/business/$BIZ/services\" | jq '[.services[] | {id, name, billable}]'\n```\n\nErrors here are `.error`, a plain string — not `.response.errors[]`.\n\nThese endpoints work on a business with **no accounting account**, so they can succeed\nwhen every `/accounting/account/...` call fails.\n\nLog time (duration is in **seconds**):\n\n```sh\nfb_curl \"/timetracking/business/$BIZ/time_entries\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"time_entry\":{\"duration\":3600,\"started_at\":\"2026-08-12T09:00:00Z\",\"note\":\"Design review\"}}'\n```\n\n## Pagination\n\nAccounting lists carry `page`, `pages`, `per_page`, `total` on the result envelope.\nWalk them:\n\n```sh\np=1\nwhile :; do\n  body=$(fb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=100&page=$p\")\n  printf '%s' \"$body\" | jq -c '.response.result.invoices[]'\n  pages=$(printf '%s' \"$body\" | jq -r '.response.result.pages')\n  [ \"$p\" -ge \"$pages\" ] && break\n  p=$((p + 1))\ndone\n```\n\n## Error shapes by family\n\n```sh\n# Accounting / events\njq -r '.response.errors[]? | \"\\(.errno): \\(.message)\"'\n# Accounting-business\njq -r '.errors? | \"\\(.message) \\(.details[]?.reason // \"\")\"'\n# Payments\njq -r '.errors?.details[]? | \"\\(.field): \\(.message)\"'\n# Projects / time tracking / comments / uploads\njq -r '.error? // empty'\n```\n\nFile v1.1.1:skill-card.md\n\n## Description:\n\nGuides shell-based FreshBooks API requests with OAuth token refresh for accounting and project data, including read and write operations.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nDevelopers and accounting teams use this skill to query FreshBooks invoices, clients, payments, expenses, projects, and time entries from a shell without running an MCP server. Its request helpers also permit changes to live accounting records.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: Authenticated requests can create or change live accounting records and email clients.\n\nMitigation: Use a least-privileged FreshBooks app and account; require human review before non-GET or client-emailing actions.\n\nRisk: OAuth tokens and command output may expose sensitive accounting access or data.\n\nMitigation: Keep the token state file private and avoid logging tokens or command output.\n\nRisk: Concurrent use or lost refresh-token state can interrupt account access.\n\nMitigation: Avoid sharing a token state file between tools and preserve each rotated token before reuse.\n\n## Reference(s):\n\n- [FreshBooks developer portal](https://my.freshbooks.com/#/developer)\n- [FreshBooks curl recipes](references/recipes.md)\n- [FreshBooks OAuth bootstrap helper](references/fb-bootstrap.mjs)\n- [FreshBooks token and request helpers](references/fb-token.sh)\n- [ClawHub skill release](https://clawhub.ai/chrischall/skills/freshbooks-mcp)\n\n## Skill Output:\n\n**Output Type(s):** [Shell commands, Configuration instructions, Guidance]\n\n**Output Format:** [Markdown with shell snippets]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Commands may read or change live FreshBooks data.]\n\n## Skill Version(s):\n\n1.1.1 (source: server-resolved release metadata)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.1.0: 6 files, 10896 bytes\n\nFiles: references/fb-bootstrap.mjs (4220b), references/fb-token.sh (4351b), references/recipes.md (8476b), skill-card.md (2223b), SKILL.md (4894b), _meta.json (133b)\n\nFile v1.1.0:SKILL.md\n\n---\nname: freshbooks-curl\ndescription: Query 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.\n---\n\n# FreshBooks from the shell\n\nFreshBooks has a real REST API reachable server-side — no browser bridge, no signed-in\ntab, no `fpx`. Authentication is **OAuth2 only**: there is no API key and no personal\naccess token, so a one-time browser authorize flow is unavoidable.\n\n## The two things that break naive clients\n\n**1. Refresh tokens are single-use and rotate.** Every refresh returns a *new* refresh\ntoken and kills the one you used. If the replacement is not written to disk before the\nprocess ends, the account is locked out and the only recovery is re-running the browser\nflow. Never hand-roll the refresh — use `fb_access_token` from\n`references/fb-token.sh`, which persists the rotation before returning, and never point\ntwo tools at the same state file.\n\n**2. Three identifiers that are not interchangeable.** `accountId` (alphanumeric, e.g.\n`xZNQ1X`), `businessId` (integer), `businessUuid`. Each URL family takes a different one\nand answers a mismatch with a bare **404** that reads like a missing record. Always\nresolve first with `fb_ids`.\n\n| Family | Path | Identifier |\n| --- | --- | --- |\n| Accounting | `/accounting/account/{accountId}/…` | `accountId` |\n| Payments | `/payments/account/{accountId}/…` | `accountId` |\n| Accounting (business) | `/accounting/businesses/{businessUuid}/…` | `businessUuid` |\n| Projects | `/projects/business/{businessId}/…` | `businessId` |\n| Time tracking | `/timetracking/business/{businessId}/…` | `businessId` |\n\n## One-time setup\n\nRegister an app at <https://my.freshbooks.com/#/developer>. The redirect URI must be\n**HTTPS with no query string** — `https://localhost` works and never needs to\nresolve. Put `FRESHBOOKS_CLIENT_ID` and `FRESHBOOKS_CLIENT_SECRET` somewhere loadable\n(e.g. `~/.secrets`).\n\nThen run the authorize flow once:\n\n```sh\nset -a; . ~/.secrets; set +a\n\n# 1. Print the authorize URL, open it, click Allow.\nnode references/fb-bootstrap.mjs url \"$FRESHBOOKS_CLIENT_ID\"\n\n# 2. The browser fails to load https://localhost?code=… — that is expected.\n#    Copy the whole URL from the address bar and exchange it (the code is single-use\n#    and expires within minutes).\nnode references/fb-bootstrap.mjs exchange \"$FRESHBOOKS_CLIENT_ID\" \"$FRESHBOOKS_CLIENT_SECRET\" \\\n  https://localhost 'https://localhost?code=PASTE_HERE'\n```\n\nSave the printed `refresh_token` as `FRESHBOOKS_REFRESH_TOKEN`. It seeds the state file\non first use; after that the state file is the source of truth and the env value is\nignored until you re-bootstrap.\n\n## Core call pattern\n\n```sh\nset -a; . ~/.secrets; set +a\n. references/fb-token.sh\n\nfb_ids                                    # resolve accountId / businessId / businessUuid\nACCT=$(fb_account_id)\n\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=5\" \\\n  | jq '.response.result.invoices[] | {id, invoice_number, amount, outstanding, status}'\n```\n\n`fb_curl <path> [curl args…]` attaches the bearer token and `Api-Version: alpha`;\neverything after the path is passed to `curl`, so writes work too.\n\n## Response envelopes differ per family\n\n- Accounting / events → `.response.result.<name>`, errors at `.response.errors[].message`\n- Accounting-business → `.data`, errors at `.errors.message` + `.errors.details[].reason`\n- Projects / time tracking / comments → bare object, errors at `.error`\n- Payments → errors at `.errors.details[].field`\n\nA single `jq` path will not work across families — see `references/recipes.md`.\n\n## Writes\n\nAccounting writes wrap the payload in the **singular** resource name:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/invoices/invoices\" \\\n  -X POST -H 'Content-Type: application/json' \\\n  -d '{\"invoice\":{\"customerid\":123,\"create_date\":\"2026-08-12\",\"lines\":[]}}'\n```\n\nMoney is `{\"amount\":\"150.00\",\"code\":\"USD\"}` — the amount is a **string**, not a number.\n\nDeletes on the accounting family are frequently soft deletes via `vis_state` on an\nupdate rather than HTTP `DELETE`. Confirm per resource before assuming.\n\n**A 200 is not proof a write persisted — re-read the record to confirm.**\n\n## Troubleshooting\n\n| Symptom | Cause |\n| --- | --- |\n| `invalid_client` on refresh | The refresh grant needs `client_secret` **and** `redirect_uri`, form-encoded — not JSON |\n| `invalid_grant` on refresh | The token was already spent. Re-run the bootstrap |\n| 404 on a valid-looking record | Wrong identifier for that URL family — run `fb_ids` |\n| 401 on every call | Access token stale and refresh failing; check the state file |\n\nSee `references/recipes.md` for ready-to-run request bodies and `jq` recipes.\n\nFile v1.1.0:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"freshbooks-mcp\",\n  \"version\": \"1.1.0\",\n  \"publishedAt\": 1790262736389\n}\n\nFile v1.1.0:references/recipes.md\n\n# FreshBooks curl recipes\n\nAll examples assume:\n\n```sh\nset -a; . ~/.secrets; set +a\n. references/fb-token.sh\nACCT=$(fb_account_id)\nBIZ=$(fb_ids | jq -r .businessId)\n```\n\nPaths marked **(unverified)** are transcribed from the official\n`freshbooks-python-sdk` source but have not been exercised against a live account.\n\n## Identity\n\n```sh\nfb_curl /auth/api/v1/users/me | jq '.response | {identity_id, email}'\nfb_ids   # accountId / businessId / businessUuid — run before anything else\n```\n\n## Invoices\n\n```sh\n# List (newest first via FreshBooks' own sort param)\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=25&page=1\" \\\n  | jq '.response.result | {page, pages, total,\n        invoices: [.invoices[] | {id, invoice_number, organization, create_date,\n                                  amount: .amount.amount, outstanding: .outstanding.amount, status}]}'\n\n# One invoice, with line items expanded\nfb_curl \"/accounting/account/$ACCT/invoices/invoices/INVOICE_ID?include[]=lines\" \\\n  | jq '.response.result.invoice'\n\n# Outstanding only — v3_status is the useful status field\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?search[v3_status]=overdue\" \\\n  | jq '[.response.result.invoices[] | {id, invoice_number, outstanding: .outstanding.amount}]'\n\n# Total outstanding across a page\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=100\" \\\n  | jq '[.response.result.invoices[].outstanding.amount | tonumber] | add'\n```\n\nCreate **(unverified)** — money amounts are strings:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/invoices/invoices\" -X POST \\\n  -H 'Content-Type: application/json' -d '{\n  \"invoice\": {\n    \"customerid\": 123,\n    \"create_date\": \"2026-08-12\",\n    \"lines\": [\n      { \"name\": \"Consulting\", \"description\": \"August retainer\",\n        \"qty\": 1, \"unit_cost\": { \"amount\": \"1500.00\", \"code\": \"USD\" } }\n    ]\n  }\n}' | jq '.response.result.invoice | {id, invoice_number, amount}'\n```\n\n## Clients\n\n```sh\nfb_curl \"/accounting/account/$ACCT/users/clients?per_page=100\" \\\n  | jq '[.response.result.clients[] | {id: .userid, organization, fname, lname, email}]'\n\n# Find a client by name\nfb_curl \"/accounting/account/$ACCT/users/clients?search[organization_like]=acme\" \\\n  | jq '.response.result.clients'\n```\n\nCreate **(unverified)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/users/clients\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"client\":{\"email\":\"ap@example.com\",\"organization\":\"Example Co\",\"fname\":\"Pat\",\"lname\":\"Doe\"}}' \\\n  | jq '.response.result.client | {userid, organization}'\n```\n\n## Payments\n\n```sh\nfb_curl \"/accounting/account/$ACCT/payments/payments?per_page=50\" \\\n  | jq '[.response.result.payments[] | {id, invoiceid, date, amount: .amount.amount, type}]'\n```\n\nRecord a payment **(unverified)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/payments/payments\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"payment\":{\"invoiceid\":456,\"amount\":{\"amount\":\"1500.00\",\"code\":\"USD\"},\"date\":\"2026-08-12\",\"type\":\"Check\"}}'\n```\n\n## Estimates — accepting is an action, not a status write\n\n`status`, `display_status` and `ui_status` are computed read-only fields and disagree with\neach other (`status: 3` / `\"viewed\"` / `\"open\"` are the same estimate). Writing them does\nnothing. Acceptance is an action on the estimate **(unverified — collection-derived)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/estimates/estimates/279405\" -X PUT \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"estimate\":{\"action_accept\":true}}' \\\n  | jq '.response.result.estimate | {id, accepted, status, display_status, ui_status}'\n```\n\nEmail it to the client — note `estimate_customized_email`, not the invoice endpoint's\n`invoice_customized_email`:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/estimates/estimates/279405\" -X PUT \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"estimate\":{\"action_email\":true,\"email_recipients\":[\"client@example.com\"]}}'\n```\n\nThere is **no decline**: no declined status, no `action_deny`, no `estimate.decline`\nwebhook. The nearest expressible thing is the soft delete, `{\"estimate\":{\"vis_state\":1}}`,\nwhich removes it rather than declining it.\n\n## Full accounting resource map\n\nSame family and envelope; only the path suffix and list key change. Four of these are not\nwhat you would guess — they are marked ⚠ and each cost a 404 before the SDK settled it.\n\n| Resource | Path suffix | List key |\n| --- | --- | --- |\n| Invoices | `invoices/invoices` | `invoices` |\n| Clients | `users/clients` | `clients` |\n| Estimates | `estimates/estimates` | `estimates` |\n| Payments | `payments/payments` | `payments` |\n| Credit notes | `credit_notes/credit_notes` | `credit_notes` |\n| Invoice profiles | `invoice_profiles/invoice_profiles` | `invoice_profiles` |\n| Items | `items/items` | `items` |\n| Taxes | `taxes/taxes` | `taxes` |\n| Expenses | `expenses/expenses` | `expenses` |\n| Expense categories ⚠ | `expenses/categories` | `categories` |\n| Staff ⚠ | `users/staffs` | `staff` |\n| Gateways ⚠ | `systems/gateways` | `gateways` |\n| Other income ⚠ | `other_incomes/other_incomes` | `other_income` |\n| Tasks (billable catalogue) | `projects/tasks` | `tasks` |\n| Bills | `bills/bills` | `bills` |\n| Bill vendors | `bill_vendors/bill_vendors` | `bill_vendors` |\n| Bill payments | `bill_payments/bill_payments` | `bill_payments` |\n\n`projects/tasks` is in the **accounting** family (accountId) despite the prefix.\n\n### `total` can exceed the rows you get back\n\nVerified live: expenses returned `total: 16` with an empty array — the count includes\nrecords the identity cannot read, and paging never surfaces them. Always check both:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=100\" \\\n  | jq '{total: .response.result.total, rows: (.response.result.expenses|length)}'\n```\n\nIf `rows` is 0 while `total` is not **and you are on a page within range**, that is a\npermission boundary rather than an empty account. Check the page first — an empty page with\na non-zero total is also just what paging past the end looks like:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=100\" \\\n  | jq '.response.result | {total, pages, page, rows: (.expenses|length),\n         verdict: (if (.expenses|length) > 0 then \"ok\"\n                   elif .page > .pages then \"past the last page\"\n                   else \"rows withheld by permissions\" end)}'\n```\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=50\" \\\n  | jq '[.response.result.expenses[] | {id, date, amount: .amount.amount, vendor, notes}]'\n```\n\n## Projects and time tracking — different id, different envelope\n\nThese take `businessId` and return **bare** objects, not `.response.result`. Pagination\nlives under `meta`, so `.response.result.total` reads `undefined` here and looks like an\nempty account:\n\n```sh\nfb_curl \"/projects/business/$BIZ/projects\" \\\n  | jq '{meta, projects: [.projects[] | {id, title, client_id, active}]}'\n\n# time_entries' meta also carries total_logged and total_unbilled\nfb_curl \"/timetracking/business/$BIZ/time_entries\" \\\n  | jq '{logged: .meta.total_logged, unbilled: .meta.total_unbilled,\n         entries: [.time_entries[] | {id, project_id, duration, started_at, note}]}'\n\nfb_curl \"/comments/business/$BIZ/services\" | jq '[.services[] | {id, name, billable}]'\n```\n\nErrors here are `.error`, a plain string — not `.response.errors[]`.\n\nThese endpoints work on a business with **no accounting account**, so they can succeed\nwhen every `/accounting/account/...` call fails.\n\nLog time (duration is in **seconds**):\n\n```sh\nfb_curl \"/timetracking/business/$BIZ/time_entries\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"time_entry\":{\"duration\":3600,\"started_at\":\"2026-08-12T09:00:00Z\",\"note\":\"Design review\"}}'\n```\n\n## Pagination\n\nAccounting lists carry `page`, `pages`, `per_page`, `total` on the result envelope.\nWalk them:\n\n```sh\np=1\nwhile :; do\n  body=$(fb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=100&page=$p\")\n  printf '%s' \"$body\" | jq -c '.response.result.invoices[]'\n  pages=$(printf '%s' \"$body\" | jq -r '.response.result.pages')\n  [ \"$p\" -ge \"$pages\" ] && break\n  p=$((p + 1))\ndone\n```\n\n## Error shapes by family\n\n```sh\n# Accounting / events\njq -r '.response.errors[]? | \"\\(.errno): \\(.message)\"'\n# Accounting-business\njq -r '.errors? | \"\\(.message) \\(.details[]?.reason // \"\")\"'\n# Payments\njq -r '.errors?.details[]? | \"\\(.field): \\(.message)\"'\n# Projects / time tracking / comments / uploads\njq -r '.error? // empty'\n```\n\nFile v1.1.0:skill-card.md\n\n## Description:\n\nQuery FreshBooks invoices, clients, estimates, payments, expenses, projects, and time tracking from a shell with curl and a rotating OAuth token.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nDevelopers and engineers use this skill to query FreshBooks data and prepare curl-based API workflows without running the FreshBooks MCP server.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: The skill stores OAuth tokens and can access real FreshBooks business data.\n\nMitigation: Protect the local token state file and secrets file, and prefer a test account or read-only workflows where possible.\n\nRisk: Some commands can change live accounting records, including invoices, estimates, payments, and time entries.\n\nMitigation: Require explicit user approval before running any POST, PUT, DELETE, email, payment, invoice, estimate, or time-entry command.\n\nRisk: FreshBooks refresh tokens rotate and can be invalidated if multiple tools share the same token state.\n\nMitigation: Use a dedicated state file for this skill and avoid pointing multiple tools at the same rotating-token store.\n\n## Reference(s):\n\n- [FreshBooks Developer Portal](https://my.freshbooks.com/#/developer)\n- [FreshBooks curl recipes](references/recipes.md)\n- [FreshBooks OAuth bootstrap helper](references/fb-bootstrap.mjs)\n- [FreshBooks token helper](references/fb-token.sh)\n- [ClawHub skill page](https://clawhub.ai/chrischall/skills/freshbooks-mcp)\n\n## Skill Output:\n\n**Output Type(s):** [Shell commands, Configuration, Code, Guidance]\n\n**Output Format:** [Markdown with inline shell and JSON examples]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Produces curl commands and helper usage patterns for FreshBooks API calls.]\n\n## Skill Version(s):\n\n1.1.0 (source: server release metadata)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment.\n\nArchive v1.0.2: 6 files, 11063 bytes\n\nFiles: references/fb-bootstrap.mjs (4220b), references/fb-token.sh (4351b), references/recipes.md (8476b), skill-card.md (2662b), SKILL.md (4894b), _meta.json (133b)\n\nFile v1.0.2:SKILL.md\n\n---\nname: freshbooks-curl\ndescription: Query 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.\n---\n\n# FreshBooks from the shell\n\nFreshBooks has a real REST API reachable server-side — no browser bridge, no signed-in\ntab, no `fpx`. Authentication is **OAuth2 only**: there is no API key and no personal\naccess token, so a one-time browser authorize flow is unavoidable.\n\n## The two things that break naive clients\n\n**1. Refresh tokens are single-use and rotate.** Every refresh returns a *new* refresh\ntoken and kills the one you used. If the replacement is not written to disk before the\nprocess ends, the account is locked out and the only recovery is re-running the browser\nflow. Never hand-roll the refresh — use `fb_access_token` from\n`references/fb-token.sh`, which persists the rotation before returning, and never point\ntwo tools at the same state file.\n\n**2. Three identifiers that are not interchangeable.** `accountId` (alphanumeric, e.g.\n`xZNQ1X`), `businessId` (integer), `businessUuid`. Each URL family takes a different one\nand answers a mismatch with a bare **404** that reads like a missing record. Always\nresolve first with `fb_ids`.\n\n| Family | Path | Identifier |\n| --- | --- | --- |\n| Accounting | `/accounting/account/{accountId}/…` | `accountId` |\n| Payments | `/payments/account/{accountId}/…` | `accountId` |\n| Accounting (business) | `/accounting/businesses/{businessUuid}/…` | `businessUuid` |\n| Projects | `/projects/business/{businessId}/…` | `businessId` |\n| Time tracking | `/timetracking/business/{businessId}/…` | `businessId` |\n\n## One-time setup\n\nRegister an app at <https://my.freshbooks.com/#/developer>. The redirect URI must be\n**HTTPS with no query string** — `https://localhost` works and never needs to\nresolve. Put `FRESHBOOKS_CLIENT_ID` and `FRESHBOOKS_CLIENT_SECRET` somewhere loadable\n(e.g. `~/.secrets`).\n\nThen run the authorize flow once:\n\n```sh\nset -a; . ~/.secrets; set +a\n\n# 1. Print the authorize URL, open it, click Allow.\nnode references/fb-bootstrap.mjs url \"$FRESHBOOKS_CLIENT_ID\"\n\n# 2. The browser fails to load https://localhost?code=… — that is expected.\n#    Copy the whole URL from the address bar and exchange it (the code is single-use\n#    and expires within minutes).\nnode references/fb-bootstrap.mjs exchange \"$FRESHBOOKS_CLIENT_ID\" \"$FRESHBOOKS_CLIENT_SECRET\" \\\n  https://localhost 'https://localhost?code=PASTE_HERE'\n```\n\nSave the printed `refresh_token` as `FRESHBOOKS_REFRESH_TOKEN`. It seeds the state file\non first use; after that the state file is the source of truth and the env value is\nignored until you re-bootstrap.\n\n## Core call pattern\n\n```sh\nset -a; . ~/.secrets; set +a\n. references/fb-token.sh\n\nfb_ids                                    # resolve accountId / businessId / businessUuid\nACCT=$(fb_account_id)\n\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=5\" \\\n  | jq '.response.result.invoices[] | {id, invoice_number, amount, outstanding, status}'\n```\n\n`fb_curl <path> [curl args…]` attaches the bearer token and `Api-Version: alpha`;\neverything after the path is passed to `curl`, so writes work too.\n\n## Response envelopes differ per family\n\n- Accounting / events → `.response.result.<name>`, errors at `.response.errors[].message`\n- Accounting-business → `.data`, errors at `.errors.message` + `.errors.details[].reason`\n- Projects / time tracking / comments → bare object, errors at `.error`\n- Payments → errors at `.errors.details[].field`\n\nA single `jq` path will not work across families — see `references/recipes.md`.\n\n## Writes\n\nAccounting writes wrap the payload in the **singular** resource name:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/invoices/invoices\" \\\n  -X POST -H 'Content-Type: application/json' \\\n  -d '{\"invoice\":{\"customerid\":123,\"create_date\":\"2026-08-12\",\"lines\":[]}}'\n```\n\nMoney is `{\"amount\":\"150.00\",\"code\":\"USD\"}` — the amount is a **string**, not a number.\n\nDeletes on the accounting family are frequently soft deletes via `vis_state` on an\nupdate rather than HTTP `DELETE`. Confirm per resource before assuming.\n\n**A 200 is not proof a write persisted — re-read the record to confirm.**\n\n## Troubleshooting\n\n| Symptom | Cause |\n| --- | --- |\n| `invalid_client` on refresh | The refresh grant needs `client_secret` **and** `redirect_uri`, form-encoded — not JSON |\n| `invalid_grant` on refresh | The token was already spent. Re-run the bootstrap |\n| 404 on a valid-looking record | Wrong identifier for that URL family — run `fb_ids` |\n| 401 on every call | Access token stale and refresh failing; check the state file |\n\nSee `references/recipes.md` for ready-to-run request bodies and `jq` recipes.\n\nFile v1.0.2:_meta.json\n\n{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"freshbooks-mcp\",\n  \"version\": \"1.0.2\",\n  \"publishedAt\": 1790199497972\n}\n\nFile v1.0.2:references/recipes.md\n\n# FreshBooks curl recipes\n\nAll examples assume:\n\n```sh\nset -a; . ~/.secrets; set +a\n. references/fb-token.sh\nACCT=$(fb_account_id)\nBIZ=$(fb_ids | jq -r .businessId)\n```\n\nPaths marked **(unverified)** are transcribed from the official\n`freshbooks-python-sdk` source but have not been exercised against a live account.\n\n## Identity\n\n```sh\nfb_curl /auth/api/v1/users/me | jq '.response | {identity_id, email}'\nfb_ids   # accountId / businessId / businessUuid — run before anything else\n```\n\n## Invoices\n\n```sh\n# List (newest first via FreshBooks' own sort param)\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=25&page=1\" \\\n  | jq '.response.result | {page, pages, total,\n        invoices: [.invoices[] | {id, invoice_number, organization, create_date,\n                                  amount: .amount.amount, outstanding: .outstanding.amount, status}]}'\n\n# One invoice, with line items expanded\nfb_curl \"/accounting/account/$ACCT/invoices/invoices/INVOICE_ID?include[]=lines\" \\\n  | jq '.response.result.invoice'\n\n# Outstanding only — v3_status is the useful status field\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?search[v3_status]=overdue\" \\\n  | jq '[.response.result.invoices[] | {id, invoice_number, outstanding: .outstanding.amount}]'\n\n# Total outstanding across a page\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=100\" \\\n  | jq '[.response.result.invoices[].outstanding.amount | tonumber] | add'\n```\n\nCreate **(unverified)** — money amounts are strings:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/invoices/invoices\" -X POST \\\n  -H 'Content-Type: application/json' -d '{\n  \"invoice\": {\n    \"customerid\": 123,\n    \"create_date\": \"2026-08-12\",\n    \"lines\": [\n      { \"name\": \"Consulting\", \"description\": \"August retainer\",\n        \"qty\": 1, \"unit_cost\": { \"amount\": \"1500.00\", \"code\": \"USD\" } }\n    ]\n  }\n}' | jq '.response.result.invoice | {id, invoice_number, amount}'\n```\n\n## Clients\n\n```sh\nfb_curl \"/accounting/account/$ACCT/users/clients?per_page=100\" \\\n  | jq '[.response.result.clients[] | {id: .userid, organization, fname, lname, email}]'\n\n# Find a client by name\nfb_curl \"/accounting/account/$ACCT/users/clients?search[organization_like]=acme\" \\\n  | jq '.response.result.clients'\n```\n\nCreate **(unverified)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/users/clients\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"client\":{\"email\":\"ap@example.com\",\"organization\":\"Example Co\",\"fname\":\"Pat\",\"lname\":\"Doe\"}}' \\\n  | jq '.response.result.client | {userid, organization}'\n```\n\n## Payments\n\n```sh\nfb_curl \"/accounting/account/$ACCT/payments/payments?per_page=50\" \\\n  | jq '[.response.result.payments[] | {id, invoiceid, date, amount: .amount.amount, type}]'\n```\n\nRecord a payment **(unverified)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/payments/payments\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"payment\":{\"invoiceid\":456,\"amount\":{\"amount\":\"1500.00\",\"code\":\"USD\"},\"date\":\"2026-08-12\",\"type\":\"Check\"}}'\n```\n\n## Estimates — accepting is an action, not a status write\n\n`status`, `display_status` and `ui_status` are computed read-only fields and disagree with\neach other (`status: 3` / `\"viewed\"` / `\"open\"` are the same estimate). Writing them does\nnothing. Acceptance is an action on the estimate **(unverified — collection-derived)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/estimates/estimates/279405\" -X PUT \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"estimate\":{\"action_accept\":true}}' \\\n  | jq '.response.result.estimate | {id, accepted, status, display_status, ui_status}'\n```\n\nEmail it to the client — note `estimate_customized_email`, not the invoice endpoint's\n`invoice_customized_email`:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/estimates/estimates/279405\" -X PUT \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"estimate\":{\"action_email\":true,\"email_recipients\":[\"client@example.com\"]}}'\n```\n\nThere is **no decline**: no declined status, no `action_deny`, no `estimate.decline`\nwebhook. The nearest expressible thing is the soft delete, `{\"estimate\":{\"vis_state\":1}}`,\nwhich removes it rather than declining it.\n\n## Full accounting resource map\n\nSame family and envelope; only the path suffix and list key change. Four of these are not\nwhat you would guess — they are marked ⚠ and each cost a 404 before the SDK settled it.\n\n| Resource | Path suffix | List key |\n| --- | --- | --- |\n| Invoices | `invoices/invoices` | `invoices` |\n| Clients | `users/clients` | `clients` |\n| Estimates | `estimates/estimates` | `estimates` |\n| Payments | `payments/payments` | `payments` |\n| Credit notes | `credit_notes/credit_notes` | `credit_notes` |\n| Invoice profiles | `invoice_profiles/invoice_profiles` | `invoice_profiles` |\n| Items | `items/items` | `items` |\n| Taxes | `taxes/taxes` | `taxes` |\n| Expenses | `expenses/expenses` | `expenses` |\n| Expense categories ⚠ | `expenses/categories` | `categories` |\n| Staff ⚠ | `users/staffs` | `staff` |\n| Gateways ⚠ | `systems/gateways` | `gateways` |\n| Other income ⚠ | `other_incomes/other_incomes` | `other_income` |\n| Tasks (billable catalogue) | `projects/tasks` | `tasks` |\n| Bills | `bills/bills` | `bills` |\n| Bill vendors | `bill_vendors/bill_vendors` | `bill_vendors` |\n| Bill payments | `bill_payments/bill_payments` | `bill_payments` |\n\n`projects/tasks` is in the **accounting** family (accountId) despite the prefix.\n\n### `total` can exceed the rows you get back\n\nVerified live: expenses returned `total: 16` with an empty array — the count includes\nrecords the identity cannot read, and paging never surfaces them. Always check both:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=100\" \\\n  | jq '{total: .response.result.total, rows: (.response.result.expenses|length)}'\n```\n\nIf `rows` is 0 while `total` is not **and you are on a page within range**, that is a\npermission boundary rather than an empty account. Check the page first — an empty page with\na non-zero total is also just what paging past the end looks like:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=100\" \\\n  | jq '.response.result | {total, pages, page, rows: (.expenses|length),\n         verdict: (if (.expenses|length) > 0 then \"ok\"\n                   elif .page > .pages then \"past the last page\"\n                   else \"rows withheld by permissions\" end)}'\n```\n\n```sh\nfb_curl \"/accounting/account/$ACCT/expenses/expenses?per_page=50\" \\\n  | jq '[.response.result.expenses[] | {id, date, amount: .amount.amount, vendor, notes}]'\n```\n\n## Projects and time tracking — different id, different envelope\n\nThese take `businessId` and return **bare** objects, not `.response.result`. Pagination\nlives under `meta`, so `.response.result.total` reads `undefined` here and looks like an\nempty account:\n\n```sh\nfb_curl \"/projects/business/$BIZ/projects\" \\\n  | jq '{meta, projects: [.projects[] | {id, title, client_id, active}]}'\n\n# time_entries' meta also carries total_logged and total_unbilled\nfb_curl \"/timetracking/business/$BIZ/time_entries\" \\\n  | jq '{logged: .meta.total_logged, unbilled: .meta.total_unbilled,\n         entries: [.time_entries[] | {id, project_id, duration, started_at, note}]}'\n\nfb_curl \"/comments/business/$BIZ/services\" | jq '[.services[] | {id, name, billable}]'\n```\n\nErrors here are `.error`, a plain string — not `.response.errors[]`.\n\nThese endpoints work on a business with **no accounting account**, so they can succeed\nwhen every `/accounting/account/...` call fails.\n\nLog time (duration is in **seconds**):\n\n```sh\nfb_curl \"/timetracking/business/$BIZ/time_entries\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"time_entry\":{\"duration\":3600,\"started_at\":\"2026-08-12T09:00:00Z\",\"note\":\"Design review\"}}'\n```\n\n## Pagination\n\nAccounting lists carry `page`, `pages`, `per_page`, `total` on the result envelope.\nWalk them:\n\n```sh\np=1\nwhile :; do\n  body=$(fb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=100&page=$p\")\n  printf '%s' \"$body\" | jq -c '.response.result.invoices[]'\n  pages=$(printf '%s' \"$body\" | jq -r '.response.result.pages')\n  [ \"$p\" -ge \"$pages\" ] && break\n  p=$((p + 1))\ndone\n```\n\n## Error shapes by family\n\n```sh\n# Accounting / events\njq -r '.response.errors[]? | \"\\(.errno): \\(.message)\"'\n# Accounting-business\njq -r '.errors? | \"\\(.message) \\(.details[]?.reason // \"\")\"'\n# Payments\njq -r '.errors?.details[]? | \"\\(.field): \\(.message)\"'\n# Projects / time tracking / comments / uploads\njq -r '.error? // empty'\n```\n\nFile v1.0.2:skill-card.md\n\n## Description:\n\nQuery FreshBooks invoices, clients, estimates, payments, expenses, projects, and time tracking from a shell with curl and a rotating OAuth token.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nDevelopers and operators use this skill to prepare authenticated FreshBooks shell commands, bootstrap OAuth credentials, resolve FreshBooks account identifiers, and inspect or update accounting, project, payment, expense, and time-tracking data when the MCP server is not installed.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: The skill can produce authenticated FreshBooks write commands that affect live accounting records despite being framed primarily as a query helper.\n\nMitigation: Review every POST, PUT, email, payment, or delete-style command before execution, and re-read changed records to confirm the intended result.\n\nRisk: FreshBooks client secrets, refresh tokens, and state files can grant broad account access if exposed.\n\nMitigation: Store credentials outside logs and shell traces, protect the token state file, and avoid sharing the same rotating-token state file between tools.\n\nRisk: FreshBooks refresh tokens rotate on use, so two writers or a failed persistence step can invalidate access.\n\nMitigation: Use the provided token helper for refreshes, keep one writer per state file, and re-run the browser bootstrap if the refresh token has already been spent.\n\n## Reference(s):\n\n- [ClawHub skill page](https://clawhub.ai/chrischall/skills/freshbooks-mcp)\n- [FreshBooks developer portal](https://my.freshbooks.com/#/developer)\n- [FreshBooks curl recipes](references/recipes.md)\n- [FreshBooks OAuth bootstrap helper](references/fb-bootstrap.mjs)\n- [FreshBooks rotating token helper](references/fb-token.sh)\n\n## Skill Output:\n\n**Output Type(s):** [text, markdown, code, shell commands, configuration, guidance]\n\n**Output Format:** [Markdown with shell commands, JavaScript helper usage, jq filters, and JSON request examples]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [May produce authenticated FreshBooks API calls; write examples can change\n\nArchive v1.0.1: 6 files, 10900 bytes\n\nFiles: references/fb-bootstrap.mjs (4220b), references/fb-token.sh (4351b), references/recipes.md (8476b), skill-card.md (2271b), SKILL.md (4894b), _meta.json (133b)","readmeExcerpt":"Skill: freshbooks-mcp Owner: chrischall Summary: Query 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. Tags: latest:1.2.1 Version history: v1.2.1 | 2026-10-09T23:25:17.058Z | auto freshbooks-mc","codeSnippets":[],"executableExamples":[{"language":"sh","snippet":"set -a; . ~/.secrets; set +a\n\n# 1. Print the authorize URL, open it, click Allow.\nnode references/fb-bootstrap.mjs url \"$FRESHBOOKS_CLIENT_ID\"\n\n# 2. The browser fails to load https://localhost?code=… — that is expected.\n#    Copy the whole URL from the address bar and exchange it (the code is single-use\n#    and expires within minutes).\nnode references/fb-bootstrap.mjs exchange \"$FRESHBOOKS_CLIENT_ID\" \"$FRESHBOOKS_CLIENT_SECRET\" \\\n  https://localhost 'https://localhost?code=PASTE_HERE'"},{"language":"sh","snippet":"set -a; . ~/.secrets; set +a\n. references/fb-token.sh\n\nfb_ids                                    # resolve accountId / businessId / businessUuid\nACCT=$(fb_account_id)\n\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=5\" \\\n  | jq '.response.result.invoices[] | {id, invoice_number, amount, outstanding, status}'"},{"language":"sh","snippet":"fb_curl \"/accounting/account/$ACCT/invoices/invoices\" \\\n  -X POST -H 'Content-Type: application/json' \\\n  -d '{\"invoice\":{\"customerid\":123,\"create_date\":\"2026-08-12\",\"lines\":[]}}'"},{"language":"sh","snippet":"set -a; . ~/.secrets; set +a\n. references/fb-token.sh\nACCT=$(fb_account_id)\nBIZ=$(fb_ids | jq -r .businessId)"},{"language":"sh","snippet":"fb_curl /auth/api/v1/users/me | jq '.response | {identity_id, email}'\nfb_ids   # accountId / businessId / businessUuid — run before anything else"},{"language":"sh","snippet":"# List (newest first via FreshBooks' own sort param)\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=25&page=1\" \\\n  | jq '.response.result | {page, pages, total,\n        invoices: [.invoices[] | {id, invoice_number, organization, create_date,\n                                  amount: .amount.amount, outstanding: .outstanding.amount, status}]}'\n\n# One invoice, with line items expanded\nfb_curl \"/accounting/account/$ACCT/invoices/invoices/INVOICE_ID?include[]=lines\" \\\n  | jq '.response.result.invoice'\n\n# Outstanding only — v3_status is the useful status field\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?search[v3_status]=overdue\" \\\n  | jq '[.response.result.invoices[] | {id, invoice_number, outstanding: .outstanding.amount}]'\n\n# Total outstanding across a page\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=100\" \\\n  | jq '[.response.result.invoices[].outstanding.amount | tonumber] | add'"}],"parameters":null,"dependencies":[],"permissions":[],"extractedFiles":[{"path":"SKILL.md","content":"---\nname: freshbooks-curl\ndescription: Query 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.\n---\n\n# FreshBooks from the shell\n\nFreshBooks has a real REST API reachable server-side — no browser bridge, no signed-in\ntab, no `fpx`. Authentication is **OAuth2 only**: there is no API key and no personal\naccess token, so a one-time browser authorize flow is unavoidable.\n\n## The two things that break naive clients\n\n**1. Refresh tokens are single-use and rotate.** Every refresh returns a *new* refresh\ntoken and kills the one you used. If the replacement is not written to disk before the\nprocess ends, the account is locked out and the only recovery is re-running the browser\nflow. Never hand-roll the refresh — use `fb_access_token` from\n`references/fb-token.sh`, which persists the rotation before returning, and never point\ntwo tools at the same state file.\n\n**2. Three identifiers that are not interchangeable.** `accountId` (alphanumeric, e.g.\n`xZNQ1X`), `businessId` (integer), `businessUuid`. Each URL family takes a different one\nand answers a mismatch with a bare **404** that reads like a missing record. Always\nresolve first with `fb_ids`.\n\n| Family | Path | Identifier |\n| --- | --- | --- |\n| Accounting | `/accounting/account/{accountId}/…` | `accountId` |\n| Payments | `/payments/account/{accountId}/…` | `accountId` |\n| Accounting (business) | `/accounting/businesses/{businessUuid}/…` | `businessUuid` |\n| Projects | `/projects/business/{businessId}/…` | `businessId` |\n| Time tracking | `/timetracking/business/{businessId}/…` | `businessId` |\n\n## One-time setup\n\nRegister an app at <https://my.freshbooks.com/#/developer>. The redirect URI must be\n**HTTPS with no query string** — `https://localhost` works and never needs to\nresolve. Put `FRESHBOOKS_CLIENT_ID` and `FRESHBOOKS_CLIENT_SECRET` somewhere loadable\n(e.g. `~/.secrets`).\n\nThen run the authorize flow once:\n\n```sh\nset -a; . ~/.secrets; set +a\n\n# 1. Print the authorize URL, open it, click Allow.\nnode references/fb-bootstrap.mjs url \"$FRESHBOOKS_CLIENT_ID\"\n\n# 2. The browser fails to load https://localhost?code=… — that is expected.\n#    Copy the whole URL from the address bar and exchange it (the code is single-use\n#    and expires within minutes).\nnode references/fb-bootstrap.mjs exchange \"$FRESHBOOKS_CLIENT_ID\" \"$FRESHBOOKS_CLIENT_SECRET\" \\\n  https://localhost 'https://localhost?code=PASTE_HERE'\n```\n\nSave the printed `refresh_token` as `FRESHBOOKS_REFRESH_TOKEN`. It seeds the state file\non first use; after that the state file is the source of truth and the env value is\nignored until you re-bootstrap.\n\n## Core call pattern\n\n```sh\nset -a; . ~/.secrets; set +a\n. references/fb-token.sh\n\nfb_ids                                    # resolve accountId / businessId / businessUuid\nACCT=$(f"},{"path":"_meta.json","content":"{\n  \"ownerId\": \"kn700jq4sjtf2anb0rk3ft4p7n856872\",\n  \"slug\": \"freshbooks-mcp\",\n  \"version\": \"1.2.1\",\n  \"publishedAt\": 1791588317058\n}"},{"path":"references/recipes.md","content":"# FreshBooks curl recipes\n\nAll examples assume:\n\n```sh\nset -a; . ~/.secrets; set +a\n. references/fb-token.sh\nACCT=$(fb_account_id)\nBIZ=$(fb_ids | jq -r .businessId)\n```\n\nPaths marked **(unverified)** are transcribed from the official\n`freshbooks-python-sdk` source but have not been exercised against a live account.\n\n## Identity\n\n```sh\nfb_curl /auth/api/v1/users/me | jq '.response | {identity_id, email}'\nfb_ids   # accountId / businessId / businessUuid — run before anything else\n```\n\n## Invoices\n\n```sh\n# List (newest first via FreshBooks' own sort param)\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=25&page=1\" \\\n  | jq '.response.result | {page, pages, total,\n        invoices: [.invoices[] | {id, invoice_number, organization, create_date,\n                                  amount: .amount.amount, outstanding: .outstanding.amount, status}]}'\n\n# One invoice, with line items expanded\nfb_curl \"/accounting/account/$ACCT/invoices/invoices/INVOICE_ID?include[]=lines\" \\\n  | jq '.response.result.invoice'\n\n# Outstanding only — v3_status is the useful status field\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?search[v3_status]=overdue\" \\\n  | jq '[.response.result.invoices[] | {id, invoice_number, outstanding: .outstanding.amount}]'\n\n# Total outstanding across a page\nfb_curl \"/accounting/account/$ACCT/invoices/invoices?per_page=100\" \\\n  | jq '[.response.result.invoices[].outstanding.amount | tonumber] | add'\n```\n\nCreate **(unverified)** — money amounts are strings:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/invoices/invoices\" -X POST \\\n  -H 'Content-Type: application/json' -d '{\n  \"invoice\": {\n    \"customerid\": 123,\n    \"create_date\": \"2026-08-12\",\n    \"lines\": [\n      { \"name\": \"Consulting\", \"description\": \"August retainer\",\n        \"qty\": 1, \"unit_cost\": { \"amount\": \"1500.00\", \"code\": \"USD\" } }\n    ]\n  }\n}' | jq '.response.result.invoice | {id, invoice_number, amount}'\n```\n\n## Clients\n\n```sh\nfb_curl \"/accounting/account/$ACCT/users/clients?per_page=100\" \\\n  | jq '[.response.result.clients[] | {id: .userid, organization, fname, lname, email}]'\n\n# Find a client by name\nfb_curl \"/accounting/account/$ACCT/users/clients?search[organization_like]=acme\" \\\n  | jq '.response.result.clients'\n```\n\nCreate **(unverified)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/users/clients\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"client\":{\"email\":\"ap@example.com\",\"organization\":\"Example Co\",\"fname\":\"Pat\",\"lname\":\"Doe\"}}' \\\n  | jq '.response.result.client | {userid, organization}'\n```\n\n## Payments\n\n```sh\nfb_curl \"/accounting/account/$ACCT/payments/payments?per_page=50\" \\\n  | jq '[.response.result.payments[] | {id, invoiceid, date, amount: .amount.amount, type}]'\n```\n\nRecord a payment **(unverified)**:\n\n```sh\nfb_curl \"/accounting/account/$ACCT/payments/payments\" -X POST \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"payment\":{\"invoiceid\":456,\"amount\":{\"amount\":\"1500.00\",\"code\":\"USD\"},\"date\":\"2026-08-12\",\"type\":\"Check\"}}'\n```\n\n## Estimates — "},{"path":"skill-card.md","content":"## Description:\n\nHelps agents access FreshBooks accounting, project, and time-tracking data from a shell using OAuth-authenticated requests.\n\nThis skill is ready for commercial/non-commercial use.\n\n## Publisher:\n\n[chrischall](https://clawhub.ai/user/chrischall)\n\n### License/Terms of Use:\n\nMIT-0\n\n## Use Case:\n\nDevelopers and finance teams use this skill to query FreshBooks records from a shell without running an MCP server, and to prepare accounting updates when authorized.\n\n### Deployment Geography for Use:\n\nGlobal\n\n## Known Risks and Mitigations:\n\nRisk: Commands can create or change live accounting records and send estimates to customers.\n\nMitigation: Test with a FreshBooks test account first; review and explicitly authorize every POST or PUT command before execution.\n\nRisk: OAuth tokens stored on disk or exposed in shell logs could allow unauthorized account access.\n\nMitigation: Keep the token state file private and avoid shell tracing or logging around token commands.\n\n## Reference(s):\n\n- [FreshBooks curl recipes](artifact/references/recipes.md)\n- [FreshBooks developer portal](https://my.freshbooks.com/#/developer)\n- [ClawHub skill release](https://clawhub.ai/chrischall/skills/freshbooks-mcp)\n\n## Skill Output:\n\n**Output Type(s):** [Shell commands, Guidance]\n\n**Output Format:** [Markdown with shell examples]\n\n**Output Parameters:** [1D]\n\n**Other Properties Related to Output:** [Examples can include live FreshBooks write requests.]\n\n## Skill Version(s):\n\n1.2.1 (source: ClawHub release metadata)\n\n## Ethical Considerations:\n\nUsers should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment."}],"languages":[],"docsSourceLabel":"CLAWHUB","editorialOverview":null,"editorialQuality":{"score":100,"threshold":65,"status":"thin","wordCount":1395,"uniquenessScore":44,"reasons":["uniqueness-below-45"]}},"media":{"evidence":{"source":"no-media","verified":false,"confidence":"low","updatedAt":"2026-10-10T16:23:36.063Z","emptyReason":"No screenshots, media assets, or demo links are available."},"primaryImageUrl":null,"mediaAssetCount":0,"assets":[],"demoUrl":null},"ownerResources":{"evidence":{"source":"unclaimed","verified":false,"confidence":"low","updatedAt":"2026-10-10T16:23:36.063Z","emptyReason":"This page has not been claimed by the agent owner."},"hasCustomPage":false,"customPageUpdatedAt":null,"customLinks":[],"structuredLinks":{"docsUrl":null,"demoUrl":null,"supportUrl":null,"pricingUrl":null,"statusUrl":null},"customPage":null},"relatedAgents":{"evidence":{"source":"protocol-neighbors","verified":false,"confidence":"medium","updatedAt":"2026-10-10T21:55:53.005Z","emptyReason":null},"items":[{"id":"8ebccd8e-3863-4187-8355-c3f14e1f9edf","entityType":"agent","canonicalPath":"/agent/iofficeai-aionui","slug":"iofficeai-aionui","name":"AionUi","description":"Free, local, open-source 24/7 Cowork app and OpenClaw for Gemini CLI, Claude Code, Codex, OpenCode, Qwen Code, Goose CLI, Auggie, and more | 🌟 Star if you like it!","url":"https://github.com/iOfficeAI/AionUi","homepage":"https://www.aionui.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-10-09T19:11:12.944Z","createdAt":"2026-02-25T03:38:16.584Z","downloads":null},{"id":"b917f68a-ebff-438e-84f8-3f4b2494c0bc","entityType":"agent","canonicalPath":"/agent/activepieces-activepieces","slug":"activepieces-activepieces","name":"activepieces","description":"AI Agents & MCPs & AI Workflow Automation • (~400 MCP servers for AI agents) • AI Automation / AI Agent with MCPs • AI Workflows & AI Agents • MCPs for AI Agents","url":"https://github.com/activepieces/activepieces","homepage":"https://www.activepieces.com","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-15T02:22:12.426Z","createdAt":"2026-02-25T03:38:12.412Z","downloads":null},{"id":"5cb26759-3a39-483f-94cf-276a98c13bb8","entityType":"agent","canonicalPath":"/agent/cherryhq-cherry-studio","slug":"cherryhq-cherry-studio","name":"cherry-studio","description":"AI productivity studio with smart chat, autonomous agents, and 300+ assistants. Unified access to frontier LLMs","url":"https://github.com/CherryHQ/cherry-studio","homepage":"https://cherry-ai.com","source":"GITHUB_REPOS","protocols":["MCP","OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-04-11T14:38:40.986Z","createdAt":"2026-02-25T03:38:19.379Z","downloads":null},{"id":"6f6582d0-5d76-4f0f-b81d-86520247950b","entityType":"agent","canonicalPath":"/agent/copilotkit-copilotkit","slug":"copilotkit-copilotkit","name":"CopilotKit","description":"The Frontend for Agents & Generative UI. React + Angular","url":"https://github.com/CopilotKit/CopilotKit","homepage":"https://docs.copilotkit.ai","source":"GITHUB_REPOS","protocols":["OPENCLAW"],"capabilities":[],"safetyScore":100,"overallRank":70,"updatedAt":"2026-03-25T09:50:57.846Z","createdAt":"2026-02-25T03:39:14.617Z","downloads":null}],"links":{"hub":"/agent","source":"/agent/source/clawhub","protocols":[{"label":"OpenClaw","href":"/agent/protocol/openclew"}]}}}