Gusto — Integration Setup
Connecting Gusto gives CMDash your employee and contractor roster: who works here, when they joined, when they left, and who reports to whom. That roster is the evidence behind joiner/leaver controls, periodic access reviews, and the contractor population auditors ask about separately.
Prerequisites
- A Gusto account, with the connecting user holding a payroll admin role
- CMDash Essentials tier or above
Gusto is the one HRIS in CMDash with a real OAuth handshake — there is no API key to paste. You authorize CMDash from Gusto's consent screen and Gusto issues a short-lived token that CMDash refreshes automatically.
Step 1: Connect in CMDash
- Navigate to Integrations in your CMDash dashboard.
- Find Gusto and click Connect.
- You are redirected to Gusto to sign in and approve the connection. CMDash requests exactly two scopes:
companies:reademployees:read
- Approve, and you are returned to CMDash.
Finish in the same browser within 10 minutes: the handshake is bound to the browser that clicked Connect by a short-lived Secure cookie, and a consent completed anywhere else is refused. That cookie needs CMDash served over HTTPS (browsers make an exception only for http://localhost), with its API on the app's own host (the /api path); a self-hosted install whose callback URL is plain HTTP is refused when Connect is clicked, with the reason.
CMDash does not request
compensations:read. That is deliberate and load-bearing: without it, the pay-rate fields on Gusto's employee payload are unreadable by CMDash's token at all. The privacy promise below is enforced by Gusto, not merely by CMDash's code.
Optional connection settings
| Setting | Purpose |
|---|---|
| Company UUIDs | Restrict the sync to specific companies. When unset, CMDash calls GET /v1/me and syncs every company your payroll-admin role can see. |
| API Version | Pins a specific X-Gusto-API-Version. When unset, the header is not sent and Gusto's default applies. |
| Base URL | Point at Gusto's demo environment (https://api.gusto-demo.com) for testing. |
What Syncs
| Data Type | Gusto API | Sync Frequency |
|---|---|---|
| Company discovery | GET /v1/me | Each sync (skipped when Company UUIDs are pinned) |
| Employees | GET /v1/companies/{uuid}/employees?page&per=100 | Every 4 hours by default (configurable per connection) |
| Contractors | GET /v1/companies/{uuid}/contractors?page&per=100 | Every 4 hours by default (configurable per connection) |
Gusto keeps contractors in a separate collection from employees, so CMDash reads both and merges them into one roster with employmentType distinguishing them.
Each roster row is normalized to: external id, work email, display name, employment status, employment type, hire date, termination date, department, job title, and a manager reference. CMDash also derives a summary alongside the rows — headcount, active/terminated counts, contractor count, joiners and leavers in the last 30 days, and terminatedButActiveCount.
Status and date mapping
Gusto exposes three overlapping signals — terminated, onboarded, and current_employment_status — and they disagree in normal operation (a scheduled future termination sets terminated: true while the person is still working). CMDash resolves them in this order:
terminated === true→terminated(Gusto's own flag wins)onboarded === false→pending(hired, not yet started or onboarding incomplete)- an employment status present →
active - otherwise →
unknown, notactive
Hire date is the earliest hire_date across the employee's jobs[], not the primary job's. Gusto models an internal promotion as a new job with a later hire date; the earliest one is the "joined the company" date an auditor means.
Termination date comes from the active entry in terminations[].
What CMDash Deliberately Does Not Read
Gusto is a payroll product; its employee object is the most sensitive of the four HRIS integrations. Two protections apply.
First — never ask. Gusto gates its most sensitive fields behind explicit include opt-ins. CMDash sends none of them:
include=all_compensations— pay rates and full compensation historyinclude=all_home_addresses— home addressesinclude=custom_fields— arbitrary tenant-defined fields of unknown sensitivity
Second — drop at the boundary. Gusto returns date_of_birth, ssn, has_ssn, phone, garnishments[], eligible_paid_time_off[], and jobs[].rate inline on the default payload, whether or not anyone wants them. CMDash constructs each roster record field by field from a fixed list and never copies a Gusto object wholesale, so these are discarded in memory at the end of the sync and never written to storage. Personal email is likewise never preferred over work_email.
Turning the roster into a control
Create a continuous-compliance rule against the roster:
- Provider: Gusto
- Data type: Workforce
- Condition:
terminatedButActiveCount <= 0 - Target status: Implemented
The control reads as Implemented while every departed worker is correctly terminated in Gusto, and drifts to At Risk / Failing when one is not — with an auto-opened remediation task. Other useful fields on the same datum: headcount, activeCount, terminatedCount, contractorCount, joinersLast30d, leaversLast30d.
Training completion
Gusto has no training product, so training completion is not synced. Security-awareness training completion in CMDash comes from KnowBe4.
Troubleshooting
- "Gusto token administers no companies": The connected user does not hold a payroll-admin role, or that role covers no companies. CMDash fails loudly here rather than reporting an empty roster, because an empty roster would silently clear every offboarding finding at once. Reconnect as a payroll admin, or pin Company UUIDs.
- 401 Unauthorized: The refresh token was revoked (the connection was removed in Gusto). Reconnect.
- Job title is blank: The employee has no
jobs[]entry yet — common for someone still onboarding. - Contractor has no work email: Expected. A contractor with no company account genuinely has nothing to join to your identity provider, so the field is left empty rather than filled with a personal address.
Rate Limits & Sync Behavior
| Setting | Value |
|---|---|
| Sync frequency | Every 4 hours by default (syncInterval is configurable per connection) |
| Page size | 100 records per request; the walk uses Gusto's X-Total-Pages header when present and stops on a short page otherwise |
| Page cap | 500 pages per collection (50,000 rows) |
| Rate limit handling | Requests are paced through CMDash's shared per-tenant token bucket |
| Circuit breaker | Repeated failures open the breaker and skip the cycle rather than hammering Gusto |
| Failed sync retry | Job queue retries 3 times with exponential backoff (starting at 30s) — see docs/developers/dead-letter.md |
Credential Rotation
Gusto access tokens are short-lived and refreshed automatically by CMDash; there is nothing to rotate manually. To revoke access entirely, remove the CMDash authorization in Gusto (Settings → Integrations), then delete the connection in CMDash.
Deployment note
The OAuth client id and secret are deployment-level configuration (GUSTO_CLIENT_ID, GUSTO_CLIENT_SECRET, and optionally GUSTO_BASE_URL for the demo environment), registered once against your Gusto developer application — not per tenant.