Connect your careers site, applicant tracking system (ATS) or HR software to JobEng · Version 1.0 · OpenAPI file
The JobEng API lets your own systems read and manage your company's jobs and applications in JobEng. Typical uses: show your open jobs on your own website, send applications from your careers page into JobEng, copy new applicants into your ATS, or get told the moment something changes.
jbk_live_ and is shown once. Keep it on your server, never in a web page or mobile app.curl https://api.jobeng.ai/api/public/v1/jobs?status=open \
-H "Authorization: Bearer jbk_live_YOUR_KEY"
Base URL: https://api.jobeng.ai/api/public/v1. Requests and answers are JSON (UTF-8). Dates are ISO 8601; times are UTC.
Send the key in the Authorization header as a bearer token (or in X-JobEng-Key). Every key belongs to one company and can only ever see and change that company's data.
| Permission | Allows |
|---|---|
jobs:read | List and read jobs |
jobs:write | Create, change and close jobs (includes jobs:read) |
applications:read | List and read applications, including the candidate's contact details and CV link |
applications:write | Add candidates to your jobs and move applications between stages (includes applications:read) |
You can have up to 10 active keys. Revoking a key in the app stops it at once. Keys can be given an expiry date. Every request is logged and shown in the app under Usage.
GET /me → {"data":{"company":{"id":"9","name":"Your Company Ltd"},"key":{"id":"3","name":"Careers site","scopes":["jobs:read"]},"limits":{"per_minute":120,"per_day":20000}}}
| Endpoint | Permission | What it does | |
|---|---|---|---|
| GET | /jobs | jobs:read | List jobs. status = open, closed, draft or all (default); plus updated_since, limit, cursor |
| GET | /jobs/{id} | jobs:read | One job |
| POST | /jobs | jobs:write | Create a job |
| PATCH | /jobs/{id} | jobs:write | Change any of the fields below |
| POST | /jobs/{id}/close | jobs:write | Close the job (it leaves your public jobs page and the job boards' feed) |
curl https://api.jobeng.ai/api/public/v1/jobs \
-H "Authorization: Bearer jbk_live_YOUR_KEY" -H "Content-Type: application/json" \
-d '{
"title": "Quantity Surveyor",
"location": "Leicester",
"employment_type": "full_time",
"salary_min": 45000, "salary_max": 55000, "salary_currency": "GBP", "salary_period": "year",
"description": "We are looking for a Quantity Surveyor to join our commercial team…",
"required_skills": ["NRM2", "CostX", "NEC4"],
"closing_date": "2026-12-31",
"status": "active"
}'
Fields: title (required), description, short_description, department, location, employment_type, experience_level, salary_min, salary_max, salary_currency (default GBP), salary_period, required_skills, preferred_skills, qualifications, responsibilities, benefits, closing_date, start_date (YYYY-MM-DD), positions, status (active, draft or paused). An active job appears on jobeng.ai/jobs, in Google for Jobs and in the job-board feed, unless you hid it in the app.
{
"id": "41", "reference": "API-MFR2K8", "title": "Quantity Surveyor", "status": "active", "open": true,
"description": "…", "department": "Commercial", "location": "Leicester",
"employment_type": "full_time", "experience_level": "mid",
"salary": { "min": 45000, "max": 55000, "currency": "GBP", "period": "year" },
"required_skills": ["NRM2", "CostX", "NEC4"], "closing_date": "2026-12-31",
"applications_count": 12, "public_url": "https://jobeng.ai/jobs/41",
"created_at": "2026-10-05T09:12:44.000Z", "updated_at": "2026-10-05T09:12:44.000Z"
}
| Endpoint | Permission | What it does | |
|---|---|---|---|
| GET | /applications | applications:read | List applications. Filters: job_id, stage, updated_since; paging: limit, cursor |
| GET | /applications/{id} | applications:read | One application |
| POST | /applications | applications:write | Add a candidate to one of your jobs — for example from the form on your own website |
| PATCH | /applications/{id} | applications:write | Move to a stage, add a note, tags or a star |
curl https://api.jobeng.ai/api/public/v1/applications \
-H "Authorization: Bearer jbk_live_YOUR_KEY" -H "Content-Type: application/json" \
-d '{
"job_id": "41",
"candidate": { "name": "Amira Hassan", "email": "amira@example.com", "phone": "07700 900123",
"location": "Nottingham", "linkedin_url": "https://www.linkedin.com/in/…", "skills": ["NRM2"] },
"cover_letter": "I have six years on NEC4 projects…",
"resume_url": "https://files.yourcompany.co.uk/cv/amira.pdf",
"source": "careers page",
"consent": true
}'
The same e-mail address can apply to the same job once every 30 days (a repeat answers 409). resume_url must be an https:// link your team can open.
curl -X PATCH https://api.jobeng.ai/api/public/v1/applications/318 \
-H "Authorization: Bearer jbk_live_YOUR_KEY" -H "Content-Type: application/json" \
-d '{ "stage": "interview", "note": "Phone screen passed — book a site visit" }'
Stages: new, applied, applicants, reviewed, shortlisted, technical, interview, interviewed, offered, offer_sent, hired, rejected, withdrawn, on_hold. Every move is recorded in the application's history with the key's name; a note is added to the recruiter notes.
{
"id": "318", "reference": "WEB-MFQ8Z1", "job_id": "41", "job_title": "Quantity Surveyor",
"candidate": { "name": "Amira Hassan", "email": "amira@example.com", "phone": "07700 900123",
"location": "Nottingham", "linkedin_url": "…", "current_title": "Assistant QS", "skills": ["NRM2"] },
"cover_letter": "…", "resume_url": "https://api.jobeng.ai/files/cv/…",
"stage": "interview", "source": "web", "scores": { "ai_match": 82, "overall": null },
"tags": [], "starred": false, "applied_at": "2026-10-04T18:20:00.000Z", "updated_at": "2026-10-05T10:02:11.000Z"
}
Lists return the newest first, 25 at a time (up to 100 with limit). When has_more is true, ask again with cursor set to next_cursor. To keep a copy in step, poll with updated_since set to the time of your last successful sync — or use webhooks.
{ "data": [ … ], "has_more": true, "next_cursor": "296" }
Errors have the same shape: {"error": {"code": "…", "message": "…"}}.
| Status | Code | Meaning |
|---|---|---|
| 400 | bad_request | A query parameter is wrong |
| 401 | missing_key · invalid_key · expired_key | No key, an unknown or revoked key, or an expired key |
| 403 | missing_scope | The key lacks the permission named in required_scope |
| 404 | not_found | No such record in your company |
| 409 | duplicate | The candidate already applied for this job in the last 30 days |
| 422 | invalid | The body is not valid — fields lists what to fix |
| 429 | rate_limited · daily_limit | Slow down — wait the number of seconds in Retry-After |
| 500 | server_error | Our fault — try again; if it continues e-mail info@sesysy.com |
Each key may make 120 requests a minute and 20,000 a day. Every answer carries X-RateLimit-Limit and X-RateLimit-Remaining.
Instead of asking again and again, let JobEng tell you. Add an https:// address in the app (API & webhooks → Webhooks), choose the events and keep the signing secret (whsec_…). JobEng checks for changes every minute and sends each event as a POST with a JSON body.
| Event | When | data |
|---|---|---|
application.created | A new application arrives (app, jobeng.ai/jobs, your website through the API) | an application |
application.updated | An application changes — for example its stage | an application |
job.created | A job is added | a job |
job.updated | A job changes | a job |
job.closed | A job is closed or filled | a job |
POST https://your-server.example.com/jobeng
JobEng-Event: application.created
JobEng-Delivery: 5521
JobEng-Signature: t=1791200000,v1=5f2b…c9
Content-Type: application/json
{ "id": "evt_8c1f…", "type": "application.created", "created_at": "2026-10-05T10:02:11.000Z",
"company_id": "9", "api_version": "1.0", "data": { …an application… } }
Answer with any 2xx status within 10 seconds. Anything else is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours. A webhook that fails 50 times in a row is paused and your owners are told in the app. Use Send test in the app to check your address; every delivery and its answer is listed there, and a failed one can be sent again.
Compute HMAC-SHA256 of t + "." + raw body with your signing secret and compare it with v1. Reject requests whose t is more than 5 minutes old.
// Node.js (Express) — use the raw body, not the parsed JSON
const crypto = require('crypto');
app.post('/jobeng', express.raw({ type: 'application/json' }), (req, res) => {
const [t, v1] = req.get('JobEng-Signature').split(',').map((p) => p.split('=')[1]);
const expected = crypto.createHmac('sha256', process.env.JOBENG_WHSEC).update(`${t}.${req.body}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
if (!fresh || !v1 || v1.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) return res.sendStatus(400);
const event = JSON.parse(req.body);
// … handle event.type / event.data …
res.sendStatus(200);
});
# Python (Flask)
import hmac, hashlib, time, os
@app.post("/jobeng")
def jobeng():
t, v1 = [p.split("=")[1] for p in request.headers["JobEng-Signature"].split(",")]
expected = hmac.new(os.environ["JOBENG_WHSEC"].encode(), f"{t}.".encode() + request.get_data(), hashlib.sha256).hexdigest()
if abs(time.time() - int(t)) > 300 or not hmac.compare_digest(expected, v1):
return "", 400
event = request.get_json()
return "", 200
Events can occasionally arrive more than once or out of order — use the event id to ignore repeats and data.updated_at to keep the newest version.
Applications contain personal data. Your company is the controller of that data and JobEng processes it for you under our Data Processing Agreement. When you copy applicants into another system you are responsible for it there: keep keys secret, store only what you need, delete it when your retention period ends, and make sure a person — not only software — makes every hiring decision (UK GDPR Article 22). Applicants can ask for a person to review their application at jobeng.ai/jobs/privacy/request.
See also the Terms of Service, Privacy Policy and sub-processors.