Post a job from your own system
Updated
If your roles already live somewhere else — an ATS, an internal tool, a spreadsheet somebody guards — this is how they reach Orbit without anyone retyping them.
What you need
A key with the read and manage jobs scope, created in Orbit → Settings → API keys. A read-only key answers 403 on every request on this page, deliberately: publishing puts an advertisement in front of the public, and the key that renders a careers site should not also be able to post advertisements.
export ORBIT_API_KEY="…a key with read and manage jobs…"
The shape: two calls
Creating and publishing are separate.
POST /v1/jobscreates a draft. Nothing is public.POST /v1/jobs/{jobId}/publishtakes it live. Publishing is free.
That is deliberate. Publishing puts the role on your hosted job page, lets candidates apply and enters it into the Wipperoz job seeker feed. A single call that did all of that would mean a key in a misconfigured loop posting advertisements before anyone read a log.
It also leaves a person in the loop when you want one: create the drafts from your system, and let a recruiter publish them from Orbit after reading them. If you do not want one, call publish yourself. Both are supported; the choice is yours rather than ours.
Managed job ads
A published ad collects applications and lists them in the order they arrived. Managing an ad is Orbit’s paid service on top: its applicants are ranked, your candidate pool is matched against it, and you can screen them, from Orbit or over the API (see Screen a candidate). A recruiter switches it on per ad, in Orbit — the read and manage jobs scope creates, edits, publishes and closes roles, and does not manage ads.
One thing reaches this API: a managed ad’s title and category are locked, as Edit a role below explains.
Create the draft
curl -X POST https://api.wipperoz.com/v1/jobs \
-H "Authorization: Bearer $ORBIT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ats-job-4471" \
-d '{
"title": "Senior Backend Engineer",
"category": "engineering",
"description": "<p>Own the services behind our hiring pipeline.</p>",
"responsibilities": ["Design and ship services on AWS Lambda and DynamoDB"],
"skills": [
{"name": "TypeScript", "level": "required"},
{"name": "DynamoDB", "level": "nice_to_have"}
],
"employmentType": "full_time",
"contractType": "permanent",
"salary": {"min": 150000, "max": 180000, "currency": "AUD", "period": "year"},
"location": {"country": "AU", "state": "NSW", "city": "Sydney", "remotePolicy": "hybrid"}
}'
{
"job": {
"id": "job_01J9A0B1C2EXAMPLE",
"status": "draft",
"title": "Senior Backend Engineer",
"category": "engineering",
"applyLink": "https://www.wipperoz.com/en/apply/job_01J9A0B1C2EXAMPLE?src=careers&account=acc_01J8Z3EXAMPLE"
}
}
Keep the id. It is how you publish, edit and close the role later, and it is the id you should store against the role in your own system.
Only title is required here. Publishing also needs a description and, the first time a role goes live, a category; nothing else ever blocks it. description takes HTML, which is what the Orbit editor produces; plain text is fine too.
Categories
category says what kind of job this is, from a fixed list. Send the value; the label is what Orbit shows.
| Value | Shown in Orbit as |
|---|---|
engineering |
Engineering |
data |
Data |
product |
Product |
design |
Design |
marketing |
Marketing |
sales |
Sales |
customer_success |
Customer success |
operations |
Operations |
finance |
Finance |
hr_people |
HR and people |
legal |
Legal |
healthcare |
Healthcare |
education |
Education |
hospitality |
Hospitality |
retail |
Retail |
logistics |
Logistics |
construction_trades |
Construction and trades |
manufacturing |
Manufacturing |
admin_support |
Admin and support |
other |
Other |
Anything else answers 400 naming the values it accepts. other is for the role that fits none of them.
Publish it
curl -X POST https://api.wipperoz.com/v1/jobs/job_01J9A0B1C2EXAMPLE/publish \
-H "Authorization: Bearer $ORBIT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ats-job-4471-publish" \
-d '{"expiryDays": 30}'
{
"job": {"id": "job_01J9A0B1C2EXAMPLE", "status": "published", "url": "…"},
"firstPublish": true
}
expiryDaysis30,60,90, ornullfor no expiry.nullis the default: a role that disappears on a date nobody chose is worse than one that stays up.firstPublishisfalsewhen the role had been live before — a closed role you are reopening keeps its URL.- A role missing what publishing needs answers
422with amissinglist rather than a vague400:titleordescriptionon any publish, andcategoryon a role’s first.
Edit a role
curl -X PATCH https://api.wipperoz.com/v1/jobs/job_01J9A0B1C2EXAMPLE \
-H "Authorization: Bearer $ORBIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"salary": {"min": 170000, "max": 200000, "currency": "AUD", "period": "year"}}'
Send only the fields you are changing; the rest are left alone.
On a draft the change applies immediately. On a published role the new words are staged rather than replacing what candidates are currently reading, and the response says so:
{"job": {"…": "…as it still reads today"}, "pendingRevision": true}
People are applying against the words on the page, and changing them underneath an open application changes what someone agreed to after they agreed to it. So a recruiter promotes the edit in Orbit — or you promote it yourself in the same call:
-d '{"salary": {...}, "publishEdits": true}'
Two fields skip the queue and always apply at once: expiresAt and maxMatches. Both exist to stop something, and holding “stop at fifty matches” behind a promotion would keep the matching running past the cap you set.
A managed ad keeps its title and category
Once an ad is managed, an edit that changes its title or category — directly, or by promoting a staged one with publishEdits — answers 409 and changes nothing: not the live ad, and not what is staged either. The answer:
{
"error": {
"code": "managed_ad_locked",
"message": "This job is a managed ad, so its title cannot change. Post the new role as a new job.",
"requestId": "5f7a9c1e-2b3d-4e5f-8a9b-0c1d2e3f4a5b",
"fields": ["title"]
}
}
fields names what the edit would have changed. Sending the current value again is not a change, so a sync that sends every field on every run keeps working.
The lock is what stops a managed ad being renamed into a different job. If the role upstream really is a different job now, post it as a new one. If the new title is simply right, a recruiter stops managing the ad in Orbit, on the role’s Settings tab, and the edit goes through. This API does not say whether an ad is managed; this 409 is where an integration finds out.
Close a role
curl -X POST https://api.wipperoz.com/v1/jobs/job_01J9A0B1C2EXAMPLE/close \
-H "Authorization: Bearer $ORBIT_API_KEY"
The hosted page stops accepting applications and says the role has closed, the role leaves the job seeker feed, and the CV access the role granted your recruiters ends with it — the reason for that access was an open application. Closing a managed ad also gives its slot back to your plan.
Applications you already received stay, and stay readable in Orbit. Closing is not deleting, and this API has no delete. Only a published role can be closed; a draft or an already-closed role answers 409.
Retries that do not post twice
Send an Idempotency-Key on every POST. Any value that identifies the intent — the role’s id in your own system is ideal.
- A retry with the same key returns the first answer, with
Idempotent-Replay: true. No second advertisement, no second publish. - The same key with a different body answers
409. Two intents wearing one name is a bug worth being told about. - Keys are remembered for 24 hours, per account.
- A request that was refused releases its key, so a
422you fix by adding what was missing can be retried with the same key.
Without the header, two identical creates make two advertisements. Networks time out; send the header.
Keeping a sync honest
- Store our
idagainst your role. It is the only stable handle. - Close what closed upstream. A stale advertisement costs a candidate their afternoon, which is worse than an absent one.
- Do not re-create on every sync.
PATCHthe role you already created; a freshPOSTmakes a second advertisement with a second URL. - Map your job families to a
categoryonce, and send it on every create. A draft without one cannot go live. - Treat
managed_ad_lockedas a decision, not a retry. Retrying the same rename gets the same409. - Rate limit: 120 requests a minute per key. Forty roles is forty calls, comfortably inside it.
Reference
Create a draft jobPOST /v1/jobs
Publish a jobPOST /v1/jobs/{jobId}/publish
Edit a jobPATCH /v1/jobs/{jobId}
Close a jobPOST /v1/jobs/{jobId}/close