SquadSync Integration API

v1 · read-only

A small REST API for pulling a company's scheduling data — locations, staff, shifts, time off, availability, time clock and the shift pool — into external operations software. Everything is GET; nothing can be changed through it. Base URL: https://<your-squadsync-host>/api/v1. Machine-readable spec: /api/v1/openapi.json.

Authentication

An admin creates a key under Settings → Company → Integrations. Keys look like ss_live_… (51 characters), belong to one company, and carry a set of scopes and, optionally, a subset of locations. The full key is shown once; SquadSync stores only a hash. Send it as a bearer token (or in X-API-Key):

curl -H "Authorization: Bearer ss_live_..." \
  https://<host>/api/v1/me

/me returns the key's name, scopes, company and the locations it may read — use it to verify a key after configuring it. Keys can expire (set at creation) and can be revoked at any time; both produce 401.

Scopes

Each endpoint requires one scope; some fields need an extra one. The recommended “Operations” preset is everything except schedule:drafts and pay:read.

ParameterMeaning
locations:readLocations: name, address, timezone
staff:readEmployees, roles and assignments (no contact details)
staff:piiAdds email and phone to employees
schedule:readPublished shifts and schedule weeks
schedule:draftsAllows include_drafts=true on /shifts
schedule:notesAdds free-text shift notes to /shifts and /pool/open (omitted otherwise). Not in the recommended preset.
timeoff:readTime-off requests (never the notes)
availability:readApproved availability in effect
timeclock:readClock-in status and raw punches
pool:readOpen shift-pool offers
pay:readWage rates on roles and assignments. Sensitive; not in the recommended preset.

Responses & errors

Every response is JSON with a stable envelope:

// success
{ "data": …, "next_cursor": "…" | null, "meta": { "request_id": "req_…", "generated_at": "2026-09-05T14:02:11.000Z", … } }

// error
{ "error": { "code": "insufficient_scope", "message": "This key is missing the pay:read scope.", "request_id": "req_…", "details": { … } } }
StatuscodeWhen
400invalid_requestBad query parameter; details lists the field(s).
401unauthorizedMissing or malformed key, or "Invalid or inactive API key" (unknown, revoked and expired keys all get that one message).
403forbiddenlocation_id the key cannot access.
403insufficient_scopeEndpoint or option needs a scope the key lacks.
429rate_limitedToo many requests; wait Retry-After seconds.
500internal_errorUnexpected failure; quote request_id when reporting it.

Headers on every response: X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, Cache-Control: private, no-store. There is no CORS: call the API from a server, never from a browser.

Pagination

List endpoints that can grow (/employees, /shifts, /time-off, /timeclock/punches) return up to limit items (1–500, default 100) and a next_cursor. Pass it back as ?cursor= with the same filters until it is null. Cursors are opaque; do not build them yourself.

/availability is paginated the same way (limit 1–500, default 200) but is ordered by employee, then weekday. Its every-Nth-week filter is applied after each page is fetched, so a page can hold fewer than limit items while next_cursor is still set — keep following the cursor until it is null.

Rate limits

Per key: 600 requests per minute and a burst cap of 60 per 10 seconds. Over the limit you get 429 with Retry-After. Requests count from the moment they are accepted (in-flight calls included); throttled calls do not count, so backing off for Retry-After always clears the limit. A weekly sync for one location is normally 2–3 requests, so this is generous — but do cache and use updated_since for incremental pulls.

Timezones

