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.
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.
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
curl -X POST https://api.expandtosouthafrica.com\
/v1/employments/emp_8fK2mQ/activate \
-H "Authorization: Bearer sk_test_..."
# sandbox simulates contract signature
# after ~30 seconds
{
"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.
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
{
"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.
# 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_error401 | Missing, unknown or revoked key. |
validation_error422 | A parameter fails validation; param names the field. |
cutoff_error422 | The change misses this month's payroll cutoff; the message states the run it will apply to. |
compliance_hold409 | The action requires specialist review (terminations always do). The case id is returned. |
rate_limit_error429 | 120 req/min live, 600 sandbox. Retry-After header included. |
{
"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.
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.
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)
);
}
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.
draft → awaiting_documents → contract_sent → activethen terminated / resignedParameters
candidateobject · required | first_name, last_name, email, phone, national_id (optional). |
positionobject · required | title required; description, seniority optional. |
compensationobject · required | gross_monthly (integer minor units), currency (ZAR), thirteenth_cheque boolean. |
benefits_tierenum | none | standard | premium. |
start_datedate · required | ISO 8601. At least 5 business days out. |
probation_monthsinteger | Default 3, max 6 (BCEA-reasonable). |
work_locationobject | city + remote boolean. |
Idempotency-Key header on every POST. Retries with the same key return the original result.All employment endpoints
/v1/employmentsCreate (this page)/v1/employmentsList; filter by status, cursor pagination/v1/employments/{id}Retrieve one/v1/employments/{id}/activateIssue the contract for signatureChanges after activation happen through Payroll variables; exits only through Terminations. There is no DELETE on an employment.
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
{
"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"
}
{
"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"
}
}
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_monthlyinteger · required | Gross salary in minor units (cents), ZAR. |
thirteenth_chequeboolean | Accrue one-twelfth monthly. Default false. |
benefits_tierenum | none | standard | premium. Priced at broker cost. |
display_currencyenum | ZAR | EUR | USD | GBP. Conversion at daily mid-market fixing. |
curl https://api.expandtosouthafrica.com/v1/quotes \
-H "Authorization: Bearer sk_test_..." \
-d gross_monthly=6000000 \
-d thirteenth_cheque=true \
-d display_currency=EUR
{
"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"
}
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.
{
"object": "termination_case",
"id": "tr_5xM2q1",
"employment": "emp_8fK2mQ",
"ground": "resignation",
"status": "in_review",
"opened": "2026-07-22T08:14:00Z"
}
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.
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
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.
{
"object": "payslip",
"id": "psl_51xTz8",
"employment": "emp_8fK2mQ",
"period": "2026-07",
"pdf_url": "https://files.expandtosouthafrica.com/psl_51xTz8.pdf"
}
Talent requests
Brief us on a role; receive a vetted shortlist within two weeks. Requests progress through received → sourcing → shortlist_ready → closed.
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.
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)
);
}
{
"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.