WorkHook API v1
For employers and recruitment agencies: post jobs and projects to WorkHook straight from your own system or ATS, keep them up to date, and get applications back - by polling or instantly by webhook.
Quick start
- Log in as the employer, open Company profile → API & integrations and create a key. The API is included with every paid employer plan.
- Send JSON to
https://workhook.co.za/api/v1with the headerAuthorization: Bearer whk_... - Post your first job:
curl -X POST https://workhook.co.za/api/v1/jobs \
-H "Authorization: Bearer whk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"external_ref": "ATS-10442",
"title": "Bookkeeper",
"description": "<p>A growing accounting practice in Centurion needs a bookkeeper ...</p>",
"category": "Accounting & Finance",
"job_type": "Permanent",
"work_mode": "On-site",
"level": "NQF 6",
"salary": {"min": 18000, "max": 24000, "currency": "ZAR", "period": "month"},
"location": {"country": "ZA", "area": "Centurion"},
"applicants_from": ["ZA"],
"must_have": ["Pastel or Sage", "3 years bookkeeping"],
"closing_date": "2026-12-15",
"apply_email": "jobs@youragency.co.za"
}' The answer (HTTP 201) is the job with its WorkHook ref and public url. It is live straight away - send "publish": false to keep it as a draft.
{
"ok": true,
"data": {
"ref": "kz99h22dbt",
"external_ref": "ATS-10442",
"url": "https://workhook.co.za/job.php?job=kz99h22dbt",
"type": "job",
"status": "Live",
"title": "Bookkeeper",
...
}
} Keys, access and limits
- Authentication:
Authorization: Bearer whk_...on every request. If your server strips that header, sendX-Api-Key: whk_...instead. - One key per system. Each key acts for one employer account. Revoke a key any time on the API page; the full key is only shown once when you create it.
- Who can use it: employers with an active paid plan (WorkHook can also switch it on for you). Publishing follows the same rules as the website - your plan's listing allowance, approval of new accounts and moderation.
- Rate limit: 60 requests a minute per key. Responses include
X-RateLimit-Remaining; above the limit you get429withRetry-After. - Format: JSON in (
Content-Type: application/json), JSON out. Dates asYYYY-MM-DD, times in ISO 8601 (South African time, +02:00). Always use https. - If your system cannot send
PATCHorDELETE, sendPOSTwithX-HTTP-Method-Override: PATCH.
Errors
Every error has the same shape. fields is there when some fields need attention:
HTTP/1.1 422
{
"ok": false,
"error": {
"code": "validation",
"message": "Some fields need attention.",
"fields": {"job_type": "Unknown value \"Fulltime\". See GET /v1/lookups for the ids and names."}
}
} | HTTP | code | Meaning |
|---|---|---|
| 400 | bad_json, bad_status | The request could not be read |
| 401 | unauthorized | No key, a wrong key or a revoked key |
| 403 | no_access, plan_limit, removed | The API is not on for this account, the plan allowance is used up (nothing was saved), or WorkHook removed the listing |
| 404 | not_found, no_cv | Not found on your account |
| 409 | duplicate_external_ref, withdrawn | You already have a job with that external_ref (its ref is returned), or the applicant withdrew |
| 422 | validation | See fields |
| 429 | rate_limited | Too many requests - wait and retry |
Endpoints
Base address: https://workhook.co.za/api/v1. {ref} is the WorkHook job ref (e.g. kz99h22dbt) or ext: + your own external_ref (e.g. ext:ATS-10442).
| Method | Path | What it does |
|---|---|---|
| GET | /me | Your account, plan, and how many job posts and project posts you have left |
| GET | /lookups | Countries, categories, job types, work modes, levels - ids and names |
| GET | /jobs | Your listings. Filters: status, updated_since, page, per_page (max 100) |
| POST | /jobs | Create a job or project (live unless "publish": false) |
| GET | /jobs/{ref} | One listing |
| PATCH | /jobs/{ref} | Change only the fields you send. "publish": true publishes a draft or re-opens a closed listing |
| POST | /jobs/{ref}/close | Close it: {"status": "Filled"} - Closed (default), Filled or Cancelled, optional note |
| DELETE | /jobs/{ref} | Cancel the listing (it is kept for your records) |
| GET | /jobs/{ref}/applications | Applications (or quotes) for one listing |
| GET | /applications | All applications on your listings. Filters: since (new or changed since), status, job, page, per_page |
| GET | /applications/{id} | One application (marks it Viewed) |
| PATCH | /applications/{id} | Move it along: status, notes (private), message + "notify": true to email the applicant |
| GET | /applications/{id}/cv | Download the CV (PDF / Word file, or HTML for CVs built on WorkHook) |
| POST | /webhooks/test | Send a test event to your webhook address |
Job fields
For lists (category, job_type, work_mode, level) send the id or the name from /lookups - short names work too when only one item fits, e.g. "On-site", "Hybrid" or "NQF 6". Countries take the ISO code (ZA). Fields marked * are needed to publish.
| Field | Type | Notes |
|---|---|---|
type | "job" | "project" | A job, or a project - work to be done (repairs, renovations, once-off work - people send quotes). Default "job". Each plan has its own allowance of job posts and project posts |
title * | string, 150 | Job title, or what needs doing |
description * | HTML or text | At least 50 characters (30 for projects). Safe HTML is kept; plain text keeps its paragraphs |
external_ref | string, 80 | Your own id. Must be unique on your account; lets you use ext: paths and stops duplicates |
category * | id | name | Some categories are only for jobs or only for projects |
job_type * | id | name | Jobs only, e.g. Permanent, Contract |
work_mode * | id | name | e.g. On-site, Remote, Hybrid |
level | id | name | NQF | Jobs only, e.g. "NQF 6" |
salary | object | {"min", "max", "currency", "period", "negotiable"}; period: hour, day, week, month, year, project. (Or flat salary_min, salary_max, ...) |
location | object | {"country": "ZA", "area": "Centurion"} (* area for projects) |
applicants_from | "anywhere" | ["ZA", ...] | Countries applicants may live in. Default: anywhere |
experience_years, experience_text | int, text | |
must_have | array | text | Applicants tick "I meet all of these" |
qualifications, not_acceptable | text | |
keywords | array | "a, b" | Helps search |
positions | int | Default 1 |
duration | string | Projects only, e.g. "2 days" |
start_by, closing_date * | date | Closing date defaults to 30 days from today |
apply_email | Where application emails go (default: your account's applications email) | |
publish | bool | Create: default true. Update: default false (stays as it is) |
Update by your own id, then close it
curl -X PATCH https://workhook.co.za/api/v1/jobs/ext:ATS-10442 \
-H "Authorization: Bearer whk_your_api_key" -H "Content-Type: application/json" \
-d '{"closing_date": "2026-12-31", "salary": {"max": 26000}}'
# ...later, when the position is filled:
curl -X POST https://workhook.co.za/api/v1/jobs/ext:ATS-10442/close \
-H "Authorization: Bearer whk_your_api_key" -H "Content-Type: application/json" \
-d '{"status": "Filled"}' Applications
curl "https://workhook.co.za/api/v1/applications?since=2026-10-27T08:00:00%2B02:00" -H "Authorization: Bearer whk_your_api_key" {
"id": 4821,
"job": {"ref": "kz99h22dbt", "external_ref": "ATS-10442", "title": "Bookkeeper"},
"status": "Applied",
"applicant": {"name": "Thandi Mokoena", "email": "thandi@example.com", "phone": "082 555 0101", "country": "ZA", "city": "Pretoria", "experience_years": 6},
"message": "I have six years of SME bookkeeping on Sage and Pastel ...",
"quote": null,
"available_from": "2026-11-15",
"cv": {"name": "Thandi CV 2026", "format": "file", "download_url": "https://workhook.co.za/api/v1/applications/4821/cv"},
"notes": null,
"applied_at": "2026-10-27T09:14:02+02:00"
} Move it along - and email the applicant if you like:
curl -X PATCH https://workhook.co.za/api/v1/applications/4821 \
-H "Authorization: Bearer whk_your_api_key" -H "Content-Type: application/json" \
-d '{"status": "Interview", "notify": true, "message": "Can you come in on Tuesday at 10:00?"}' Statuses: Applied, Viewed, Shortlisted, Interview, Offered, Hired, Rejected (and Withdrawn, set by the applicant).
Webhooks
Set your webhook address on the API page. WorkHook then POSTs JSON to it when something happens - no polling needed:
application.created- someone applied or sent a quoteapplication.withdrawn- the applicant withdrewtest- from the "Send a test" button orPOST /webhooks/test
POST https://your-system.example/workhook-webhook
Content-Type: application/json
X-WorkHook-Event: application.created
X-WorkHook-Delivery: 913
X-WorkHook-Timestamp: 1793091242
X-WorkHook-Signature: sha256=5d1c...
{"id": 913, "event": "application.created", "created_at": "2026-10-27T09:14:02+02:00",
"data": {"application": { ...same as GET /applications/{id}... }}} Answer with any 2xx status within 6 seconds. Otherwise we try again after 5 minutes, 30 minutes, 2 hours and 6 hours. Check the signature so you know it came from WorkHook: HMAC-SHA256 of timestamp + "." + body with your signing secret.
$body = file_get_contents("php://input");
$ts = $_SERVER["HTTP_X_WORKHOOK_TIMESTAMP"];
$sig = $_SERVER["HTTP_X_WORKHOOK_SIGNATURE"];
$ok = hash_equals("sha256=" . hash_hmac("sha256", $ts . "." . $body, $secret), $sig)
&& abs(time() - (int)$ts) < 300;
if (!$ok) { http_response_code(401); exit; }
$event = json_decode($body, true);const crypto = require("crypto");
// rawBody = the exact request body as a string
const ts = req.headers["x-workhook-timestamp"];
const expected = "sha256=" + crypto.createHmac("sha256", secret)
.update(ts + "." + rawBody).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected),
Buffer.from(req.headers["x-workhook-signature"] || ""));Lookups
GET /lookups returns every list with ids and names. The current values:
1Accounting & Finance (jobs only)2Administration & Office (jobs only)3Agriculture & Farming4Building & Construction5Customer Service & Call Centre (jobs only)6Design & Creative7Domestic & Household8Education & Training (jobs only)9Engineering (jobs only)10Hospitality & Tourism (jobs only)11Human Resources (jobs only)12IT & Software Development13Legal (jobs only)14Logistics, Driving & Transport15Maintenance & Trades16Manufacturing & Production (jobs only)17Marketing & Social Media18Medical & Healthcare (jobs only)19Mining (jobs only)20NGO & Community (jobs only)21Retail (jobs only)22Sales (jobs only)23Security (jobs only)24Writing, Translation & Content26Handyman & Small Repairs (projects only)27Renovations & Building Work (projects only)28Plumbing (projects only)29Electrical (projects only)30Painting & Decorating (projects only)31Tiling & Flooring (projects only)32Roofing & Waterproofing (projects only)33Carpentry & Furniture (projects only)34Welding & Steel Work (projects only)35Gardening & Landscaping (projects only)36Cleaning (projects only)37Moving & Deliveries (projects only)38Appliance & Electronics Repair (projects only)39Solar, Water & Backup Power (projects only)40Pool Services (projects only)41Pest Control (projects only)42Vehicle Repairs (projects only)43Computer & Phone Help (projects only)44Events & Catering (projects only)45Tutoring & Lessons (projects only)46Photography & Video (projects only)25Other
1Permanent2Contract3Temporary4Part Time5Freelance6Internship7Learnership8Volunteer
1Remote2Partially remote (hybrid)3In-house (on-site)4Field / travelling
1No formal qualification2NQF 1 - Grade 93NQF 2 - Grade 104NQF 3 - Grade 115NQF 4 - Matric / Grade 126NQF 5 - Higher Certificate7NQF 6 - Diploma / Advanced Certificate8NQF 7 - Bachelor's / Advanced Diploma9NQF 8 - Honours / Postgraduate Diploma10NQF 9 - Master's11NQF 10 - Doctorate
Questions or need a hand connecting your system? Email support@pilmobi.com. Using the API means you accept the Terms and Conditions.