Every instant (start, end, at, *_at) is ISO-8601 UTC with a trailing Z. Shifts and punches also carry timezone (the location's IANA zone) plus local_date, local_start / local_end / local_time (HH:mm) — the wall-clock reading in that zone, DST-correct. Date-range parameters (start, end, as_of) are local calendar dates and are resolved per location, so one call spanning Chicago and Denver stores means “Monday” in each. Schedule weeks start on Monday.

Endpoints

GET /health

No auth. { "ok": true, "version": "1" }

GET /me

Any key. Key name/prefix, scopes, company, readable locations, server time.

{ "data": {
    "key": { "name": "PickleHub", "prefix": "ss_live_a1b2" },
    "scopes": ["locations:read", "staff:read", "staff:pii", "schedule:read", "timeoff:read", "availability:read", "timeclock:read", "pool:read"],
    "company": { "id": "cmp_…", "name": "Pickleman's SLU" },
    "locations": [{ "id": "loc_…", "name": "Saint Louis University", "timezone": "America/Chicago" }],
    "server_time": "2026-09-05T14:02:11.000Z"
  }, "meta": { … } }

GET /locations

Scope locations:read. id, name, timezone, address, state, active.

GET /roles?location_id

Scope staff:read. id, name, color, role_type, location_id, department {id, name} (+ wage_rate with pay:read). role_type is derived from the role name: driver/delivery → driver; shift lead / lead / manager → shift-lead; admin → administrative; hybrid → hybrid; anything else → in-shop.

GET /employees

Scope staff:read. Ordered by created_at; paginated.

ParameterMeaning
location_idOptional. One of the key's locations; omitted = all of them. 403 if the key cannot access it.
statusactive (default) | inactive | all
updated_sinceISO instant; only employees changed since then.
cursorOpaque cursor from the previous page's next_cursor.
limit1–500, default 100.
{ "data": [{
    "id": "usr_…", "first_name": "Jim", "last_name": "Smith", "name": "Jim Smith",
    "email": "jim@example.com", "phone": "+13145550100",          // only with staff:pii
    "status": "active", "schedulable": true, "hire_date": "2025-03-01", "user_type": "employee",
    "locations": [{ "id": "loc_…", "is_primary": true }],
    "roles": [{ "id": "rol_…", "name": "Driver", "role_type": "driver", "location_id": "loc_…", "is_primary": true }],
    "external_ids": { "punch_id": "1042" },
    "created_at": "…", "updated_at": "…"
  }], "next_cursor": null, "meta": { … } }

GET /shifts

Scope schedule:read. Shifts whose local start date is within start..end, sorted by start. Only shifts on published schedules unless include_drafts=true (needs schedule:drafts). Cancelled shifts are included with status: "cancelled" — skip them if you only want what is on the wall. The free-text notes field is only present when the key has schedule:notes.

ParameterMeaning
location_idOptional. One of the key's locations; omitted = all of them. 403 if the key cannot access it.
startLocal date yyyy-MM-dd (inclusive). Default: Monday of this week.
endLocal date yyyy-MM-dd (inclusive). Default: start + 6 days. Max 62 days.
employee_idOnly this employee's shifts.
updated_sinceISO instant; only shifts changed since then.
include_draftstrue to include DRAFT schedules (schedule:drafts).
cursorOpaque cursor from the previous page's next_cursor.
limit1–500, default 100.
GET /api/v1/shifts?location_id=loc_…&start=2026-09-07&end=2026-09-13

{ "data": [{
    "id": "sh_…", "location_id": "loc_…",
    "schedule": { "id": "sc_…", "week_start": "2026-09-07", "status": "published" },
    "employee": { "id": "usr_…", "name": "Jim Smith" },          // null = open shift
    "role": { "id": "rol_…", "name": "Driver", "color": "#3B82F6" }, "role_type": "driver",
    "start": "2026-09-08T22:00:00.000Z", "end": null,
    "is_close": true, "end_label": "CL",
    "timezone": "America/Chicago", "local_date": "2026-09-08", "local_start": "17:00", "local_end": null, "local_end_date": null,
    "hours": 8, "status": "scheduled", "from_pool": false, "updated_at": "…"   // + "notes" with schedule:notes
  }], "next_cursor": null,
  "meta": { "request_id": "req_…", "generated_at": "…", "start": "2026-09-07", "end": "2026-09-13", "include_drafts": false } }

end_label is "CL" (close) for every shift with is_close true; otherwise null. end may still carry the expected close time. hours counts open-ended shifts as 8.

GET /schedules?location_id&start&end

Scope schedule:read. One row per location + week: id, location_id, department_id, week_start, status (draft|published), published_at, shift_count, updated_at. Range up to 371 days.

GET /time-off

Scope timeoff:read. Requests overlapping the range for employees at the location(s). Notes are never included.

ParameterMeaning
location_idOptional. One of the key's locations; omitted = all of them. 403 if the key cannot access it.
startLocal date yyyy-MM-dd (inclusive). Default: Monday of this week.
endLocal date yyyy-MM-dd (inclusive). Default: start + 6 days. Max 186 days.
statusapproved (default) | pending | all
employee_idOnly this employee.
cursorOpaque cursor from the previous page's next_cursor.
limit1–500, default 100.
{ "data": [{
    "id": "to_…", "employee": { "id": "usr_…", "name": "Jim Smith" },
    "all_day": true, "start": null, "end": null, "start_date": "2026-09-10", "end_date": "2026-09-12",
    "category": "paid_vacation", "status": "approved", "updated_at": "…"
  }] }
// partial-day: "all_day": false, "start": "2026-09-10T19:00:00.000Z", "end": "2026-09-10T22:00:00.000Z"

GET /availability

Scope availability:read. Approved entries in effect during the week of as_of (default today): day_of_week (0 = Sunday), type, all_day, windows [{start, end, type}], week_interval, anchor_date, effective_from/to. meta.no_availability_means_available tells you how to treat days with no entry. Paginated by employee then weekday; a page may be shorter than limit while next_cursor is still set (see Pagination).

ParameterMeaning
location_idOptional. One of the key's locations; omitted = all of them. 403 if the key cannot access it.
employee_idOnly this employee.
as_ofLocal date yyyy-MM-dd. Default: today in the first location's timezone.
cursorOpaque cursor from the previous page's next_cursor.
limit1–500, default 200.

GET /timeclock/status?location_id

Scope timeclock:read. Everyone currently clocked in: employee, location_id, clocked_in_at, local_clocked_in_at, on_break, shift {id} | null, hours_so_far.

GET /timeclock/punches

Scope timeclock:read. Raw punches in the local date range: type (clock_in|clock_out|break_start|break_end), at, timezone, local_date, local_time, shift_id, source, flag, edited.

ParameterMeaning
location_idOptional. One of the key's locations; omitted = all of them. 403 if the key cannot access it.
startLocal date yyyy-MM-dd (inclusive). Default: Monday of this week.
endLocal date yyyy-MM-dd (inclusive). Default: start + 6 days. Max 62 days.
employee_idOnly this employee.
cursorOpaque cursor from the previous page's next_cursor.
limit1–500, default 100.

GET /pool/open?location_id

Scope pool:read. Upcoming shifts on published schedules currently offered to the pool: id, status, offered_by {id, name}, shift {…same shape as /shifts…}, created_at. The shift's notes needs schedule:notes.

PickleHub adapter

PickleHub keeps shifts in a schedule_shifts table keyed by location, employee name, role_type, local shift_date / start_time / end_time and an end_label. The mapping from a SquadSync shift is direct:

schedule_shiftsfrom /shifts
location_idlocation_settings.squadsync_location_id ↔ shift.location_id
employee_nameemployee.name (null employee = open shift; skip or store as unassigned)
employee_idmatch /employees email (staff:pii) to your employees.email; fall back to name
role_typerole_type — hybrid → 'in-shop' (or your own rule)
shift_datelocal_date
start_timelocal_start
end_timelocal_end (null when open-ended)
end_labelend_label ('CL' or null)
source'squadsync'
external_idid

Per location PickleHub stores two settings: location_settings.squadsync_api_key (a key created for that franchisee's company, Operations preset) and location_settings.squadsync_location_id. The sync for a week is then:

# 1. verify the key and find the location id
curl -s -H "Authorization: Bearer $SQUADSYNC_API_KEY" https://<host>/api/v1/me

# 2. employees with emails (for employee_id matching)
curl -s -H "Authorization: Bearer $SQUADSYNC_API_KEY" \
  "https://<host>/api/v1/employees?location_id=$LOC&status=active&limit=500"

# 3. the week's published shifts (local dates, Monday..Sunday)
curl -s -H "Authorization: Bearer $SQUADSYNC_API_KEY" \
  "https://<host>/api/v1/shifts?location_id=$LOC&start=2026-09-07&end=2026-09-13&limit=500"

Then delete the location's source = 'squadsync' rows in that date range and insert the fresh set (the same delete-then-insert pattern as the Google Calendar importer). A drop-in Deno edge function implementing exactly this ships in the SquadSync repo at docs/integrations/picklehub-fetch-schedule-squadsync.ts.

Questions or need another field? Contact your SquadSync admin. Privacy