Screening
Inviting a candidate to an AI screening on a managed ad. Needs the `screening` scope.
Invite a candidate to a screening
POST /v1/jobs/{jobId}/candidates/{candidateId}/screening
Invites one of a role's candidates to an AI screening with one of the account's screening templates. The candidate is reached the way Orbit reaches them — an interview request on their Virtual CV, or an email — and their address is never returned.
Only a **managed** ad can screen (409 ad_not_managed; managing an ad is done in Orbit). Each managed ad includes a number of screenings; past them, a screening is **overage** at the template's cost per screening, locked now and billed only if the screening completes. There is no per-call confirmation — the key's screening scope is the consent — and the account's monthly overage cap is the control: a screening that would pass it is refused with 402 overage_cap_reached. The response says which it was (billing) and the locked price.
One screening per candidate per role at a time: while one is open, another answers 409 with the open one. This route is bounded by that and by the overage cap rather than by the per-minute rate limit, and its responses carry no X-RateLimit-* headers.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
jobIdrequired | path | string | Example job_01J8Z3K9Q0EXAMPLE. |
candidateIdrequired | path | string | The role's candidate — for someone who applied, the application id. Example app_01J9B2C3D4EXAMPLE. |
Request body
Required. Shape: ScreeningRequest.
| Field | Type | Description |
|---|---|---|
templateIdrequired | string | One of the account's active screening templates. |
completeBy | string (date-time) | The deadline. Defaults to seven days from now. |
message | string | A note the candidate reads in the invitation. |
language | string | The language of the invitation email, when the candidate is reached by email. One of: en, es, fr, pt. Default en. |
{
"templateId": "tpl_01J9C3D4E5EXAMPLE",
"completeBy": "2026-10-02T00:00:00.000Z",
"message": "Thanks for applying — this takes about fifteen minutes.",
"language": "en"
}Responses
201 — The screening was sent. Body: ScreeningTriggered.
{
"screening": {
"screeningId": "8d3f6a2e-4b1c-4f0a-9e7d-2c5b8a1f0e3d",
"jobId": "job_01J8Z3K9Q0EXAMPLE",
"candidateId": "app_01J9B2C3D4EXAMPLE",
"templateId": "tpl_01J9C3D4E5EXAMPLE",
"status": "invited",
"deliveryPath": "token",
"completeBy": "2026-10-02T00:00:00.000Z",
"requestedAt": "2026-09-25T01:10:00.000Z"
},
"billing": "overage",
"priceCents": 300,
"currency": "AUD"
}400 — A query parameter is malformed. The message says which. Body: Error.
{
"error": {
"code": "bad_request",
"message": "`limit` must be an integer between 1 and 100",
"requestId": "5f7a9c1e-2b3d-4e5f-8a9b-0c1d2e3f4a5b"
}
}401 — No API key, or one that is not valid. Body: Error.
{
"error": {
"code": "unauthorized",
"message": "This endpoint requires an API key. Send it as `Authorization: Bearer <key>` or in `x-api-key`.",
"requestId": "5f7a9c1e-2b3d-4e5f-8a9b-0c1d2e3f4a5b"
}
}402 — This screening is past the ad's included ones and would take the account past its monthly overage cap. A billing admin can raise the cap in Orbit. Body: Error.
{
"error": {
"code": "overage_cap_reached",
"message": "This screening would pass the account's monthly overage cap.",
"requestId": "5f7a9c1e-2b3d-4e5f-8a9b-0c1d2e3f4a5b",
"priceCents": 300,
"currency": "AUD",
"capCents": 20000,
"spendCents": 19800
}
}403 — The key is valid but does not carry the scope this route needs. Body: Error.
{
"error": {
"code": "forbidden",
"message": "This key does not carry the `jobs:read` scope. Mint a key with it in Orbit → Settings → API keys.",
"requestId": "5f7a9c1e-2b3d-4e5f-8a9b-0c1d2e3f4a5b"
}
}404 — No such job on this account. Body: Error.
{
"error": {
"code": "not_found",
"message": "No such job on this account",
"requestId": "5f7a9c1e-2b3d-4e5f-8a9b-0c1d2e3f4a5b"
}
}409 — The role is not a managed ad, so it cannot screen (ad_not_managed).
The same status answers a screening already open for this candidate
on this role (conflict) and a template whose price cannot be read
(price_unavailable).
Body: Error.
{
"error": {
"code": "ad_not_managed",
"message": "Only a managed ad can screen candidates. Manage this ad in Orbit first.",
"requestId": "5f7a9c1e-2b3d-4e5f-8a9b-0c1d2e3f4a5b"
}
}429 — The candidate did not apply and has had as much unsolicited contact as they allow this week. Body: Error.
Schemas
Error
| Field | Type | Description |
|---|---|---|
errorrequired | object |
ScreeningRequest
| Field | Type | Description |
|---|---|---|
templateIdrequired | string | One of the account's active screening templates. |
completeBy | string (date-time) | The deadline. Defaults to seven days from now. |
message | string | A note the candidate reads in the invitation. |
language | string | The language of the invitation email, when the candidate is reached by email. One of: en, es, fr, pt. Default en. |
ScreeningTriggered
| Field | Type | Description |
|---|---|---|
screeningrequired | object | |
billingrequired | string | included — one of the managed ad's included screenings; overage — billed at priceCents if it completes. One of: included, overage. |
priceCentsrequired | integer | The template's cost per screening, locked now, in minor units, excluding GST. |
currencyrequired | string | One of: AUD, USD. |
JobList
| Field | Type | Description |
|---|---|---|
jobsrequired | Job[] | |
nextCursorrequired | string | null | Pass back as cursor for the next page. null when there is no more. |
Skill
| Field | Type | Description |
|---|---|---|
namerequired | string | |
levelrequired | string | One of: required, nice_to_have. |
Salary
| Field | Type | Description |
|---|---|---|
min | number | |
max | number | |
currency | string | ISO 4217, as entered by the recruiter. |
period | string | Free text as entered, typically year, month, day or hour. |
Location
| Field | Type | Description |
|---|---|---|
country | string | |
state | string | |
city | string | |
remotePolicy | string | One of: onsite, hybrid, remote. |
onsiteDays | string |
Company
| Field | Type | Description |
|---|---|---|
name | string | |
logoUrl | string (uri) |
Job
Enough to build a JobPosting JSON-LD block: title, description, postedAt → datePosted, expiresAt → validThrough, company → hiringOrganization, location → jobLocation, salary → baseSalary, employmentType.
| Field | Type | Description |
|---|---|---|
idrequired | string | |
statusrequired | string | published accepts applications. The other two are returned, not omitted, so a careers page can render its own closed state. One of: published, closed, expired. |
titlerequired | string | |
category | string | What kind of job this is. Absent on a role published before categories existed. One of: engineering, data, product, design, marketing, sales, customer_success, operations, finance, hr_people, legal, healthcare, education, hospitality, retail, logistics, construction_trades, manufacturing, admin_support, other. |
descriptionrequired | string | |
responsibilitiesrequired | string[] | |
skillsrequired | Skill[] | |
benefitsrequired | string[] | |
seniority | string | |
yearsExperience | string | |
employmentType | string | One of: full_time, part_time. |
contractType | string | One of: permanent, part_time, fixed_term, contract, casual. |
salary | Salary | |
location | Location | |
workStyle | string | |
companyrequired | Company | |
postedAt | string (date-time) | |
updatedAt | string (date-time) | The latest of an edit, the publish and the close. What updatedSince compares against. |
expiresAt | string (date-time) | The role stops accepting applications at this moment. Use as validThrough. |
closedAt | string (date-time) | |
url | string (uri) | The Wipperoz-hosted job page, when the role has one. |
applyLinkrequired | string (uri) | The hosted apply page. Link candidates here; it records that they came from your site. |
JobWrite
What you may set on a job. Every field is optional on PATCH; title is required on POST. The vocabulary is the one the reads return, so an enum you already map for rendering is the enum you send. What you may **not** set: status, id, slug, url, applyLink, company, postedAt, updatedAt and closedAt are ours — derived, permanent, or a lifecycle transition with its own endpoint. Sending one is a 400, deliberately, rather than being ignored: a field you thought you set and we silently dropped is worse than an error.
| Field | Type | Description |
|---|---|---|
title | string | |
category | string | What kind of job this is, from a fixed list. Required before a role's first publish. Locked, with the title, once the ad is managed in Orbit. One of: engineering, data, product, design, marketing, sales, customer_success, operations, finance, hr_people, legal, healthcare, education, hospitality, retail, logistics, construction_trades, manufacturing, admin_support, other. |
description | string | Rich text HTML, as the Orbit editor produces. Plain text is fine. |
responsibilities | string[] | |
skills | Skill[] | |
benefits | string[] | |
seniority | string | |
yearsExperience | string | |
employmentType | string | One of: full_time, part_time. |
contractType | string | One of: permanent, part_time, fixed_term, contract, casual. |
salary | Salary | |
location | Location | |
workStyle | string | |
expiresAt | string | null (date-time) | When the role stops accepting applications. null clears it — live until closed by hand. Applies immediately, even on a published ad. |
maxMatches | integer | null | Stop generating AI matches once this many exist. null removes the cap. Applies immediately, even on a published ad. |
AuthoredJob
The job as its own account sees it. Identical to Job, except that status may also be draft — the state a job is created in, which the read endpoints never return. A draft already carries an applyLink. It does not resolve until the role is published; it is there so a careers page can be built against the record before it goes live.
| Field | Type | Description |
|---|---|---|
idrequired | string | |
statusrequired | string | One of: draft, published, closed, expired. |
titlerequired | string | |
category | string | What kind of job this is. Absent on a role published before categories existed. One of: engineering, data, product, design, marketing, sales, customer_success, operations, finance, hr_people, legal, healthcare, education, hospitality, retail, logistics, construction_trades, manufacturing, admin_support, other. |
descriptionrequired | string | |
responsibilitiesrequired | string[] | |
skillsrequired | Skill[] | |
benefitsrequired | string[] | |
seniority | string | |
yearsExperience | string | |
employmentType | string | One of: full_time, part_time. |
contractType | string | One of: permanent, part_time, fixed_term, contract, casual. |
salary | Salary | |
location | Location | |
workStyle | string | |
companyrequired | Company | |
postedAt | string (date-time) | |
updatedAt | string (date-time) | The latest of an edit, the publish and the close. What updatedSince compares against. |
expiresAt | string (date-time) | The role stops accepting applications at this moment. Use as validThrough. |
closedAt | string (date-time) | |
url | string (uri) | The Wipperoz-hosted job page, when the role has one. |
applyLinkrequired | string (uri) | The hosted apply page. Link candidates here; it records that they came from your site. |