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.
While the account's subscription is paused, no screening is sent: 402 subscription_paused, with resumesAt — when the subscription resumes and screening works again. Screenings sent before the pause run to completion.
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 — No screening was sent, for one of two reasons (error.code):
- overage_cap_reached — 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. The error carries the
screening's priceCents and currency, and the account's capCents
and spendCents.
- subscription_paused — the account's subscription is paused. The
error carries resumesAt (ISO 8601), when it resumes; a billing
admin can resume it early in Orbit. For example:
{"error": {"code": "subscription_paused", "message": "Your
subscription is paused until 14 Jan 2027 — resume to use this.",
"requestId": "…", "resumesAt": "2027-01-14T00:00:00.000Z"}}
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
Where the role is. address is the place in the recruiter's words — a picked suggestion's full address, or text typed by hand — and it is what the Wipperoz job page shows first. city, state and country are set only when the place was picked from a suggestion (or sent by you), so a location typed by hand is address alone: show address when it is there, and build structured data (jobLocation) from the parts. A job needs a country to be published for the first time.
| Field | Type | Description |
|---|
address | string | The place as written. At most 300 characters when you send it. |
country | string | Required to publish a job for the first time. For a remote role, the country candidates can be in. |
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 | |
clientId | string | Which of the account's clients the job is for — the client's id, opaque, never its name. Absent when the job is for none. |
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, contract, temporary, other, volunteer, internship. |
contractType | string | One of: permanent, part_time, fixed_term, contract, casual. |
salary | Salary | |
location | Location | Where the role is. address is the place in the recruiter's words —
a picked suggestion's full address, or text typed by hand — and it is
what the Wipperoz job page shows first. city, state and country
are set only when the place was picked from a suggestion (or sent by
you), so a location typed by hand is address alone: show address
when it is there, and build structured data (jobLocation) from the
parts.
A job needs a country to be published for the first time.
|
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, contract, temporary, other, volunteer, internship. |
contractType | string | One of: permanent, part_time, fixed_term, contract, casual. |
salary | Salary | |
location | Location | Where the role is. address is the place in the recruiter's words —
a picked suggestion's full address, or text typed by hand — and it is
what the Wipperoz job page shows first. city, state and country
are set only when the place was picked from a suggestion (or sent by
you), so a location typed by hand is address alone: show address
when it is there, and build structured data (jobLocation) from the
parts.
A job needs a country to be published for the first time.
|
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. |
clientId | string | null | Which of the account's clients the job is for (a clientId from Orbit → Clients), or null for none. The job's, not the advertisement's — never staged, never shown to candidates — so it applies immediately, even on a published ad. Not one of yours is 422 client_not_found; an archived client is 422 client_archived. |
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 | |
clientId | string | Which of the account's clients the job is for — the client's id, opaque, never its name. Absent when the job is for none. |
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, contract, temporary, other, volunteer, internship. |
contractType | string | One of: permanent, part_time, fixed_term, contract, casual. |
salary | Salary | |
location | Location | Where the role is. address is the place in the recruiter's words —
a picked suggestion's full address, or text typed by hand — and it is
what the Wipperoz job page shows first. city, state and country
are set only when the place was picked from a suggestion (or sent by
you), so a location typed by hand is address alone: show address
when it is there, and build structured data (jobLocation) from the
parts.
A job needs a country to be published for the first time.
|
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. |
ConsentText
| Field | Type | Description |
|---|
consentTextIdrequired | string | |
versionrequired | string | Send this back in consent.version. Changes whenever the wording does. |
localerequired | string | One of: en, es, fr, pt. |
textrequired | string | Render this unmodified beside your apply button. |
UploadSlot
| Field | Type | Description |
|---|
uploadIdrequired | string | Name this in cv.uploadId within the hour. |
uploadUrlrequired | string | PUT the PDF here with Content-Type: application/pdf and Content-Length equal to bytes, before expiresAt. |
expiresAtrequired | string (date-time) | |
ConsentReceipt
Your record of showing the candidate the consent text and recording their acceptance. Stored verbatim with the application, beside our own copy of the text in locale — what the candidate read.
| Field | Type | Description |
|---|
consentTextIdrequired | string | One of: orbit_api_apply. |
versionrequired | string | The version you rendered, from GET /v1/consent-text. Currently api_apply_v1. |
locale | string | The language you showed it in. The claim email goes out in it too. One of: en, es, fr, pt. Default en. |
acceptedAtrequired | string | integer | When the candidate accepted it — ISO 8601, or epoch milliseconds. Not more than five minutes ahead of our clock. |
iprequired | string | The candidate's address at the point of acceptance. |
userAgentrequired | string | |
ApplicationWrite
| Field | Type | Description |
|---|
namerequired | string | |
emailrequired | string (email) | The candidate's own address. The claim link goes here, and one application per address per job is kept. |
phone | string | |
location | string | |
cvrequired | object | |
coverNote | string | |
answers | object[] | Your own screening questions and what the candidate answered, shown to the recruiter as typed. |
consentrequired | ConsentReceipt | Your record of showing the candidate the consent text and recording
their acceptance. Stored verbatim with the application, beside our
own copy of the text in locale — what the candidate read.
|
lawfulBasisrequired | object | |
ApplicationCreated
| Field | Type | Description |
|---|
applicationIdrequired | string | |
candidateIdrequired | string | The id POST /v1/jobs/{jobId}/candidates/{candidateId}/screening takes for this person. The same value as applicationId. |
statusrequired | string | One of: received. |
cvStatusrequired | string | One of: processing. |
jobrequired | object | |
ApplicationStatus
| Field | Type | Description |
|---|
applicationIdrequired | string | |
jobIdrequired | string | |
candidateIdrequired | string | |
statusrequired | string | withdrawn only when the PDF could not be read; reason says so. One of: received, withdrawn. |
cvStatusrequired | string | One of: processing, staged, claimed, deleted, failed. |
createdAtrequired | string (date-time) | |
claimedAt | string (date-time) | Once the candidate has claimed the Virtual CV. |
reason | string | On withdrawn. One of: cv_unreadable. |
CandidateWrite
| Field | Type | Description |
|---|
namerequired | string | |
emailrequired | string (email) | The person's own address. One import per address per account; the claim email goes here. |
cvrequired | object | |
sendClaimEmail | boolean | Whether to email the person that a Virtual CV was built for them, with a way to claim or delete it. A person who already has an account is told either way. Default true. |
locale | string | The claim email's language. One of: en, es, fr, pt. Default en. |
lawfulBasisrequired | object | |
CandidateCreated
| Field | Type | Description |
|---|
candidateIdrequired | string | The id My candidates knows this person by. |
statusrequired | string | One of: queued, existing_account, suppressed, already_imported, rejected. |
cvStatusrequired | string | processing only when a Virtual CV is being built (queued). One of: processing, none. |
rejection | object | On rejected. |