Version v1
SureWork API
Read and update your company's people, leave, time, payroll summaries, placements and documents from your other systems, and get told the moment something changes. The API is available on the Enterprise and Unlimited plans.
Getting started
- In SureWork, open Settings, then API and webhooks, and choose Create API key.
- Name it after the system that will use it, and choose what it may see or do. Copy the key when it is shown: you can't see it again.
- Check it works.
curl https://www.surework.co.za/api/v1/me -H "Authorization: Bearer swk_…"The answer names the key, its access, the company and the person whose access it uses. Base URL: https://www.surework.co.za/api/v1.
Authentication and scopes
Send the key as Authorization: Bearer swk_… on every request. Never put it in a URL: a key in a query string is refused (key_in_url). Keys start with swk_, so secret scanners can recognise a leaked one, and end in a checksum.
A key acts with the access of the person who holds it (by default the person who created it), limited to the areas you tick when you create it. It never sees or does more than that person could in SureWork, and everything it changes is recorded in the audit log with the key's name. If the holder leaves or loses access, the key is paused until someone takes it over.
| Scope | What it allows | Uses these permissions |
|---|---|---|
employees:read | People: See employee records | company: read; employee: read |
employees:write | People: Add employees, and update contact, department, job title and manager | company: read; employee: read, create, update |
organisation:read | Organisation: See departments and job titles | company: read; department: read |
leave:read | Leave: See leave types, balances and requests (includes sick leave, which is health information) | company: read; leave: read; leave_policy: read |
leave:write | Leave: Record leave for people, and approve or decline requests | company: read; leave: read, approve, manage; leave_policy: read |
time:read | Time: See time entries | company: read; time: read |
payroll:read | Payroll: See payslip summaries (gross, deductions and net pay, not the payslip files) | company: read; payroll: read |
placements:read | Consultants: See placements and clients (never rates) | company: read; placement: read; client: read |
documents:read | Documents: See document details and download files | company: read; document: read |
A write scope always includes its read scope. A key can be limited to IP addresses and given an expiry when you create it.
Requests and responses
- JSON in and out, UTF-8,
snake_casefield names. Every object has anobjectand anid. - Dates are
YYYY-MM-DD. Instants are RFC 3339 in UTC, like2026-10-12T06:15:03.412Z. - Money is a string with two decimals and a currency:
{ "amount": "22314.55", "currency": "ZAR" }. - Day and hour quantities are numbers rounded to two decimals. Enum values are lower-case:
pending,cancellation_requested. - Absent values are
null, never left out. Ignore fields you don't know: new ones are added over time. - Send
Content-Type: application/jsonon POST and PATCH. Bodies can be up to 1 MB. Unknown fields in a body are refused. - Every response has an
X-Request-Id. Give it to SureWork support if you need help with a request. - No CORS headers are sent: the API is for servers, not browsers.
Pagination and syncing
Lists return { "object": "list", "data": [...], "has_more": true, "next_cursor": "…" }. Pass next_cursor as cursor to get the next page, and keep the same filters while paging. limit is 1 to 200, default 50.
Lists are ordered by updated_at, then id, so updated_since=<instant> plus cursors is a complete sync: keep the latest updated_at you saw and start from there next time. A row that changes while you are paging appears again at the end, so every row is seen at least once.
Errors
Errors use application/problem+json (RFC 9457): type, title, status, detail (what happened and what to do next), code, request_id and, for problems with a field, errors.
{
"type": "https://www.surework.co.za/developers/api#error-validation_failed",
"title": "Validation failed",
"status": 422,
"detail": "id_number: ID number checksum is invalid",
"code": "validation_failed",
"request_id": "req_4hX9Qm2LkZ7uVbN3TcPa",
"errors": [{ "field": "id_number", "message": "ID number checksum is invalid" }]
}| Code | Status | Meaning |
|---|---|---|
missing_parameter | 400 | A required query parameter was left out. |
unknown_parameter | 400 | The request used a query parameter this endpoint doesn't accept. |
invalid_cursor | 400 | The cursor isn't valid, or it was made with different filters. |
invalid_json | 400 | The request body isn't valid JSON. |
key_in_url | 400 | A key was sent in the query string. |
idempotency_key_required | 400 | Every POST needs an Idempotency-Key header. |
missing_api_key | 401 | The Authorization header is missing. |
invalid_api_key | 401 | The key isn't one SureWork recognises. |
api_key_expired | 401 | The key has passed its expiry time. |
api_key_revoked | 401 | The key was revoked and can't be used again. |
api_key_suspended | 401 | The key is paused, for example because its holder no longer has access. |
insufficient_scope | 403 | The key doesn't have the scope this endpoint needs. |
feature_not_in_plan | 403 | The company's plan doesn't include this module. |
ip_not_allowed | 403 | The key has an allowed-address list and the request came from another address. |
company_inactive | 403 | The company's account isn't active (for example, suspended or expired). |
forbidden | 403 | The key holder isn't allowed to do this. |
not_found | 404 | The record doesn't exist, or this key can't see it. |
method_not_allowed | 405 | The endpoint doesn't support that HTTP method. The Allow header lists the ones it does. |
idempotency_request_in_progress | 409 | A request with the same Idempotency-Key is still running. |
link_expired | 410 | A download link is older than five minutes. |
precondition_failed | 412 | The If-Match value no longer matches the record. |
payload_too_large | 413 | The request body is over 1 MB. |
unsupported_media_type | 415 | POST and PATCH bodies must be application/json. |
validation_failed | 422 | Something in the request isn't valid. The errors list names each field. |
business_rule | 422 | SureWork's rules don't allow this (for example, leave that overlaps other leave). |
field_not_updatable | 422 | The body names a field the API doesn't let you change. |
worker_type_not_supported | 422 | The API only adds staff. Consultants and contractors are added in SureWork. |
self_service_not_available | 422 | API keys record leave for other people, not for the key holder. |
idempotency_key_reused | 422 | The Idempotency-Key was used before with a different request. |
narrow_your_filter | 422 | The filter matches too many records to page in one go. |
rate_limited | 429 | Too many requests. The Retry-After header says how long to wait. |
internal_error | 500 | Something went wrong on SureWork's side. |
maintenance | 503 | SureWork is down for maintenance. |
Idempotency
Every POST needs an Idempotency-Key header (1 to 255 characters; a UUID works well), and PATCH may send one. SureWork keeps the answer for 24 hours: repeating the same request with the same key returns the same status and body with Idempotent-Replayed: true and does nothing twice. The same key with a different request is idempotency_key_reused; the same key while the first request is still running is idempotency_request_in_progress.
Rate limits
Each key may make 300 requests in every 60-second window, reads and writes together. Every response says where you are:
RateLimit-Policy: "default";q=300;w=60
RateLimit: "default";r=212;t=19Over the limit you get 429 with Retry-After. The window is fixed, so a burst either side of a boundary can reach twice the limit. Repeated requests with an invalid key from one address are also answered 429.
ETags
A request for one object returns a weak ETag. Send it back as If-None-Match to get 304 when nothing changed, or as If-Match on a PATCH to be sure nobody changed the record since you read it (otherwise 412 precondition_failed).
Versioning and changes
This is v1. Within v1 only additive changes are made: new endpoints, new optional fields, new enum values and new event types. A breaking change would be /api/v2, with v1 kept for at least 12 months and the change announced here.
Endpoints
27 endpoints, grouped by area. Each one lists the scope a key needs. Anything a key can't see answers 404, exactly like something that doesn't exist.
Getting started
/api/v1/meCheck your key
Scope: none
Returns the key's name, scopes, company and holder. Any valid key can call it, whatever its scopes.
Example response
{
"object": "api_key",
"id": "0b6f3c1e-7d2a-4f55-9d1e-2f7a9c4b8e10",
"name": "ClockWise sync",
"key_prefix": "swk_7Gq2Lm9X…0wnw",
"scopes": [
"employees:read",
"leave:read",
"leave:write"
],
"company": {
"id": "org_8KXv2",
"name": "Mokoena Staffing (Pty) Ltd"
},
"holder": {
"name": "Thandi Mokoena"
},
"expires_at": "2027-09-29T10:00:00.000Z",
"rate_limit": {
"limit": 300,
"window_seconds": 60
}
}People
/api/v1/employeesList employees
Scope: employees:read
The employees this key may see: the whole company for an Owner or HR Manager's key, a manager's reporting line for a manager's key. Ordered by updated_at then id.
| Parameter | In | Type | Notes |
|---|---|---|---|
limit | query | integer | Page size, 1 to 200. Default 50. |
cursor | query | string | The next_cursor of the previous page. Keep the same filters while paging. |
updated_since | query | RFC 3339 instant | Only rows changed after this instant (RFC 3339). With cursors this is a complete sync. |
status | query | pending_start | active | on_leave | suspended | terminated | retired | |
department_id | query | uuid | |
worker_type | query | staff | payroll_consultant | independent_contractor |
Example response
{
"object": "list",
"data": [
{
"object": "employee",
"id": "a3e1b7c2-5d4f-4e8a-9b6c-1f2e3d4c5b6a",
"employee_number": "EMP00012",
"first_name": "Sipho",
"middle_name": null,
"last_name": "Dlamini",
"preferred_name": null,
"display_name": "Sipho Dlamini",
"email": "sipho@example.co.za",
"phone": "+27825550142",
"status": "pending_start",
"worker_type": "staff",
"employment_type": "permanent",
"hire_date": "2026-11-01",
"probation_end_date": null,
"contract_end_date": null,
"termination_date": null,
"department_id": null,
"job_title_id": null,
"manager_id": null,
"work_days": [
1,
2,
3,
4,
5
],
"address": {
"street_address": null,
"suburb": null,
"city": null,
"province": null,
"postal_code": null
},
"created_at": "2026-10-12T06:15:03.412Z",
"updated_at": "2026-10-12T06:15:03.412Z"
}
],
"has_more": false,
"next_cursor": null
}/api/v1/employees/{id}Get an employee
Scope: employees:read
| Parameter | In | Type | Notes |
|---|---|---|---|
id | path | string | Required. |
If-None-Match | header | string | The ETag you already have. Answers 304 if nothing changed. |
Example response
{
"object": "employee",
"id": "a3e1b7c2-5d4f-4e8a-9b6c-1f2e3d4c5b6a",
"employee_number": "EMP00012",
"first_name": "Sipho",
"middle_name": null,
"last_name": "Dlamini",
"preferred_name": null,
"display_name": "Sipho Dlamini",
"email": "sipho@example.co.za",
"phone": "+27825550142",
"status": "pending_start",
"worker_type": "staff",
"employment_type": "permanent",
"hire_date": "2026-11-01",
"probation_end_date": null,
"contract_end_date": null,
"termination_date": null,
"department_id": null,
"job_title_id": null,
"manager_id": null,
"work_days": [
1,
2,
3,
4,
5
],
"address": {
"street_address": null,
"suburb": null,
"city": null,
"province": null,
"postal_code": null
},
"created_at": "2026-10-12T06:15:03.412Z",
"updated_at": "2026-10-12T06:15:03.412Z"
}/api/v1/employeesAdd an employee
Scope: employees:write
Adds a member of staff with the same rules as the Add employee form. Consultants and contractors are added in SureWork. ID or passport numbers, date of birth and gender can be sent but are never returned. Send an Idempotency-Key.
| Parameter | In | Type | Notes |
|---|---|---|---|
Idempotency-Key | header | string | Required. A unique value for this request. A UUID works well. |
Example request body
{
"first_name": "Sipho",
"last_name": "Dlamini",
"email": "sipho@example.co.za",
"phone": "082 555 0142",
"employment_type": "permanent",
"hire_date": "2026-11-01",
"id_number": "9001015800088"
}Example response
{
"object": "employee",
"id": "a3e1b7c2-5d4f-4e8a-9b6c-1f2e3d4c5b6a",
"employee_number": "EMP00012",
"first_name": "Sipho",
"middle_name": null,
"last_name": "Dlamini",
"preferred_name": null,
"display_name": "Sipho Dlamini",
"email": "sipho@example.co.za",
"phone": "+27825550142",
"status": "pending_start",
"worker_type": "staff",
"employment_type": "permanent",
"hire_date": "2026-11-01",
"probation_end_date": null,
"contract_end_date": null,
"termination_date": null,
"department_id": null,
"job_title_id": null,
"manager_id": null,
"work_days": [
1,
2,
3,
4,
5
],
"address": {
"street_address": null,
"suburb": null,
"city": null,
"province": null,
"postal_code": null
},
"created_at": "2026-10-12T06:15:03.412Z",
"updated_at": "2026-10-12T06:15:03.412Z"
}Can also answer: validation_failed, worker_type_not_supported
/api/v1/employees/{id}Update an employee
Scope: employees:write
Changes preferred_name, phone, address, department_id, job_title_id or manager_id. Any other field is refused with field_not_updatable. Send If-Match with the ETag you read to be sure nobody changed the record in between.
| Parameter | In | Type | Notes |
|---|---|---|---|
id | path | string | Required. |
If-Match | header | string | The ETag you read. The change is refused if the record changed since. |
Example request body
{
"phone": "082 555 0199",
"department_id": "6f1e2d3c-4b5a-4978-8a6b-5c4d3e2f1a0b"
}Example response
{
"object": "employee",
"id": "a3e1b7c2-5d4f-4e8a-9b6c-1f2e3d4c5b6a",
"employee_number": "EMP00012",
"first_name": "Sipho",
"middle_name": null,
"last_name": "Dlamini",
"preferred_name": null,
"display_name": "Sipho Dlamini",
"email": "sipho@example.co.za",
"phone": "+27825550142",
"status": "pending_start",
"worker_type": "staff",
"employment_type": "permanent",
"hire_date": "2026-11-01",
"probation_end_date": null,
"contract_end_date": null,
"termination_date": null,
"department_id": null,
"job_title_id": null,
"manager_id": null,
"work_days": [
1,
2,
3,
4,
5
],
"address": {
"street_address": null,
"suburb": null,
"city": null,
"province": null,
"postal_code": null
},
"created_at": "2026-10-12T06:15:03.412Z",
"updated_at": "2026-10-12T06:15:03.412Z"
}Can also answer: field_not_updatable, precondition_failed, validation_failed
Organisation
/api/v1/departmentsList departments
Scope: organisation:read
| Parameter | In | Type | Notes |
|---|---|---|---|
limit | query | integer | Page size, 1 to 200. Default 50. |
cursor | query | string | The next_cursor of the previous page. Keep the same filters while paging. |
updated_since | query | RFC 3339 instant | Only rows changed after this instant (RFC 3339). With cursors this is a complete sync. |
/api/v1/departments/{id}Get a department
Scope: organisation:read
| Parameter | In | Type | Notes |
|---|---|---|---|
id | path | string | Required. |
If-None-Match | header | string | The ETag you already have. Answers 304 if nothing changed. |
/api/v1/job-titlesList job titles
Scope: organisation:read
| Parameter | In | Type | Notes |
|---|---|---|---|
limit | query | integer | Page size, 1 to 200. Default 50. |
cursor | query | string | The next_cursor of the previous page. Keep the same filters while paging. |
updated_since | query | RFC 3339 instant | Only rows changed after this instant (RFC 3339). With cursors this is a complete sync. |
/api/v1/job-titles/{id}Get a job title
Scope: organisation:read
| Parameter | In | Type | Notes |
|---|---|---|---|
id | path | string | Required. |
If-None-Match | header | string | The ETag you already have. Answers 304 if nothing changed. |
Leave
/api/v1/leave-typesList leave types
Scope: leave:read
| Parameter | In | Type | Notes |
|---|---|---|---|
limit | query | integer | Page size, 1 to 200. Default 50. |
cursor | query | string | The next_cursor of the previous page. Keep the same filters while paging. |
updated_since | query | RFC 3339 instant | Only rows changed after this instant (RFC 3339). With cursors this is a complete sync. |
/api/v1/employees/{id}/leave-balancesGet an employee's leave balances
Scope: leave:read
Current-cycle balances for every leave type that applies to the employee. Leave without a balance, like maternity, has a null cycle and zero days.
| Parameter | In | Type | Notes |
|---|---|---|---|
id | path | string | Required. |
as_of | query | string | Balances on this date. Default today. |
/api/v1/leave-requestsList leave requests
Scope: leave:read
Requests for the people this key may see. The reason, comments and certificates are never included.
| Parameter | In | Type | Notes |
|---|---|---|---|
limit | query | integer | Page size, 1 to 200. Default 50. |
cursor | query | string | The next_cursor of the previous page. Keep the same filters while paging. |
updated_since | query | RFC 3339 instant | Only rows changed after this instant (RFC 3339). With cursors this is a complete sync. |
employee_id | query | uuid | |
status | query | pending | approved | rejected | cancelled | cancellation_requested | |
leave_type_id | query | uuid | |
from | query | string | Leave that ends on or after this date. |
to | query | string | Leave that starts on or before this date. |
Example response
{
"object": "list",
"data": [
{
"object": "leave_request",
"id": "5c1d9a70-3b2e-4f61-8a7d-9e0f1a2b3c4d",
"employee_id": "a3e1b7c2-5d4f-4e8a-9b6c-1f2e3d4c5b6a",
"leave_type_id": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
"leave_type_code": "ANNUAL",
"start_date": "2026-10-12",
"end_date": "2026-10-16",
"half_day": false,
"half_day_period": null,
"days": 5,
"status": "approved",
"source": "hr_on_behalf",
"created_at": "2026-10-01T08:00:00.000Z",
"decided_at": "2026-10-01T08:00:00.000Z",
"updated_at": "2026-10-01T08:00:00.000Z"
}
],
"has_more": false,
"next_cursor": null
}/api/v1/leave-requests/{id}Get a leave request
Scope: leave:read
| Parameter | In | Type | Notes |
|---|---|---|---|
id | path | string | Required. |
If-None-Match | header | string | The ETag you already have. Answers 304 if nothing changed. |
Example response
{
"object": "leave_request",
"id": "5c1d9a70-3b2e-4f61-8a7d-9e0f1a2b3c4d",
"employee_id": "a3e1b7c2-5d4f-4e8a-9b6c-1f2e3d4c5b6a",
"leave_type_id": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
"leave_type_code": "ANNUAL",
"start_date": "2026-10-12",
"end_date": "2026-10-16",
"half_day": false,
"half_day_period": null,
"days": 5,
"status": "approved",
"source": "hr_on_behalf",
"created_at": "2026-10-01T08:00:00.000Z",
"decided_at": "2026-10-01T08:00:00.000Z",
"updated_at": "2026-10-01T08:00:00.000Z"
}/api/v1/leave-requestsRecord leave for someone
Scope: leave:write
Records leave for another person, as HR would in SureWork, with every leave rule applied. Leave that needs proof (like a medical certificate) must be recorded in SureWork. Send an Idempotency-Key.
| Parameter | In | Type | Notes |
|---|---|---|---|
Idempotency-Key | header | string | Required. A unique value for this request. A UUID works well. |
Example request body
{
"employee_id": "a3e1b7c2-5d4f-4e8a-9b6c-1f2e3d4c5b6a",
"leave_type_id": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
"start_date": "2026-10-12",
"end_date": "2026-10-16",
"approve_now": true
}Example response
{
"object": "leave_request",
"id": "5c1d9a70-3b2e-4f61-8a7d-9e0f1a2b3c4d",
"employee_id": "a3e1b7c2-5d4f-4e8a-9b6c-1f2e3d4c5b6a",
"leave_type_id": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
"leave_type_code": "ANNUAL",
"start_date": "2026-10-12",
"end_date": "2026-10-16",
"half_day": false,
"half_day_period": null,
"days": 5,
"status": "approved",
"source": "hr_on_behalf",
"created_at": "2026-10-01T08:00:00.000Z",
"decided_at": "2026-10-01T08:00:00.000Z",
"updated_at": "2026-10-01T08:00:00.000Z"
}Can also answer: validation_failed, self_service_not_available
/api/v1/leave-requests/{id}/decisionApprove or decline leave
Scope: leave:write
Approves or declines a pending request, with the same rules as the app: declining needs a denial_reason and a comment of at least 10 characters, and nobody decides their own leave.
| Parameter | In | Type | Notes |
|---|---|---|---|
id | path | string | Required. |
Idempotency-Key | header | string | Required. A unique value for this request. A UUID works well. |
Example request body
{
"decision": "decline",
"denial_reason": "operational_requirements",
"comment": "We need everyone in that week."
}Example response
{
"object": "leave_request",
"id": "5c1d9a70-3b2e-4f61-8a7d-9e0f1a2b3c4d",
"employee_id": "a3e1b7c2-5d4f-4e8a-9b6c-1f2e3d4c5b6a",
"leave_type_id": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
"leave_type_code": "ANNUAL",
"start_date": "2026-10-12",
"end_date": "2026-10-16",
"half_day": false,
"half_day_period": null,
"days": 5,
"status": "rejected",
"source": "hr_on_behalf",
"created_at": "2026-10-01T08:00:00.000Z",
"decided_at": "2026-10-01T08:00:00.000Z",
"updated_at": "2026-10-01T08:00:00.000Z"
}Can also answer: validation_failed, business_rule
Time
/api/v1/time-entriesList time entries
Scope: time:read
Clock-in and clock-out records for the people this key may see. Locations and photos are never included.
| Parameter | In | Type | Notes |
|---|---|---|---|
limit | query | integer | Page size, 1 to 200. Default 50. |
cursor | query | string | The next_cursor of the previous page. Keep the same filters while paging. |
updated_since | query | RFC 3339 instant | Only rows changed after this instant (RFC 3339). With cursors this is a complete sync. |
employee_id | query | uuid | |
status | query | active | completed | pending_approval | approved | rejected | edited | cancelled | |
from | query | string | First work date. |
to | query | string | Last work date. At most 366 days after from. |
Example response
{
"object": "list",
"data": [
{
"object": "time_entry",
"id": "d4e5f6a7-b8c9-4d0e-8f1a-2b3c4d5e6f70",
"employee_id": "a3e1b7c2-5d4f-4e8a-9b6c-1f2e3d4c5b6a",
"work_date": "2026-10-12",
"clock_in": "2026-10-12T05:58:00.000Z",
"clock_out": "2026-10-12T14:03:00.000Z",
"break_minutes": 30,
"worked_hours": 7.58,
"entry_type": "regular",
"clock_method": "web",
"status": "approved",
"is_late": false,
"created_at": "2026-10-12T05:58:00.000Z",
"updated_at": "2026-10-13T07:00:00.000Z"
}
],
"has_more": false,
"next_cursor": null
}Can also answer: narrow_your_filter
/api/v1/time-entries/{id}Get a time entry
Scope: time:read
| Parameter | In | Type | Notes |
|---|---|---|---|
id | path | string | Required. |
If-None-Match | header | string | The ETag you already have. Answers 304 if nothing changed. |
Example response
{
"object": "time_entry",
"id": "d4e5f6a7-b8c9-4d0e-8f1a-2b3c4d5e6f70",
"employee_id": "a3e1b7c2-5d4f-4e8a-9b6c-1f2e3d4c5b6a",
"work_date": "2026-10-12",
"clock_in": "2026-10-12T05:58:00.000Z",
"clock_out": "2026-10-12T14:03:00.000Z",
"break_minutes": 30,
"worked_hours": 7.58,
"entry_type": "regular",
"clock_method": "web",
"status": "approved",
"is_late": false,
"created_at": "2026-10-12T05:58:00.000Z",
"updated_at": "2026-10-13T07:00:00.000Z"
}Payroll
/api/v1/payslipsList payslips
Scope: payroll:read
Payslip summaries for paid and void payslips: gross, deductions and net as money. The earning and deduction lines are never included.
| Parameter | In | Type | Notes |
|---|---|---|---|
limit | query | integer | Page size, 1 to 200. Default 50. |
cursor | query | string | The next_cursor of the previous page. Keep the same filters while paging. |
updated_since | query | RFC 3339 instant | Only rows changed after this instant (RFC 3339). With cursors this is a complete sync. |
employee_id | query | uuid | |
run_id | query | uuid | |
period | query | string | The pay period, YYYY-MM. |
tax_year | query | string |
Example response
{
"object": "list",
"data": [
{
"object": "payslip",
"id": "f6a7b8c9-d0e1-4f2a-8b3c-4d5e6f708192",
"payslip_number": "PS-EMP00012-202609",
"employee_id": "a3e1b7c2-5d4f-4e8a-9b6c-1f2e3d4c5b6a",
"employee_number": "EMP00012",
"run_id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"period": {
"year": 2026,
"month": 9
},
"payment_date": "2026-09-25",
"tax_year": "2027",
"status": "paid",
"gross_earnings": {
"amount": "28500.00",
"currency": "ZAR"
},
"total_deductions": {
"amount": "6185.45",
"currency": "ZAR"
},
"net_pay": {
"amount": "22314.55",
"currency": "ZAR"
},
"created_at": "2026-09-24T09:00:00.000Z",
"updated_at": "2026-09-25T08:00:00.000Z"
}
],
"has_more": false,
"next_cursor": null
}/api/v1/payslips/{id}Get a payslip
Scope: payroll:read
| Parameter | In | Type | Notes |
|---|---|---|---|
id | path | string | Required. |
If-None-Match | header | string | The ETag you already have. Answers 304 if nothing changed. |
Example response
{
"object": "payslip",
"id": "f6a7b8c9-d0e1-4f2a-8b3c-4d5e6f708192",
"payslip_number": "PS-EMP00012-202609",
"employee_id": "a3e1b7c2-5d4f-4e8a-9b6c-1f2e3d4c5b6a",
"employee_number": "EMP00012",
"run_id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"period": {
"year": 2026,
"month": 9
},
"payment_date": "2026-09-25",
"tax_year": "2027",
"status": "paid",
"gross_earnings": {
"amount": "28500.00",
"currency": "ZAR"
},
"total_deductions": {
"amount": "6185.45",
"currency": "ZAR"
},
"net_pay": {
"amount": "22314.55",
"currency": "ZAR"
},
"created_at": "2026-09-24T09:00:00.000Z",
"updated_at": "2026-09-25T08:00:00.000Z"
}Consultants
/api/v1/placementsList placements
Scope: placements:read
Consultants placed with clients. Rates and margins are never included.
| Parameter | In | Type | Notes |
|---|---|---|---|
limit | query | integer | Page size, 1 to 200. Default 50. |
cursor | query | string | The next_cursor of the previous page. Keep the same filters while paging. |
updated_since | query | RFC 3339 instant | Only rows changed after this instant (RFC 3339). With cursors this is a complete sync. |
status | query | planned | active | ended | cancelled | |
client_id | query | uuid | |
employee_id | query | uuid |
Example response
{
"object": "list",
"data": [
{
"object": "placement",
"id": "07182930-a1b2-4c3d-8e4f-5a6b7c8d9e0f",
"reference": "PLC-00012",
"employee_id": "a3e1b7c2-5d4f-4e8a-9b6c-1f2e3d4c5b6a",
"client_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"role_title": "Senior accountant",
"work_location": "client_site",
"status": "active",
"start_date": "2026-06-01",
"end_date": "2026-12-31",
"time_capture": "consultant",
"created_at": "2026-05-20T09:00:00.000Z",
"updated_at": "2026-06-01T06:00:00.000Z"
}
],
"has_more": false,
"next_cursor": null
}/api/v1/placements/{id}Get a placement
Scope: placements:read
| Parameter | In | Type | Notes |
|---|---|---|---|
id | path | string | Required. |
If-None-Match | header | string | The ETag you already have. Answers 304 if nothing changed. |
Example response
{
"object": "placement",
"id": "07182930-a1b2-4c3d-8e4f-5a6b7c8d9e0f",
"reference": "PLC-00012",
"employee_id": "a3e1b7c2-5d4f-4e8a-9b6c-1f2e3d4c5b6a",
"client_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"role_title": "Senior accountant",
"work_location": "client_site",
"status": "active",
"start_date": "2026-06-01",
"end_date": "2026-12-31",
"time_capture": "consultant",
"created_at": "2026-05-20T09:00:00.000Z",
"updated_at": "2026-06-01T06:00:00.000Z"
}/api/v1/clientsList clients
Scope: placements:read
The companies consultants are placed with. Billing and bank details are never included.
| Parameter | In | Type | Notes |
|---|---|---|---|
limit | query | integer | Page size, 1 to 200. Default 50. |
cursor | query | string | The next_cursor of the previous page. Keep the same filters while paging. |
updated_since | query | RFC 3339 instant | Only rows changed after this instant (RFC 3339). With cursors this is a complete sync. |
status | query | active | inactive |
/api/v1/clients/{id}Get a client
Scope: placements:read
| Parameter | In | Type | Notes |
|---|---|---|---|
id | path | string | Required. |
If-None-Match | header | string | The ETag you already have. Answers 304 if nothing changed. |
Documents
/api/v1/documentsList documents
Scope: documents:read
Document details for the documents this key may open. Medical certificates, disciplinary records, warning letters, ID, passport and bank confirmation documents, payslips, tax certificates and other payroll documents, and anything marked confidential are never included. Each document has a five-minute download link.
| Parameter | In | Type | Notes |
|---|---|---|---|
limit | query | integer | Page size, 1 to 200. Default 50. |
cursor | query | string | The next_cursor of the previous page. Keep the same filters while paging. |
updated_since | query | RFC 3339 instant | Only rows changed after this instant (RFC 3339). With cursors this is a complete sync. |
owner_type | query | employee | candidate | company | department | job | payroll_run | leave_request | customer | supplier | client | other | |
employee_id | query | uuid | |
category | query | employment_contract | offer_letter | termination_letter | resignation_letter | work_permit | proof_of_address | qualification | certification | training_certificate | license | cv | leave_form | performance_review | skills_assessment | policy_document | procedure | template | company_registration | compliance | invoice | receipt | quotation | statement | correspondence | recruitment | onboarding | other | contractor_agreement | placement_particulars | irp30_certificate | psp_affidavit | tax_compliance_status | bbbee_evidence | client_agreement | client_timesheet | agency_registration | performance_improvement |
Example response
{
"object": "list",
"data": [
{
"object": "document",
"id": "e5f6a7b8-c9d0-4e1f-9a2b-3c4d5e6f7a80",
"reference": "DOC-2026-000123",
"name": "Employment contract - Sipho Dlamini",
"category": "employment_contract",
"owner_type": "employee",
"owner_id": "a3e1b7c2-5d4f-4e8a-9b6c-1f2e3d4c5b6a",
"employee_id": "a3e1b7c2-5d4f-4e8a-9b6c-1f2e3d4c5b6a",
"status": "active",
"valid_from": null,
"valid_until": null,
"current_version": 1,
"file_name": "contract.pdf",
"content_type": "application/pdf",
"size_bytes": 84213,
"file": {
"download_url": "https://app.surework.co.za/api/v1/files/eyJkIjoi…",
"expires_at": "2026-10-12T06:20:03.412Z"
},
"created_at": "2026-10-12T06:15:03.412Z",
"updated_at": "2026-10-12T06:15:03.412Z"
}
],
"has_more": false,
"next_cursor": null
}/api/v1/documents/{id}Get a document
Scope: documents:read
| Parameter | In | Type | Notes |
|---|---|---|---|
id | path | string | Required. |
If-None-Match | header | string | The ETag you already have. Answers 304 if nothing changed. |
Example response
{
"object": "document",
"id": "e5f6a7b8-c9d0-4e1f-9a2b-3c4d5e6f7a80",
"reference": "DOC-2026-000123",
"name": "Employment contract - Sipho Dlamini",
"category": "employment_contract",
"owner_type": "employee",
"owner_id": "a3e1b7c2-5d4f-4e8a-9b6c-1f2e3d4c5b6a",
"employee_id": "a3e1b7c2-5d4f-4e8a-9b6c-1f2e3d4c5b6a",
"status": "active",
"valid_from": null,
"valid_until": null,
"current_version": 1,
"file_name": "contract.pdf",
"content_type": "application/pdf",
"size_bytes": 84213,
"file": {
"download_url": "https://app.surework.co.za/api/v1/files/eyJkIjoi…",
"expires_at": "2026-10-12T06:20:03.412Z"
},
"created_at": "2026-10-12T06:15:03.412Z",
"updated_at": "2026-10-12T06:15:03.412Z"
}/api/v1/files/{token}Download a file
Scope: none
The download_url of a document. It works for five minutes and needs no Authorization header, so you can give it to a browser. The key must still be working and allowed to read the document. The answer is the file itself.
| Parameter | In | Type | Notes |
|---|---|---|---|
token | path | string | Required. |
Can also answer: link_expired, forbidden, not_found
Webhooks
/api/v1/eventsList recent events
Scope: none
Events from the last 30 days, oldest first, limited to the types this key may read right now: the plan must include the module, and the person holding the key must still be allowed to see it. Events are recorded while the company's plan includes the API and at least one API key or webhook endpoint is active, from that moment on (nothing is recorded retroactively), and kept for 30 days. Use it to catch up after an outage; webhooks tell you about new events as they happen. An event appears here about a minute after the change.
| Parameter | In | Type | Notes |
|---|---|---|---|
limit | query | integer | |
cursor | query | string | |
type | query | string | Only events of this type, for example leave_request.approved. |
created_after | query | RFC 3339 instant | Only events recorded after this instant. |
What the API never returns
ID and passport numbers, tax numbers, bank details, salary and pay rates, date of birth, gender, race, disability and occupational level, leave reasons and certificates, GPS locations and photos, placement rates and margins, and payslip lines and files. Medical certificates, disciplinary records, warning letters, ID, passport and bank confirmation documents, payslips, tax certificates and other payroll documents, and anything marked confidential are not listed, and a direct request for them is a 404.
Webhooks
A webhook tells your system when something changes. Add an endpoint under Settings, API and webhooks: give it an https:// address, choose all events or some, and copy its signing secret. The secret is shown when you add the endpoint and when you rotate it, and never again. SureWork sends a POST to the address for each event, usually within a minute of the change. Webhooks carry only IDs and a link, never personal details: fetch the object with your key.
An endpoint is sent what the person who set it up could see in SureWork: a department manager's endpoint hears about their own team, an endpoint set up by someone without payroll access never hears about pay runs, and if that person leaves or loses the right to manage the API, nothing is sent until someone who can takes the endpoint over. Events are sent after the change is saved. In rare cases a change that is rolled back right after it is saved can still send an event, so fetch the object for its current state. If the person who set up an endpoint changes its address, or subscribes it to more events, they become the person it sends as.
The envelope
{
"id": "0b6f3c1e-7d2a-4f55-9d1e-2f7a9c4b8e10",
"type": "leave_request.approved",
"timestamp": "2026-10-12T06:15:03.412Z",
"api_version": "v1",
"company_id": "org_8KXv2…",
"data": {
"object": "leave_request",
"id": "5c1d9a70-…",
"employee_id": "a3e1…",
"url": "https://www.surework.co.za/api/v1/leave-requests/5c1d9a70-…"
}
}employee.updated also has changed_fields: the API field names that changed, never their values. payslips.published has payroll_run as its object, with the period and a link to the payslips.
Verifying the signature
Deliveries follow the open Standard Webhooks specification, so its libraries work. Each request has webhook-id (the event id, the same on retries), webhook-timestamp (unix seconds) and webhook-signature: v1, and the base64 HMAC-SHA256 of {id}.{timestamp}.{body} made with the secret's bytes (the base64 after whsec_). While a secret is being rotated two signatures are sent, separated by a space.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
// secret: the whsec_… value from the endpoint's page in SureWork.
// rawBody: the request body exactly as received (before any JSON parsing).
export function verify(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) throw new Error("Too old");
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64");
const want = Buffer.from(expected);
const ok = headers["webhook-signature"].split(" ").some((part) => {
const [version, signature] = part.split(",");
const given = Buffer.from(signature ?? "");
// Compare byte lengths: timingSafeEqual throws when they differ.
return version === "v1" && given.length === want.length && timingSafeEqual(given, want);
});
if (!ok) throw new Error("Bad signature");
}Delivery, retries and order
Answer with any 2xx status within 15 seconds. Anything else is retried after 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 24 hours after the first attempt, seven tries in all. An endpoint that answers 410 Gone, or that has failed for 5 days, is turned off, and the people who manage your keys are told. Events can arrive more than once and out of order: use webhook-id to de-duplicate and fetch the object for its current state. Redirects are not followed, and SureWork never sends to private or internal addresses. You can see every delivery in SureWork, send a test event, and resend a delivery (or every delivery that failed in the last three days) for up to 15 days.
Event catalogue
37 event types. GET /events lists the last 30 days of events your key may read right now, about a minute after each change, so you can catch up after an outage. Events are recorded while your company's plan includes the API and at least one API key or webhook endpoint is active, from that moment on: nothing is recorded retroactively, and events are kept for 30 days.
| Event | Scope to receive it | When |
|---|---|---|
employee.created | employees:read | An employee was added. |
employee.updated | employees:read | An employee's record changed. |
employee.started | employees:read | An employee's start date arrived. |
employee.termination_scheduled | employees:read | An end of employment was scheduled. |
employee.termination_cancelled | employees:read | A scheduled end of employment was cancelled. |
employee.terminated | employees:read | Employment ended. |
employee.reactivated | employees:read | A former employee was brought back. |
department.created | organisation:read | A department was added. |
department.updated | organisation:read | A department changed. |
department.deleted | organisation:read | A department was deleted. |
job_title.created | organisation:read | A job title was added. |
job_title.updated | organisation:read | A job title changed. |
job_title.deleted | organisation:read | A job title was deleted. |
leave_request.created | leave:read | Leave was requested or recorded. |
leave_request.approved | leave:read | Leave was approved. |
leave_request.rejected | leave:read | Leave was declined. |
leave_request.cancellation_requested | leave:read | Someone asked to cancel approved leave. |
leave_request.cancelled | leave:read | Leave was cancelled. |
time_entry.created | time:read | Someone clocked in, or an entry was added. |
time_entry.updated | time:read | A time entry changed. |
time_entry.approved | time:read | A time entry was approved. |
time_entry.rejected | time:read | A time entry was rejected. |
time_entry.deleted | time:read | A time entry was deleted. |
payslips.published | payroll:read | A pay run was paid and its payslips are ready. |
payslips.voided | payroll:read | A pay run was reversed and its payslips are void. |
placement.created | placements:read | A placement was created. |
placement.started | placements:read | A placement started. |
placement.extended | placements:read | A placement was extended. |
placement.ended | placements:read | A placement ended. |
placement.cancelled | placements:read | A placement was cancelled. |
client.created | placements:read | A client was added. |
client.updated | placements:read | A client changed. |
document.created | documents:read | A document was added. |
document.updated | documents:read | A document changed. |
document.signed | documents:read | Everyone has signed a document. |
document.deleted | documents:read | A document was deleted. |
test.ping | none | Sent when you use Send test event, to check the connection. |
Your responsibilities under POPIA
SureWork processes your company's personal information on your behalf. A key is your written instruction to disclose some of it to the system you give the key to, and when you create one you confirm your company has an agreement with the people who run that system, and that they may only use the information for your purposes (POPIA sections 20 and 21). Treat keys like passwords: store them in a vault, give each system its own, set an expiry, limit addresses where you can, and revoke a key the moment you doubt it. If personal information may have been seen by someone who shouldn't have it, record it in the breach register.
Download OpenAPI
The whole API as an OpenAPI 3.1 document, for code generators and API clients. No key is needed to read it.