Cenivoz API
The Cenivoz API connects your own software (a dealership management system, an applicant tracking system, an HR platform, your careers website) to Cenivoz's AI-led interviews.
With it your software can:
- create and update jobs, and open or close them;
- add applicants to a job, upload their resume and cover letter, update them as they move through your pipeline, archive them;
- schedule interviews (a web link the applicant opens, or a phone call), reschedule or cancel them, or have Cenivoz email the applicant a link to book their own time;
- follow every interview: its status, the result (scores, recommendation, summary, screening answers), the transcript and the recording;
- read statistics: how many people applied and how many interviews took place;
- be told the moment something happens through webhooks: a resume is processed, an interview is scheduled, starts, finishes or is missed, an applicant's status changes.
Everything you can do in the Cenivoz app, your team still can: changes made in the app and through the API are the same records, and both appear in your company's audit log (API changes are marked "via API").
How it fits together
Your software Cenivoz
───────────── ──────────────
POST /oauth/token (client ID+secret) ─▶ access token (1 hour)
POST /v1/jobs, /v1/applicants, ... ─▶ jobs, applicants, resumes
POST /v1/interviews ─▶ the interviewer talks to the applicant (web or phone)
GET /v1/interviews/{id}/result ◀─ scores, recommendation, transcript
your webhook URL ◀─ interview.completed, applicant.resume_parsed, ...
Two environments
| Sandbox | Production | |
|---|---|---|
| Base URL | https://connect-sandbox.cenivoz.com |
https://connect.cenivoz.com |
| Data | its own test data | your real jobs, applicants and interviews |
| Resumes | processed instantly with sample content | read by Cenivoz's resume reader |
| Interviews | simulated: they run by themselves in seconds, with sample results | real conversations with your applicants |
| Emails, calls | never sent | sent as configured |
| Credentials | cp_test_… / cps_test_… |
cp_live_… / cps_live_… |
Build and test your integration against the sandbox first. When it works, create production credentials and change the base URL: the API behaves the same in both.
Getting access
- Cenivoz switches the API on for your company (sandbox first, production when you are ready). Ask your Cenivoz contact.
- In the Cenivoz app, open Developers (in the menu under Company). Create a credential: you get a client ID and a client secret. The secret is shown once; store it safely.
- Optionally add a webhook endpoint (a URL on your server) on the same page.
- Follow the quick start.
What is in these docs
- Quick start: from credentials to a scored interview in ten minutes.
- Authentication: tokens, scopes, rotating and revoking secrets.
- Sandbox: how the simulation behaves and how to test each outcome.
- Conventions: ids,
externalId, dates, pagination, idempotency, rate limits, versioning. - Typical integrations: the usual flows, step by step.
- Webhooks: events, signatures, retries.
- Going live: the checklist.
- API reference: every endpoint, field, object, event and error code.
Also available: the interactive API explorer (try every call against the sandbox), the OpenAPI document and a Postman collection with sandbox and production environments.
Quick start
This walks through the whole journey against the sandbox: get a token, create a job and an applicant, upload a resume, schedule an interview and read the result. In the sandbox the interview runs by itself, so the whole thing takes about a minute.
You need a sandbox client ID and client secret from the Developers page of the Cenivoz app.
export CP_BASE=https://connect-sandbox.cenivoz.com
export CLIENT_ID=cp_test_...
export CLIENT_SECRET=cps_test_...
1. Get an access token
curl -s -X POST "$CP_BASE/oauth/token" \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=client_credentials
{ "access_token": "eyJhbGciOi...", "token_type": "Bearer", "expires_in": 3600, "scope": "jobs:read jobs:write ..." }
Keep the token and reuse it for the hour. Send it on every call:
export TOKEN=eyJhbGciOi...
curl -s "$CP_BASE/v1/me" -H "Authorization: Bearer $TOKEN"
2. Pick a job category
Every job has a category; it decides how the interviewer runs the interview (the questions for a lube technician are not the ones for a service advisor).
curl -s "$CP_BASE/v1/categories" -H "Authorization: Bearer $TOKEN"
3. Create a job
curl -s -X POST "$CP_BASE/v1/jobs" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: job-ats-1042" \
-d '{ "title": "Lube Technician", "category": "CATEGORY_ID", "location": "Austin, TX", "externalId": "ats-job-1042" }'
externalId is your own id for the job. It must be unique, so a retried create can never make a duplicate, and you can find the job by it later (GET /v1/jobs?externalId=ats-job-1042).
4. Add an applicant and their resume
curl -s -X POST "$CP_BASE/v1/applicants" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "name": "Jordan Rivera", "email": "jordan@example.com", "phone": "+15125550100", "jobId": "JOB_ID", "externalId": "ats-cand-88231" }'
curl -s -X POST "$CP_BASE/v1/applicants/APPLICANT_ID/resume" \
-H "Authorization: Bearer $TOKEN" \
-F "resume=@jordan-rivera.pdf"
The resume is processed in the background (202 Accepted). In the sandbox it takes about three seconds; in production usually under a minute. Wait for the applicant.resume_parsed webhook, or poll:
curl -s "$CP_BASE/v1/applicants/APPLICANT_ID/resume-status" -H "Authorization: Bearer $TOKEN"
5. Schedule the interview
curl -s -X POST "$CP_BASE/v1/interviews" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "applicantId": "APPLICANT_ID", "medium": "web" }'
Without scheduledAt the link (interviewUrl) works right away. With scheduledAt (a time on a 15-minute mark) Cenivoz emails the applicant the time and link and reminds them 15 minutes before, unless you send "notifyApplicant": false and tell them yourself.
In the sandbox, this interview now runs by itself: it starts after about five seconds and ends about eight seconds later, then it is scored.
6. Read the result
curl -s "$CP_BASE/v1/interviews/INTERVIEW_ID" -H "Authorization: Bearer $TOKEN"
curl -s "$CP_BASE/v1/interviews/INTERVIEW_ID/result" -H "Authorization: Bearer $TOKEN"
curl -s "$CP_BASE/v1/interviews/INTERVIEW_ID/transcript" -H "Authorization: Bearer $TOKEN"
When scoringStatus is scored, the result has the scores, the recommendation (Strong Hire, Hire, Borderline or No Hire), a summary, strengths and weaknesses. The same JSON arrives at your webhook as interview.completed.
The same in code
Node.js (18+)
const BASE = process.env.CP_BASE;
let token = null;
let tokenExpires = 0;
async function getToken() {
if (token && Date.now() < tokenExpires - 60_000) return token;
const res = await fetch(`${BASE}/oauth/token`, {
method: 'POST',
headers: {
Authorization: `Basic ${Buffer.from(`${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`).toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'grant_type=client_credentials',
});
if (!res.ok) throw new Error(`token: ${res.status} ${await res.text()}`);
const data = await res.json();
token = data.access_token;
tokenExpires = Date.now() + data.expires_in * 1000;
return token;
}
async function api(method, path, body, headers = {}) {
const res = await fetch(`${BASE}/v1${path}`, {
method,
headers: { Authorization: `Bearer ${await getToken()}`, 'Content-Type': 'application/json', ...headers },
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json();
if (!res.ok) throw Object.assign(new Error(data.error.message), { code: data.error.code, details: data.error.details });
return data;
}
const job = await api('POST', '/jobs', { title: 'Lube Technician', category: 'CATEGORY_ID', externalId: 'ats-job-1042' });
const applicant = await api('POST', '/applicants', { name: 'Jordan Rivera', email: 'jordan@example.com', jobId: job.id });
Python (requests)
import os, time, requests
BASE = os.environ["CP_BASE"]
_token = {"value": None, "expires": 0}
def token():
if _token["value"] and time.time() < _token["expires"] - 60:
return _token["value"]
r = requests.post(f"{BASE}/oauth/token", data={"grant_type": "client_credentials"},
auth=(os.environ["CLIENT_ID"], os.environ["CLIENT_SECRET"]), timeout=15)
r.raise_for_status()
data = r.json()
_token.update(value=data["access_token"], expires=time.time() + data["expires_in"])
return _token["value"]
def api(method, path, **kwargs):
r = requests.request(method, f"{BASE}/v1{path}", headers={"Authorization": f"Bearer {token()}"}, timeout=30, **kwargs)
if r.status_code >= 400:
err = r.json()["error"]
raise RuntimeError(f'{err["code"]}: {err["message"]}')
return r.json()
job = api("POST", "/jobs", json={"title": "Lube Technician", "category": "CATEGORY_ID", "externalId": "ats-job-1042"})
with open("jordan-rivera.pdf", "rb") as f:
api("POST", f"/applicants/{applicant_id}/resume", files={"resume": ("jordan-rivera.pdf", f, "application/pdf")})
PHP (cURL)
function cp_token(): string {
static $token = null, $expires = 0;
if ($token && time() < $expires - 60) return $token;
$ch = curl_init(getenv('CP_BASE') . '/oauth/token');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_USERPWD => getenv('CLIENT_ID') . ':' . getenv('CLIENT_SECRET'),
CURLOPT_POSTFIELDS => 'grant_type=client_credentials',
CURLOPT_RETURNTRANSFER => true,
]);
$data = json_decode(curl_exec($ch), true);
$token = $data['access_token'];
$expires = time() + $data['expires_in'];
return $token;
}
function cp_api(string $method, string $path, ?array $body = null): array {
$ch = curl_init(getenv('CP_BASE') . '/v1' . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . cp_token(), 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => $body ? json_encode($body) : null,
CURLOPT_RETURNTRANSFER => true,
]);
return json_decode(curl_exec($ch), true);
}
Authentication
The API uses OAuth 2.0 client credentials: your software has a client ID and a secret, exchanges them for a short-lived access token, and sends the token with every call. No person signs in.
Credentials
Credentials are created on the Developers page of the Cenivoz app (only by your team, never by Cenivoz staff, so a secret is only ever seen by the people who use it):
- a client ID (
cp_test_…for the sandbox,cp_live_…for production): not secret; - a client secret (
cps_test_…/cps_live_…): shown once when created. Store it in your server's secret store. Cenivoz keeps only a one-way hash and cannot show it again.
Creating or rotating a production credential asks for your password. Each credential has a name (shown in your audit log next to every change it makes) and a set of scopes.
Getting a token
curl -X POST "https://connect-sandbox.cenivoz.com/oauth/token" -u "$CLIENT_ID:$CLIENT_SECRET" -d grant_type=client_credentials
The credentials can also be sent in the body as client_id and client_secret (form or JSON), but not both ways at once. The answer:
{ "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600, "scope": "jobs:read jobs:write ..." }
Send the token as Authorization: Bearer <access_token> on every /v1 call. Reuse it until it is about to expire (one hour); do not request a token per call: the token endpoint is rate limited per address.
A token belongs to one environment. A sandbox token sent to the production URL (or the reverse) is refused with 403 wrong_environment, and the message names the right URL.
Scopes
A credential can do only what its scopes allow; a call without the needed scope gets 403 insufficient_scope naming it. New credentials get every scope unless you untick some. You can also ask for a token with fewer scopes: -d scope="jobs:read applicants:read".
Changing a credential's scopes on the Developers page takes effect within seconds, also on tokens already issued.
Rotating a secret
On the Developers page, Rotate creates a new secret. By default the old one keeps working for 24 hours, so you can deploy the new secret without downtime; choose "stop the old secret now" if it leaked.
Revoking
Revoke stops a credential for good, within seconds (tokens already issued stop too). Cenivoz can also revoke a credential, or switch your API access off, if something goes wrong.
Keeping it safe
- Keep the secret on your server. Never put it in a browser, a mobile app or a public repository.
- Use one credential per system (and per environment), so you can rotate or revoke one without touching the others.
- Give each credential only the scopes it needs.
- Every call is logged (Developers → Request log, 30 days), and every change appears in your audit log as "via API".
Sandbox
The sandbox (https://connect-sandbox.cenivoz.com) is a complete copy of the API with its own data. It runs the same code and the same rules as production (the same validation, the 15-minute grid, one interview at a time, the resume requirement, the same errors), but nothing in it reaches a real person:
- no emails are sent (the API reports them as sent);
- no phone calls are placed (phone interviews can still be scheduled and are simulated);
- no AI reads resumes or runs interviews: the sandbox plays them out with sample content.
Your categories, screening questionnaires, interview languages and interviewer name are copied from your real account (refreshed every minute), so jobs are set up exactly as in production.
What happens by itself
| You do | The sandbox does |
|---|---|
| Upload a resume | About 3 seconds later it is parsed with a sample profile, and the match score follows. applicant.resume_parsed is sent. |
| Schedule an interview without a time (or at a time that has come) | About 5 seconds later it starts (interview.started), about 8 seconds later it ends, about 2 seconds later it is scored: interview.completed with the result. The applicant moves to interviewed. |
| Schedule an interview for a later time | Nothing until that time, then as above. |
| Send a scheduling link | About 5 seconds later the applicant "books" the current time, and the interview runs as above. |
Ask for a welcome email (sendWelcomeEmail) |
Once the resume is processed it is marked sent (nothing is emailed) with its booking link (interview.invited); the applicant then "books" and the interview runs, as with a scheduling link. |
Scores are generated from the interview's id, so the same interview always gets the same result: your tests are repeatable.
Testing a specific outcome
Tag the applicant to steer the simulation (tags when creating or updating):
| Tag | Effect |
|---|---|
sim:resume-fail |
the resume cannot be read: applicant.resume_failed |
sim:left-early |
the applicant leaves after a minute: completionType: ended_early, scoringStatus: insufficient_data |
sim:missed |
the applicant never joins: interview.missed |
sim:error |
the interview cannot take place (e.g. an unanswered call): interview.failed |
sim:manual |
nothing runs by itself; drive it with the endpoints below |
Or drive it on demand (sandbox credentials only):
# Play an interview out now: completed (with a chosen score), left_early, missed or failed
curl -X POST "https://connect-sandbox.cenivoz.com/v1/sandbox/interviews/INTERVIEW_ID/simulate" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "outcome": "completed", "overallScore": 82 }'
# Finish an uploaded resume now: parsed or failed
curl -X POST "https://connect-sandbox.cenivoz.com/v1/sandbox/applicants/APPLICANT_ID/resume/simulate" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{ "outcome": "failed" }'
# Start over: delete all of your sandbox jobs, applicants, interviews, events and deliveries
curl -X POST "https://connect-sandbox.cenivoz.com/v1/sandbox/reset" -H "Authorization: Bearer $TOKEN"
Testing webhooks
Add a webhook endpoint for the sandbox on the Developers page, then:
- press Send test event (or
POST /v1/webhook-endpoints/{id}/test): awebhook.testevent arrives within a second or two; - create an applicant and upload a resume, or schedule an interview: the real events follow within seconds.
Every attempt, with your server's answer, is under Developers → Deliveries.
Limits
- Sandbox data is deleted after 30 days.
- Sandbox interviews have no recordings (
audioUrlisnull). - The sandbox's rate limit is the same as production's.
Conventions
Requests and responses
- JSON in, JSON out (
Content-Type: application/json), UTF-8. The one exception is uploading a resume as a file (multipart/form-data). - Request bodies are strict: an unknown or misspelled property is a
422 validation_failedthat names it, never silently ignored. - Responses may gain new fields at any time: ignore fields you do not know. Removing or renaming a field would only happen under a new version (
/v2). - Every record has
idandobject(its type:job,applicant,interview, ...). - Empty text fields are
""; missing values that are not text (dates, numbers) arenull.
Ids and externalId
Cenivoz's ids carry a prefix: job_…, cand_… (applicants), sess_… (interviews), evt_… (events), whd_… (deliveries), whe_… (webhook endpoints). Treat them as opaque strings.
Jobs, applicants and interviews can also carry your id as externalId (up to 100 characters). It is unique per record type within your company and environment:
- a second create with the same
externalIdreturns409 duplicate_external_idwitherror.details.existingId: the record you already have, so a retry never makes a duplicate; - find records by it:
GET /v1/applicants?externalId=ats-cand-88231; - it is included in every response and every event, so your webhook handler can map events back to your own records without a lookup table.
Dates and times
All timestamps are ISO 8601 in UTC (2035-01-15T16:00:00.000Z). Where a date is accepted (applicationDeadline, list filters) you can send a plain date (2035-12-31) or a timestamp.
Interview times (scheduledAt) must be in the future and on a 15-minute mark (:00, :15, :30, :45).
Pagination
Lists are newest first and come in pages:
{ "object": "list", "data": [ ... ], "hasMore": true, "nextCursor": "eyJjIjoxNz..." }
Pass nextCursor back as ?cursor= for the next page; limit is 1 to 100 (default 25). Cursors are stable: records added while you page through do not make you skip or repeat any.
Keeping in sync: updatedSince
The job, applicant and interview lists accept updatedSince: only records changed at or after that time, including changes made by your team in the app and automatic ones (a resume processed, an interview finished). Store the time you started your last sync, and ask for changes since then (minus a minute, for safety). Webhooks are the faster way; updatedSince (or GET /v1/events) is the safety net if your receiver was down.
Idempotency
Network calls fail in the middle. To make a retry safe, send an Idempotency-Key header (any unique string, e.g. a UUID) on POST, PATCH and PUT:
- the same key with the same request returns the first response again (with the header
Idempotent-Replayed: true), without doing the work twice; - the same key with a different request is
422 idempotency_conflict; - while the first request is still running, a repeat gets
409 request_in_progress(wait a second and retry); - only successful answers are remembered (for 24 hours): after an error, fix the request and retry with the same key.
Error format
{ "error": { "code": "validation_failed", "message": "The request did not pass validation", "details": [ { "field": "category", "message": "is required" } ], "requestId": "req_9c1e5f0a72d43b86e1" } }
Branch on code (stable); show or log message (for people; wording may change). Quote requestId (also the X-Request-Id header) when you contact support. The error reference lists every code with what to do.
Rate limits
Each credential may make a number of requests a minute (120 unless agreed otherwise). Every response says where you stand:
| Header | Meaning |
|---|---|
RateLimit-Limit |
requests allowed per minute |
RateLimit-Remaining |
left in the current minute |
RateLimit-Reset |
seconds until the minute resets |
Over the limit you get 429 rate_limited with Retry-After (seconds). Spread bulk imports out, and back off on 429 and 5xx.
Versioning
The API is versioned in the path (/v1) and dated (apiVersion in /v1/me and in every event). Within /v1 changes are additive only: new endpoints, new optional request fields, new response fields, new event types and new error codes (handle unknown ones as their HTTP status class). Breaking changes would come as /v2, announced in advance, with /v1 kept running.
What the interviewer will not do
The interviewer never discusses pay, never promises an outcome and never asks about a driving record. Salary fields on a job are for your records only. Hiring decisions stay with your team: the result is a recommendation.
Typical integrations
Lifecycles
Applicant status (applicant.status), your pipeline:
new ─▶ contacted ─▶ interview_ready ─▶ interviewed ─▶ hired
└─▶ rejected
Cenivoz moves an applicant forward automatically (to interview_ready when an interview is scheduled or a link sent, to interviewed when it ends), never backwards and never over hired / rejected. Set hired and rejected yourself with PATCH /v1/applicants/{id}.
Interview status (interview.status):
awaiting_booking ─▶ scheduled ─▶ in_progress ─▶ completed
│ └─▶ left_early
├─▶ missed (time passed by 30 minutes, never joined)
├─▶ cancelled (cancelled, or replaced by a newer interview)
└─▶ error (could not take place, e.g. an unanswered call)
completed and left_early are followed shortly by the evaluation: scoringStatus goes from pending to scored (or insufficient_data if too little was said, or failed). interview.completed is sent once the evaluation is final.
1. Your careers site or ATS sends applicants to be interviewed
- When a job opens in your system:
POST /v1/jobswith your id asexternalId(once; afterwardsPATCHit). - When someone applies:
POST /v1/applicants(withexternalId), thenPOST /v1/applicants/{id}/resume. - On
applicant.resume_parsed: eitherPOST /v1/interviews/scheduling-linkand let Cenivoz email them a link to book a time, orPOST /v1/interviewswith a time you agreed with them (notifyApplicant: falseif your system sends the invitation, withinterviewUrl).
- On
interview.completed: storedata.result(scores, recommendation, summary) against the applicant in your system, and link to the transcript and recording if you need them. - When your team decides:
PATCH /v1/applicants/{id}withstatus: hiredorrejected.
2. Your DMS owns the jobs; Cenivoz owns the interviews
- Mirror openings: create jobs with
externalId= your requisition id, close them withPATCH { "status": "closed" }(orfilled) when the requisition closes. - Keep both sides in sync by listening to
job.updatedandapplicant.status_changed: changes your team makes in the Cenivoz app arrive there too. - Nightly, as a safety net:
GET /v1/applicants?updatedSince=…andGET /v1/interviews?updatedSince=….
3. Reporting
GET /v1/stats/overview?from=2035-01-01&to=2035-02-01 gives applicants added (by current status), interviews scheduled, started, completed and left early, average score and duration. GET /v1/stats/jobs gives the same per job.
Things to know
- One interview at a time per company: two interviews cannot overlap. Pick another time on
409 slot_taken. - A new interview replaces the old one: scheduling an applicant again cancels their earlier interviews that have not started (
interview.cancelled). - The resume is required before an interview (
409 resume_required). An archived applicant cannot be scheduled (409 applicant_archived). - Phone interviews need the applicant's phone number in international format.
- Languages:
GET /v1/interview-languageslists those enabled for your company; pass acodeaslanguage. - Screening questions: attach your questionnaires to a job (
screeningQuestionnaireIds); the interview then asks them, andresult.screeningholds each answer.
Webhooks
A webhook endpoint is a URL on your server. When something happens in your account (an interview finishes, a resume is processed, a status changes), Cenivoz sends the event to it as an HTTPS POST with a JSON body, within seconds.
Add endpoints on the Developers page of the Cenivoz app, separately for the sandbox and production (up to five each). For each you choose which events it receives (every event by default) and get a signing secret (whsec_…).
What arrives
POST /cenivoz/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: Cenivoz-Webhooks/1.0
Cenivoz-Event-Id: evt_0d9e1c7b5a3f2468
Cenivoz-Event-Type: interview.completed
Cenivoz-Delivery-Id: whd_6a0f4c2e8b1d3975
Cenivoz-Attempt: 1
Cenivoz-Signature: t=2051256134,v1=5f2b9c...
{ "id": "evt_0d9e1c7b5a3f2468", "object": "event", "type": "interview.completed", "apiVersion": "2026-10-01",
"environment": "production", "createdAt": "2035-01-15T16:13:19.000Z",
"data": { "interview": { ... }, "result": { ... } } }
data holds the record exactly as the REST API returns it. The event reference lists every type with an example.
Answering
- Answer with any 2xx status within 10 seconds. Do the real work afterwards (put the event on a queue): a slow answer counts as a failure.
- Anything else (another status, a timeout, a TLS error, a refused connection, a redirect: redirects are not followed) is a failure, and the event is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours: eight attempts over about two days. After that it is marked "gave up"; you can still send it again from the Developers page or with
POST /v1/webhook-deliveries/{id}/redeliver. - After 50 failures in a row the endpoint is switched off (the Developers page and your audit log say so). Fix your receiver, send a test event, and switch it back on.
Verifying the signature
Check every request, so nobody else can post fake events to your URL. The header is
Cenivoz-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>
where v1 = HMAC-SHA256 of "<t>.<raw request body>" with your endpoint's signing secret. Use the raw body exactly as received (before any JSON parsing), compare in constant time, and reject timestamps more than five minutes old. For 24 hours after you roll the secret there are two v1 values (old and new secret): accept the request if either matches.
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
function verify(header, rawBody, secret, toleranceSeconds = 300) {
const parts = String(header || '').split(',').map((p) => p.trim());
const t = Number((parts.find((p) => p.startsWith('t=')) || '').slice(2));
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
return parts.filter((p) => p.startsWith('v1=')).some((p) => {
const got = Buffer.from(p.slice(3), 'hex');
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
}
app.post('/cenivoz/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.get('Cenivoz-Signature'), req.body.toString('utf8'), process.env.CP_WEBHOOK_SECRET)) {
return res.status(400).send('bad signature');
}
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200); // answer first...
queue.add(event); // ...then process (de-duplicate on event.id)
});
Python (Flask)
import hmac, hashlib, time, json, os
from flask import Flask, request
app = Flask(__name__)
def verify(header, raw_body, secret, tolerance=300):
parts = [p.strip() for p in (header or "").split(",")]
ts = next((p[2:] for p in parts if p.startswith("t=")), None)
if not ts or abs(time.time() - int(ts)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(p[3:], expected) for p in parts if p.startswith("v1="))
@app.post("/cenivoz/webhooks")
def webhook():
if not verify(request.headers.get("Cenivoz-Signature"), request.get_data(), os.environ["CP_WEBHOOK_SECRET"]):
return "bad signature", 400
event = json.loads(request.get_data())
enqueue(event) # de-duplicate on event["id"]
return "", 200
PHP
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_CENIVOZ_SIGNATURE'] ?? '';
$secret = getenv('CP_WEBHOOK_SECRET');
$parts = array_map('trim', explode(',', $header));
$t = 0; $sigs = [];
foreach ($parts as $p) {
if (str_starts_with($p, 't=')) $t = (int) substr($p, 2);
if (str_starts_with($p, 'v1=')) $sigs[] = substr($p, 3);
}
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
$valid = $t && abs(time() - $t) <= 300 && array_filter($sigs, fn ($s) => hash_equals($expected, $s));
if (!$valid) { http_response_code(400); exit('bad signature'); }
$event = json_decode($raw, true);
http_response_code(200);
// process $event (de-duplicate on $event['id'])
De-duplication and order
- An event can arrive more than once (a retry after your server answered too slowly, a redelivery). Its
idnever changes: store the ids you have handled and skip repeats. - Events can arrive out of order (a retry of an older event after a newer one). Use
createdAtand the record'supdatedAtto keep the newest state, or fetch the record from the API when in doubt. - Events describe the state of a record, not every step: if an interview went from scheduled to finished in a few seconds, you may receive
interview.completedwithoutinterview.started.
Missed something?
GET /v1/events lists every event of the last 30 days (also without any webhook endpoint), newest first, filterable by type and since. Developers → Deliveries shows every delivery with each attempt and your server's answer.
Testing
Use Send test event on the Developers page (or POST /v1/webhook-endpoints/{id}/test) for a webhook.test event, and the sandbox for real events from simulated interviews. During development, a tunnel (ngrok, Cloudflare Tunnel) gives your local server a public https URL.
Going live
When your integration works in the sandbox:
- Ask Cenivoz to switch production on for your company.
- Create a production credential on the Developers page (it asks for your password). Give it only the scopes it needs. Store the secret in your production secret store.
- Add a production webhook endpoint (https, public) and store its signing secret. Press Send test event and check your server verified it.
- Change the base URL from
https://connect-sandbox.cenivoz.comtohttps://connect.cenivoz.com. Nothing else changes.
Checklist
- Tokens are cached and reused for their hour, not requested per call.
- Every create sends an
externalIdand/or anIdempotency-Key, so retries never duplicate. - Errors are handled by
code;429and5xxare retried with backoff (respectRetry-After). - The webhook receiver verifies
Cenivoz-Signature, answers 2xx within 10 seconds, and de-duplicates on the eventid. - Unknown response fields, event types and error codes are ignored safely.
- Applicant emails and phone numbers are correct: in production Cenivoz really emails and calls (unless you send
notifyApplicant: false). - Interview times are on a 15-minute mark and in the future; a
409 slot_takenleads to another time. - Secrets are not in source code, logs or browsers.
- Someone watches Developers → Deliveries and Request log (or your own logging of
requestId).
Differences from the sandbox
| Sandbox | Production | |
|---|---|---|
| Resume processing | ~3 s, sample content | usually under a minute, read from the file |
| Interviews | run by themselves in seconds | happen when the applicant joins / answers |
| Emails to applicants | never | the scheduled-interview email, reminder, scheduling link and welcome email, as you request them |
| Phone interviews | simulated | real calls |
| Recordings | none | GET /v1/interviews/{id}/recording |
/v1/sandbox/* |
available | 403 forbidden |
Support
Quote the requestId of the failing call (in the error body and the X-Request-Id header), the time, and the environment.
Changelog
2026-10-01 — v1
First release of the Cenivoz API:
- OAuth 2.0 client credentials; sandbox and production environments with separate credentials and base URLs; scopes.
- Jobs, applicants (resumes as files or base64, cover letters), interviews (web and phone, scheduling links, reschedule, cancel), results, transcripts, recording links, statistics.
externalIdon jobs, applicants and interviews;Idempotency-Keyon writes; cursor pagination;updatedSincefor incremental sync.- Signed webhooks with retries for 14 event types;
GET /v1/events; delivery history and redelivery. - Sandbox simulator with
sim:tags and/v1/sandbox/*helpers.
API reference
Base URLs: sandbox https://connect-sandbox.cenivoz.com, production https://connect.cenivoz.com. API version 2026-10-01. All endpoints below are relative to the base URL; send Authorization: Bearer <access token> on every /v1 call. The OpenAPI document is at /openapi.json on each base URL.
Authentication endpoint
Get an access token
POST /oauth/token · no access token needed
OAuth 2.0 client credentials. Send grant_type=client_credentials with your client ID and secret, either as HTTP Basic authentication (recommended) or as client_id / client_secret in the body (application/x-www-form-urlencoded or JSON), not both. Optionally send scope (space-separated) for a token that can do less. Tokens last one hour: reuse them.
| Field | Type | Required | Description |
|---|---|---|---|
grant_type |
string: client_credentials |
yes | Always client_credentials. |
client_id |
string | no | Your client ID (cp_live_… or cp_test_…). Send it and the secret here, or use HTTP Basic authentication instead: not both. |
client_secret |
string | no | Your client secret (cps_live_… or cps_test_…). |
scope |
string | no | Optional. A space-separated subset of the scopes your credential holds, to get a token that can do less. Default: all of them. |
curl -X POST "https://connect-sandbox.cenivoz.com/oauth/token" \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=client_credentials
Response 200
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjaWQiOiJjcF9saXZlXzViMWU5ZDBj...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "jobs:read jobs:write applicants:read applicants:write interviews:read interviews:write stats:read events:read webhooks:read webhooks:write"
}
Errors (RFC 6749 shape { "error", "error_description" }):
| Status | error |
Meaning | What to do |
|---|---|---|---|
| 400 | invalid_request |
A parameter is missing, or credentials were sent both as Basic auth and in the body. | Send grant_type=client_credentials and the credentials once. |
| 400 | invalid_scope |
You asked for a scope your credential does not hold. | Ask only for scopes the credential has, or leave scope out to get all of them. |
| 400 | unsupported_grant_type |
Only client_credentials is supported. |
Use grant_type=client_credentials. |
| 401 | invalid_client |
Wrong, unknown or revoked client id or secret (the same answer for all of these). | Check the credentials. After rotating a secret, the old one works for 24 hours. |
| 403 | access_denied |
The company is suspended, API access is not enabled for this environment, or these credentials belong to the other environment (the message names the right URL). | Use the right URL, or contact Cenivoz. |
| 429 | too_many_requests |
Too many token requests from your address, or too many failed attempts for this client id. See Retry-After. |
Cache your token (it lasts an hour) instead of requesting one per call. |
Scopes
| Scope | Allows |
|---|---|
jobs:read |
List and view jobs, job categories, screening questionnaires and interview languages |
jobs:write |
Create and update jobs, open and close them |
applicants:read |
List and view applicants and their resume status |
applicants:write |
Create, update, archive and restore applicants; upload resumes and cover letters |
interviews:read |
List interviews; read their status, results, transcripts and recordings |
interviews:write |
Schedule, reschedule and cancel interviews; send scheduling links |
stats:read |
Read applicant and interview statistics |
events:read |
List the events that happened (the same events webhooks push) |
webhooks:read |
Read the webhook delivery history |
webhooks:write |
Redeliver webhook events and send test events |
Endpoints
| Method | Path | What it does | Scope |
|---|---|---|---|
| GET | /v1/me |
Describe the credential | — |
| GET | /v1/company |
Retrieve your company | jobs:read |
| GET | /v1/categories |
List job categories | jobs:read |
| GET | /v1/questionnaires |
List screening questionnaires | jobs:read |
| GET | /v1/interview-languages |
List interview languages | jobs:read |
| POST | /v1/jobs |
Create a job | jobs:write |
| GET | /v1/jobs |
List jobs | jobs:read |
| GET | /v1/jobs/{jobId} |
Retrieve a job | jobs:read |
| PATCH | /v1/jobs/{jobId} |
Update a job | jobs:write |
| POST | /v1/applicants |
Create an applicant | applicants:write |
| GET | /v1/applicants |
List applicants | applicants:read |
| GET | /v1/applicants/{applicantId} |
Retrieve an applicant | applicants:read |
| PATCH | /v1/applicants/{applicantId} |
Update an applicant | applicants:write |
| POST | /v1/applicants/{applicantId}/archive |
Archive an applicant | applicants:write |
| POST | /v1/applicants/{applicantId}/restore |
Restore an applicant | applicants:write |
| POST | /v1/applicants/{applicantId}/resume |
Upload a resume | applicants:write |
| GET | /v1/applicants/{applicantId}/resume-status |
Check resume processing | applicants:read |
| PUT | /v1/applicants/{applicantId}/cover-letter |
Set the cover letter | applicants:write |
| DELETE | /v1/applicants/{applicantId}/cover-letter |
Remove the cover letter | applicants:write |
| POST | /v1/interviews |
Schedule an interview | interviews:write |
| POST | /v1/interviews/scheduling-link |
Email a self-scheduling link | interviews:write |
| GET | /v1/interviews |
List interviews | interviews:read |
| GET | /v1/interviews/{interviewId} |
Retrieve an interview (status) | interviews:read |
| POST | /v1/interviews/{interviewId}/reschedule |
Reschedule an interview | interviews:write |
| POST | /v1/interviews/{interviewId}/cancel |
Cancel an interview | interviews:write |
| GET | /v1/interviews/{interviewId}/result |
Retrieve the result | interviews:read |
| GET | /v1/interviews/{interviewId}/transcript |
Retrieve the transcript | interviews:read |
| GET | /v1/interviews/{interviewId}/recording |
Get recording links | interviews:read |
| GET | /v1/stats/overview |
Applicants and interviews for a period | stats:read |
| GET | /v1/stats/jobs |
Statistics per job | stats:read |
| GET | /v1/events |
List events | events:read |
| GET | /v1/events/{eventId} |
Retrieve an event | events:read |
| GET | /v1/webhook-endpoints |
List webhook endpoints | webhooks:read |
| POST | /v1/webhook-endpoints/{endpointId}/test |
Send a test event | webhooks:write |
| GET | /v1/webhook-deliveries |
List webhook deliveries | webhooks:read |
| POST | /v1/webhook-deliveries/{deliveryId}/redeliver |
Redeliver an event | webhooks:write |
| POST | /v1/sandbox/interviews/{interviewId}/simulate |
Simulate an interview (sandbox) | interviews:write |
| POST | /v1/sandbox/applicants/{applicantId}/resume/simulate |
Finish resume processing now (sandbox) | applicants:write |
| POST | /v1/sandbox/reset |
Reset sandbox data | jobs:write |
Account
Describe the credential
GET /v1/me
Which company, environment and scopes the access token belongs to. A good first call to check that authentication works.
Example request
curl -X GET "https://connect-sandbox.cenivoz.com/v1/me" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: Me
{
"clientId": "cp_live_5b1e9d0c7a3f42e8b6d1c093",
"clientName": "DMS integration",
"companyId": "company_a1b2c3d4e5f60718",
"companyName": "Lone Star Motors",
"environment": "production",
"scopes": [
"jobs:read",
"jobs:write",
"applicants:read",
"applicants:write",
"interviews:read",
"interviews:write",
"stats:read",
"events:read",
"webhooks:read",
"webhooks:write"
],
"rateLimitPerMinute": 120,
"apiVersion": "2026-10-01"
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429)
Retrieve your company
GET /v1/company · scope jobs:read
Your company as Cenivoz knows it: name, interviewer name, default interview language.
Example request
curl -X GET "https://connect-sandbox.cenivoz.com/v1/company" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: Company
{
"id": "company_a1b2c3d4e5f60718",
"object": "company",
"name": "Lone Star Motors",
"interviewerName": "Ava",
"defaultInterviewLanguage": "en",
"website": "https://lonestarmotors.example.com",
"phone": "+15125550199",
"email": "hiring@lonestarmotors.example.com"
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429)
Reference
List job categories
GET /v1/categories · scope jobs:read
A job is created with one of these category ids. The category decides the interview style (the role playbook the interviewer follows).
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
limit |
integer (1-100) | no | Page size: 1 to 100. Default 25. |
cursor |
string (max 300 chars) | no | The nextCursor of the previous page. Opaque: pass it back unchanged. |
Example request
curl -X GET "https://connect-sandbox.cenivoz.com/v1/categories" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: CategoryList
{
"object": "list",
"data": [
{
"id": "cat_2b6d0e94a1f37c55",
"object": "category",
"name": "Lube Technician",
"interviewRole": "Lube Technician"
}
],
"hasMore": false,
"nextCursor": null
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), validation_failed (422)
List screening questionnaires
GET /v1/questionnaires · scope jobs:read
Your company's screening questionnaires (created in the Cenivoz app). Attach them to a job with screeningQuestionnaireIds: the interview then asks these questions instead of its own technical and behavioral ones.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
limit |
integer (1-100) | no | Page size: 1 to 100. Default 25. |
cursor |
string (max 300 chars) | no | The nextCursor of the previous page. Opaque: pass it back unchanged. |
Example request
curl -X GET "https://connect-sandbox.cenivoz.com/v1/questionnaires" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: QuestionnaireList
{
"object": "list",
"data": [
{
"id": "qnr_8c4e2a0f6b1d3957",
"object": "questionnaire",
"name": "Technician basics",
"description": "Licence and availability",
"questions": [
{
"id": "q_1a2b3c4d5e6f7081",
"text": "Do you have a valid driver licence?",
"type": "yes_no",
"expectedAnswer": "yes"
},
{
"id": "q_9f8e7d6c5b4a3921",
"text": "Which shifts can you work?",
"type": "open",
"expectedAnswer": null
}
],
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:11.000Z"
}
],
"hasMore": false,
"nextCursor": null
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), validation_failed (422)
List interview languages
GET /v1/interview-languages · scope jobs:read
The languages your interviews can be held in (Cenivoz enables them per company), and whether each works for web and phone interviews. Pass a code as language when scheduling.
Example request
curl -X GET "https://connect-sandbox.cenivoz.com/v1/interview-languages" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: InterviewLanguageList
{
"object": "list",
"data": [
{
"code": "en",
"name": "English",
"nativeName": "English",
"web": true,
"phone": true,
"isDefault": true
},
{
"code": "es",
"name": "Spanish",
"nativeName": "Español",
"web": true,
"phone": true,
"isDefault": false
}
]
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429)
Jobs
Create a job
POST /v1/jobs · scope jobs:write · accepts Idempotency-Key
Creates an open job. title and category are required. Pass your own id as externalId to make retries safe and to find the job later by it.
Body (JSON)
| Field | Type | Required | Description |
|---|---|---|---|
title |
string (max 200 chars) | yes | The job title, e.g. "Lube Technician". |
description |
string (max 20000 chars) | no | What the job involves. The interviewer uses it to answer the applicant's questions. |
department |
string (max 200 chars) | no | The department, e.g. "Service". |
location |
string (max 200 chars) | no | Where the job is, e.g. "Austin, TX". |
employmentType |
string: full_time, part_time, contract, intern |
no | full_time (default), part_time, contract or intern. |
category |
string (max 100 chars) | yes | The job category: an id from GET /v1/categories. It decides the interview style (the role playbook the interviewer follows). Required when creating. |
salaryMin |
number (0-100000000) | no | The lower end of the pay range, in the unit given by salaryType. The interviewer never discusses pay; this is for your records. |
salaryMax |
number (0-100000000) | no | The upper end of the pay range. Must not be less than salaryMin. |
salaryType |
string: hour, week, month, year |
no | What the salary figures are per: hour, week, month or year (default). |
requirements |
string (max 20000 chars) | no | What the applicant needs: skills, licences, experience. |
shiftTiming |
string (max 500 chars) | no | The working hours or shifts, e.g. "Mon-Fri 8am-5pm". |
benefits |
string (max 5000 chars) | no | Benefits offered. |
applicationDeadline |
date or timestamp or or null | no | The last moment applicants can be added: a date (2035-12-31) or a timestamp. After it, adding an applicant to the job fails. null removes it. |
critical |
boolean | no | Marks the job as a priority opening. |
screeningQuestionnaireIds |
array of string (max 10 items) | no | Screening questionnaires to attach (ids from GET /v1/questionnaires, at most 10). When a job has questionnaires, the interview asks the company's questions instead of its own technical and behavioral questions. Send [] to remove them. |
externalId |
string (max 100 chars) | no | Your own identifier for this record (for example the id in your ATS or DMS), up to 100 characters. Unique per record type within your company and environment: a second create with the same value fails with 409 duplicate_external_id (naming the existing record) instead of making a duplicate. Look records up with ?externalId=. |
Example request
curl -X POST "https://connect-sandbox.cenivoz.com/v1/jobs" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2a9e-create-1" \
-d '{"title":"Lube Technician","category":"cat_0123456789abcdef","department":"Service","location":"Austin, TX","employmentType":"full_time","salaryMin":18,"salaryMax":24,"salaryType":"hour","requirements":"Valid driver licence. Six months of quick-lube experience preferred.","externalId":"ats-job-1042"}'
Response 201: Job
{
"id": "job_7f3a91c2e4b05d18",
"object": "job",
"title": "Lube Technician",
"description": "Perform oil changes, tire rotations and multi-point inspections in a busy quick-lube bay.",
"department": "Service",
"location": "Austin, TX",
"employmentType": "full_time",
"category": "cat_2b6d0e94a1f37c55",
"categoryName": "Lube Technician",
"salaryMin": 18,
"salaryMax": 24,
"salaryType": "hour",
"requirements": "Valid driver licence. Six months of quick-lube experience preferred.",
"shiftTiming": "Mon-Fri 7am-4pm, every other Saturday",
"benefits": "Health, dental, 401(k) match, tool allowance",
"applicationDeadline": null,
"status": "open",
"critical": false,
"screeningQuestionnaireIds": [],
"externalId": "ats-job-1042",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:11.000Z"
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), validation_failed (422), idempotency_conflict (422), duplicate_external_id (409)
List jobs
GET /v1/jobs · scope jobs:read
Newest first. Filter by status, category, title, your externalId, or updatedSince for incremental sync.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
status |
string: open, filled, closed |
no | Only jobs with this status. |
category |
string (max 100 chars) | no | Only jobs in this category id. |
search |
string (max 100 chars) | no | Only jobs whose title contains this text (case-insensitive). |
externalId |
string (max 100 chars) | no | Your own identifier for this record (for example the id in your ATS or DMS), up to 100 characters. Unique per record type within your company and environment: a second create with the same value fails with 409 duplicate_external_id (naming the existing record) instead of making a duplicate. Look records up with ?externalId=. |
updatedSince |
date or timestamp | no | Only jobs changed at or after this time (for incremental sync). |
limit |
integer (1-100) | no | Page size: 1 to 100. Default 25. |
cursor |
string (max 300 chars) | no | The nextCursor of the previous page. Opaque: pass it back unchanged. |
Example request
curl -X GET "https://connect-sandbox.cenivoz.com/v1/jobs" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: JobList
{
"object": "list",
"data": [
{
"id": "job_7f3a91c2e4b05d18",
"object": "job",
"title": "Lube Technician",
"description": "Perform oil changes, tire rotations and multi-point inspections in a busy quick-lube bay.",
"department": "Service",
"location": "Austin, TX",
"employmentType": "full_time",
"category": "cat_2b6d0e94a1f37c55",
"categoryName": "Lube Technician",
"salaryMin": 18,
"salaryMax": 24,
"salaryType": "hour",
"requirements": "Valid driver licence. Six months of quick-lube experience preferred.",
"shiftTiming": "Mon-Fri 7am-4pm, every other Saturday",
"benefits": "Health, dental, 401(k) match, tool allowance",
"applicationDeadline": null,
"status": "open",
"critical": false,
"screeningQuestionnaireIds": [],
"externalId": "ats-job-1042",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:11.000Z"
}
],
"hasMore": true,
"nextCursor": "eyJjIjoyMDUxMjU2NTMxMDAwLCJpIjoiNjU4ZjFhMmIzYzRkNWU2ZjcwODE5MmEzIn0"
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), validation_failed (422)
Retrieve a job
GET /v1/jobs/{jobId} · scope jobs:read
404 not_found if the id does not exist in your company (in this environment).
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
jobId |
string (max 100 chars) | yes | The job's id. |
Example request
curl -X GET "https://connect-sandbox.cenivoz.com/v1/jobs/JOB_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: Job
{
"id": "job_7f3a91c2e4b05d18",
"object": "job",
"title": "Lube Technician",
"description": "Perform oil changes, tire rotations and multi-point inspections in a busy quick-lube bay.",
"department": "Service",
"location": "Austin, TX",
"employmentType": "full_time",
"category": "cat_2b6d0e94a1f37c55",
"categoryName": "Lube Technician",
"salaryMin": 18,
"salaryMax": 24,
"salaryType": "hour",
"requirements": "Valid driver licence. Six months of quick-lube experience preferred.",
"shiftTiming": "Mon-Fri 7am-4pm, every other Saturday",
"benefits": "Health, dental, 401(k) match, tool allowance",
"applicationDeadline": null,
"status": "open",
"critical": false,
"screeningQuestionnaireIds": [],
"externalId": "ats-job-1042",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:11.000Z"
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), not_found (404)
Update a job
PATCH /v1/jobs/{jobId} · scope jobs:write · accepts Idempotency-Key
Send only the fields to change. Set status to closed or filled to stop new applicants, or back to open.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
jobId |
string (max 100 chars) | yes | The job's id. |
Body (JSON)
| Field | Type | Required | Description |
|---|---|---|---|
title |
string (max 200 chars) | no | The job title, e.g. "Lube Technician". |
description |
string (max 20000 chars) | no | What the job involves. The interviewer uses it to answer the applicant's questions. |
department |
string (max 200 chars) | no | The department, e.g. "Service". |
location |
string (max 200 chars) | no | Where the job is, e.g. "Austin, TX". |
employmentType |
string: full_time, part_time, contract, intern |
no | full_time (default), part_time, contract or intern. |
category |
string (max 100 chars) | no | The job category: an id from GET /v1/categories. It decides the interview style (the role playbook the interviewer follows). Required when creating. |
salaryMin |
number (0-100000000) | no | The lower end of the pay range, in the unit given by salaryType. The interviewer never discusses pay; this is for your records. |
salaryMax |
number (0-100000000) | no | The upper end of the pay range. Must not be less than salaryMin. |
salaryType |
string: hour, week, month, year |
no | What the salary figures are per: hour, week, month or year (default). |
requirements |
string (max 20000 chars) | no | What the applicant needs: skills, licences, experience. |
shiftTiming |
string (max 500 chars) | no | The working hours or shifts, e.g. "Mon-Fri 8am-5pm". |
benefits |
string (max 5000 chars) | no | Benefits offered. |
applicationDeadline |
date or timestamp or or null | no | The last moment applicants can be added: a date (2035-12-31) or a timestamp. After it, adding an applicant to the job fails. null removes it. |
critical |
boolean | no | Marks the job as a priority opening. |
screeningQuestionnaireIds |
array of string (max 10 items) | no | Screening questionnaires to attach (ids from GET /v1/questionnaires, at most 10). When a job has questionnaires, the interview asks the company's questions instead of its own technical and behavioral questions. Send [] to remove them. |
status |
string: open, filled, closed |
no | Change the job's status. open: accepting applicants. closed: not accepting applicants. filled: closed because someone was hired. |
Example request
curl -X PATCH "https://connect-sandbox.cenivoz.com/v1/jobs/JOB_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status":"closed"}'
Response 200: Job
{
"id": "job_7f3a91c2e4b05d18",
"object": "job",
"title": "Lube Technician",
"description": "Perform oil changes, tire rotations and multi-point inspections in a busy quick-lube bay.",
"department": "Service",
"location": "Austin, TX",
"employmentType": "full_time",
"category": "cat_2b6d0e94a1f37c55",
"categoryName": "Lube Technician",
"salaryMin": 18,
"salaryMax": 24,
"salaryType": "hour",
"requirements": "Valid driver licence. Six months of quick-lube experience preferred.",
"shiftTiming": "Mon-Fri 7am-4pm, every other Saturday",
"benefits": "Health, dental, 401(k) match, tool allowance",
"applicationDeadline": null,
"status": "open",
"critical": false,
"screeningQuestionnaireIds": [],
"externalId": "ats-job-1042",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:11.000Z"
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), not_found (404), validation_failed (422), idempotency_conflict (422)
Applicants
Create an applicant
POST /v1/applicants · scope applicants:write · accepts Idempotency-Key
Adds an applicant to an open job. Cenivoz emails them nothing unless sendWelcomeEmail is true. Upload their resume next: an interview cannot be scheduled until it has been processed.
Body (JSON)
| Field | Type | Required | Description |
|---|---|---|---|
name |
string (max 200 chars) | yes | The applicant's full name. Required when creating. Note: once the resume has been processed, the name found in the resume replaces this one. |
email |
string (email) (max 254 chars) | no | The applicant's email address. Needed for scheduling links and emailed invitations. |
phone |
string (max 40 chars) | no | The applicant's phone number in international format (+15125550100). Required for phone interviews. |
jobId |
string (max 100 chars) | yes | The job the person applied for: a job id. The job must be open and not past its deadline. Required when creating. |
location |
string (max 200 chars) | no | Where the applicant lives. |
linkedinUrl |
string (uri) (max 500 chars) | no | A link to their LinkedIn profile (http or https). |
portfolioUrl |
string (uri) (max 500 chars) | no | A link to their portfolio or website (http or https). |
expectedSalary |
string (max 100 chars) | no | What they are asking for, as free text. |
noticePeriod |
string (max 100 chars) | no | How soon they could start, as free text, e.g. "2 weeks". |
source |
string (max 100 chars) | no | Where the applicant came from (a free label). Defaults to api. |
tags |
array of string (max 30 items) | no | Free labels, up to 30. In the sandbox, sim:resume-fail, sim:left-early, sim:missed, sim:error and sim:manual control the simulation (see the sandbox guide). |
externalId |
string (max 100 chars) | no | Your own identifier for this record (for example the id in your ATS or DMS), up to 100 characters. Unique per record type within your company and environment: a second create with the same value fails with 409 duplicate_external_id (naming the existing record) instead of making a duplicate. Look records up with ?externalId=. |
sendWelcomeEmail |
boolean | no | true makes Cenivoz email the applicant its standard welcome message with a link to book their interview, as soon as their resume has been processed (the same email the app sends). Default false: Cenivoz sends nothing and you decide when to invite them. |
Example request
curl -X POST "https://connect-sandbox.cenivoz.com/v1/applicants" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2a9e-create-1" \
-d '{"name":"Jordan Rivera","email":"jordan.rivera@example.com","phone":"+15125550100","jobId":"job_0123456789abcdef","source":"dealer-website","externalId":"ats-cand-88231"}'
Response 201: Applicant
{
"id": "cand_51c0a8e2d7f94b36",
"object": "applicant",
"name": "Jordan Rivera",
"email": "jordan.rivera@example.com",
"phone": "+15125550100",
"jobId": "job_7f3a91c2e4b05d18",
"status": "new",
"location": "Round Rock, TX",
"linkedinUrl": "",
"portfolioUrl": "",
"expectedSalary": "$22/hour",
"noticePeriod": "2 weeks",
"source": "dealer-website",
"tags": [],
"resume": {
"status": "parsed",
"fileName": "jordan-rivera.pdf",
"uploadedAt": "2035-01-15T15:02:11.000Z",
"error": null
},
"match": {
"status": "scored",
"score": 78,
"reasoning": "Two years in a quick-lube bay and an ASE entry-level certification match the core of the role.",
"strengths": [
"Quick-lube experience",
"ASE G1 certified"
],
"gaps": [
"No tire-balancing experience listed"
]
},
"coverLetter": {
"present": false,
"source": null,
"updatedAt": null
},
"welcomeEmail": "none",
"archivedAt": null,
"externalId": "ats-cand-88231",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:14.000Z",
"profile": {
"skills": [
"Oil changes",
"Tire rotation",
"Multi-point inspection",
"Customer service"
],
"certifications": [
"ASE G1"
],
"experience": [
{
"company": "Speedy Lube",
"title": "Lube Technician",
"startDate": "2022-05",
"endDate": "",
"description": "Oil and filter changes, fluid top-ups, tire rotations."
}
],
"education": [
{
"institution": "Austin Community College",
"degree": "Certificate",
"field": "Automotive Technology",
"startDate": "2021-01",
"endDate": "2022-05"
}
]
},
"coverLetterText": null
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), validation_failed (422), idempotency_conflict (422), duplicate_external_id (409)
List applicants
GET /v1/applicants · scope applicants:read
Newest first. Archived applicants are left out unless archived=true. Use updatedSince to pick up changes (status, resume processed, match score) since your last sync.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
jobId |
string (max 100 chars) | no | Only applicants of this job. |
status |
string: new, contacted, interview_ready, interviewed, hired, rejected |
no | Only applicants with this pipeline status. |
search |
string (max 100 chars) | no | Only applicants whose name, email or phone contains this text (case-insensitive). |
archived |
boolean | no | true lists only archived applicants. The default lists only active ones. |
createdFrom |
date or timestamp | no | Only applicants added on or after this date or time. |
createdTo |
date or timestamp | no | Only applicants added before this date or time. |
updatedSince |
date or timestamp | no | Only applicants changed at or after this time (for incremental sync). |
externalId |
string (max 100 chars) | no | Your own identifier for this record (for example the id in your ATS or DMS), up to 100 characters. Unique per record type within your company and environment: a second create with the same value fails with 409 duplicate_external_id (naming the existing record) instead of making a duplicate. Look records up with ?externalId=. |
limit |
integer (1-100) | no | Page size: 1 to 100. Default 25. |
cursor |
string (max 300 chars) | no | The nextCursor of the previous page. Opaque: pass it back unchanged. |
Example request
curl -X GET "https://connect-sandbox.cenivoz.com/v1/applicants" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: ApplicantList
{
"object": "list",
"data": [
{
"id": "cand_51c0a8e2d7f94b36",
"object": "applicant",
"name": "Jordan Rivera",
"email": "jordan.rivera@example.com",
"phone": "+15125550100",
"jobId": "job_7f3a91c2e4b05d18",
"status": "new",
"location": "Round Rock, TX",
"linkedinUrl": "",
"portfolioUrl": "",
"expectedSalary": "$22/hour",
"noticePeriod": "2 weeks",
"source": "dealer-website",
"tags": [],
"resume": {
"status": "parsed",
"fileName": "jordan-rivera.pdf",
"uploadedAt": "2035-01-15T15:02:11.000Z",
"error": null
},
"match": {
"status": "scored",
"score": 78,
"reasoning": "Two years in a quick-lube bay and an ASE entry-level certification match the core of the role.",
"strengths": [
"Quick-lube experience",
"ASE G1 certified"
],
"gaps": [
"No tire-balancing experience listed"
]
},
"coverLetter": {
"present": false,
"source": null,
"updatedAt": null
},
"welcomeEmail": "none",
"archivedAt": null,
"externalId": "ats-cand-88231",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:14.000Z"
}
],
"hasMore": false,
"nextCursor": null
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), validation_failed (422)
Retrieve an applicant
GET /v1/applicants/{applicantId} · scope applicants:read
Includes the profile read from the resume (skills, experience, education) and the cover letter text.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
applicantId |
string (max 100 chars) | yes | The applicant's id. |
Example request
curl -X GET "https://connect-sandbox.cenivoz.com/v1/applicants/APPLICANT_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: Applicant
{
"id": "cand_51c0a8e2d7f94b36",
"object": "applicant",
"name": "Jordan Rivera",
"email": "jordan.rivera@example.com",
"phone": "+15125550100",
"jobId": "job_7f3a91c2e4b05d18",
"status": "new",
"location": "Round Rock, TX",
"linkedinUrl": "",
"portfolioUrl": "",
"expectedSalary": "$22/hour",
"noticePeriod": "2 weeks",
"source": "dealer-website",
"tags": [],
"resume": {
"status": "parsed",
"fileName": "jordan-rivera.pdf",
"uploadedAt": "2035-01-15T15:02:11.000Z",
"error": null
},
"match": {
"status": "scored",
"score": 78,
"reasoning": "Two years in a quick-lube bay and an ASE entry-level certification match the core of the role.",
"strengths": [
"Quick-lube experience",
"ASE G1 certified"
],
"gaps": [
"No tire-balancing experience listed"
]
},
"coverLetter": {
"present": false,
"source": null,
"updatedAt": null
},
"welcomeEmail": "none",
"archivedAt": null,
"externalId": "ats-cand-88231",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:14.000Z",
"profile": {
"skills": [
"Oil changes",
"Tire rotation",
"Multi-point inspection",
"Customer service"
],
"certifications": [
"ASE G1"
],
"experience": [
{
"company": "Speedy Lube",
"title": "Lube Technician",
"startDate": "2022-05",
"endDate": "",
"description": "Oil and filter changes, fluid top-ups, tire rotations."
}
],
"education": [
{
"institution": "Austin Community College",
"degree": "Certificate",
"field": "Automotive Technology",
"startDate": "2021-01",
"endDate": "2022-05"
}
]
},
"coverLetterText": null
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), not_found (404)
Update an applicant
PATCH /v1/applicants/{applicantId} · scope applicants:write · accepts Idempotency-Key
Send only the fields to change, including status (e.g. hired, rejected). Moving an applicant to another job requires that job to be open; the match score is then worked out again.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
applicantId |
string (max 100 chars) | yes | The applicant's id. |
Body (JSON)
| Field | Type | Required | Description |
|---|---|---|---|
name |
string (max 200 chars) | no | The applicant's full name. Required when creating. Note: once the resume has been processed, the name found in the resume replaces this one. |
email |
string (email) (max 254 chars) | no | The applicant's email address. Needed for scheduling links and emailed invitations. |
phone |
string (max 40 chars) | no | The applicant's phone number in international format (+15125550100). Required for phone interviews. |
jobId |
string (max 100 chars) | no | The job the person applied for: a job id. The job must be open and not past its deadline. Required when creating. |
location |
string (max 200 chars) | no | Where the applicant lives. |
linkedinUrl |
string (uri) (max 500 chars) | no | A link to their LinkedIn profile (http or https). |
portfolioUrl |
string (uri) (max 500 chars) | no | A link to their portfolio or website (http or https). |
expectedSalary |
string (max 100 chars) | no | What they are asking for, as free text. |
noticePeriod |
string (max 100 chars) | no | How soon they could start, as free text, e.g. "2 weeks". |
source |
string (max 100 chars) | no | Where the applicant came from (a free label). Defaults to api. |
tags |
array of string (max 30 items) | no | Free labels, up to 30. In the sandbox, sim:resume-fail, sim:left-early, sim:missed, sim:error and sim:manual control the simulation (see the sandbox guide). |
status |
string: new, contacted, interview_ready, interviewed, hired, rejected |
no | Move the applicant along your pipeline. Where the applicant is in your pipeline: new, contacted, interview_ready (an interview was scheduled or a scheduling link sent), interviewed (an interview took place), hired, rejected. Cenivoz moves an applicant forward automatically when an interview is scheduled and when it ends, but never backwards and never over hired / rejected. |
Example request
curl -X PATCH "https://connect-sandbox.cenivoz.com/v1/applicants/APPLICANT_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status":"hired"}'
Response 200: Applicant
{
"id": "cand_51c0a8e2d7f94b36",
"object": "applicant",
"name": "Jordan Rivera",
"email": "jordan.rivera@example.com",
"phone": "+15125550100",
"jobId": "job_7f3a91c2e4b05d18",
"status": "new",
"location": "Round Rock, TX",
"linkedinUrl": "",
"portfolioUrl": "",
"expectedSalary": "$22/hour",
"noticePeriod": "2 weeks",
"source": "dealer-website",
"tags": [],
"resume": {
"status": "parsed",
"fileName": "jordan-rivera.pdf",
"uploadedAt": "2035-01-15T15:02:11.000Z",
"error": null
},
"match": {
"status": "scored",
"score": 78,
"reasoning": "Two years in a quick-lube bay and an ASE entry-level certification match the core of the role.",
"strengths": [
"Quick-lube experience",
"ASE G1 certified"
],
"gaps": [
"No tire-balancing experience listed"
]
},
"coverLetter": {
"present": false,
"source": null,
"updatedAt": null
},
"welcomeEmail": "none",
"archivedAt": null,
"externalId": "ats-cand-88231",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:14.000Z",
"profile": {
"skills": [
"Oil changes",
"Tire rotation",
"Multi-point inspection",
"Customer service"
],
"certifications": [
"ASE G1"
],
"experience": [
{
"company": "Speedy Lube",
"title": "Lube Technician",
"startDate": "2022-05",
"endDate": "",
"description": "Oil and filter changes, fluid top-ups, tire rotations."
}
],
"education": [
{
"institution": "Austin Community College",
"degree": "Certificate",
"field": "Automotive Technology",
"startDate": "2021-01",
"endDate": "2022-05"
}
]
},
"coverLetterText": null
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), not_found (404), validation_failed (422), idempotency_conflict (422)
Archive an applicant
POST /v1/applicants/{applicantId}/archive · scope applicants:write
Hides the applicant from the default list and cancels their upcoming interviews. Reversible; repeating it changes nothing.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
applicantId |
string (max 100 chars) | yes | The applicant's id. |
Example request
curl -X POST "https://connect-sandbox.cenivoz.com/v1/applicants/APPLICANT_ID/archive" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: Applicant
{
"id": "cand_51c0a8e2d7f94b36",
"object": "applicant",
"name": "Jordan Rivera",
"email": "jordan.rivera@example.com",
"phone": "+15125550100",
"jobId": "job_7f3a91c2e4b05d18",
"status": "new",
"location": "Round Rock, TX",
"linkedinUrl": "",
"portfolioUrl": "",
"expectedSalary": "$22/hour",
"noticePeriod": "2 weeks",
"source": "dealer-website",
"tags": [],
"resume": {
"status": "parsed",
"fileName": "jordan-rivera.pdf",
"uploadedAt": "2035-01-15T15:02:11.000Z",
"error": null
},
"match": {
"status": "scored",
"score": 78,
"reasoning": "Two years in a quick-lube bay and an ASE entry-level certification match the core of the role.",
"strengths": [
"Quick-lube experience",
"ASE G1 certified"
],
"gaps": [
"No tire-balancing experience listed"
]
},
"coverLetter": {
"present": false,
"source": null,
"updatedAt": null
},
"welcomeEmail": "none",
"archivedAt": null,
"externalId": "ats-cand-88231",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:14.000Z",
"profile": {
"skills": [
"Oil changes",
"Tire rotation",
"Multi-point inspection",
"Customer service"
],
"certifications": [
"ASE G1"
],
"experience": [
{
"company": "Speedy Lube",
"title": "Lube Technician",
"startDate": "2022-05",
"endDate": "",
"description": "Oil and filter changes, fluid top-ups, tire rotations."
}
],
"education": [
{
"institution": "Austin Community College",
"degree": "Certificate",
"field": "Automotive Technology",
"startDate": "2021-01",
"endDate": "2022-05"
}
]
},
"coverLetterText": null
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), not_found (404)
Restore an applicant
POST /v1/applicants/{applicantId}/restore · scope applicants:write
Brings an archived applicant back. Interviews cancelled by archiving are not revived: schedule them again.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
applicantId |
string (max 100 chars) | yes | The applicant's id. |
Example request
curl -X POST "https://connect-sandbox.cenivoz.com/v1/applicants/APPLICANT_ID/restore" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: Applicant
{
"id": "cand_51c0a8e2d7f94b36",
"object": "applicant",
"name": "Jordan Rivera",
"email": "jordan.rivera@example.com",
"phone": "+15125550100",
"jobId": "job_7f3a91c2e4b05d18",
"status": "new",
"location": "Round Rock, TX",
"linkedinUrl": "",
"portfolioUrl": "",
"expectedSalary": "$22/hour",
"noticePeriod": "2 weeks",
"source": "dealer-website",
"tags": [],
"resume": {
"status": "parsed",
"fileName": "jordan-rivera.pdf",
"uploadedAt": "2035-01-15T15:02:11.000Z",
"error": null
},
"match": {
"status": "scored",
"score": 78,
"reasoning": "Two years in a quick-lube bay and an ASE entry-level certification match the core of the role.",
"strengths": [
"Quick-lube experience",
"ASE G1 certified"
],
"gaps": [
"No tire-balancing experience listed"
]
},
"coverLetter": {
"present": false,
"source": null,
"updatedAt": null
},
"welcomeEmail": "none",
"archivedAt": null,
"externalId": "ats-cand-88231",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:14.000Z",
"profile": {
"skills": [
"Oil changes",
"Tire rotation",
"Multi-point inspection",
"Customer service"
],
"certifications": [
"ASE G1"
],
"experience": [
{
"company": "Speedy Lube",
"title": "Lube Technician",
"startDate": "2022-05",
"endDate": "",
"description": "Oil and filter changes, fluid top-ups, tire rotations."
}
],
"education": [
{
"institution": "Austin Community College",
"degree": "Certificate",
"field": "Automotive Technology",
"startDate": "2021-01",
"endDate": "2022-05"
}
]
},
"coverLetterText": null
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), not_found (404)
Upload a resume
POST /v1/applicants/{applicantId}/resume · scope applicants:write
Either multipart/form-data with the file in the field resume, or JSON { "fileName", "contentBase64" }. PDF, DOCX, DOC or TXT, up to 5 MB; the file's content must match its type. Returns 202: the resume is processed in the background (usually under a minute). Follow GET /v1/applicants/{id}/resume-status, or wait for applicant.resume_parsed. Processing fills the profile; it fills email and phone only if they were empty, and replaces name with the name in the resume. An earlier resume is kept in the applicant's history.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
applicantId |
string (max 100 chars) | yes | The applicant's id. |
Body: multipart/form-data with the file in resume, or JSON:
| Field | Type | Required | Description |
|---|---|---|---|
fileName |
string (max 200 chars) | yes | The file name, with its extension (.pdf, .docx, .doc or .txt). |
contentBase64 |
string (max 7340036 chars) | yes | The file's bytes, base64-encoded. At most 5 MB before encoding. |
Example request
curl -X POST "https://connect-sandbox.cenivoz.com/v1/applicants/APPLICANT_ID/resume" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-F "resume=@jordan-rivera.pdf"
Response 202: ResumeStatus
{
"applicantId": "cand_51c0a8e2d7f94b36",
"status": "pending",
"fileName": "jordan-rivera.pdf",
"uploadedAt": "2035-01-15T15:02:11.000Z",
"error": null,
"matchStatus": "none"
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), not_found (404), validation_failed (422), payload_too_large (413), unsupported_media_type (415), queue_unavailable (503), invalid_request (400)
Check resume processing
GET /v1/applicants/{applicantId}/resume-status · scope applicants:read
pending while it is being processed, parsed when done, failed if it could not be read.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
applicantId |
string (max 100 chars) | yes | The applicant's id. |
Example request
curl -X GET "https://connect-sandbox.cenivoz.com/v1/applicants/APPLICANT_ID/resume-status" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: ResumeStatus
{
"applicantId": "cand_51c0a8e2d7f94b36",
"status": "pending",
"fileName": "jordan-rivera.pdf",
"uploadedAt": "2035-01-15T15:02:11.000Z",
"error": null,
"matchStatus": "none"
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), not_found (404)
Set the cover letter
PUT /v1/applicants/{applicantId}/cover-letter · scope applicants:write · accepts Idempotency-Key
Saves the cover letter as text (it replaces an earlier one). The interviewer reads it before the interview, except in screening interviews.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
applicantId |
string (max 100 chars) | yes | The applicant's id. |
Body (JSON)
| Field | Type | Required | Description |
|---|---|---|---|
text |
string (max 8000 chars) | yes | The cover letter as plain text, up to 8,000 characters. The interviewer reads it (except in screening interviews). It replaces any earlier cover letter. |
Example request
curl -X PUT "https://connect-sandbox.cenivoz.com/v1/applicants/APPLICANT_ID/cover-letter" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"text":"Dear hiring team, I have four years of experience in a busy quick-lube bay..."}'
Response 200: Applicant
{
"id": "cand_51c0a8e2d7f94b36",
"object": "applicant",
"name": "Jordan Rivera",
"email": "jordan.rivera@example.com",
"phone": "+15125550100",
"jobId": "job_7f3a91c2e4b05d18",
"status": "new",
"location": "Round Rock, TX",
"linkedinUrl": "",
"portfolioUrl": "",
"expectedSalary": "$22/hour",
"noticePeriod": "2 weeks",
"source": "dealer-website",
"tags": [],
"resume": {
"status": "parsed",
"fileName": "jordan-rivera.pdf",
"uploadedAt": "2035-01-15T15:02:11.000Z",
"error": null
},
"match": {
"status": "scored",
"score": 78,
"reasoning": "Two years in a quick-lube bay and an ASE entry-level certification match the core of the role.",
"strengths": [
"Quick-lube experience",
"ASE G1 certified"
],
"gaps": [
"No tire-balancing experience listed"
]
},
"coverLetter": {
"present": false,
"source": null,
"updatedAt": null
},
"welcomeEmail": "none",
"archivedAt": null,
"externalId": "ats-cand-88231",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:14.000Z",
"profile": {
"skills": [
"Oil changes",
"Tire rotation",
"Multi-point inspection",
"Customer service"
],
"certifications": [
"ASE G1"
],
"experience": [
{
"company": "Speedy Lube",
"title": "Lube Technician",
"startDate": "2022-05",
"endDate": "",
"description": "Oil and filter changes, fluid top-ups, tire rotations."
}
],
"education": [
{
"institution": "Austin Community College",
"degree": "Certificate",
"field": "Automotive Technology",
"startDate": "2021-01",
"endDate": "2022-05"
}
]
},
"coverLetterText": null
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), not_found (404), validation_failed (422), idempotency_conflict (422)
Remove the cover letter
DELETE /v1/applicants/{applicantId}/cover-letter · scope applicants:write
Removes the applicant's cover letter. Repeating it changes nothing.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
applicantId |
string (max 100 chars) | yes | The applicant's id. |
Example request
curl -X DELETE "https://connect-sandbox.cenivoz.com/v1/applicants/APPLICANT_ID/cover-letter" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: Applicant
{
"id": "cand_51c0a8e2d7f94b36",
"object": "applicant",
"name": "Jordan Rivera",
"email": "jordan.rivera@example.com",
"phone": "+15125550100",
"jobId": "job_7f3a91c2e4b05d18",
"status": "new",
"location": "Round Rock, TX",
"linkedinUrl": "",
"portfolioUrl": "",
"expectedSalary": "$22/hour",
"noticePeriod": "2 weeks",
"source": "dealer-website",
"tags": [],
"resume": {
"status": "parsed",
"fileName": "jordan-rivera.pdf",
"uploadedAt": "2035-01-15T15:02:11.000Z",
"error": null
},
"match": {
"status": "scored",
"score": 78,
"reasoning": "Two years in a quick-lube bay and an ASE entry-level certification match the core of the role.",
"strengths": [
"Quick-lube experience",
"ASE G1 certified"
],
"gaps": [
"No tire-balancing experience listed"
]
},
"coverLetter": {
"present": false,
"source": null,
"updatedAt": null
},
"welcomeEmail": "none",
"archivedAt": null,
"externalId": "ats-cand-88231",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:14.000Z",
"profile": {
"skills": [
"Oil changes",
"Tire rotation",
"Multi-point inspection",
"Customer service"
],
"certifications": [
"ASE G1"
],
"experience": [
{
"company": "Speedy Lube",
"title": "Lube Technician",
"startDate": "2022-05",
"endDate": "",
"description": "Oil and filter changes, fluid top-ups, tire rotations."
}
],
"education": [
{
"institution": "Austin Community College",
"degree": "Certificate",
"field": "Automotive Technology",
"startDate": "2021-01",
"endDate": "2022-05"
}
]
},
"coverLetterText": null
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), not_found (404)
Interviews
Schedule an interview
POST /v1/interviews · scope interviews:write · accepts Idempotency-Key
The applicant must have a processed resume. One interview takes place at a time per company: a clash returns 409 slot_taken. A web interview returns interviewUrl (give it to the applicant, unless Cenivoz emails it: notifyApplicant). A phone interview calls the applicant at scheduledAt (or right away without it). Scheduling replaces the applicant's other interviews that have not started.
Body (JSON)
| Field | Type | Required | Description |
|---|---|---|---|
applicantId |
string (max 100 chars) | yes | The applicant to interview: an applicant id. They must not be archived and must have a processed resume. |
medium |
string: web, phone |
yes | web: the applicant opens a link (interviewUrl) in their browser. phone: the interviewer calls them at the given time (needs the applicant's phone). |
scheduledAt |
timestamp | no | When the interview takes place, as a UTC timestamp on a 15-minute mark (09:00, 09:15, ...). Optional for web (without it the link works right away); for phone, omitting it places the call immediately. |
videoRequired |
boolean | no | Ask the applicant for their camera (web only). The interviewer stays audio-only; this decides whether video is recorded. |
language |
string (max 10 chars) | no | The interview language: a code from GET /v1/interview-languages. Default: your company's default language. |
notifyApplicant |
boolean | no | true (default) lets Cenivoz email the applicant the interview time and link (for a web interview with scheduledAt) and send its reminder 15 minutes before. false: Cenivoz sends the applicant nothing and you send interviewUrl yourself. |
externalId |
string (max 100 chars) | no | Your own identifier for this record (for example the id in your ATS or DMS), up to 100 characters. Unique per record type within your company and environment: a second create with the same value fails with 409 duplicate_external_id (naming the existing record) instead of making a duplicate. Look records up with ?externalId=. |
Example request
curl -X POST "https://connect-sandbox.cenivoz.com/v1/interviews" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2a9e-create-1" \
-d '{"applicantId":"cand_0123456789abcdef","medium":"web","scheduledAt":"2035-01-15T16:00:00Z","language":"en","notifyApplicant":true,"externalId":"ats-int-5521"}'
Response 201: Interview
{
"id": "sess_m1x2k9_4c7e0a91f2d3",
"object": "interview",
"applicantId": "cand_51c0a8e2d7f94b36",
"jobId": "job_7f3a91c2e4b05d18",
"medium": "web",
"status": "scheduled",
"language": "en",
"scheduledAt": "2035-01-15T16:00:00.000Z",
"timezone": "UTC",
"videoRequired": false,
"plannedDurationMinutes": 15,
"startedAt": null,
"endedAt": null,
"durationSeconds": null,
"completionType": null,
"scoringStatus": "pending",
"recordingConsent": null,
"interviewUrl": "https://app.example.com/?code=sess_m1x2k9_4c7e0a91f2d3",
"externalId": "ats-int-5521",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:11.000Z"
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), validation_failed (422), idempotency_conflict (422), slot_taken (409), resume_required (409), applicant_archived (409), phone_required (409), phone_unavailable (400), duplicate_external_id (409)
Email a self-scheduling link
POST /v1/interviews/scheduling-link · scope interviews:write · accepts Idempotency-Key
Cenivoz emails the applicant a link to pick their own time (a web interview). The interview is awaiting_booking until they do; then interview.scheduled follows. The applicant needs an email address and a processed resume.
Body (JSON)
| Field | Type | Required | Description |
|---|---|---|---|
applicantId |
string (max 100 chars) | yes | The applicant to invite. They need an email address and a processed resume. |
language |
string (max 10 chars) | no | The interview language: a code from GET /v1/interview-languages. Default: your company's default language. |
externalId |
string (max 100 chars) | no | Your own identifier for this record (for example the id in your ATS or DMS), up to 100 characters. Unique per record type within your company and environment: a second create with the same value fails with 409 duplicate_external_id (naming the existing record) instead of making a duplicate. Look records up with ?externalId=. |
Example request
curl -X POST "https://connect-sandbox.cenivoz.com/v1/interviews/scheduling-link" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2a9e-create-1" \
-d '{"applicantId":"cand_0123456789abcdef"}'
Response 201: Interview
{
"id": "sess_m1x2k9_4c7e0a91f2d3",
"object": "interview",
"applicantId": "cand_51c0a8e2d7f94b36",
"jobId": "job_7f3a91c2e4b05d18",
"medium": "web",
"status": "scheduled",
"language": "en",
"scheduledAt": "2035-01-15T16:00:00.000Z",
"timezone": "UTC",
"videoRequired": false,
"plannedDurationMinutes": 15,
"startedAt": null,
"endedAt": null,
"durationSeconds": null,
"completionType": null,
"scoringStatus": "pending",
"recordingConsent": null,
"interviewUrl": "https://app.example.com/?code=sess_m1x2k9_4c7e0a91f2d3",
"externalId": "ats-int-5521",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:11.000Z"
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), validation_failed (422), idempotency_conflict (422), resume_required (409), applicant_archived (409), email_required (409), duplicate_external_id (409)
List interviews
GET /v1/interviews · scope interviews:read
Newest first. Filter by status, job, applicant, dates, your externalId, or updatedSince.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
status |
string: awaiting_booking, scheduled, in_progress, completed, left_early, missed, cancelled, error |
no | Only interviews in this state. awaiting_booking: a scheduling link was sent and the applicant has not picked a time. scheduled: waiting to happen. in_progress: happening now. completed: ran to the end. left_early: the applicant ended it early or the connection dropped. missed: the time passed and the applicant never joined. cancelled: cancelled, or replaced by a newer interview for the same applicant. error: could not take place (for example a phone call that was not answered). |
jobId |
string (max 100 chars) | no | Only interviews for this job. |
applicantId |
string (max 100 chars) | no | Only interviews of this applicant. |
createdFrom |
date or timestamp | no | Only interviews created on or after this date or time. |
createdTo |
date or timestamp | no | Only interviews created before this date or time. |
updatedSince |
date or timestamp | no | Only interviews changed at or after this time (for incremental sync). |
externalId |
string (max 100 chars) | no | Your own identifier for this record (for example the id in your ATS or DMS), up to 100 characters. Unique per record type within your company and environment: a second create with the same value fails with 409 duplicate_external_id (naming the existing record) instead of making a duplicate. Look records up with ?externalId=. |
limit |
integer (1-100) | no | Page size: 1 to 100. Default 25. |
cursor |
string (max 300 chars) | no | The nextCursor of the previous page. Opaque: pass it back unchanged. |
Example request
curl -X GET "https://connect-sandbox.cenivoz.com/v1/interviews" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: InterviewList
{
"object": "list",
"data": [
{
"id": "sess_m1x2k9_4c7e0a91f2d3",
"object": "interview",
"applicantId": "cand_51c0a8e2d7f94b36",
"jobId": "job_7f3a91c2e4b05d18",
"medium": "web",
"status": "scheduled",
"language": "en",
"scheduledAt": "2035-01-15T16:00:00.000Z",
"timezone": "UTC",
"videoRequired": false,
"plannedDurationMinutes": 15,
"startedAt": null,
"endedAt": null,
"durationSeconds": null,
"completionType": null,
"scoringStatus": "pending",
"recordingConsent": null,
"interviewUrl": "https://app.example.com/?code=sess_m1x2k9_4c7e0a91f2d3",
"externalId": "ats-int-5521",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:11.000Z"
}
],
"hasMore": false,
"nextCursor": null
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), validation_failed (422)
Retrieve an interview (status)
GET /v1/interviews/{interviewId} · scope interviews:read
The interview and its status. Poll this, or listen for the interview.* events.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
interviewId |
string (max 100 chars) | yes | The interview's id. |
Example request
curl -X GET "https://connect-sandbox.cenivoz.com/v1/interviews/INTERVIEW_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: Interview
{
"id": "sess_m1x2k9_4c7e0a91f2d3",
"object": "interview",
"applicantId": "cand_51c0a8e2d7f94b36",
"jobId": "job_7f3a91c2e4b05d18",
"medium": "web",
"status": "scheduled",
"language": "en",
"scheduledAt": "2035-01-15T16:00:00.000Z",
"timezone": "UTC",
"videoRequired": false,
"plannedDurationMinutes": 15,
"startedAt": null,
"endedAt": null,
"durationSeconds": null,
"completionType": null,
"scoringStatus": "pending",
"recordingConsent": null,
"interviewUrl": "https://app.example.com/?code=sess_m1x2k9_4c7e0a91f2d3",
"externalId": "ats-int-5521",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:11.000Z"
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), not_found (404)
Reschedule an interview
POST /v1/interviews/{interviewId}/reschedule · scope interviews:write · accepts Idempotency-Key
Moves a scheduled interview that has not started to a new time (same link).
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
interviewId |
string (max 100 chars) | yes | The interview's id. |
Body (JSON)
| Field | Type | Required | Description |
|---|---|---|---|
scheduledAt |
timestamp | yes | The new time: a UTC timestamp on a 15-minute mark (and in the future for a phone interview). |
notifyApplicant |
boolean | no | true (default) emails the applicant the new time. false sends nothing. |
Example request
curl -X POST "https://connect-sandbox.cenivoz.com/v1/interviews/INTERVIEW_ID/reschedule" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2a9e-create-1" \
-d '{"scheduledAt":"2035-01-16T15:30:00Z"}'
Response 200: Interview
{
"id": "sess_m1x2k9_4c7e0a91f2d3",
"object": "interview",
"applicantId": "cand_51c0a8e2d7f94b36",
"jobId": "job_7f3a91c2e4b05d18",
"medium": "web",
"status": "scheduled",
"language": "en",
"scheduledAt": "2035-01-15T16:00:00.000Z",
"timezone": "UTC",
"videoRequired": false,
"plannedDurationMinutes": 15,
"startedAt": null,
"endedAt": null,
"durationSeconds": null,
"completionType": null,
"scoringStatus": "pending",
"recordingConsent": null,
"interviewUrl": "https://app.example.com/?code=sess_m1x2k9_4c7e0a91f2d3",
"externalId": "ats-int-5521",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:11.000Z"
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), not_found (404), validation_failed (422), idempotency_conflict (422), slot_taken (409), invalid_state (409), phone_unavailable (400)
Cancel an interview
POST /v1/interviews/{interviewId}/cancel · scope interviews:write
Only for an interview that has not started. The link stops working and a queued phone call is removed. Repeating it changes nothing.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
interviewId |
string (max 100 chars) | yes | The interview's id. |
Example request
curl -X POST "https://connect-sandbox.cenivoz.com/v1/interviews/INTERVIEW_ID/cancel" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: Interview
{
"id": "sess_m1x2k9_4c7e0a91f2d3",
"object": "interview",
"applicantId": "cand_51c0a8e2d7f94b36",
"jobId": "job_7f3a91c2e4b05d18",
"medium": "web",
"status": "scheduled",
"language": "en",
"scheduledAt": "2035-01-15T16:00:00.000Z",
"timezone": "UTC",
"videoRequired": false,
"plannedDurationMinutes": 15,
"startedAt": null,
"endedAt": null,
"durationSeconds": null,
"completionType": null,
"scoringStatus": "pending",
"recordingConsent": null,
"interviewUrl": "https://app.example.com/?code=sess_m1x2k9_4c7e0a91f2d3",
"externalId": "ats-int-5521",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:11.000Z"
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), not_found (404), invalid_state (409)
Retrieve the result
GET /v1/interviews/{interviewId}/result · scope interviews:read
Scores, recommendation, summary and screening answers. Scoring finishes shortly after the interview ends: while scoringStatus is pending there are no scores yet. The same JSON is pushed as interview.completed.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
interviewId |
string (max 100 chars) | yes | The interview's id. |
Example request
curl -X GET "https://connect-sandbox.cenivoz.com/v1/interviews/INTERVIEW_ID/result" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: InterviewResult
{
"interviewId": "sess_m1x2k9_4c7e0a91f2d3",
"applicantId": "cand_51c0a8e2d7f94b36",
"jobId": "job_7f3a91c2e4b05d18",
"status": "completed",
"completionType": "completed",
"scoringStatus": "scored",
"startedAt": "2035-01-15T16:00:41.000Z",
"endedAt": "2035-01-15T16:13:05.000Z",
"durationSeconds": 744,
"scores": {
"overall": 81,
"communication": 84,
"technical": 77,
"problemSolving": 79,
"confidence": 80,
"behavior": 86
},
"recommendation": "Hire",
"summary": "Jordan described a methodical quick-lube routine, explained how they double-check drain plugs and filters, and handled the upset-customer scenario calmly.",
"strengths": [
"Clear, step-by-step answers",
"Safety-minded routine"
],
"weaknesses": [
"Limited experience with tire balancing"
],
"missingSkills": [
"Tire balancing"
],
"recommendedTraining": [
"Wheel balancing and TPMS service"
],
"screening": [
{
"question": "Do you have a valid driver licence?",
"type": "yes_no",
"status": "answered",
"answer": "yes",
"expectedAnswer": "yes",
"summary": "Yes, a Texas class C licence."
}
],
"externalId": "ats-int-5521"
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), not_found (404)
Retrieve the transcript
GET /v1/interviews/{interviewId}/transcript · scope interviews:read
What was said, turn by turn. 404 not_found before the interview has taken place.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
interviewId |
string (max 100 chars) | yes | The interview's id. |
Example request
curl -X GET "https://connect-sandbox.cenivoz.com/v1/interviews/INTERVIEW_ID/transcript" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: Transcript
{
"interviewId": "sess_m1x2k9_4c7e0a91f2d3",
"entries": [
{
"speaker": "interviewer",
"message": "Hi Jordan, thanks for joining. How are you doing today?",
"elapsedMs": 0,
"timestamp": "2035-01-15T16:00:41.000Z"
},
{
"speaker": "applicant",
"message": "Doing well, thanks for having me.",
"elapsedMs": 4200,
"timestamp": "2035-01-15T16:00:45.000Z"
}
]
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), not_found (404)
Get recording links
GET /v1/interviews/{interviewId}/recording · scope interviews:read
Temporary links (one hour) to the audio recording and any camera recordings. Ask again for fresh links; do not store or share them.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
interviewId |
string (max 100 chars) | yes | The interview's id. |
Example request
curl -X GET "https://connect-sandbox.cenivoz.com/v1/interviews/INTERVIEW_ID/recording" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: Recording
{
"interviewId": "sess_m1x2k9_4c7e0a91f2d3",
"audioUrl": "https://api.example.com/api/interviews/sess_m1x2k9_4c7e0a91f2d3/audio?access=2051222400.Zm9vYmFy",
"videoUrls": [],
"expiresAt": "2035-01-15T17:13:19.000Z"
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), not_found (404)
Statistics
Applicants and interviews for a period
GET /v1/stats/overview · scope stats:read
How many applicants applied and how many interviews were conducted (default: the last 30 days, at most 366), optionally for one job.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
from |
date or timestamp | no | The start of the period (inclusive). Default: 30 days before to. |
to |
date or timestamp | no | The end of the period (exclusive). Default: now. The period may not be longer than 366 days. |
jobId |
string (max 100 chars) | no | Only this job (GET /v1/stats/overview only). |
Example request
curl -X GET "https://connect-sandbox.cenivoz.com/v1/stats/overview" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: StatsOverview
{
"period": {
"from": "2034-12-16T00:00:00.000Z",
"to": "2035-01-15T00:00:00.000Z",
"jobId": null
},
"applicants": {
"applied": 42,
"byStatus": {
"new": 9,
"interview_ready": 6,
"interviewed": 18,
"hired": 4,
"rejected": 5
},
"archived": 3
},
"interviews": {
"scheduled": 31,
"cancelled": 2,
"conducted": {
"started": 24,
"completed": 21,
"leftEarly": 3,
"inProgress": 0,
"averageScore": 72.4,
"averageDurationSeconds": 701
}
}
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), validation_failed (422)
Statistics per job
GET /v1/stats/jobs · scope stats:read
Applicants and interviews per job for a period. Includes every open job, even without activity.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
from |
date or timestamp | no | The start of the period (inclusive). Default: 30 days before to. |
to |
date or timestamp | no | The end of the period (exclusive). Default: now. The period may not be longer than 366 days. |
jobId |
string (max 100 chars) | no | Only this job (GET /v1/stats/overview only). |
Example request
curl -X GET "https://connect-sandbox.cenivoz.com/v1/stats/jobs" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: StatsJobs
{
"period": {
"from": "2034-12-16T00:00:00.000Z",
"to": "2035-01-15T00:00:00.000Z",
"jobId": null
},
"data": [
{
"jobId": "job_7f3a91c2e4b05d18",
"title": "Lube Technician",
"status": "open",
"applicants": 17,
"interviewsStarted": 11,
"interviewsCompleted": 10,
"averageScore": 74.1
}
]
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), validation_failed (422)
Events and webhooks
List events
GET /v1/events · scope events:read
Everything that happened in your account (the same events webhooks push), newest first, kept for 30 days. Use it to catch up after downtime, or instead of webhooks.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
type |
string: job.created, job.updated, applicant.created, applicant.updated, applicant.status_changed, applicant.resume_parsed, applicant.resume_failed, interview.invited, interview.scheduled, interview.started, interview.completed, interview.missed, interview.cancelled, interview.failed |
no | Only events of this type. |
since |
date or timestamp | no | Only events recorded at or after this time. Events are kept for 30 days. |
limit |
integer (1-100) | no | Page size: 1 to 100. Default 25. |
cursor |
string (max 300 chars) | no | The nextCursor of the previous page. Opaque: pass it back unchanged. |
Example request
curl -X GET "https://connect-sandbox.cenivoz.com/v1/events" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: EventList
{
"object": "list",
"data": [
{
"id": "evt_0d9e1c7b5a3f2468",
"object": "event",
"type": "applicant.created",
"apiVersion": "2026-10-01",
"environment": "production",
"createdAt": "2035-01-15T15:02:14.000Z",
"data": {
"applicant": {
"id": "cand_51c0a8e2d7f94b36",
"object": "applicant",
"name": "Jordan Rivera",
"email": "jordan.rivera@example.com",
"phone": "+15125550100",
"jobId": "job_7f3a91c2e4b05d18",
"status": "new",
"location": "Round Rock, TX",
"linkedinUrl": "",
"portfolioUrl": "",
"expectedSalary": "$22/hour",
"noticePeriod": "2 weeks",
"source": "dealer-website",
"tags": [],
"resume": {
"status": "none",
"fileName": null,
"uploadedAt": null,
"error": null
},
"match": {
"status": "none",
"score": null,
"reasoning": null,
"strengths": [],
"gaps": []
},
"coverLetter": {
"present": false,
"source": null,
"updatedAt": null
},
"welcomeEmail": "none",
"archivedAt": null,
"externalId": "ats-cand-88231",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:14.000Z",
"profile": {
"skills": [],
"certifications": [],
"experience": [],
"education": []
},
"coverLetterText": null
}
}
}
],
"hasMore": false,
"nextCursor": null
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), validation_failed (422)
Retrieve an event
GET /v1/events/{eventId} · scope events:read
One event, exactly as it was pushed.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
eventId |
string (max 100 chars) | yes | The event's id (evt_…). |
Example request
curl -X GET "https://connect-sandbox.cenivoz.com/v1/events/EVENT_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: Event
{
"id": "evt_0d9e1c7b5a3f2468",
"object": "event",
"type": "interview.completed",
"apiVersion": "2026-10-01",
"environment": "production",
"createdAt": "2035-01-15T15:02:14.000Z",
"data": {
"interview": {
"id": "sess_m1x2k9_4c7e0a91f2d3",
"object": "interview",
"applicantId": "cand_51c0a8e2d7f94b36",
"jobId": "job_7f3a91c2e4b05d18",
"medium": "web",
"status": "completed",
"language": "en",
"scheduledAt": "2035-01-15T16:00:00.000Z",
"timezone": "UTC",
"videoRequired": false,
"plannedDurationMinutes": 15,
"startedAt": "2035-01-15T16:00:41.000Z",
"endedAt": "2035-01-15T16:13:05.000Z",
"durationSeconds": 744,
"completionType": "completed",
"scoringStatus": "scored",
"recordingConsent": {
"givenAt": "2035-01-15T16:00:38.000Z",
"scope": [
"audio",
"video"
],
"channel": "interview_page",
"version": "2026-10-12"
},
"interviewUrl": null,
"externalId": "ats-int-5521",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T16:13:19.000Z"
},
"result": {
"interviewId": "sess_m1x2k9_4c7e0a91f2d3",
"applicantId": "cand_51c0a8e2d7f94b36",
"jobId": "job_7f3a91c2e4b05d18",
"status": "completed",
"completionType": "completed",
"scoringStatus": "scored",
"startedAt": "2035-01-15T16:00:41.000Z",
"endedAt": "2035-01-15T16:13:05.000Z",
"durationSeconds": 744,
"scores": {
"overall": 81,
"communication": 84,
"technical": 77,
"problemSolving": 79,
"confidence": 80,
"behavior": 86
},
"recommendation": "Hire",
"summary": "Jordan described a methodical quick-lube routine, explained how they double-check drain plugs and filters, and handled the upset-customer scenario calmly.",
"strengths": [
"Clear, step-by-step answers",
"Safety-minded routine"
],
"weaknesses": [
"Limited experience with tire balancing"
],
"missingSkills": [
"Tire balancing"
],
"recommendedTraining": [
"Wheel balancing and TPMS service"
],
"screening": [
{
"question": "Do you have a valid driver licence?",
"type": "yes_no",
"status": "answered",
"answer": "yes",
"expectedAnswer": "yes",
"summary": "Yes, a Texas class C licence."
}
],
"externalId": "ats-int-5521"
}
}
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), not_found (404)
List webhook endpoints
GET /v1/webhook-endpoints · scope webhooks:read
Your webhook endpoints in this environment. Add and edit them (and see their signing secrets) on the Cenivoz Developers page.
Example request
curl -X GET "https://connect-sandbox.cenivoz.com/v1/webhook-endpoints" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: WebhookEndpointList
{
"object": "list",
"data": [
{
"id": "whe_3e8b2c1d9a7f6054",
"object": "webhook_endpoint",
"url": "https://hooks.lonestarmotors.example.com/cenivoz",
"description": "DMS sync",
"events": [],
"status": "active",
"disabledReason": null,
"createdAt": "2035-01-15T15:02:11.000Z"
}
],
"hasMore": false,
"nextCursor": null
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429)
Send a test event
POST /v1/webhook-endpoints/{endpointId}/test · scope webhooks:write
Queues a webhook.test event to the endpoint (tried once, whatever the endpoint's state).
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
endpointId |
string (max 100 chars) | yes | The endpoint's id (whe_…). |
Example request
curl -X POST "https://connect-sandbox.cenivoz.com/v1/webhook-endpoints/ENDPOINT_ID/test" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 202: TestEventResult
{
"queued": 1,
"deliveries": [
{
"id": "whd_1b3d5f7a9c0e2468",
"object": "webhook_delivery",
"eventId": "evt_0d9e1c7b5a3f2468",
"endpointId": "whe_3e8b2c1d9a7f6054",
"type": "webhook.test",
"kind": "test",
"status": "pending",
"attemptCount": 0,
"nextAttemptAt": "2035-01-15T15:02:14.000Z",
"lastStatusCode": null,
"lastError": null,
"createdAt": "2035-01-15T15:02:14.000Z"
}
]
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), not_found (404), no_endpoint (409)
List webhook deliveries
GET /v1/webhook-deliveries · scope webhooks:read
Delivery history for this environment, newest first, kept for 30 days.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
status |
string: pending, succeeded, failed, dead |
no | Only deliveries with this status. |
type |
string (max 100 chars) | no | Only deliveries of this event type, e.g. interview.completed. |
endpointId |
string (max 100 chars) | no | Only deliveries to this endpoint. |
limit |
integer (1-100) | no | Page size: 1 to 100. Default 25. |
cursor |
string (max 300 chars) | no | The nextCursor of the previous page. Opaque: pass it back unchanged. |
Example request
curl -X GET "https://connect-sandbox.cenivoz.com/v1/webhook-deliveries" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: WebhookDeliveryList
{
"object": "list",
"data": [
{
"id": "whd_6a0f4c2e8b1d3975",
"object": "webhook_delivery",
"eventId": "evt_0d9e1c7b5a3f2468",
"endpointId": "whe_3e8b2c1d9a7f6054",
"type": "interview.completed",
"kind": "event",
"status": "succeeded",
"attemptCount": 1,
"nextAttemptAt": null,
"lastStatusCode": 200,
"lastError": null,
"createdAt": "2035-01-15T15:02:14.000Z"
}
],
"hasMore": false,
"nextCursor": null
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), validation_failed (422)
Redeliver an event
POST /v1/webhook-deliveries/{deliveryId}/redeliver · scope webhooks:write
Sends the same event to the same endpoint again (same event id, so your de-duplication still works).
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
deliveryId |
string (max 100 chars) | yes | The delivery's id (whd_…). |
Example request
curl -X POST "https://connect-sandbox.cenivoz.com/v1/webhook-deliveries/DELIVERY_ID/redeliver" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 202: WebhookDelivery
{
"id": "whd_6a0f4c2e8b1d3975",
"object": "webhook_delivery",
"eventId": "evt_0d9e1c7b5a3f2468",
"endpointId": "whe_3e8b2c1d9a7f6054",
"type": "interview.completed",
"kind": "event",
"status": "succeeded",
"attemptCount": 1,
"nextAttemptAt": null,
"lastStatusCode": 200,
"lastError": null,
"createdAt": "2035-01-15T15:02:14.000Z"
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), not_found (404), no_endpoint (409), endpoint_disabled (409)
Sandbox
Simulate an interview (sandbox)
POST /v1/sandbox/interviews/{interviewId}/simulate · scope interviews:write · sandbox only
Plays the interview out now with the outcome you choose, and the matching events are sent. Sandbox interviews also run by themselves (see the sandbox guide); this is for testing a specific outcome on demand.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
interviewId |
string (max 100 chars) | yes | The interview's id. |
Body (JSON)
| Field | Type | Required | Description |
|---|---|---|---|
outcome |
string: completed, left_early, missed, failed |
no | What happens: completed (ran to the end and is scored), left_early (the applicant left after a minute; no scores), missed (the applicant never joined) or failed (the call could not take place). Default completed. |
overallScore |
integer (0-100) | no | For completed: the overall score to give (0-100). Default: a fixed pseudo-random score derived from the interview id. |
Example request
curl -X POST "https://connect-sandbox.cenivoz.com/v1/sandbox/interviews/INTERVIEW_ID/simulate" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"outcome":"completed","overallScore":82}'
Response 200: SimulationResult
{
"simulated": true,
"interview": {
"id": "sess_m1x2k9_4c7e0a91f2d3",
"object": "interview",
"applicantId": "cand_51c0a8e2d7f94b36",
"jobId": "job_7f3a91c2e4b05d18",
"medium": "web",
"status": "completed",
"language": "en",
"scheduledAt": "2035-01-15T16:00:00.000Z",
"timezone": "UTC",
"videoRequired": false,
"plannedDurationMinutes": 15,
"startedAt": "2035-01-15T16:00:41.000Z",
"endedAt": "2035-01-15T16:13:05.000Z",
"durationSeconds": 744,
"completionType": "completed",
"scoringStatus": "scored",
"recordingConsent": {
"givenAt": "2035-01-15T16:00:38.000Z",
"scope": [
"audio",
"video"
],
"channel": "interview_page",
"version": "2026-10-12"
},
"interviewUrl": null,
"externalId": "ats-int-5521",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T16:13:19.000Z"
}
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), not_found (404), validation_failed (422), invalid_state (409), forbidden (403)
Finish resume processing now (sandbox)
POST /v1/sandbox/applicants/{applicantId}/resume/simulate · scope applicants:write · sandbox only
Finishes the processing of an uploaded resume at once, as parsed (with a sample profile) or failed.
Path parameters
| Field | Type | Required | Description |
|---|---|---|---|
applicantId |
string (max 100 chars) | yes | The applicant's id. |
Body (JSON)
| Field | Type | Required | Description |
|---|---|---|---|
outcome |
string: parsed, failed |
no | parsed (default): the resume is read and the profile filled with sample content. failed: it could not be read. |
Example request
curl -X POST "https://connect-sandbox.cenivoz.com/v1/sandbox/applicants/APPLICANT_ID/resume/simulate" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"outcome":"parsed"}'
Response 200: Applicant
{
"id": "cand_51c0a8e2d7f94b36",
"object": "applicant",
"name": "Jordan Rivera",
"email": "jordan.rivera@example.com",
"phone": "+15125550100",
"jobId": "job_7f3a91c2e4b05d18",
"status": "new",
"location": "Round Rock, TX",
"linkedinUrl": "",
"portfolioUrl": "",
"expectedSalary": "$22/hour",
"noticePeriod": "2 weeks",
"source": "dealer-website",
"tags": [],
"resume": {
"status": "parsed",
"fileName": "jordan-rivera.pdf",
"uploadedAt": "2035-01-15T15:02:11.000Z",
"error": null
},
"match": {
"status": "scored",
"score": 78,
"reasoning": "Two years in a quick-lube bay and an ASE entry-level certification match the core of the role.",
"strengths": [
"Quick-lube experience",
"ASE G1 certified"
],
"gaps": [
"No tire-balancing experience listed"
]
},
"coverLetter": {
"present": false,
"source": null,
"updatedAt": null
},
"welcomeEmail": "none",
"archivedAt": null,
"externalId": "ats-cand-88231",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:14.000Z",
"profile": {
"skills": [
"Oil changes",
"Tire rotation",
"Multi-point inspection",
"Customer service"
],
"certifications": [
"ASE G1"
],
"experience": [
{
"company": "Speedy Lube",
"title": "Lube Technician",
"startDate": "2022-05",
"endDate": "",
"description": "Oil and filter changes, fluid top-ups, tire rotations."
}
],
"education": [
{
"institution": "Austin Community College",
"degree": "Certificate",
"field": "Automotive Technology",
"startDate": "2021-01",
"endDate": "2022-05"
}
]
},
"coverLetterText": null
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), not_found (404), validation_failed (422), invalid_state (409), forbidden (403)
Reset sandbox data
POST /v1/sandbox/reset · scope jobs:write · sandbox only
Deletes all of your company's sandbox jobs, applicants, interviews, events and deliveries. Sandbox credentials only.
Example request
curl -X POST "https://connect-sandbox.cenivoz.com/v1/sandbox/reset" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Response 200: SandboxReset
{
"reset": true,
"deleted": {
"jobs": 3,
"applicants": 12,
"interviews": 9,
"events": 57
}
}
Errors: unauthorized (401), insufficient_scope (403), rate_limited (429), forbidden (403)
Objects
Fields marked "sometimes" are only present in some responses (for example profile only when retrieving one applicant). New fields may be added to responses at any time without a new version: ignore fields you do not know.
Me
Who you are.
| Field | Type | Present | Description |
|---|---|---|---|
clientId |
string | always | The client ID this token was issued to. |
clientName |
string | always | The name you gave the credential. |
companyId |
string | always | Your company's id in Cenivoz. |
companyName |
string | always | Your company's name. |
environment |
string: sandbox, production |
always | sandbox or production: decided by the credential. |
scopes |
array of string | always | The scopes this token holds right now (a token can hold fewer than its credential). |
rateLimitPerMinute |
integer | always | How many requests per minute this credential may make. |
apiVersion |
string | always | The API version (dated). Breaking changes would come under a new /v2 path. |
Company
Your company as Cenivoz knows it.
| Field | Type | Present | Description |
|---|---|---|---|
id |
string | always | Your company's id. |
object |
string: company |
always | Always company. |
name |
string | always | The company name. |
interviewerName |
string | always | The name the interviewer introduces itself with. |
defaultInterviewLanguage |
string | always | The default interview language code. |
website |
string | always | The company website. Empty string if none. |
phone |
string | always | The company phone. Empty string if none. |
email |
string | always | The company email. Empty string if none. |
Category
A job category.
| Field | Type | Present | Description |
|---|---|---|---|
id |
string | always | The category id. Use it as category when creating a job. |
object |
string: category |
always | Always category. |
name |
string | always | The category's name, e.g. "Service Technician". |
interviewRole |
string or null | always | The interview playbook used for jobs in this category, or null if the general one is used. |
Questionnaire
A screening questionnaire (managed in the Cenivoz app).
| Field | Type | Present | Description |
|---|---|---|---|
id |
string | always | The questionnaire id. Attach it to a job with screeningQuestionnaireIds. |
object |
string: questionnaire |
always | Always questionnaire. |
name |
string | always | The questionnaire's name. |
description |
string | always | What it is for. Empty string if none. |
questions |
array of QuestionnaireQuestion | always | The questions, in the order they are asked. |
createdAt |
timestamp | always | When it was created. |
updatedAt |
timestamp | always | When it was last changed. |
QuestionnaireQuestion
One screening question.
| Field | Type | Present | Description |
|---|---|---|---|
id |
string | always | The question id. |
text |
string | always | The question as the interviewer asks it. |
type |
string: open, yes_no |
always | open (free answer) or yes_no (has a required answer). |
expectedAnswer |
string: yes, no or null |
always | For yes_no: the answer the job needs. |
InterviewLanguage
A language your interviews can be held in.
| Field | Type | Present | Description |
|---|---|---|---|
code |
string | always | The language code to send as language. |
name |
string | always | The language in English, e.g. "Spanish". |
nativeName |
string | always | The language in itself, e.g. "Español". |
web |
boolean | always | Whether web interviews can be held in it. |
phone |
boolean | always | Whether phone interviews can be held in it. |
isDefault |
boolean | always | Whether it is your company's default. |
Job
A job opening.
| Field | Type | Present | Description |
|---|---|---|---|
id |
string | always | The job's id (job_…). |
object |
string: job |
always | Always job. |
title |
string | always | The job title. |
description |
string | always | What the job involves. Empty string if none. |
department |
string | always | The department. Empty string if none. |
location |
string | always | Where the job is. Empty string if none. |
employmentType |
string: full_time, part_time, contract, intern |
always | full_time, part_time, contract or intern. |
category |
string or null | always | The category id. |
categoryName |
string or null | always | The category's name. |
salaryMin |
number or null | always | The lower end of the pay range, or null. |
salaryMax |
number or null | always | The upper end of the pay range, or null. |
salaryType |
string: hour, week, month, year |
always | What the salary figures are per: hour, week, month or year. |
requirements |
string | always | What the applicant needs. Empty string if none. |
shiftTiming |
string | always | The working hours or shifts. Empty string if none. |
benefits |
string | always | Benefits offered. Empty string if none. |
applicationDeadline |
timestamp or null | always | The last moment applicants can be added, or null. |
status |
string: open, filled, closed |
always | open: accepting applicants. closed: not accepting applicants. filled: closed because someone was hired. |
critical |
boolean | always | Whether the job is marked as a priority. |
screeningQuestionnaireIds |
array of string | always | The screening questionnaires attached to the job. When present, the interview asks their questions. |
externalId |
string or null | always | Your own id for this job, if you gave one. |
createdAt |
timestamp | always | When the job was created. |
updatedAt |
timestamp | always | When the job was last changed. |
Applicant
A person who applied for a job.
| Field | Type | Present | Description |
|---|---|---|---|
id |
string | always | The applicant's id (cand_…). |
object |
string: applicant |
always | Always applicant. |
name |
string | always | The applicant's name. |
email |
string | sometimes | Email address. Empty string if none. |
phone |
string | sometimes | Phone number. Empty string if none. |
jobId |
string or null | sometimes | The job they applied for. |
status |
string: new, contacted, interview_ready, interviewed, hired, rejected |
always | Where the applicant is in your pipeline: new, contacted, interview_ready (an interview was scheduled or a scheduling link sent), interviewed (an interview took place), hired, rejected. Cenivoz moves an applicant forward automatically when an interview is scheduled and when it ends, but never backwards and never over hired / rejected. |
location |
string | sometimes | Where the applicant lives. Empty string if unknown. |
linkedinUrl |
string | sometimes | LinkedIn profile link. Empty string if none. |
portfolioUrl |
string | sometimes | Portfolio or website link. Empty string if none. |
expectedSalary |
string | sometimes | What they are asking for. Empty string if unknown. |
noticePeriod |
string | sometimes | How soon they could start. Empty string if unknown. |
source |
string | sometimes | Where the applicant came from. |
tags |
array of string | sometimes | Free labels. |
resume |
ApplicantResume | always | The resume and how far its processing has got. |
match |
ApplicantMatch | always | The pre-interview match: how well the resume fits the job. |
coverLetter |
ApplicantCoverLetter | always | Whether there is a cover letter. |
welcomeEmail |
string: none, pending, sending, sent, skipped |
always | The welcome email with a booking link: none (not requested), pending (waiting for the resume to be processed), sending, sent or skipped (it could not be sent, e.g. no email address). |
profile |
ApplicantProfile | sometimes | What was read from the resume. Only when retrieving one applicant (and in applicant events). |
coverLetterText |
string or null | sometimes | The cover letter text the interviewer reads, or null. Only when retrieving one applicant. |
archivedAt |
timestamp or null | sometimes | When the applicant was archived, or null if active. |
externalId |
string or null | sometimes | Your own id for this applicant, if you gave one. |
createdAt |
timestamp | always | When the applicant was added. |
updatedAt |
timestamp | always | When the applicant was last changed (including automatic changes such as the resume being processed). |
ApplicantResume
The applicant's resume and its processing state.
| Field | Type | Present | Description |
|---|---|---|---|
status |
string: none, pending, parsed, failed |
always | none: no resume uploaded. pending: uploaded and being processed. parsed: processed; the profile is filled in. failed: could not be read (see error). |
fileName |
string or null | always | The uploaded file's name. |
uploadedAt |
timestamp or null | always | When the resume was uploaded. |
error |
string or null | always | Why processing failed, when status is failed. |
ApplicantMatch
Pre-interview match: how well the resume fits the job. Produced after the resume is processed.
| Field | Type | Present | Description |
|---|---|---|---|
status |
string: none, pending, scored, failed |
always | none: not scored (no resume or job). pending: being scored. scored: score is available. failed: scoring failed. |
score |
number or null | always | How well the resume fits the job, 0-100, or null until scored. |
reasoning |
string or null | always | One sentence explaining the score. |
strengths |
array of string | always | What in the resume supports the score. |
gaps |
array of string | always | What the job needs that the resume does not show. |
ApplicantProfile
What was read from the resume. Empty until resume.status is parsed. Included when retrieving one applicant, not in lists.
| Field | Type | Present | Description |
|---|---|---|---|
skills |
array of string | always | Skills found in the resume. |
certifications |
array of string | always | Certifications found in the resume. |
experience |
array of ExperienceEntry | always | Positions found in the resume. |
education |
array of EducationEntry | always | Education found in the resume. |
ApplicantCoverLetter
Whether the applicant has a cover letter. The text itself is returned by GET /v1/applicants/{id}.
| Field | Type | Present | Description |
|---|---|---|---|
present |
boolean | always | Whether the applicant has a cover letter. |
source |
string: file, pasted or null |
always | file (uploaded in the app) or pasted (text, e.g. sent through the API). |
updatedAt |
timestamp or null | always | When it was added or last edited. |
ExperienceEntry
One position from the resume.
| Field | Type | Present | Description |
|---|---|---|---|
company |
string | sometimes | The employer. |
title |
string | sometimes | The job title held. |
startDate |
string | sometimes | When it started, as written in the resume (for example 2019-03). |
endDate |
string | sometimes | When it ended, or empty if current. |
description |
string | sometimes | What they did there. |
EducationEntry
One education entry from the resume.
| Field | Type | Present | Description |
|---|---|---|---|
institution |
string | sometimes | The school or college. |
degree |
string | sometimes | The degree or certificate. |
field |
string | sometimes | The field of study. |
startDate |
string | sometimes | When it started, as written in the resume. |
endDate |
string | sometimes | When it ended, as written in the resume. |
ResumeStatus
How far the applicant's resume has got.
| Field | Type | Present | Description |
|---|---|---|---|
applicantId |
string | always | The applicant. |
status |
string: none, pending, parsed, failed |
always | none, pending, parsed or failed. See the applicant's resume.status. |
fileName |
string or null | always | The uploaded file's name. |
uploadedAt |
timestamp or null | always | When the resume was uploaded. |
error |
string or null | always | Why processing failed, when status is failed. |
matchStatus |
string: none, pending, scored, failed |
always | State of the resume-versus-job scoring that follows processing: none, pending, scored or failed. |
Interview
An interview.
| Field | Type | Present | Description |
|---|---|---|---|
id |
string | always | The interview's id (sess_…). It is also the code in interviewUrl. |
object |
string: interview |
always | Always interview. |
applicantId |
string | always | The applicant being interviewed. |
jobId |
string or null | always | The job the applicant applied for. |
medium |
string: web, phone |
always | web or phone. |
status |
string: awaiting_booking, scheduled, in_progress, completed, left_early, missed, cancelled, error |
always | awaiting_booking: a scheduling link was sent and the applicant has not picked a time. scheduled: waiting to happen. in_progress: happening now. completed: ran to the end. left_early: the applicant ended it early or the connection dropped. missed: the time passed and the applicant never joined. cancelled: cancelled, or replaced by a newer interview for the same applicant. error: could not take place (for example a phone call that was not answered). |
language |
string | always | The interview language code. |
scheduledAt |
timestamp or null | always | When it is scheduled for. null for a web link that works right away, or while awaiting booking. |
timezone |
string | always | The applicant's time zone if known, otherwise UTC. |
videoRequired |
boolean | always | Whether the applicant's camera was asked for. |
plannedDurationMinutes |
integer | always | The planned length in minutes. |
startedAt |
timestamp or null | always | When the applicant joined, or null if it has not started. |
endedAt |
timestamp or null | always | When it ended, or null if it has not ended. |
durationSeconds |
integer or null | always | How long it lasted, once ended. |
completionType |
string: completed, ended_early, disconnected or null |
always | How it ended: completed (reached the end), ended_early (the applicant left) or disconnected (the connection dropped and did not return). null until it ends. |
scoringStatus |
string: pending, scored, insufficient_data, failed |
always | The evaluation that follows the interview. pending: not ready (or the interview has not ended). scored: available from GET /v1/interviews/{id}/result. insufficient_data: too little was said to evaluate. failed: it could not be produced. |
recordingConsent |
object or null | always | The applicant's own agreement to being recorded. A web interview cannot start without it. null while they have not been asked yet, and for a phone interview scheduled for them (there is no page to ask on). |
interviewUrl |
string or null | always | The link to give the applicant, for a web interview that can still be taken. null otherwise. |
externalId |
string or null | always | Your own id for this interview, if you gave one. |
createdAt |
timestamp | always | When the interview was created. |
updatedAt |
timestamp | always | When it was last changed. |
InterviewResult
The outcome of an interview. The same JSON is pushed in the interview.completed event.
| Field | Type | Present | Description |
|---|---|---|---|
interviewId |
string | always | The interview. |
applicantId |
string | always | The applicant. |
jobId |
string or null | always | The job the applicant applied for. |
status |
string: awaiting_booking, scheduled, in_progress, completed, left_early, missed, cancelled, error |
always | awaiting_booking: a scheduling link was sent and the applicant has not picked a time. scheduled: waiting to happen. in_progress: happening now. completed: ran to the end. left_early: the applicant ended it early or the connection dropped. missed: the time passed and the applicant never joined. cancelled: cancelled, or replaced by a newer interview for the same applicant. error: could not take place (for example a phone call that was not answered). |
completionType |
string or null | always | How it ended: completed, ended_early or disconnected. null until it ends. |
scoringStatus |
string: pending, scored, insufficient_data, failed |
always | pending: the evaluation is not ready yet (poll again, or wait for interview.completed). scored: scores and the feedback below are filled in. insufficient_data: too little was said to evaluate, so there are no scores. failed: the evaluation could not be produced. |
startedAt |
timestamp or null | always | When the applicant joined. |
endedAt |
timestamp or null | always | When it ended. |
durationSeconds |
integer or null | always | How long it lasted. |
scores |
ScoreSet or or null | always | The scores, each 0-100, or null unless scoringStatus is scored. |
recommendation |
string: Strong Hire, Hire, Borderline, No Hire or null |
always | The overall recommendation: Strong Hire, Hire, Borderline or No Hire. null unless scoringStatus is scored. |
summary |
string or null | always | A paragraph summarising the interview. |
strengths |
array of string | always | What the applicant did well. |
weaknesses |
array of string | always | Where the applicant was weaker. |
missingSkills |
array of string | always | Skills the job needs that the applicant did not demonstrate. |
recommendedTraining |
array of string | always | Training that would help. |
screening |
array of ScreeningAnswer | always | How each screening question went. Empty if the job has no screening questions. |
externalId |
string or null | always | Your own id for this interview, if you gave one. |
ScoreSet
The scores, each 0-100.
| Field | Type | Present | Description |
|---|---|---|---|
overall |
integer | always | The overall score, 0-100. |
communication |
integer | always | How clearly the applicant communicated, 0-100. |
technical |
integer | always | Technical knowledge for the role, 0-100. |
problemSolving |
integer | always | How the applicant approached problems, 0-100. |
confidence |
integer | always | How confident the applicant came across, 0-100. |
behavior |
integer | always | Professionalism and attitude, 0-100. |
ScreeningAnswer
How one screening question went.
| Field | Type | Present | Description |
|---|---|---|---|
question |
string | always | The screening question that was asked. |
type |
string: open, yes_no |
always | yes_no (has a required answer) or open (free answer). |
status |
string: pending, asked, answered, skipped |
always | pending, asked, answered or skipped. |
answer |
string or null | always | What the applicant answered: yes / no for a yes/no question, else their answer. null if unanswered. |
expectedAnswer |
string: yes, no or null |
always | For yes_no: the answer the job needs. |
summary |
string or null | always | A short summary of what the applicant said. |
Transcript
What was said in an interview.
| Field | Type | Present | Description |
|---|---|---|---|
interviewId |
string | always | The interview. |
entries |
array of TranscriptEntry | always | The conversation, in order. |
TranscriptEntry
One turn of the conversation.
| Field | Type | Present | Description |
|---|---|---|---|
speaker |
string: interviewer, applicant |
always | Who spoke: interviewer or applicant. |
message |
string | always | What was said. |
elapsedMs |
integer | always | Milliseconds from the start of the interview. |
timestamp |
timestamp | always | When it was said. |
Recording
Temporary links to the interview recording.
| Field | Type | Present | Description |
|---|---|---|---|
interviewId |
string | always | The interview. |
audioUrl |
string or null | always | A link to the recording (WAV, supports HTTP range requests), or null if there is none. It works without authentication until expiresAt: do not share it. |
videoUrls |
array of string | always | Links to the applicant's camera recordings (WebM), if video was recorded. Same expiry. |
expiresAt |
timestamp | always | When the links stop working (one hour). Ask again for fresh ones. |
StatsOverview
How many applicants applied and how many interviews were conducted.
| Field | Type | Present | Description |
|---|---|---|---|
period |
StatsPeriod | always | The period the figures cover. |
applicants |
object | always | Applicants. |
interviews |
object | always | Interviews. |
StatsConducted
Interviews that took place.
| Field | Type | Present | Description |
|---|---|---|---|
started |
integer | always | Interviews that began in the period. |
completed |
integer | always | Of those, how many ran to the end (completionType completed). |
leftEarly |
integer | always | Of those, how many ended early or disconnected. |
inProgress |
integer | always | Of those, how many are happening right now. |
averageScore |
number or null | always | The average overall score of those that were scored, to one decimal, or null if none were. |
averageDurationSeconds |
number or null | always | The average length of those that ended, in seconds, or null if none ended. |
StatsJobs
Figures per job.
| Field | Type | Present | Description |
|---|---|---|---|
period |
StatsPeriod | always | The period the figures cover. |
data |
array of StatsJobRow | always | One row per job with activity in the period, plus every open job. Sorted by applicants, most first. |
StatsJobRow
Figures for one job.
| Field | Type | Present | Description |
|---|---|---|---|
jobId |
string | always | The job these figures are for. |
title |
string or null | always | The job title. |
status |
string or null | always | The job's status now. |
applicants |
integer | always | Applicants added in the period. |
interviewsStarted |
integer | always | Interviews that began in the period. |
interviewsCompleted |
integer | always | Of those, how many ran to the end. |
averageScore |
number or null | always | The average overall score of those scored, or null. |
StatsPeriod
The period the figures cover.
| Field | Type | Present | Description |
|---|---|---|---|
from |
timestamp | always | The start of the period (inclusive). |
to |
timestamp | always | The end of the period (exclusive). |
jobId |
string or null | sometimes | The job the figures are limited to, or null for all jobs. |
Event
Something that happened. Webhooks POST exactly this JSON.
| Field | Type | Present | Description |
|---|---|---|---|
id |
string | always | The event's id (evt_…). Unique and stable: the same event keeps its id on every retry and redelivery. De-duplicate on it. |
object |
string: event |
always | Always event. |
type |
string: job.created, job.updated, applicant.created, applicant.updated, applicant.status_changed, applicant.resume_parsed, applicant.resume_failed, interview.invited, interview.scheduled, interview.started, interview.completed, interview.missed, interview.cancelled, interview.failed, webhook.test |
always | What happened. See the event reference. |
apiVersion |
string | always | The API version the payload follows. |
environment |
string: sandbox, production |
always | sandbox or production. |
createdAt |
timestamp | always | When the change happened (not when it was sent). |
data |
object | always | The event's content: the record as the REST API returns it (see each event type). |
WebhookEndpoint
A URL that receives your events (set up on the Cenivoz Developers page).
| Field | Type | Present | Description |
|---|---|---|---|
id |
string | always | The endpoint's id (whe_…). |
object |
string: webhook_endpoint |
always | Always webhook_endpoint. |
url |
string | always | Where events are sent. |
description |
string | always | Your note about it. Empty string if none. |
events |
array of string | always | The event types it receives. Empty means every type. |
status |
string: active, disabled |
always | active or disabled (by you, or automatically after many failures in a row). |
disabledReason |
string or null | always | Why it is disabled, when it is. |
createdAt |
timestamp | always | When it was added. |
WebhookDelivery
One event on its way to one endpoint.
| Field | Type | Present | Description |
|---|---|---|---|
id |
string | always | The delivery's id (whd_…). |
object |
string: webhook_delivery |
always | Always webhook_delivery. |
eventId |
string | always | The event being delivered (evt_…). |
endpointId |
string | always | The endpoint it was sent to. |
type |
string | always | The event type, e.g. interview.completed. |
kind |
string: event, test, redelivery |
always | event: a normal delivery. redelivery: sent again on request. test: a test event. |
status |
string: pending, sending, succeeded, failed, dead |
always | pending: waiting to be sent or retried. sending: in flight. succeeded: your server answered 2xx. failed: a test event that was not accepted. dead: gave up after all attempts (it can still be redelivered). |
attemptCount |
integer | always | How many times it was tried. |
nextAttemptAt |
timestamp or null | always | When the next attempt is due, while pending. |
lastStatusCode |
integer or null | always | The HTTP status your server answered on the last attempt, if it answered. |
lastError |
string or null | always | Why the last attempt failed, if it did. |
createdAt |
timestamp | always | When the delivery was queued. |
SimulationResult
The interview after the simulation. The matching events are sent to your webhooks as usual.
| Field | Type | Present | Description |
|---|---|---|---|
simulated |
boolean | always | Always true. |
interview |
Interview | always | The interview as it is now. |
SandboxReset
The result of resetting the sandbox.
| Field | Type | Present | Description |
|---|---|---|---|
reset |
boolean | always | Always true. |
deleted |
object | always | How many of each were deleted. |
TestEventResult
Test events on their way.
| Field | Type | Present | Description |
|---|---|---|---|
queued |
integer | always | How many test deliveries were queued (one per active endpoint, or the one asked for). |
deliveries |
array of WebhookDelivery | always | The queued deliveries. Follow them with GET /v1/webhook-deliveries. |
ErrorResponse
Every error response has this shape (except POST /oauth/token, which follows RFC 6749).
| Field | Type | Present | Description |
|---|---|---|---|
error |
object | always | What went wrong. |
ValidationDetail
One problem found by validation.
| Field | Type | Present | Description |
|---|---|---|---|
field |
string | always | The property the problem is about, in dot notation (salaryMin, tags.2), or (body) for the body as a whole. |
message |
string | always | What is wrong with it. |
TokenResponse
| Field | Type | Present | Description |
|---|---|---|---|
access_token |
string | always | The token. Send it as Authorization: Bearer <access_token>. Treat it as opaque. |
token_type |
string: Bearer |
always | Always Bearer. |
expires_in |
integer | always | Seconds until the token expires (3600: one hour). Reuse it until then. |
scope |
string | always | The scopes the token carries, space-separated. |
OAuthError
Errors of POST /oauth/token follow RFC 6749 and are NOT in the usual error envelope.
| Field | Type | Present | Description |
|---|---|---|---|
error |
string | always | An OAuth error code: invalid_request, invalid_client, unsupported_grant_type, invalid_scope, access_denied or too_many_requests. |
error_description |
string | always | A sentence for humans. |
Lists
InterviewLanguageList, CategoryList, QuestionnaireList, JobList, ApplicantList, InterviewList, EventList, WebhookEndpointList, WebhookDeliveryList all have the same shape: { "object": "list", "data": [...], "hasMore", "nextCursor" }. Pass nextCursor as ?cursor= for the next page.
Events
Each event is POSTed to your webhook endpoints and listed by GET /v1/events. data holds the record exactly as the REST API returns it at that moment.
| Event | When |
|---|---|
job.created |
A job was created (through the API or the app). data.job. |
job.updated |
A job changed, including its status. data.job is the job after the change. |
applicant.created |
An applicant was added. data.applicant. |
applicant.updated |
Anything about an applicant changed (also sent alongside the more specific events below). data.applicant. |
applicant.status_changed |
The applicant's pipeline status changed (by your team, by you through the API, or automatically when an interview is scheduled or ends). data.applicant.status is the new status. |
applicant.resume_parsed |
The resume was processed; data.applicant.profile is filled in. The pre-interview match follows shortly (applicant.updated). |
applicant.resume_failed |
The resume could not be read. data.applicant.resume.error says why. Upload another file. |
interview.invited |
A self-scheduling link was sent; the applicant has not picked a time yet. |
interview.scheduled |
An interview has a time (or a link that works now). Sent again when the time changes. |
interview.started |
The applicant joined (web) or answered the call (phone). |
interview.completed |
The interview ended and its evaluation is final. data.result is the same as GET /v1/interviews/{id}/result; check data.result.scoringStatus (scored, insufficient_data or failed) and data.interview.completionType. |
interview.missed |
The time passed (by more than the 30-minute join window) and the applicant never joined. The link no longer works; schedule a new interview to try again. |
interview.cancelled |
The interview was cancelled, or replaced by a newer interview for the same applicant. |
interview.failed |
The interview could not take place (for example a phone call that was not answered). data.reason explains. |
webhook.test |
A test event you asked for. Not related to any record. |
job.created
A job was created (through the API or the app). data.job.
{
"id": "evt_0d9e1c7b5a3f2468",
"object": "event",
"type": "job.created",
"apiVersion": "2026-10-01",
"environment": "production",
"createdAt": "2035-01-15T15:02:14.000Z",
"data": {
"job": {
"id": "job_7f3a91c2e4b05d18",
"object": "job",
"title": "Lube Technician",
"description": "Perform oil changes, tire rotations and multi-point inspections in a busy quick-lube bay.",
"department": "Service",
"location": "Austin, TX",
"employmentType": "full_time",
"category": "cat_2b6d0e94a1f37c55",
"categoryName": "Lube Technician",
"salaryMin": 18,
"salaryMax": 24,
"salaryType": "hour",
"requirements": "Valid driver licence. Six months of quick-lube experience preferred.",
"shiftTiming": "Mon-Fri 7am-4pm, every other Saturday",
"benefits": "Health, dental, 401(k) match, tool allowance",
"applicationDeadline": null,
"status": "open",
"critical": false,
"screeningQuestionnaireIds": [],
"externalId": "ats-job-1042",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:11.000Z"
}
}
}
job.updated
A job changed, including its status. data.job is the job after the change.
{
"id": "evt_0d9e1c7b5a3f2468",
"object": "event",
"type": "job.updated",
"apiVersion": "2026-10-01",
"environment": "production",
"createdAt": "2035-01-15T15:02:14.000Z",
"data": {
"job": {
"id": "job_7f3a91c2e4b05d18",
"object": "job",
"title": "Lube Technician",
"description": "Perform oil changes, tire rotations and multi-point inspections in a busy quick-lube bay.",
"department": "Service",
"location": "Austin, TX",
"employmentType": "full_time",
"category": "cat_2b6d0e94a1f37c55",
"categoryName": "Lube Technician",
"salaryMin": 18,
"salaryMax": 24,
"salaryType": "hour",
"requirements": "Valid driver licence. Six months of quick-lube experience preferred.",
"shiftTiming": "Mon-Fri 7am-4pm, every other Saturday",
"benefits": "Health, dental, 401(k) match, tool allowance",
"applicationDeadline": null,
"status": "closed",
"critical": false,
"screeningQuestionnaireIds": [],
"externalId": "ats-job-1042",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:14.000Z"
}
}
}
applicant.created
An applicant was added. data.applicant.
{
"id": "evt_0d9e1c7b5a3f2468",
"object": "event",
"type": "applicant.created",
"apiVersion": "2026-10-01",
"environment": "production",
"createdAt": "2035-01-15T15:02:14.000Z",
"data": {
"applicant": {
"id": "cand_51c0a8e2d7f94b36",
"object": "applicant",
"name": "Jordan Rivera",
"email": "jordan.rivera@example.com",
"phone": "+15125550100",
"jobId": "job_7f3a91c2e4b05d18",
"status": "new",
"location": "Round Rock, TX",
"linkedinUrl": "",
"portfolioUrl": "",
"expectedSalary": "$22/hour",
"noticePeriod": "2 weeks",
"source": "dealer-website",
"tags": [],
"resume": {
"status": "none",
"fileName": null,
"uploadedAt": null,
"error": null
},
"match": {
"status": "none",
"score": null,
"reasoning": null,
"strengths": [],
"gaps": []
},
"coverLetter": {
"present": false,
"source": null,
"updatedAt": null
},
"welcomeEmail": "none",
"archivedAt": null,
"externalId": "ats-cand-88231",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:14.000Z",
"profile": {
"skills": [],
"certifications": [],
"experience": [],
"education": []
},
"coverLetterText": null
}
}
}
applicant.updated
Anything about an applicant changed (also sent alongside the more specific events below). data.applicant.
{
"id": "evt_0d9e1c7b5a3f2468",
"object": "event",
"type": "applicant.updated",
"apiVersion": "2026-10-01",
"environment": "production",
"createdAt": "2035-01-15T15:02:14.000Z",
"data": {
"applicant": {
"id": "cand_51c0a8e2d7f94b36",
"object": "applicant",
"name": "Jordan Rivera",
"email": "jordan.rivera@example.com",
"phone": "+15125550100",
"jobId": "job_7f3a91c2e4b05d18",
"status": "new",
"location": "Round Rock, TX",
"linkedinUrl": "",
"portfolioUrl": "",
"expectedSalary": "$22/hour",
"noticePeriod": "2 weeks",
"source": "dealer-website",
"tags": [],
"resume": {
"status": "parsed",
"fileName": "jordan-rivera.pdf",
"uploadedAt": "2035-01-15T15:02:11.000Z",
"error": null
},
"match": {
"status": "scored",
"score": 78,
"reasoning": "Two years in a quick-lube bay and an ASE entry-level certification match the core of the role.",
"strengths": [
"Quick-lube experience",
"ASE G1 certified"
],
"gaps": [
"No tire-balancing experience listed"
]
},
"coverLetter": {
"present": false,
"source": null,
"updatedAt": null
},
"welcomeEmail": "none",
"archivedAt": null,
"externalId": "ats-cand-88231",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:14.000Z",
"profile": {
"skills": [
"Oil changes",
"Tire rotation",
"Multi-point inspection",
"Customer service"
],
"certifications": [
"ASE G1"
],
"experience": [
{
"company": "Speedy Lube",
"title": "Lube Technician",
"startDate": "2022-05",
"endDate": "",
"description": "Oil and filter changes, fluid top-ups, tire rotations."
}
],
"education": [
{
"institution": "Austin Community College",
"degree": "Certificate",
"field": "Automotive Technology",
"startDate": "2021-01",
"endDate": "2022-05"
}
]
},
"coverLetterText": null
}
}
}
applicant.status_changed
The applicant's pipeline status changed (by your team, by you through the API, or automatically when an interview is scheduled or ends). data.applicant.status is the new status.
{
"id": "evt_0d9e1c7b5a3f2468",
"object": "event",
"type": "applicant.status_changed",
"apiVersion": "2026-10-01",
"environment": "production",
"createdAt": "2035-01-15T15:02:14.000Z",
"data": {
"applicant": {
"id": "cand_51c0a8e2d7f94b36",
"object": "applicant",
"name": "Jordan Rivera",
"email": "jordan.rivera@example.com",
"phone": "+15125550100",
"jobId": "job_7f3a91c2e4b05d18",
"status": "interviewed",
"location": "Round Rock, TX",
"linkedinUrl": "",
"portfolioUrl": "",
"expectedSalary": "$22/hour",
"noticePeriod": "2 weeks",
"source": "dealer-website",
"tags": [],
"resume": {
"status": "parsed",
"fileName": "jordan-rivera.pdf",
"uploadedAt": "2035-01-15T15:02:11.000Z",
"error": null
},
"match": {
"status": "scored",
"score": 78,
"reasoning": "Two years in a quick-lube bay and an ASE entry-level certification match the core of the role.",
"strengths": [
"Quick-lube experience",
"ASE G1 certified"
],
"gaps": [
"No tire-balancing experience listed"
]
},
"coverLetter": {
"present": false,
"source": null,
"updatedAt": null
},
"welcomeEmail": "none",
"archivedAt": null,
"externalId": "ats-cand-88231",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:14.000Z",
"profile": {
"skills": [
"Oil changes",
"Tire rotation",
"Multi-point inspection",
"Customer service"
],
"certifications": [
"ASE G1"
],
"experience": [
{
"company": "Speedy Lube",
"title": "Lube Technician",
"startDate": "2022-05",
"endDate": "",
"description": "Oil and filter changes, fluid top-ups, tire rotations."
}
],
"education": [
{
"institution": "Austin Community College",
"degree": "Certificate",
"field": "Automotive Technology",
"startDate": "2021-01",
"endDate": "2022-05"
}
]
},
"coverLetterText": null
}
}
}
applicant.resume_parsed
The resume was processed; data.applicant.profile is filled in. The pre-interview match follows shortly (applicant.updated).
{
"id": "evt_0d9e1c7b5a3f2468",
"object": "event",
"type": "applicant.resume_parsed",
"apiVersion": "2026-10-01",
"environment": "production",
"createdAt": "2035-01-15T15:02:14.000Z",
"data": {
"applicant": {
"id": "cand_51c0a8e2d7f94b36",
"object": "applicant",
"name": "Jordan Rivera",
"email": "jordan.rivera@example.com",
"phone": "+15125550100",
"jobId": "job_7f3a91c2e4b05d18",
"status": "new",
"location": "Round Rock, TX",
"linkedinUrl": "",
"portfolioUrl": "",
"expectedSalary": "$22/hour",
"noticePeriod": "2 weeks",
"source": "dealer-website",
"tags": [],
"resume": {
"status": "parsed",
"fileName": "jordan-rivera.pdf",
"uploadedAt": "2035-01-15T15:02:11.000Z",
"error": null
},
"match": {
"status": "scored",
"score": 78,
"reasoning": "Two years in a quick-lube bay and an ASE entry-level certification match the core of the role.",
"strengths": [
"Quick-lube experience",
"ASE G1 certified"
],
"gaps": [
"No tire-balancing experience listed"
]
},
"coverLetter": {
"present": false,
"source": null,
"updatedAt": null
},
"welcomeEmail": "none",
"archivedAt": null,
"externalId": "ats-cand-88231",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:14.000Z",
"profile": {
"skills": [
"Oil changes",
"Tire rotation",
"Multi-point inspection",
"Customer service"
],
"certifications": [
"ASE G1"
],
"experience": [
{
"company": "Speedy Lube",
"title": "Lube Technician",
"startDate": "2022-05",
"endDate": "",
"description": "Oil and filter changes, fluid top-ups, tire rotations."
}
],
"education": [
{
"institution": "Austin Community College",
"degree": "Certificate",
"field": "Automotive Technology",
"startDate": "2021-01",
"endDate": "2022-05"
}
]
},
"coverLetterText": null
}
}
}
applicant.resume_failed
The resume could not be read. data.applicant.resume.error says why. Upload another file.
{
"id": "evt_0d9e1c7b5a3f2468",
"object": "event",
"type": "applicant.resume_failed",
"apiVersion": "2026-10-01",
"environment": "production",
"createdAt": "2035-01-15T15:02:14.000Z",
"data": {
"applicant": {
"id": "cand_51c0a8e2d7f94b36",
"object": "applicant",
"name": "Jordan Rivera",
"email": "jordan.rivera@example.com",
"phone": "+15125550100",
"jobId": "job_7f3a91c2e4b05d18",
"status": "new",
"location": "Round Rock, TX",
"linkedinUrl": "",
"portfolioUrl": "",
"expectedSalary": "$22/hour",
"noticePeriod": "2 weeks",
"source": "dealer-website",
"tags": [],
"resume": {
"status": "failed",
"fileName": "scan.pdf",
"uploadedAt": "2035-01-15T15:02:11.000Z",
"error": "The resume could not be read"
},
"match": {
"status": "scored",
"score": 78,
"reasoning": "Two years in a quick-lube bay and an ASE entry-level certification match the core of the role.",
"strengths": [
"Quick-lube experience",
"ASE G1 certified"
],
"gaps": [
"No tire-balancing experience listed"
]
},
"coverLetter": {
"present": false,
"source": null,
"updatedAt": null
},
"welcomeEmail": "none",
"archivedAt": null,
"externalId": "ats-cand-88231",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:14.000Z",
"profile": {
"skills": [
"Oil changes",
"Tire rotation",
"Multi-point inspection",
"Customer service"
],
"certifications": [
"ASE G1"
],
"experience": [
{
"company": "Speedy Lube",
"title": "Lube Technician",
"startDate": "2022-05",
"endDate": "",
"description": "Oil and filter changes, fluid top-ups, tire rotations."
}
],
"education": [
{
"institution": "Austin Community College",
"degree": "Certificate",
"field": "Automotive Technology",
"startDate": "2021-01",
"endDate": "2022-05"
}
]
},
"coverLetterText": null
}
}
}
interview.invited
A self-scheduling link was sent; the applicant has not picked a time yet.
{
"id": "evt_0d9e1c7b5a3f2468",
"object": "event",
"type": "interview.invited",
"apiVersion": "2026-10-01",
"environment": "production",
"createdAt": "2035-01-15T15:02:14.000Z",
"data": {
"interview": {
"id": "sess_m1x2k9_4c7e0a91f2d3",
"object": "interview",
"applicantId": "cand_51c0a8e2d7f94b36",
"jobId": "job_7f3a91c2e4b05d18",
"medium": "web",
"status": "awaiting_booking",
"language": "en",
"scheduledAt": null,
"timezone": "UTC",
"videoRequired": false,
"plannedDurationMinutes": 15,
"startedAt": null,
"endedAt": null,
"durationSeconds": null,
"completionType": null,
"scoringStatus": "pending",
"recordingConsent": null,
"interviewUrl": "https://app.example.com/?code=sess_m1x2k9_4c7e0a91f2d3",
"externalId": "ats-int-5521",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:11.000Z"
}
}
}
interview.scheduled
An interview has a time (or a link that works now). Sent again when the time changes.
{
"id": "evt_0d9e1c7b5a3f2468",
"object": "event",
"type": "interview.scheduled",
"apiVersion": "2026-10-01",
"environment": "production",
"createdAt": "2035-01-15T15:02:14.000Z",
"data": {
"interview": {
"id": "sess_m1x2k9_4c7e0a91f2d3",
"object": "interview",
"applicantId": "cand_51c0a8e2d7f94b36",
"jobId": "job_7f3a91c2e4b05d18",
"medium": "web",
"status": "scheduled",
"language": "en",
"scheduledAt": "2035-01-15T16:00:00.000Z",
"timezone": "UTC",
"videoRequired": false,
"plannedDurationMinutes": 15,
"startedAt": null,
"endedAt": null,
"durationSeconds": null,
"completionType": null,
"scoringStatus": "pending",
"recordingConsent": null,
"interviewUrl": "https://app.example.com/?code=sess_m1x2k9_4c7e0a91f2d3",
"externalId": "ats-int-5521",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:11.000Z"
}
}
}
interview.started
The applicant joined (web) or answered the call (phone).
{
"id": "evt_0d9e1c7b5a3f2468",
"object": "event",
"type": "interview.started",
"apiVersion": "2026-10-01",
"environment": "production",
"createdAt": "2035-01-15T15:02:14.000Z",
"data": {
"interview": {
"id": "sess_m1x2k9_4c7e0a91f2d3",
"object": "interview",
"applicantId": "cand_51c0a8e2d7f94b36",
"jobId": "job_7f3a91c2e4b05d18",
"medium": "web",
"status": "in_progress",
"language": "en",
"scheduledAt": "2035-01-15T16:00:00.000Z",
"timezone": "UTC",
"videoRequired": false,
"plannedDurationMinutes": 15,
"startedAt": "2035-01-15T16:00:41.000Z",
"endedAt": null,
"durationSeconds": null,
"completionType": null,
"scoringStatus": "pending",
"recordingConsent": null,
"interviewUrl": "https://app.example.com/?code=sess_m1x2k9_4c7e0a91f2d3",
"externalId": "ats-int-5521",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:11.000Z"
}
}
}
interview.completed
The interview ended and its evaluation is final. data.result is the same as GET /v1/interviews/{id}/result; check data.result.scoringStatus (scored, insufficient_data or failed) and data.interview.completionType.
{
"id": "evt_0d9e1c7b5a3f2468",
"object": "event",
"type": "interview.completed",
"apiVersion": "2026-10-01",
"environment": "production",
"createdAt": "2035-01-15T15:02:14.000Z",
"data": {
"interview": {
"id": "sess_m1x2k9_4c7e0a91f2d3",
"object": "interview",
"applicantId": "cand_51c0a8e2d7f94b36",
"jobId": "job_7f3a91c2e4b05d18",
"medium": "web",
"status": "completed",
"language": "en",
"scheduledAt": "2035-01-15T16:00:00.000Z",
"timezone": "UTC",
"videoRequired": false,
"plannedDurationMinutes": 15,
"startedAt": "2035-01-15T16:00:41.000Z",
"endedAt": "2035-01-15T16:13:05.000Z",
"durationSeconds": 744,
"completionType": "completed",
"scoringStatus": "scored",
"recordingConsent": {
"givenAt": "2035-01-15T16:00:38.000Z",
"scope": [
"audio",
"video"
],
"channel": "interview_page",
"version": "2026-10-12"
},
"interviewUrl": null,
"externalId": "ats-int-5521",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T16:13:19.000Z"
},
"result": {
"interviewId": "sess_m1x2k9_4c7e0a91f2d3",
"applicantId": "cand_51c0a8e2d7f94b36",
"jobId": "job_7f3a91c2e4b05d18",
"status": "completed",
"completionType": "completed",
"scoringStatus": "scored",
"startedAt": "2035-01-15T16:00:41.000Z",
"endedAt": "2035-01-15T16:13:05.000Z",
"durationSeconds": 744,
"scores": {
"overall": 81,
"communication": 84,
"technical": 77,
"problemSolving": 79,
"confidence": 80,
"behavior": 86
},
"recommendation": "Hire",
"summary": "Jordan described a methodical quick-lube routine, explained how they double-check drain plugs and filters, and handled the upset-customer scenario calmly.",
"strengths": [
"Clear, step-by-step answers",
"Safety-minded routine"
],
"weaknesses": [
"Limited experience with tire balancing"
],
"missingSkills": [
"Tire balancing"
],
"recommendedTraining": [
"Wheel balancing and TPMS service"
],
"screening": [
{
"question": "Do you have a valid driver licence?",
"type": "yes_no",
"status": "answered",
"answer": "yes",
"expectedAnswer": "yes",
"summary": "Yes, a Texas class C licence."
}
],
"externalId": "ats-int-5521"
}
}
}
interview.missed
The time passed (by more than the 30-minute join window) and the applicant never joined. The link no longer works; schedule a new interview to try again.
{
"id": "evt_0d9e1c7b5a3f2468",
"object": "event",
"type": "interview.missed",
"apiVersion": "2026-10-01",
"environment": "production",
"createdAt": "2035-01-15T15:02:14.000Z",
"data": {
"interview": {
"id": "sess_m1x2k9_4c7e0a91f2d3",
"object": "interview",
"applicantId": "cand_51c0a8e2d7f94b36",
"jobId": "job_7f3a91c2e4b05d18",
"medium": "web",
"status": "missed",
"language": "en",
"scheduledAt": "2035-01-15T16:00:00.000Z",
"timezone": "UTC",
"videoRequired": false,
"plannedDurationMinutes": 15,
"startedAt": null,
"endedAt": null,
"durationSeconds": null,
"completionType": null,
"scoringStatus": "pending",
"recordingConsent": null,
"interviewUrl": null,
"externalId": "ats-int-5521",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:11.000Z"
}
}
}
interview.cancelled
The interview was cancelled, or replaced by a newer interview for the same applicant.
{
"id": "evt_0d9e1c7b5a3f2468",
"object": "event",
"type": "interview.cancelled",
"apiVersion": "2026-10-01",
"environment": "production",
"createdAt": "2035-01-15T15:02:14.000Z",
"data": {
"interview": {
"id": "sess_m1x2k9_4c7e0a91f2d3",
"object": "interview",
"applicantId": "cand_51c0a8e2d7f94b36",
"jobId": "job_7f3a91c2e4b05d18",
"medium": "web",
"status": "cancelled",
"language": "en",
"scheduledAt": "2035-01-15T16:00:00.000Z",
"timezone": "UTC",
"videoRequired": false,
"plannedDurationMinutes": 15,
"startedAt": null,
"endedAt": null,
"durationSeconds": null,
"completionType": null,
"scoringStatus": "pending",
"recordingConsent": null,
"interviewUrl": null,
"externalId": "ats-int-5521",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:11.000Z"
}
}
}
interview.failed
The interview could not take place (for example a phone call that was not answered). data.reason explains.
{
"id": "evt_0d9e1c7b5a3f2468",
"object": "event",
"type": "interview.failed",
"apiVersion": "2026-10-01",
"environment": "production",
"createdAt": "2035-01-15T15:02:14.000Z",
"data": {
"interview": {
"id": "sess_m1x2k9_4c7e0a91f2d3",
"object": "interview",
"applicantId": "cand_51c0a8e2d7f94b36",
"jobId": "job_7f3a91c2e4b05d18",
"medium": "phone",
"status": "error",
"language": "en",
"scheduledAt": "2035-01-15T16:00:00.000Z",
"timezone": "UTC",
"videoRequired": false,
"plannedDurationMinutes": 15,
"startedAt": null,
"endedAt": null,
"durationSeconds": null,
"completionType": null,
"scoringStatus": "pending",
"recordingConsent": null,
"interviewUrl": null,
"externalId": "ats-int-5521",
"createdAt": "2035-01-15T15:02:11.000Z",
"updatedAt": "2035-01-15T15:02:11.000Z"
},
"reason": "The call was not answered"
}
}
webhook.test
A test event you asked for. Not related to any record.
{
"id": "evt_0d9e1c7b5a3f2468",
"object": "event",
"type": "webhook.test",
"apiVersion": "2026-10-01",
"environment": "production",
"createdAt": "2035-01-15T15:02:14.000Z",
"data": {
"message": "This is a test event from Cenivoz. If you can read it, your endpoint works.",
"endpointId": "whe_3e8b2c1d9a7f6054"
}
}
Errors
Every error (except the token endpoint) has the shape { "error": { "code", "message", "details", "requestId" } }. Branch on code, never on message. Retry: never = the same request fails the same way; later = transient, retry with backoff and the same Idempotency-Key; fix = change something first.