SquadSync Integration API
v1 · read-onlyA 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.
| Parameter | Meaning |
|---|---|
| locations:read | Locations: name, address, timezone |
| staff:read | Employees, roles and assignments (no contact details) |
| staff:pii | Adds email and phone to employees |
| schedule:read | Published shifts and schedule weeks |
| schedule:drafts | Allows include_drafts=true on /shifts |
| schedule:notes | Adds free-text shift notes to /shifts and /pool/open (omitted otherwise). Not in the recommended preset. |
| timeoff:read | Time-off requests (never the notes) |
| availability:read | Approved availability in effect |
| timeclock:read | Clock-in status and raw punches |
| pool:read | Open shift-pool offers |
| pay:read | Wage 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": { … } } }| Status | code | When |
|---|---|---|
| 400 | invalid_request | Bad query parameter; details lists the field(s). |
| 401 | unauthorized | Missing or malformed key, or "Invalid or inactive API key" (unknown, revoked and expired keys all get that one message). |
| 403 | forbidden | location_id the key cannot access. |
| 403 | insufficient_scope | Endpoint or option needs a scope the key lacks. |
| 429 | rate_limited | Too many requests; wait Retry-After seconds. |
| 500 | internal_error | Unexpected 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.
| Parameter | Meaning |
|---|---|
| location_id | Optional. One of the key's locations; omitted = all of them. 403 if the key cannot access it. |
| status | active (default) | inactive | all |
| updated_since | ISO instant; only employees changed since then. |
| cursor | Opaque cursor from the previous page's next_cursor. |
| limit | 1–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.
| Parameter | Meaning |
|---|---|
| location_id | Optional. One of the key's locations; omitted = all of them. 403 if the key cannot access it. |
| start | Local date yyyy-MM-dd (inclusive). Default: Monday of this week. |
| end | Local date yyyy-MM-dd (inclusive). Default: start + 6 days. Max 62 days. |
| employee_id | Only this employee's shifts. |
| updated_since | ISO instant; only shifts changed since then. |
| include_drafts | true to include DRAFT schedules (schedule:drafts). |
| cursor | Opaque cursor from the previous page's next_cursor. |
| limit | 1–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.
| Parameter | Meaning |
|---|---|
| location_id | Optional. One of the key's locations; omitted = all of them. 403 if the key cannot access it. |
| start | Local date yyyy-MM-dd (inclusive). Default: Monday of this week. |
| end | Local date yyyy-MM-dd (inclusive). Default: start + 6 days. Max 186 days. |
| status | approved (default) | pending | all |
| employee_id | Only this employee. |
| cursor | Opaque cursor from the previous page's next_cursor. |
| limit | 1–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).
| Parameter | Meaning |
|---|---|
| location_id | Optional. One of the key's locations; omitted = all of them. 403 if the key cannot access it. |
| employee_id | Only this employee. |
| as_of | Local date yyyy-MM-dd. Default: today in the first location's timezone. |
| cursor | Opaque cursor from the previous page's next_cursor. |
| limit | 1–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.
| Parameter | Meaning |
|---|---|
| location_id | Optional. One of the key's locations; omitted = all of them. 403 if the key cannot access it. |
| start | Local date yyyy-MM-dd (inclusive). Default: Monday of this week. |
| end | Local date yyyy-MM-dd (inclusive). Default: start + 6 days. Max 62 days. |
| employee_id | Only this employee. |
| cursor | Opaque cursor from the previous page's next_cursor. |
| limit | 1–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_shifts | from /shifts |
|---|---|
| location_id | location_settings.squadsync_location_id ↔ shift.location_id |
| employee_name | employee.name (null employee = open shift; skip or store as unassigned) |
| employee_id | match /employees email (staff:pii) to your employees.email; fall back to name |
| role_type | role_type — hybrid → 'in-shop' (or your own rule) |
| shift_date | local_date |
| start_time | local_start |
| end_time | local_end (null when open-ended) |
| end_label | end_label ('CL' or null) |
| source | 'squadsync' |
| external_id | id |
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