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

  1. Log in as the employer, open Company profile → API & integrations and create a key. The API is included with every paid employer plan.
  2. Send JSON to https://workhook.co.za/api/v1 with the header Authorization: Bearer whk_...
  3. 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, send X-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 get 429 with Retry-After.
  • Format: JSON in (Content-Type: application/json), JSON out. Dates as YYYY-MM-DD, times in ISO 8601 (South African time, +02:00). Always use https.
  • If your system cannot send PATCH or DELETE, send POST with X-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."}
  }
}
HTTPcodeMeaning
400bad_json, bad_statusThe request could not be read
401unauthorizedNo key, a wrong key or a revoked key
403no_access, plan_limit, removedThe API is not on for this account, the plan allowance is used up (nothing was saved), or WorkHook removed the listing
404not_found, no_cvNot found on your account
409duplicate_external_ref, withdrawnYou already have a job with that external_ref (its ref is returned), or the applicant withdrew
422validationSee fields
429rate_limitedToo 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).

MethodPathWhat it does
GET/meYour account, plan, and how many job posts and project posts you have left
GET/lookupsCountries, categories, job types, work modes, levels - ids and names
GET/jobsYour listings. Filters: status, updated_since, page, per_page (max 100)
POST/jobsCreate 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}/closeClose 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}/applicationsApplications (or quotes) for one listing
GET/applicationsAll 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}/cvDownload the CV (PDF / Word file, or HTML for CVs built on WorkHook)
POST/webhooks/testSend 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.

FieldTypeNotes
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, 150Job title, or what needs doing
description *HTML or textAt least 50 characters (30 for projects). Safe HTML is kept; plain text keeps its paragraphs
external_refstring, 80Your own id. Must be unique on your account; lets you use ext: paths and stops duplicates
category *id | nameSome categories are only for jobs or only for projects
job_type *id | nameJobs only, e.g. Permanent, Contract
work_mode *id | namee.g. On-site, Remote, Hybrid
levelid | name | NQFJobs only, e.g. "NQF 6"
salaryobject{"min", "max", "currency", "period", "negotiable"}; period: hour, day, week, month, year, project. (Or flat salary_min, salary_max, ...)
locationobject{"country": "ZA", "area": "Centurion"} (* area for projects)
applicants_from"anywhere" | ["ZA", ...]Countries applicants may live in. Default: anywhere
experience_years, experience_textint, text
must_havearray | textApplicants tick "I meet all of these"
qualifications, not_acceptabletext
keywordsarray | "a, b"Helps search
positionsintDefault 1
durationstringProjects only, e.g. "2 days"
start_by, closing_date *dateClosing date defaults to 30 days from today
apply_emailemailWhere application emails go (default: your account's applications email)
publishboolCreate: 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 quote
  • application.withdrawn - the applicant withdrew
  • test - from the "Send a test" button or POST /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.

PHP
$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);
Node.js
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:

Categories
  • 1 Accounting & Finance (jobs only)
  • 2 Administration & Office (jobs only)
  • 3 Agriculture & Farming
  • 4 Building & Construction
  • 5 Customer Service & Call Centre (jobs only)
  • 6 Design & Creative
  • 7 Domestic & Household
  • 8 Education & Training (jobs only)
  • 9 Engineering (jobs only)
  • 10 Hospitality & Tourism (jobs only)
  • 11 Human Resources (jobs only)
  • 12 IT & Software Development
  • 13 Legal (jobs only)
  • 14 Logistics, Driving & Transport
  • 15 Maintenance & Trades
  • 16 Manufacturing & Production (jobs only)
  • 17 Marketing & Social Media
  • 18 Medical & Healthcare (jobs only)
  • 19 Mining (jobs only)
  • 20 NGO & Community (jobs only)
  • 21 Retail (jobs only)
  • 22 Sales (jobs only)
  • 23 Security (jobs only)
  • 24 Writing, Translation & Content
  • 26 Handyman & Small Repairs (projects only)
  • 27 Renovations & Building Work (projects only)
  • 28 Plumbing (projects only)
  • 29 Electrical (projects only)
  • 30 Painting & Decorating (projects only)
  • 31 Tiling & Flooring (projects only)
  • 32 Roofing & Waterproofing (projects only)
  • 33 Carpentry & Furniture (projects only)
  • 34 Welding & Steel Work (projects only)
  • 35 Gardening & Landscaping (projects only)
  • 36 Cleaning (projects only)
  • 37 Moving & Deliveries (projects only)
  • 38 Appliance & Electronics Repair (projects only)
  • 39 Solar, Water & Backup Power (projects only)
  • 40 Pool Services (projects only)
  • 41 Pest Control (projects only)
  • 42 Vehicle Repairs (projects only)
  • 43 Computer & Phone Help (projects only)
  • 44 Events & Catering (projects only)
  • 45 Tutoring & Lessons (projects only)
  • 46 Photography & Video (projects only)
  • 25 Other
Job types
  • 1 Permanent
  • 2 Contract
  • 3 Temporary
  • 4 Part Time
  • 5 Freelance
  • 6 Internship
  • 7 Learnership
  • 8 Volunteer
Work modes
  • 1 Remote
  • 2 Partially remote (hybrid)
  • 3 In-house (on-site)
  • 4 Field / travelling
Levels
  • 1 No formal qualification
  • 2 NQF 1 - Grade 9
  • 3 NQF 2 - Grade 10
  • 4 NQF 3 - Grade 11
  • 5 NQF 4 - Matric / Grade 12
  • 6 NQF 5 - Higher Certificate
  • 7 NQF 6 - Diploma / Advanced Certificate
  • 8 NQF 7 - Bachelor's / Advanced Diploma
  • 9 NQF 8 - Honours / Postgraduate Diploma
  • 10 NQF 9 - Master's
  • 11 NQF 10 - Doctorate

Questions or need a hand connecting your system? Email support@pilmobi.com. Using the API means you accept the Terms and Conditions.

Would you like something added to WorkHook? Tell us at support@pilmobi.com or, for advertising and partnerships, sales@pilmobi.com.