Diagnostic report on the Jobber API integration that powers the Sperry Tree Care campaign performance chart, including the scope-grant friction encountered and the working architecture deployed.
The daily Sperry campaign chart was undercounting weekly Requests by roughly 10x — reporting 11 inbound requests for the week of Apr 19 when the actual count was 119. Root cause: the production script was counting newly-created Jobber Clients flagged as isLead=true, which only catches first-time customers. Repeat customers submitting new Requests (the majority of inbound volume) were invisible to the metric.
The straightforward fix — swap to Jobber's top-level Query.requests field — was blocked by Jobber's OAuth scope-grant logic. Despite the read_requests scope being toggled ON and saved in the developer center, every access token grant silently stripped it from the resulting permissions. After three full disconnect/reconnect cycles and a Jobber app config save-and-reauth, the scope remained absent from token grants while other newly-toggled scopes (Marketing, Custom Field Configurations, Users) came through correctly.
Workaround deployed: query Requests through the nested Client.requests connection. This path is gated only by read_clients (which we have) and returns the same Request data. The corrected daily script and a 26-week historical backfill are now live; the chart for week ending Apr 19 reads 119, matching ground truth.
The production script sperry_requests_update.py ran daily at 06:03 AM, querying Jobber's GraphQL clients connection and counting clients with isLead=true created in the current week. This metric was named "Requests" in the chart but measured something different — first-time customer creations, not actual Request submissions.
For a tree care business with high repeat-customer volume, the gap between "new lead clients" and "Request submissions" is substantial. Most weekly inbound activity comes from existing clients re-engaging — those Requests never trigger an isLead flag because the client record already exists.
| Phase | Action | Outcome |
|---|---|---|
| Diagnose | Compared script output (11) to known correct value (119) — identified isLead vs Request mismatch | Win |
| Probe | Tested top-level Query.requests with existing access token | Blocked: permissions |
| Scope check | Inspected access token JWT — only read_clients read_quotes read_jobs read_invoices granted despite Requests scope toggled ON | Discovery |
| Reauth #1 | Disconnected via appDisconnect mutation, re-ran OAuth flow | Same 4 scopes |
| Reauth #2 | Added explicit scope= param to auth URL | Param ignored — Jobber doesn't accept it |
| App save | Suspected uncommitted scope changes — clicked Save App in dev center | Unlocked: marketing, custom_field_configurations, users, expenses |
| Reauth #3 | Fresh consent flow with all scopes saved + clean app config | read_requests still silently dropped |
| Pivot | Schema introspection on Client type — found Client.requests connection | Win |
| Verify | Tested nested query under read_clients scope alone | Returns Request data |
| Deploy | Patched script, ran live — 118 this week, 60 prior week (within 1 of 119 target) | Production |
Silent scope dropping. The Jobber developer center allows toggling scopes ON in DRAFT mode, displays them as enabled, and saves the configuration successfully — but the OAuth grant logic strips specific scopes (notably read_requests, read_scheduled_items, and most write_* scopes) from access tokens without any error, warning, or indication. There is no documented mechanism to enable these scopes in DRAFT mode short of submitting the app for marketplace review.
Missing OAuth parameters. Jobber's OAuth implementation does not accept the standard scope= URL parameter. Scopes are derived solely from app configuration. Standard OAuth tooling and libraries that pass scopes via URL will appear to work but produce silently underscoped tokens.
Misleading dev center UX. The "Request a review" button leads to a Google Form with extensive marketplace publication requirements (logo, ToS URL, Privacy Policy URL, end-to-end testing checklist). Per Jobber's own Custom Integrations documentation, apps connecting to fewer than 5 paying accounts do not require review and can operate in DRAFT permanently — but the UI strongly implies review is the path forward when scopes don't grant correctly.
| Component | Role |
|---|---|
sperry_requests_update.py | Daily updater. Auths to Jobber, queries weekly Request counts, patches CF Worker, redeploys. |
run_secrets.json | Stores rotating Jobber refresh token + client credentials. Token is rotated by Jobber on every refresh; script saves new token immediately to disk before any further work. |
| Jobber GraphQL API | Source of truth. Queried via Client.requests nested connection (read_clients scope only). |
CF Worker sperry-requests-apr2026 | Renders the chart. rawCounts[] array drives YoY table, stat cards, EV panel — all via JS, never hardcoded HTML. |
Scheduled Task sperry-requests-daily-update | Cron 0 6 * * *. Runs the script daily at 06:03 AM Pacific. Built-in zero-guard aborts deploy if both counts return 0. |
Jobber's OAuth scope check applies at the field level. The top-level Query.requests field requires read_requests. The nested Client.requests connection requires only read_clients. Since Client records bump their updatedAt timestamp whenever a new Request is submitted on their behalf, we can filter clients to those touched in the last 2 weeks, then iterate their nested Requests and bucket by week.
query Clients($after: String, $filter: ClientFilterAttributes) {
clients(
first: 30,
after: $after,
filter: $filter,
sort: { key: UPDATED_AT, direction: DESCENDING }
) {
pageInfo { hasNextPage endCursor }
nodes {
id
updatedAt
requests(first: 50) {
nodes { id createdAt }
}
}
}
}
Filter: { updatedAt: { after: prior_sunday } }. Returns approximately 280 active clients per run, well under the throttle budget. Total query cost ~150 units against a 10,000 budget.
| Week Index | Date Range | Old Method | New Method | Delta |
|---|---|---|---|---|
| 328 | Apr 19 – Apr 25 | 11 | 119 | +108 |
| 327 | Apr 12 – Apr 18 | 10 | 60 | +50 |
| 326 | Apr 5 – Apr 11 | — | 77 | consistent |
| 322 | Mar 8 – Mar 14 | — | 97 | consistent |
Indices 302–326 were already populated with correct historical Request data from prior runs of a different methodology — only [327] and [328] (last 2 weeks) had been overwritten with the broken isLead-based count.
The scheduled task sperry-requests-daily-update runs at 06:03 AM Pacific. It:
run_secrets.json).Client.requests nested query with safety stop at 50 pages.rawCounts[], patches indices [this_idx] and [prior_idx].python3 "/Users/Jason/My Drive (jason@bosstorque.ai)/1_Clients/Sperry Tree Care/2. Campaign Performance/scripts/sperry_requests_update.py"
If the saved refresh token becomes invalid (HTTP 401 on token refresh), the connection must be re-authorized:
https://api.getjobber.com/api/oauth/authorize?response_type=code&client_id=7e52aab6-cb92-4318-9261-beb1f4249447&redirect_uri=https://localhost/callback
localhost — copy the code=... value from the URL bar.https://api.getjobber.com/api/oauth/token with grant_type=authorization_code, the code, client_id, client_secret, and the same redirect_uri.JOBBER_REFRESH_TOKEN in run_secrets.json.To re-baseline historical weeks under the new methodology, run sperry_requests_backfill.py with WEEKS_BACK set to the desired number of weeks. The 26-week (6-month) backfill executed on Apr 24, 2026 covers indices 302–328.
The query fetches the most recent 50 Requests per Client. For the rare repeat customer with more than 50 Requests in a 6-month window, older Requests would be missed. Sperry's customer pattern (1–3 Requests per client per year) makes this a non-issue in practice, but a high-volume contract account could expose it.
The current architecture depends on the Client.requests nested connection remaining accessible under read_clients alone. If Jobber tightens its scope enforcement to require read_requests for all Request data (including nested), this script breaks immediately and silently — the query would error and counts would zero out, triggering the zero-guard abort.
7e52aab6-cb92-4318-9261-beb1f4249447 and the specific scopes affected (read_requests, read_scheduled_items, write_clients, write_quotes, write_jobs, write_invoices).| Item | Value |
|---|---|
| Jobber App Name | BOSSTORQUE Sperry Report |
| Status | DRAFT (does not require review per Jobber custom integration policy) |
| Jobber Account | Sperry Tree Care (account_id 1970331) |
| Client ID | 7e52aab6-cb92-4318-9261-beb1f4249447 |
| Granted Scopes | read_clients, read_quotes, read_jobs, read_invoices, read_jobber_payments, read_users, write_users, write_tax_rates, read_expenses, read_custom_field_configurations, write_custom_field_configurations, read_marketing, write_marketing |
| Silently Dropped | read_requests, read_scheduled_items, write_clients, write_quotes, write_jobs, write_invoices |
| Chart URL | sperry-requests-apr2026.jason-8ce.workers.dev |
| Terms of Service URL | sperry-bosstorque-tos.jason-8ce.workers.dev |
| Privacy Policy URL | sperrytreecare.com/privacy-policy |