Expand to South Africa · API documentation

ExpandTo docs
API operational expandtosouthafrica.com Get sandbox keys

Quickstart

Create a sandbox employment, simulate activation and receive your first webhook, in 15 minutes and copy-paste complete. Sandbox keys (sk_test_) are self-serve; payroll runs are simulated.

Sandbox first. Everything documented here works identically with sk_test_ keys. Live keys require a signed agreement. That is a feature, not friction.

1. Create an employment

POST the candidate, position, compensation and start date. You get a draft employment back.

2. Activate

Activation issues the contract to the candidate. The sandbox signs it for you after about 30 seconds.

3. Listen

Register a webhook endpoint and watch employment.activated arrive. Retries: exponential backoff, 72 hours.

1 · Createbash
curl https://api.expandtosouthafrica.com/v1/employments \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: 5f1c2c1e" \
  -d candidate[email]="[email protected]" \
  -d position[title]="Senior Accountant" \
  -d compensation[gross_monthly]=4800000 \
  -d start_date=2026-09-01
2 · Activatebash
curl -X POST https://api.expandtosouthafrica.com\
  /v1/employments/emp_8fK2mQ/activate \
  -H "Authorization: Bearer sk_test_..."

# sandbox simulates contract signature
# after ~30 seconds
3 · Webhookjson
{
  "type": "employment.activated",
  "created": "2026-07-19T09:14:02Z",
  "data": {
    "id": "emp_8fK2mQ",
    "status": "active",
    "start_date": "2026-09-01"
  }
}

Authentication

Bearer keys over HTTPS, nothing exotic. Two prefixes: sk_test_ for the sandbox, self-serve from the dashboard, and sk_live_ for production, issued once a service agreement is signed.

Keys are shown once at creation and can be rotated with a 24-hour overlap window. Scope keys per environment; never ship a live key to a browser or mobile client.

Payroll data is sensitive. Live keys unlock identity and payroll data. Treat them like production database credentials: vault storage, rotation on staff changes, least privilege.
Requestbash
curl https://api.expandtosouthafrica.com/v1/employments \
  -H "Authorization: Bearer sk_test_51NxT..."

# sk_test_  sandbox, self-serve
# sk_live_  production, issued after
#           a signed agreement
401json
{
  "error": {
    "type": "authentication_error",
    "code": "invalid_api_key",
    "message": "Unknown API key. Keys are shown once at creation; rotate from the dashboard.",
    "doc_url": "https://docs.expandtosouthafrica.com/auth"
  }
}

Conventions

Money is always integer minor units with an ISO 4217 currency (e.g. gross_monthly=4800000 for R48 000). Dates are ISO 8601. Identifiers are prefixed (emp_, psl_, tr_) and stable across environments.

Versioning

Major version in the URL path (/v1/). Additive changes ship without a version bump. Every response carries an X-API-Version header. Deprecations get at least 12 months notice.

Pagination

Cursor-based. List endpoints return has_more, next_cursor and previous_cursor. Default limit 25, max 100.

Versioningbash
# Major version in the URL
https://api.expandtosouthafrica.com/v1/

# Additive changes are non-breaking
# X-API-Version response header on every call
# Deprecations: minimum 12 months notice

Errors

Errors name the problem and the fix, without apology. Every error carries a stable code, the offending param where relevant, and a doc_url deep-linking this reference.

Types

authentication_error
401
Missing, unknown or revoked key.
validation_error
422
A parameter fails validation; param names the field.
cutoff_error
422
The change misses this month's payroll cutoff; the message states the run it will apply to.
compliance_hold
409
The action requires specialist review (terminations always do). The case id is returned.
rate_limit_error
429
120 req/min live, 600 sandbox. Retry-After header included.
Error envelopejson
{
  "error": {
    "type": "validation_error",
    "code": "cutoff_passed",
    "message": "Payroll cutoff for 2026-08 was 2026-08-10. This change applies to the 2026-09 run.",
    "param": "effective_month",
    "doc_url": "https://docs.expandtosouthafrica.com/errors#cutoff_passed"
  }
}

Onboarding end-to-end

Every candidate goes through the same states: draft → awaiting_documents → contract_sent → active. This guide covers each transition, what triggers it, what to store on your side, and where verification and legal handoffs sit.

Kickoff

Create the employment. Store the returned id; that is your permanent handle.

Documents

Our onboarding specialist verifies national ID, banking and (where relevant) qualifications. This is not automated, SA labour law expects a person in the loop.

Contract issued

