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

  1. In SureWork, open Settings, then API and webhooks, and choose Create API key.
  2. 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.
  3. 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.

ScopeWhat it allowsUses these permissions
employees:readPeople: See employee recordscompany: read; employee: read
employees:writePeople: Add employees, and update contact, department, job title and managercompany: read; employee: read, create, update
organisation:readOrganisation: See departments and job titlescompany: read; department: read
leave:readLeave: See leave types, balances and requests (includes sick leave, which is health information)company: read; leave: read; leave_policy: read
leave:writeLeave: Record leave for people, and approve or decline requestscompany: read; leave: read, approve, manage; leave_policy: read
time:readTime: See time entriescompany: read; time: read
payroll:readPayroll: See payslip summaries (gross, deductions and net pay, not the payslip files)company: read; payroll: read
placements:readConsultants: See placements and clients (never rates)company: read; placement: read; client: read
documents:readDocuments: See document details and download filescompany: 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_case field names. Every object has an object and an id.
  • Dates are YYYY-MM-DD. Instants are RFC 3339 in UTC, like 2026-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/json on 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" }]
}
CodeStatusMeaning
missing_parameter400A required query parameter was left out.
unknown_parameter400The request used a query parameter this endpoint doesn't accept.
invalid_cursor400The cursor isn't valid, or it was made with different filters.
invalid_json400The request body isn't valid JSON.
key_in_url400A key was sent in the query string.
idempotency_key_required400Every POST needs an Idempotency-Key header.
missing_api_key401The Authorization header is missing.
invalid_api_key401The key isn't one SureWork recognises.
api_key_expired401The key has passed its expiry time.
api_key_revoked401The key was revoked and can't be used again.
api_key_suspended401The key is paused, for example because its holder no longer has access.
insufficient_scope403The key doesn't have the scope this endpoint needs.
feature_not_in_plan403The company's plan doesn't include this module.
ip_not_allowed403The key has an allowed-address list and the request came from another address.
company_inactive403The company's account isn't active (for example, suspended or expired).
forbidden403The key holder isn't allowed to do this.
not_found404The record doesn't exist, or this key can't see it.
method_not_allowed405The endpoint doesn't support that HTTP method. The Allow header lists the ones it does.
idempotency_request_in_progress409A request with the same Idempotency-Key is still running.
precondition_failed412The If-Match value no longer matches the record.
payload_too_large413The request body is over 1 MB.
unsupported_media_type415POST and PATCH bodies must be application/json.
validation_failed422Something in the request isn't valid. The errors list names each field.
business_rule422SureWork's rules don't allow this (for example, leave that overlaps other leave).
field_not_updatable422The body names a field the API doesn't let you change.
worker_type_not_supported422The API only adds staff. Consultants and contractors are added in SureWork.
self_service_not_available422API keys record leave for other people, not for the key holder.
idempotency_key_reused422The Idempotency-Key was used before with a different request.
narrow_your_filter422The filter matches too many records to page in one go.
rate_limited429Too many requests. The Retry-After header says how long to wait.
internal_error500Something went wrong on SureWork's side.
maintenance503SureWork 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=19

Over 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

GET/api/v1/me

Check 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

GET/api/v1/employees

List 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.

ParameterInTypeNotes
limitqueryintegerPage size, 1 to 200. Default 50.
cursorquerystringThe next_cursor of the previous page. Keep the same filters while paging.
updated_sincequeryRFC 3339 instantOnly rows changed after this instant (RFC 3339). With cursors this is a complete sync.
statusquerypending_start | active | on_leave | suspended | terminated | retired
department_idqueryuuid
worker_typequerystaff | 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
}
GET/api/v1/employees/{id}

Get an employee

Scope: employees:read

ParameterInTypeNotes
idpathstringRequired.
If-None-MatchheaderstringThe 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"
}
POST/api/v1/employees

Add 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.

ParameterInTypeNotes
Idempotency-KeyheaderstringRequired. 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

PATCH/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.

ParameterInTypeNotes
idpathstringRequired.
If-MatchheaderstringThe 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

GET/api/v1/departments

List departments

Scope: organisation:read

