Skip to content
Cenivoz

Developers

Cenivoz API

Connect your dealership system, ATS or HR platform: push jobs and applicants, schedule AI-led interviews and get the scored results back by webhook. Build against the sandbox first, then switch to production when you are ready.

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

  1. Cenivoz switches the API on for your company (sandbox first, production when you are ready). Ask your Cenivoz contact.
  2. 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.
  3. Optionally add a webhook endpoint (a URL on your server) on the same page.
  4. 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): a webhook.test event 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 (audioUrl is null).
  • 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_failed that 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 id and object (its type: job, applicant, interview, ...).
  • Empty text fields are ""; missing values that are not text (dates, numbers) are null.

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 externalId returns 409 duplicate_external_id with error.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

  1. When a job opens in your system: POST /v1/jobs with your id as externalId (once; afterwards PATCH it).
  2. When someone applies: POST /v1/applicants (with externalId), then POST /v1/applicants/{id}/resume.
  3. On applicant.resume_parsed: either
    • POST /v1/interviews/scheduling-link and let Cenivoz email them a link to book a time, or
    • POST /v1/interviews with a time you agreed with them (notifyApplicant: false if your system sends the invitation, with interviewUrl).
  4. On interview.completed: store data.result (scores, recommendation, summary) against the applicant in your system, and link to the transcript and recording if you need them.
  5. When your team decides: PATCH /v1/applicants/{id} with status: hired or rejected.

2. Your DMS owns the jobs; Cenivoz owns the interviews

  • Mirror openings: create jobs with externalId = your requisition id, close them with PATCH { "status": "closed" } (or filled) when the requisition closes.
  • Keep both sides in sync by listening to job.updated and applicant.status_changed: changes your team makes in the Cenivoz app arrive there too.
  • Nightly, as a safety net: GET /v1/applicants?updatedSince=… and GET /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-languages lists those enabled for your company; pass a code as language.
  • Screening questions: attach your questionnaires to a job (screeningQuestionnaireIds); the interview then asks them, and result.screening holds 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 id never 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 createdAt and the record's updatedAt to 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.completed without interview.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:

  1. Ask Cenivoz to switch production on for your company.
  2. 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.
  3. Add a production webhook endpoint (https, public) and store its signing secret. Press Send test event and check your server verified it.
  4. Change the base URL from https://connect-sandbox.cenivoz.com to https://connect.cenivoz.com. Nothing else changes.

Checklist

  • Tokens are cached and reused for their hour, not requested per call.
  • Every create sends an externalId and/or an Idempotency-Key, so retries never duplicate.
  • Errors are handled by code; 429 and 5xx are retried with backoff (respect Retry-After).
  • The webhook receiver verifies Cenivoz-Signature, answers 2xx within 10 seconds, and de-duplicates on the event id.
  • 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_taken leads 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.
  • externalId on jobs, applicants and interviews; Idempotency-Key on writes; cursor pagination; updatedSince for 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)

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 /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.

Status Code Retry Meaning What to do
400 invalid_json never The request body could not be parsed as JSON. Send well-formed JSON with Content-Type: application/json.
400 invalid_request never Something outside field validation is wrong: an unreadable cursor, a bad Idempotency-Key, a multipart upload without the file. Read message. Use cursors exactly as returned; keys of 1-200 letters, digits, _ - : ..
400 phone_unavailable later Phone interviews are not set up on the server right now. Use medium: "web", or try again later.
401 unauthorized fix The Authorization: Bearer header is missing or malformed, or the token is expired, forged, or belongs to a credential that was revoked. The message is the same for all of these on purpose. Request a new token at POST /oauth/token and retry once. If that fails, the credential was revoked.
403 access_disabled fix Cenivoz has not enabled (or has paused) API access for your company in this environment. Contact Cenivoz.
403 company_suspended fix Your company is suspended in Cenivoz; every credential is refused until it is reactivated. Contact Cenivoz.
403 forbidden never The /v1/sandbox/... endpoints work only with sandbox credentials. Use a sandbox credential.
403 insufficient_scope fix The endpoint needs a scope this credential (or this token) does not hold. error.details.requiredScope names it. Add the scope to the credential on the Developers page (effective within seconds), or request a token with it.
403 wrong_environment never A sandbox token was sent to the production API, or a production token to the sandbox. The message names the right base URL. Send it to the URL of the credential's environment.
404 not_found never The id does not exist in your company and this environment. A record of another company looks exactly the same, on purpose. Also returned for an unknown URL. Check the id and the environment (a sandbox id does not exist in production, and vice versa).
409 applicant_archived fix Archived applicants cannot be scheduled. Call POST /v1/applicants/{id}/restore first.
409 conflict later Two requests changed the same thing at the same instant and a uniqueness rule caught it. Repeat the request.
409 duplicate_external_id never A record of the same type in this environment already has this externalId. error.details.existingId names it. Use the existing record, or a different externalId.
409 email_required fix A scheduling link is emailed, so the applicant needs an email address. PATCH the applicant with email, or schedule a time yourself.
409 endpoint_disabled fix Your endpoint was switched off (by you, or automatically after many failures in a row). Fix your receiver, send a test event, and switch the endpoint on again.
409 invalid_state never For example: rescheduling or cancelling an interview that has started, finished, been missed or cancelled; simulating a finished interview. Schedule a new interview instead.
409 no_endpoint fix There is no (active) webhook endpoint to send to in this environment. Add one on the Developers page.
409 phone_required fix A phone interview needs a phone number on the applicant. PATCH the applicant with phone.
409 request_in_progress later A request with this Idempotency-Key has not finished yet. Wait a second and repeat the identical request: you will get the original response.
409 resume_required fix An interview needs the applicant's resume to have been processed (resume.status is parsed). Upload the resume and wait for applicant.resume_parsed (or poll resume-status).
409 slot_taken fix Cenivoz runs one interview at a time per company, and one is already scheduled at or overlapping the requested time. Pick another time (for example 15 minutes later).
413 payload_too_large never JSON bodies are limited to 256 KB and resume files to 5 MB (base64-encoded uploads: 5 MB before encoding). Send less data.
415 unsupported_media_type never JSON must be sent as application/json. A resume must be a PDF, DOCX, DOC or TXT whose content matches that type (a renamed file is refused). Send the right type.
422 idempotency_conflict never The same key must always carry the same request. Use a new key for a new request.
422 validation_failed never error.details lists every problem as { "field", "message" }: unknown properties, wrong types, values outside the allowed set, and rules such as "the job must be open" or "a time on a 15-minute mark". Fix each listed field.
429 rate_limited later Your credential exceeded its per-minute allowance. Retry-After says how many seconds to wait. Wait Retry-After seconds, then retry. Spread bulk work out.
500 internal_error later An unexpected error. Nothing internal is revealed. Retry with the same Idempotency-Key. If it persists, contact support with error.requestId.
503 queue_unavailable later The resume could not be queued for processing. Upload it again shortly.
Email