Activation triggers the BCEA-compliant contract for e-signature. In sandbox this is auto-signed after ~30 seconds.

Active

Employee is on payroll from start_date. First payslip is issued at the next SARS cutoff.

Compliant offboarding

Terminations always enter mandatory specialist review, a hard rule, not a soft default. The API tracks the case to completion; you don't get to skip it, and neither do we.

Why

Under LRA s188 fairness requirements, an at-will dismissal isn't a thing in South Africa. The specialist confirms grounds, notice period, and severance where applicable.

What you send

Ground (misconduct · incapacity · operational_requirements · resignation), effective date, and any supporting notes.

What you get back

A termination_case object with a status you can poll or receive via webhook.

Payroll calendar

SA payroll runs monthly. Every month has one cutoff for compensation changes; anything sent after cutoff applies to the next run. Retrieve the current calendar with GET /payroll/cutoffs.

The calendar covers 18 months ahead. As of 2026-07 you can query 2027 with GET /payroll/cutoffs?year=2027.

GET /payroll/cutoffsbash
curl https://api.expandtosouthafrica.com/v1/payroll/cutoffs?year=2026 \
  -H "Authorization: Bearer sk_test_..."

Webhook signatures

Every webhook carries an ESA-Signature header: HMAC-SHA256 over timestamp + "." + payload, with a 5-minute tolerance. Verification samples in Node, Python, Ruby and PHP ship in the OpenAPI spec.

Verify (Node)bash
const crypto = require("crypto");

function verify(payload, header, secret) {
  const [t, sig] = header.split(",");
  const ts = t.split("=")[1];
  // reject if older than 5 minutes
  const expected = crypto
    .createHmac("sha256", secret)
    .update(ts + "." + payload)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(sig.split("=")[1]),
    Buffer.from(expected)
  );
}
POST/v1/employments

Create an employment

The core object: one employment of one person. Amounts are integer minor units with ISO 4217 currency; dates are ISO 8601; the service is South Africa-only, so there is no country parameter.

draftawaiting_documentscontract_sentactivethen terminated / resigned

Parameters

candidate
object · required
first_name, last_name, email, phone, national_id (optional).
position
object · required
title required; description, seniority optional.
compensation
object · required
gross_monthly (integer minor units), currency (ZAR), thirteenth_cheque boolean.
benefits_tier
enum
none | standard | premium.
start_date
date · required
ISO 8601. At least 5 business days out.
probation_months
integer
Default 3, max 6 (BCEA-reasonable).
work_location
object
city + remote boolean.
Idempotency required. Send an Idempotency-Key header on every POST. Retries with the same key return the original result.

All employment endpoints

POST/v1/employmentsCreate (this page)
GET/v1/employmentsList; filter by status, cursor pagination
GET/v1/employments/{id}Retrieve one
POST/v1/employments/{id}/activateIssue the contract for signature

Changes after activation happen through Payroll variables; exits only through Terminations. There is no DELETE on an employment.

curlbash
curl https://api.expandtosouthafrica.com/v1/employments \
  -H "Authorization: Bearer sk_live_..." \
  -H "Idempotency-Key: 5f1c2c1e" \
  -d candidate[first_name]="Naledi" \
  -d candidate[last_name]="Dlamini" \
  -d candidate[email]="[email protected]" \
  -d position[title]="Senior Accountant" \
  -d compensation[gross_monthly]=4800000 \
  -d compensation[currency]=ZAR \
  -d compensation[thirteenth_cheque]=true \
  -d benefits_tier="standard" \
  -d start_date=2026-09-01
Responsejson
{
  "id": "emp_8fK2mQ",
  "object": "employment",
  "status": "draft",
  "candidate": {
    "first_name": "Naledi",
    "last_name": "Dlamini",
    "email": "[email protected]"
  },
  "compensation": {
    "gross_monthly": 4800000,
    "currency": "ZAR",
    "thirteenth_cheque": true
  },
  "benefits_tier": "standard",
  "start_date": "2026-09-01"
}
Errorjson
{
  "error": {
    "type": "validation_error",
    "code": "invalid_start_date",
    "message": "start_date must be at least 5 business days in the future.",
    "param": "start_date",
    "doc_url": "https://docs.expandtosouthafrica.com/errors#invalid_start_date"
  }
}
POST/v1/quotes

Quote an employment cost

The full monthly employer cost for a gross salary: UIF, SDL, COIDA estimate, optional 13th-cheque accrual and the service fee. Stateless, no auth-scoped data, safe to call from your own tooling.