ParameterInTypeNotes
limitqueryintegerPage size, 1 to 200. Default 50.
cursorquerystringThe next_cursor of the previous page. Keep the same filters while paging.
updated_sincequeryRFC 3339 instantOnly rows changed after this instant (RFC 3339). With cursors this is a complete sync.
GET/api/v1/departments/{id}

Get a department

Scope: organisation:read

ParameterInTypeNotes
idpathstringRequired.
If-None-MatchheaderstringThe ETag you already have. Answers 304 if nothing changed.
GET/api/v1/job-titles

List job titles

Scope: organisation:read

ParameterInTypeNotes
limitqueryintegerPage size, 1 to 200. Default 50.
cursorquerystringThe next_cursor of the previous page. Keep the same filters while paging.
updated_sincequeryRFC 3339 instantOnly rows changed after this instant (RFC 3339). With cursors this is a complete sync.
GET/api/v1/job-titles/{id}

Get a job title

Scope: organisation:read

ParameterInTypeNotes
idpathstringRequired.
If-None-MatchheaderstringThe ETag you already have. Answers 304 if nothing changed.

Leave

GET/api/v1/leave-types

List leave types

Scope: leave:read

ParameterInTypeNotes
limitqueryintegerPage size, 1 to 200. Default 50.
cursorquerystringThe next_cursor of the previous page. Keep the same filters while paging.
updated_sincequeryRFC 3339 instantOnly rows changed after this instant (RFC 3339). With cursors this is a complete sync.
GET/api/v1/employees/{id}/leave-balances

Get 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.

ParameterInTypeNotes
idpathstringRequired.
as_ofquerystringBalances on this date. Default today.
GET/api/v1/leave-requests

List leave requests

Scope: leave:read

Requests for the people this key may see. The reason, comments and certificates are never included.

ParameterInTypeNotes
limitqueryintegerPage size, 1 to 200. Default 50.
cursorquerystringThe next_cursor of the previous page. Keep the same filters while paging.
updated_sincequeryRFC 3339 instantOnly rows changed after this instant (RFC 3339). With cursors this is a complete sync.
employee_idqueryuuid
statusquerypending | approved | rejected | cancelled | cancellation_requested
leave_type_idqueryuuid
fromquerystringLeave that ends on or after this date.
toquerystringLeave 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
}
GET/api/v1/leave-requests/{id}

Get a leave request

Scope: leave:read

ParameterInTypeNotes
idpathstringRequired.
If-None-MatchheaderstringThe 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"
}
POST/api/v1/leave-requests

Record 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.

ParameterInTypeNotes
Idempotency-KeyheaderstringRequired. 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

POST/api/v1/leave-requests/{id}/decision

Approve 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.

ParameterInTypeNotes
idpathstringRequired.
Idempotency-KeyheaderstringRequired. 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

GET/api/v1/time-entries

List time entries

Scope: time:read

Clock-in and clock-out records for the people this key may see. Locations and photos are never included.

ParameterInTypeNotes
limitqueryintegerPage size, 1 to 200. Default 50.
cursorquerystringThe next_cursor of the previous page. Keep the same filters while paging.
updated_sincequeryRFC 3339 instantOnly rows changed after this instant (RFC 3339). With cursors this is a complete sync.
employee_idqueryuuid
statusqueryactive | completed | pending_approval | approved | rejected | edited | cancelled
fromquerystringFirst work date.
toquerystringLast 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

GET/api/v1/time-entries/{id}

Get a time entry

Scope: time:read

ParameterInTypeNotes
idpathstringRequired.
If-None-MatchheaderstringThe 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

GET/api/v1/payslips

List 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.

ParameterInTypeNotes
limitqueryintegerPage size, 1 to 200. Default 50.
cursorquerystringThe next_cursor of the previous page. Keep the same filters while paging.
updated_sincequeryRFC 3339 instantOnly rows changed after this instant (RFC 3339). With cursors this is a complete sync.
employee_idqueryuuid
run_idqueryuuid
periodquerystringThe pay period, YYYY-MM.
tax_yearquerystring

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
}
GET/api/v1/payslips/{id}

Get a payslip

Scope: payroll:read

ParameterInTypeNotes
idpathstringRequired.
If-None-MatchheaderstringThe 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

GET/api/v1/placements

List placements

Scope: placements:read

Consultants placed with clients. Rates and margins are never included.

ParameterInTypeNotes
limitqueryintegerPage size, 1 to 200. Default 50.
cursorquerystringThe next_cursor of the previous page. Keep the same filters while paging.
updated_sincequeryRFC 3339 instantOnly rows changed after this instant (RFC 3339). With cursors this is a complete sync.
statusqueryplanned | active | ended | cancelled
client_idqueryuuid
employee_idqueryuuid

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
}
GET/api/v1/placements/{id}

Get a placement

Scope: placements:read

ParameterInTypeNotes
idpathstringRequired.
If-None-MatchheaderstringThe 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"
}
GET/api/v1/clients

List clients

Scope: placements:read

The companies consultants are placed with. Billing and bank details are never included.

ParameterInTypeNotes
limitqueryintegerPage size, 1 to 200. Default 50.
cursorquerystringThe next_cursor of the previous page. Keep the same filters while paging.
updated_sincequeryRFC 3339 instantOnly rows changed after this instant (RFC 3339). With cursors this is a complete sync.
statusqueryactive | inactive
GET/api/v1/clients/{id}

Get a client

Scope: placements:read

ParameterInTypeNotes
idpathstringRequired.
If-None-MatchheaderstringThe ETag you already have. Answers 304 if nothing changed.

Documents

GET/api/v1/documents

List 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.

ParameterInTypeNotes
limitqueryintegerPage size, 1 to 200. Default 50.
cursorquerystringThe next_cursor of the previous page. Keep the same filters while paging.
updated_sincequeryRFC 3339 instantOnly rows changed after this instant (RFC 3339). With cursors this is a complete sync.
owner_typequeryemployee | candidate | company | department | job | payroll_run | leave_request | customer | supplier | client | other
employee_idqueryuuid
categoryqueryemployment_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
}
GET/api/v1/documents/{id}

Get a document

Scope: documents:read

ParameterInTypeNotes
idpathstringRequired.
If-None-MatchheaderstringThe 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"
}
GET/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.

ParameterInTypeNotes
tokenpathstringRequired.

Can also answer: link_expired, forbidden, not_found

Webhooks

GET/api/v1/events

List 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.

ParameterInTypeNotes
limitqueryinteger
cursorquerystring
typequerystringOnly events of this type, for example leave_request.approved.
created_afterqueryRFC 3339 instantOnly 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.

EventScope to receive itWhen
employee.createdemployees:readAn employee was added.
employee.updatedemployees:readAn employee's record changed.
employee.startedemployees:readAn employee's start date arrived.
employee.termination_scheduledemployees:readAn end of employment was scheduled.
employee.termination_cancelledemployees:readA scheduled end of employment was cancelled.
employee.terminatedemployees:readEmployment ended.
employee.reactivatedemployees:readA former employee was brought back.
department.createdorganisation:readA department was added.
department.updatedorganisation:readA department changed.
department.deletedorganisation:readA department was deleted.
job_title.createdorganisation:readA job title was added.
job_title.updatedorganisation:readA job title changed.
job_title.deletedorganisation:readA job title was deleted.
leave_request.createdleave:readLeave was requested or recorded.
leave_request.approvedleave:readLeave was approved.
leave_request.rejectedleave:readLeave was declined.
leave_request.cancellation_requestedleave:readSomeone asked to cancel approved leave.
leave_request.cancelledleave:readLeave was cancelled.
time_entry.createdtime:readSomeone clocked in, or an entry was added.
time_entry.updatedtime:readA time entry changed.
time_entry.approvedtime:readA time entry was approved.
time_entry.rejectedtime:readA time entry was rejected.
time_entry.deletedtime:readA time entry was deleted.
payslips.publishedpayroll:readA pay run was paid and its payslips are ready.
payslips.voidedpayroll:readA pay run was reversed and its payslips are void.
placement.createdplacements:readA placement was created.
placement.startedplacements:readA placement started.
placement.extendedplacements:readA placement was extended.
placement.endedplacements:readA placement ended.
placement.cancelledplacements:readA placement was cancelled.
client.createdplacements:readA client was added.
client.updatedplacements:readA client changed.
document.createddocuments:readA document was added.
document.updateddocuments:readA document changed.
document.signeddocuments:readEveryone has signed a document.
document.deleteddocuments:readA document was deleted.
test.pingnoneSent 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.

https://www.surework.co.za/api/v1/openapi.json