The public cost calculator on the marketing site runs on this exact endpoint. One engine, no drift between marketing numbers and API numbers; the response carries the figures_verified date.

Parameters

gross_monthly
integer · required
Gross salary in minor units (cents), ZAR.
thirteenth_cheque
boolean
Accrue one-twelfth monthly. Default false.
benefits_tier
enum
none | standard | premium. Priced at broker cost.
display_currency
enum
ZAR | EUR | USD | GBP. Conversion at daily mid-market fixing.
curlbash
curl https://api.expandtosouthafrica.com/v1/quotes \
  -H "Authorization: Bearer sk_test_..." \
  -d gross_monthly=6000000 \
  -d thirteenth_cheque=true \
  -d display_currency=EUR
Responsejson
{
  "object": "quote",
  "currency": "ZAR",
  "gross_monthly": 6000000,
  "employer_costs": {
    "uif": 17712,
    "sdl": 60000,
    "coida_estimate": 6500
  },
  "thirteenth_accrual": 500000,
  "service_fee": { "amount": 350, "currency": "EUR" },
  "total_monthly": 6584212,
  "display": { "currency": "EUR", "total": 3287.44 },
  "figures_verified": "2026-07-03"
}
POST/v1/terminations

Terminations

Every termination request enters mandatory specialist review, status:in_review, and returns a termination_case. There is no DELETE on an employment.

Grounds

misconduct · incapacity · operational_requirements (s189/s189A) · resignation.

Responsejson
{
  "object": "termination_case",
  "id": "tr_5xM2q1",
  "employment": "emp_8fK2mQ",
  "ground": "resignation",
  "status": "in_review",
  "opened": "2026-07-22T08:14:00Z"
}
PATCH/v1/employments/{id}/variables

Payroll variables

Compensation changes, bonuses, expenses, leave and benefit changes go here. Every change is validated against the current month's cutoff; misses roll into next month with a clear message.

curlbash
curl -X PATCH https://api.expandtosouthafrica.com/v1/employments/emp_8fK2mQ/variables \
  -H "Authorization: Bearer sk_live_..." \
  -d effective_month=2026-08 \
  -d bonus=150000
GET/v1/payslips

Payslips & invoices

Payslips are issued after every payroll run and returned in PDF via a signed URL. Invoices reflect the total employer cost per employment: statutory + benefits + service fee.

Responsejson
{
  "object": "payslip",
  "id": "psl_51xTz8",
  "employment": "emp_8fK2mQ",
  "period": "2026-07",
  "pdf_url": "https://files.expandtosouthafrica.com/psl_51xTz8.pdf"
}
POST/v1/talent_requests

Talent requests

Brief us on a role; receive a vetted shortlist within two weeks. Requests progress through receivedsourcingshortlist_readyclosed.

POST/v1/webhook_endpoints

Webhooks

Register an endpoint URL with the event types you want. Every delivery is signed with an ESA-Signature header: HMAC-SHA256 over timestamp + payload, 5-minute tolerance. Verification samples ship in four languages.

Event types

employment.createdemployment.contract_sentemployment.activatedcompensation_change.appliedpayslip.availableinvoice.createdtermination.review_startedtermination.completedtalent_request.shortlist_ready

Failed deliveries retry with exponential backoff for 72 hours; the full event log is queryable at GET /events.

Verify (Node)bash
const crypto = require("crypto");

function verify(payload, header, secret) {
  const [t, sig] = header.split(",");
  const ts = t.split("=")[1];
  const expected = crypto
    .createHmac("sha256", secret)
    .update(ts + "." + payload)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(sig.split("=")[1]),
    Buffer.from(expected)
  );
}
Eventjson
{
  "id": "evt_92hFq1",
  "type": "payslip.available",
  "created": "2026-07-25T06:00:11Z",
  "data": {
    "payslip": "psl_51xTz8",
    "employment": "emp_8fK2mQ",
    "period": "2026-07"
  }
}

Changelog

Additive changes ship without a version bump; anything breaking waits for /v2 with at least 12 months of deprecation notice.

2026-07-02AddedGET /payroll/cutoffs?year=2027 now available; calendar extended 18 months ahead.
2026-06-14Addedbenefits_tier=premium adds gap cover and retirement annuity administration.
2026-05-30ChangedQuotes: FX source switched to mid-market daily fixings. Non-breaking; values shift under 0.5%.
2026-04-18Deprecatedemployment.contract_signed webhook alias. Use employment.activated. Removal no earlier than 2027-04.