Screening API
Send a candidate, a job and a use case. Our voice agent phones the candidate, runs a structured screen, and posts back a verdict, the captured fields, a timestamped transcript, the full call log and a recording link. You can follow the transcript live while the call runs. The agent introduces itself under your recruiter name and your company. The candidate never hears ours.
| Base URL | https://fury.humanbit.ai/v1 |
|---|---|
| Format | JSON in, JSON out. Timestamps are ISO 8601 UTC. Phone numbers are E.164. |
| Auth | Authorization: Bearer hb_live_… or hb_test_… |
| Delivery | Signed webhooks to the URL on your account, plus GET as a fallback. |
What the job-screening agent does on every call (the language-freelancer agent is described under Use cases):
- It opens with: “Hi Ravi, this is Priya from Acme Talent. I'm calling about a Backend Engineer role at Acme. Do you have two minutes?”
- If the candidate is busy, it books a callback time. If they're not interested, it accepts the first no. If it's the wrong person, it apologises. If they ask to be removed, it confirms and flags it.
- It covers the deal-breakers first: notice period, current and expected CTC, location and work mode, competing offers, experience. A firm mismatch ends the call politely.
- Only then does it pitch the role, ask the role-specific questions it writes from your job brief, and ask your custom questions.
- After the call, a separate review pass reads the transcript and produces the result. The verdict rules are mechanical, so an optimistic “Proceed” never reaches you on a candidate who is over budget or over notice.
Quickstart
1. Place a call with your test key. Nobody is dialled. You get the whole lifecycle and every webhook within about 10 seconds.
curl https://fury.humanbit.ai/v1/calls \
-H "Authorization: Bearer $HB_TEST_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: cand-881-backend-attempt-1" \
-d '{
"candidate": { "name": "Ravi Kumar", "phone": "+919876543210" },
"job": {
"role_name": "Backend Engineer",
"client_company": "Acme",
"role_location": "Bangalore",
"work_mode": "hybrid",
"experience_range": "3 to 6 years",
"ctc_min": "18 lakhs per annum",
"ctc_max": "30 lakhs per annum",
"max_notice_period": "60 days",
"role_description": "Python, Kafka, Postgres; owns the payments ledger service."
},
"agent": { "name": "Priya", "company_name": "Acme Talent" },
"custom_fields": [
{ "key": "open_to_night_shift", "question": "Are you open to a night shift once a month?" }
],
"external_ref": "cand-881",
"metadata": { "crm_candidate_id": 881, "requisition": "REQ-204" }
}'
2. Receive the webhooks. You'll get call.status four times (dialing, ringing, in_progress, processing), then one call.completed carrying everything. Verify the signature, store the payload, and answer 2xx.
3. Switch to your live key. Same request, real call. Download the recording before retention.delete_after.
POST /v1/webhooks/test. It sends a signed webhook.test event and tells you what your server answered.Authentication & test mode
Every account has two keys. They differ only in what they do:
| Key | Effect |
|---|---|
hb_live_… | Real phone calls, inside calling hours, within your concurrency limit. |
hb_test_… | No call is placed. The full status sequence runs in about 10 seconds, and call.completed carries a realistic sample result built from your job brief, with "test": true. Calling hours don't apply. |
Calls made with one key can't be seen with the other. Keys are secrets for your server only. Never put them in a browser or mobile app. If a key leaks, ask us to rotate it. The old key stops working immediately.
Your data, not ours
We are a pipe, not a database. Everything a call produces is delivered to you, and you are the system of record.
- After the
call.completedwebhook is delivered and the retention window has passed (24 hours by default, up to 72 on request), we delete the transcript, result, call log, recording, the request you sent and the phone number. - Until then,
GET /v1/calls/{id}returns the full payload, and the recording link works. After deletion,GETreturns only ids, timestamps, outcome and duration ("purged": true), and the recording link returns410. - If your endpoint is down, we keep retrying for about 16 hours and hold the data until delivery succeeds or retries are exhausted. After that the retention window applies.
- One exception: when a candidate asks on the call not to be contacted again, we keep a salted hash of their number on your account, so that no future call ever reaches them. See Suppressions.
recording.url is a time-limited link. Fetch the MP3 into your own storage when you receive call.completed. Don't store our link.Use cases (agents)
Each use case is a separate agent with its own script, its own result schema and its own time limit. Choose one per call with "use_case". If you leave it out, it defaults to job-screening. GET /v1/use-cases returns this table as JSON, so your integration can read the required fields instead of hardcoding them.
| use_case | For | Call | Verdicts |
|---|---|---|---|
job-screening | Full-time, salaried roles. Deal-breakers first (notice, current and expected CTC, location and mode, other offers, experience), then the pitch and role-specific probes the agent writes from your brief. | Up to 12 min, voice saavi | proceed hold reject callback_scheduled not_interested |
language-freelancer | Remote freelance work in Indian languages: annotation, transcription review, translation, voice data. Five checks: laptop, English, languages spoken and written, hours per day, current earnings. Then it states that the work is freelance and gives your next step. | About 3 min, at most 7, voice monika | qualified not_qualified incomplete callback_scheduled not_interested wrong_person voicemail |
Need a different kind of call, such as campus hiring, blue-collar roles, or reference checks? Each one is a new agent with its own slug. Ask your account manager.
job for job-screening
See the job brief under POST /v1/calls.
job for language-freelancer
The agent says only what is written here. Every value is something it may say aloud, so write it as spoken English.
| Field | Used for | |
|---|---|---|
role_name | required | “It's a Kannada transcription reviewer role, fully remote.” |
work_description | required | What the work actually is, in one line, e.g. “listen to short audio clips and correct the AI's transcript”. |
languages | required | The target languages, e.g. “Kannada or Telugu”. The candidate must speak and read/write at least one of them to qualify. |
pay_range | required | Spoken pay, e.g. “fifteen to twenty-five thousand rupees a month”. The agent never quotes any other figure. |
next_step | required | What happens after the call, e.g. “The team will send a short form on WhatsApp today.” The agent never names a channel that isn't written here. |
company_description | required | One or two sentences about your company. This is also the answer to “Is this a scam?”, so make it concrete. |
company_location | optional | City, for “Where are you based?”. Default: “India”. |
payment_terms | optional | The answer to “How will I get paid?”. Default: the team explains it during onboarding. The agent never collects bank details. |
profile_source | optional | The answer to “How did you get my number?”, e.g. "I came across your profile on Naukri." |
additional_details | optional | Anything else it may say if asked: training, experience needed, minimum hours. |
{
"use_case": "language-freelancer",
"candidate": { "name": "Asha Rao", "phone": "+919876500001" },
"job": {
"role_name": "Kannada transcription reviewer",
"work_description": "listen to short audio clips and correct the AI's transcript",
"languages": "Kannada or Telugu",
"pay_range": "fifteen to twenty-five thousand rupees a month",
"next_step": "The team will send a short form on WhatsApp today.",
"company_description": "Acme Talent places freelancers with AI companies; about two thousand people work with us today.",
"profile_source": "I came across your profile on Naukri."
},
"agent": { "name": "Meera", "company_name": "Acme Talent" }
}
Result fields for language-freelancer
fields: candidate_name, has_laptop, english_communication (Good / Basic / Poor), languages_spoken, languages_read_written, hours_per_day, current_monthly_earnings, current_occupation, told_freelance, told_next_step, callback_time, not_interested_reason, asked_to_be_removed, unanswered_questions.
structured: has_laptop (bool), english_ok (bool), target_languages_written (array, lowercase), hours_per_day (number), monthly_earnings_inr (number). risk_flags: scam_concern, phone_only, speaks_but_cannot_write, low_english, very_low_hours.
Verdict rules, in order: voicemail if no human answered. Then callback_scheduled, not_interested or wrong_person if the call ended that way. Then not_qualified if the candidate firmly has no laptop, speaks and writes none of the languages, or can't hold a basic English conversation. Then qualified if they have a laptop, can communicate in English, and speak and write at least one of the languages. Otherwise incomplete.
Call lifecycle
A call that is never answered goes from ringing straight to ended. If you asked for retries, it goes back to queued instead and keeps the same call id. attempt counts the dials. processing is the 10–60 seconds after hang-up while the result and recording are produced.
Outcomes
Every ended call has exactly one outcome:
| outcome | Meaning | Has result? |
|---|---|---|
answered | A person picked up and the call ran. See end_reason and result.verdict for how it went. | Yes (null if nobody spoke) |
voicemail | An answering machine or IVR picked up. The agent hung up without leaving a message. | Usually no |
no_answer | Rang for 45 s unanswered, or was rejected or unreachable, and no retries were left. | No |
failed | The call could not be placed. error says why. | No |
cancelled | You cancelled it. | Only if it had already been answered |
suppressed | The number was added to your do-not-call list after this call was queued, so it was never dialled. | No |
end_reason on answered calls
completed · not_interested · callback · wrong_person · removed · time_cap · voicemail · candidate_hung_up
Real-time transcript
You can follow a call while it happens, for example to show a recruiter the conversation live in your CRM. Each finished turn (one person's sentence or two, once they stop talking) is available about 1–2 seconds after it's spoken. There are three ways to get it, and you can use any of them:
| Way | Best for | How |
|---|---|---|
| Webhook per turn | Systems that already take our webhooks | Set "realtime_transcript": true on POST /v1/calls. Each turn arrives as a call.transcript event at your webhook URL. |
| Event stream | A live view in your app | GET /v1/calls/{id}/stream streams server-sent events: status, transcript, then one completed, after which the connection closes. Works for any call and needs no flag. |
| Polling | Simple scripts | GET /v1/calls/{id} while status is in_progress or processing includes live_transcript: every finished turn so far. |
A turn looks the same in all three:
{ "speaker": "candidate", "text": "Sixty days, but buyout is possible.", "at_s": 41.3, "interrupted": false }
// speaker: "agent" | "candidate" at_s: seconds since the agent joined the call
// interrupted: true when the agent was cut off mid-sentence by the candidate
The call.transcript webhook carries data: {id, external_ref, metadata, index, turn}. index starts at 0 and has no gaps. Webhooks can arrive out of order, so sort by index. The authoritative transcript, with per-turn latency and speech-recognition confidence, is the one in call.completed.
Calling rules
These rules are enforced for every account. They protect the candidates and your sender reputation.
| Rule | Detail |
|---|---|
| Numbers | Indian mobile numbers only: 10 digits, or +91 followed by 10 digits, starting 6–9. |
| Calling hours | 09:30–20:30 IST by default (adjustable per account). Calls queued outside the window wait for it. They are not dropped. |
| Concurrency | Each account has a limit on simultaneous calls. Extra calls wait in the queue in schedule_at order. GET /v1/account shows the limit and the current queue. |
| One open call per number | A second call to a number that already has a queued or live call is refused with 409 call_in_progress. |
| Do-not-call | A number on your suppression list is refused with 409 do_not_call. Candidates who ask to be removed on a call are added automatically. |
| Ringing | 45 seconds, then no_answer. |
| Length | job-screening: the agent starts wrapping up at 10 minutes and the call ends at 12. language-freelancer: wraps up at 5 minutes and ends at 7. |
| AI disclosure | If a candidate asks whether they're speaking to an AI, the agent says yes. It always does, and this can't be configured. |
| Fixed behaviour | The voice rules, safety rules (never asks for bank or payment details, never states a salary or offer that is not in your brief, never asks about religion, caste, marital status, health, age or other protected topics) and verdict rules are fixed. You shape what is asked. You can't change how the agent behaves. |
Endpoints
/v1/calls
Queue a screening call. Returns 202 with the call object in status queued.
Headers
| Header | Description | |
|---|---|---|
Idempotency-Key | recommended | Any string up to 200 characters, unique per intended call. Repeating a request with the same key returns the original call with 200 and never dials twice. Use it on every retry of a network error. |
Body
| Field | Description | |
|---|---|---|
use_case | optional | Which agent makes the call: job-screening (default) or language-freelancer. See Use cases. |
candidate.name | required | Full name. The agent uses the first name only. |
candidate.phone | required | Indian mobile number. |
candidate.variables | optional | A string→string map of extra facts about this candidate that the agent may use, e.g. {"current_company": "Infosys"}. |
job | required | The role brief. Its fields depend on use_case: job-screening or language-freelancer. Unknown keys are rejected so a typo can't silently drop a fact. Each value can be up to 3,000 characters. |
realtime_transcript | optional | true sends each finished turn as a call.transcript webhook while the call runs. Default false. See Real-time transcript. |
agent.name | optional | The recruiter name the agent uses, e.g. "Priya". Defaults to your account's setting. |
agent.company_name | optional* | The company the agent says it calls from (usually your agency). *Required unless your account has a default. |
agent.voice | optional | A voice id from GET /v1/voices. |
custom_fields | optional | Up to 8 extra questions, each {"key", "question"}. The agent asks them once the deal-breakers are covered, and the answers come back in result.custom[key] ("Not captured" if not asked or not answered). key is lowercase snake_case, up to 40 characters. |
external_ref | optional | Your id for this candidate or application. It is echoed on every event and can be filtered on with GET /v1/calls?external_ref=. |
metadata | optional | Any JSON object up to 4 KB, echoed back unchanged on every event. |
schedule_at | optional | ISO 8601 with a timezone, up to 30 days ahead. Use it for a callback the candidate asked for. It is still subject to calling hours. |
retry_no_answer | optional | 0–3 (default 0). Extra dials if nobody answers. Same call id, and attempt increments. |
retry_after_minutes | optional | 10–1440 (default 120). Gap between retries. |
The job brief (job-screening)
Write values the way a recruiter would say them aloud: "28 lakhs per annum", not 28. The agent reads them on the phone.
| Field | Used for | |
|---|---|---|
role_name | required | The opener and the pitch. |
role_location | required | The location deal-breaker. |
work_mode | required | office, hybrid, remote, or a phrase such as “3 days in office”. |
experience_range | required | Relevance check. |
ctc_max | required | The budget ceiling. It is disclosed only if the candidate's current or expected CTC is above it. |
max_notice_period | required | The notice deal-breaker. |
ctc_min | optional | Bottom of the band. It is never spoken first. |
client_company | optional | The hiring company, if you are an agency. The agent says it is recruiting for them. |
company_description | optional | What the hiring company does. Used in the two-sentence pitch. |
role_description | optional | Stack, scope and must-haves. The agent writes its role-specific probes and green and red flags from this. |
funding_stage, employee_size, reporting_to | optional | Answers to the questions candidates commonly ask. |
additional_details | optional | Benefits, interview process, anything else the agent may say if asked. |
extra_context | optional | Private instructions, e.g. “If asked where we found their profile, say Naukri.” |
The agent never invents a salary, benefit or technology that is not in the brief. A thin brief makes for a shorter screen, not a made-up one.
Responses
202 | Queued. Body: the call object. |
200 | Same Idempotency-Key as an earlier request. Body: that call. |
409 | call_in_progress or do_not_call. |
422 | invalid_request, invalid_use_case, invalid_phone or invalid_voice. The message lists every problem at once. |
/v1/calls/{id}/stream
A live feed of server-sent events (text/event-stream) for one call. On connect you get the current status and every turn so far, then each new one as it happens. A : keep-alive comment is sent about every second. After completed (whose data is the full call object) the server closes the connection. If you disconnect, reconnect and skip the indexes you already have.
event: status
data: {"id": "call_…", "status": "in_progress", "sequence": 4}
event: transcript
data: {"id": "call_…", "index": 0, "turn": {"speaker": "agent", "text": "Hi Ravi, this is Priya from Acme Talent. …", "at_s": 0.0, "interrupted": false}}
event: completed
data: { …the call object… }
import json, httpx
with httpx.stream("GET", f"https://fury.humanbit.ai/v1/calls/{call_id}/stream",
headers={"Authorization": f"Bearer {KEY}"}, timeout=None) as r:
event = None
for line in r.iter_lines():
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: "):
data = json.loads(line[6:])
if event == "transcript":
print(data["turn"]["speaker"], ":", data["turn"]["text"])
elif event == "completed":
save(data) # the full call object, same as the call.completed webhook
// Node 18+. EventSource can't send an Authorization header, so read the stream with fetch.
const res = await fetch(`https://fury.humanbit.ai/v1/calls/${callId}/stream`, {
headers: { Authorization: `Bearer ${KEY}` },
});
const decoder = new TextDecoder();
let buf = "";
for await (const chunk of res.body) {
buf += decoder.decode(chunk, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const block = buf.slice(0, i); buf = buf.slice(i + 2);
const event = block.match(/^event: (.*)$/m)?.[1];
const data = block.match(/^data: (.*)$/m)?.[1];
if (event === "transcript") console.log(JSON.parse(data).turn.text);
}
}
curl -N https://fury.humanbit.ai/v1/calls/$CALL_ID/stream -H "Authorization: Bearer $HB_KEY"
/v1/calls/{id}
The call as it stands. Once status is ended, this returns the same body as the call.completed webhook (with a fresh recording link) until the data is deleted. Use it to reconcile if you missed a webhook. Don't poll it in a tight loop. Webhooks are the primary channel.
/v1/calls
Summaries, newest first. Query: status, external_ref, limit (1–200, default 50), before (a call id, for paging). The response is {"data": [...], "has_more", "next_before"}. Summaries don't include results. Fetch a call by id for those.
/v1/calls/{id}/cancel
A queued call ends at once with outcome: cancelled. A ringing call is hung up. A call in progress is hung up mid-conversation. That's abrupt for the candidate, so use it only when you must. If the call was already answered, the result is still produced and delivered. Returns 409 already_ended if it has already ended.
/v1/calls/{id}/redeliver
Queue the call.completed webhook again, for example after fixing a bug in your handler. Only works before the data is deleted.
/v1/calls/{id}/recording?exp=…&sig=…
The stereo MP3, 96 kbps: left channel = candidate, right channel = agent, on one aligned timeline. The link is self-authenticating, so don't send your API key with it. Get it from recording.url. It returns 410 after recording.expires_at.
/v1/suppressions
Add a number to your account's do-not-call list. Body: {"phone": "+919876543210", "reason": "optional"}. Queued calls to that number end as suppressed and are never dialled. We store only a salted hash.
/v1/suppressions/{phone}
Remove a number, for example when a candidate opts back in. Returns {"removed": true|false}.
/v1/account
Your settings (webhook URL, concurrency, calling hours, retention, agent defaults) and a live count of open calls by status, for the key's mode.
/v1/use-cases
The agents you can choose with use_case: title, summary, time limit, default voice, required and optional job fields, result field keys, and verdicts.
/v1/voices
The voices you can pass as agent.voice: saavi (warm, professional, the default for job-screening) and monika (casual and young, the default for language-freelancer). Both are female, Indian-English voices that have been used on real Indian phone calls.
/v1/health
Needs no authentication. Returns {"ok": true}.
/v1/webhooks/test
Sends a signed webhook.test event to your webhook URL now. Returns {"delivered": bool, "status_code"}.
/v1/deliveries
The webhook delivery log: each event, its state (pending, delivered, failed), attempts, the last error your endpoint returned, and the next retry time. Query: state, limit.
Webhooks
We POST JSON to the webhook URL on your account. Every delivery has the same envelope:
{
"id": "dlv_79279b748a53b857745c", // unique per event: use it to de-duplicate
"type": "call.completed",
"created_at": "2026-10-16T09:24:57.069597Z",
"data": { ... }
}
| Event | When | data |
|---|---|---|
call.status | Each move to dialing, ringing, in_progress, processing, or back to queued for a retry. | {id, status, sequence, external_ref, metadata, at} |
call.transcript | Each finished turn during the call, only when the call was created with "realtime_transcript": true. | {id, index, turn, external_ref, metadata}. See Real-time transcript. |
call.completed | Once per call, when it ends with any outcome. | The full call object: result, transcript, log, recording, usage. |
webhook.test | Only when you call POST /v1/webhooks/test. | {"hello": "world"} |
Headers on every delivery:
X-Webhook-Id | Same as the envelope id. |
X-Webhook-Event | Same as type. |
X-Webhook-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
Verifying signatures
Compute HMAC-SHA256 with your whsec_… secret over the string "{t}.{raw body}", compare it in constant time with v1, and reject a t more than 5 minutes old. Use the raw request body. Re-serialised JSON won't match.
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, sig = parts["t"], parts["v1"]
if abs(time.time() - int(t)) > 300:
return False
want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, sig)
# Flask: verify(request.get_data(), request.headers["X-Webhook-Signature"], WHSEC)
# FastAPI: verify(await request.body(), request.headers["x-webhook-signature"], WHSEC)
const crypto = require("crypto");
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const want = crypto.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(want), Buffer.from(parts.v1));
}
// Express: app.post("/hooks/screening", express.raw({ type: "application/json" }),
// (req, res) => verify(req.body.toString(), req.get("X-Webhook-Signature"), WHSEC) ...)
Retries & ordering
- Any
2xxwithin 15 seconds counts as delivered. Anything else, including a timeout, is retried after 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h and 8 h. After that the delivery is markedfailedand shows inGET /v1/deliveries. - Answer fast and do the work afterwards: store the body, return
200, then process it. - Delivery is at least once. De-duplicate on the envelope
id. - Events can arrive out of order when a retry is involved. Use
data.sequenceoncall.statusto discard stale ones, and treatcall.completedas final whenever it arrives.
The call object
This is what call.completed carries, and what GET /v1/calls/{id} returns once a call has ended. While a call is open, GET returns the first block of fields only.
{
"id": "call_66e7c041787915e90c13",
"object": "call",
"use_case": "job-screening",
"mode": "live", // "test" for test-key calls (and "test": true)
"status": "ended",
"outcome": "answered", // see Outcomes
"external_ref": "cand-881",
"attempt": 1,
"metadata": { "crm_candidate_id": 881, "requisition": "REQ-204" },
"candidate": { "name": "Ravi Kumar", "phone": "+919876543210" },
"job": { ...exactly as you sent it... },
"agent": { "name": "Priya", "company_name": "Acme Talent", "voice": "saavi" },
"timeline": {
"created_at": "2026-10-16T09:24:46.618Z",
"dialed_at": "2026-10-16T09:24:47.933Z",
"answered_at": "2026-10-16T09:24:51.986Z",
"ended_at": "2026-10-16T09:31:02.014Z"
},
"duration_s": 370.0, // answered → hang-up
"billable_minutes": 7, // ceil(duration_s / 60); 0 if unanswered
"end_reason": "completed",
"result": { ...see The result... }, // null when there was no conversation
"transcript": [
{ "speaker": "agent", "text": "Hi Ravi, this is Priya from Acme Talent. …", "at_s": 0.0, "interrupted": false },
{ "speaker": "candidate", "text": "Yes, go ahead.", "at_s": 5.2, "interrupted": false, "stt_confidence": 0.94 },
{ "speaker": "agent", "text": "What's your notice period?", "at_s": 6.9, "interrupted": false,
"latency_s": { "e2e_latency": 1.1, "end_of_turn_delay": 0.4, "llm_node_ttft": 0.5, "tts_node_ttfb": 0.2 } }
],
"transcript_text": "Agent: Hi Ravi, …\nCandidate: Yes, go ahead.\n…",
"recording": {
"url": "https://fury.humanbit.ai/v1/calls/call_66e7…/recording?exp=…&sig=…",
"expires_at": "2026-10-17T09:31:02Z",
"format": "mp3",
"channels": { "left": "candidate", "right": "agent" }
}, // null if there is no audio
"quality": {
"candidate_audio_mean_db": -31.4,
"candidate_audio_silent": false, // true = the line carried no audio from their side
"agent_reply_latency_p50_s": 1.2,
"agent_turns": 18, "candidate_turns": 17, "interruptions": 2
},
"usage": { // raw model usage for this call
"llm_prompt_tokens": 41230, "llm_completion_tokens": 1630,
"tts_characters_count": 2210, "tts_audio_duration": 152.3, "stt_audio_duration": 365.0
},
"retention": { "delete_after": "2026-10-17T09:31:02Z" },
"error": null,
"events": [
{ "status": "queued", "at": "…" }, { "status": "dialing", "at": "…" },
{ "status": "ringing", "at": "…" }, { "status": "in_progress", "at": "…" },
{ "status": "processing", "at": "…" }, { "status": "ended", "at": "…", "outcome": "answered" }
]
}
The result
This section describes job-screening. For language-freelancer, see its result fields. Both share verdict, summary, fields, structured, risk_flags, custom and do_not_call.
"result": {
"verdict": "hold",
"summary": "3–5 lines on fit, motivation, communication and red flags.",
"fields": { "notice_period": "60 days", "expected_ctc": "26 lakhs per annum", ... },
"structured": { "current_ctc_lpa": 18, "expected_ctc_lpa": 26, "notice_days": 60, "has_other_offers": false },
"risk_flags": ["expected_above_budget"],
"must_capture": { "relevance": "…", "budget_fit": "…", "notice_fit": "…", "location_fit": "…" },
"custom": { "open_to_night_shift": "Yes, once a month is fine" },
"do_not_call": false
}
verdict
The verdict is applied in this order, mechanically, so you can act on it without listening to the call:
| verdict | Rule |
|---|---|
callback_scheduled / not_interested | The call ended that way. For a callback, the time is in fields.callback_time. |
reject | A firm mismatch: irrelevant experience; expected CTC above budget and the candidate didn't accept the budget; notice above the maximum and not negotiable; location or work mode refused. |
hold | A soft mismatch (“negotiable”, buyout possible), any must-capture item not captured, or a competing offer. |
proceed | None of the above: relevant, within budget or accepted it, notice within the maximum, location and mode accepted, and everything captured. |
fields
All values are strings, and anything not covered is exactly "Not captured". Keys: candidate_name, current_company_and_role, total_experience, key_responsibilities, reason_for_leaving, desired_next_role, current_ctc, expected_ctc, expected_ctc_basis, budget_disclosed_and_reaction, notice_period, notice_negotiable_or_buyout, last_working_day, gap_reason, current_location, open_to_location_and_mode, relocation_timeline, callback_time, not_interested_reason, asked_to_be_removed, open_questions, other_offers.
structured
Numbers for writing straight into your ATS: CTC in lakhs per annum and notice in days. A value is null if it wasn't said. It is never guessed from a job title.
risk_flags
Zero or more of: expected_above_budget, competing_offer, notice_above_max, location_or_mode_mismatch, skills_depth_unclear.
do_not_call
If this is true, the candidate asked to be removed. We have already suppressed the number on your account. Mark them in your own system too, and stop all other outreach to them.
Errors
Every error on /v1 has the same shape:
{ "error": { "code": "invalid_request", "message": "job: job is missing required fields: ctc_max" } }
| HTTP | code | Meaning |
|---|---|---|
| 401 | unauthorized | Missing, malformed or revoked key. |
| 404 | not_found | No such call for this key, including calls made with your other key. |
| 409 | call_in_progress | This number already has an open call. |
| 409 | do_not_call | The number is on your suppression list. |
| 409 | already_ended, not_available, no_webhook | The action doesn't fit the call's (or the account's) current state. |
| 410 | expired, deleted | The recording is past its retention window. |
| 422 | invalid_request, invalid_use_case, invalid_phone, invalid_voice | The body failed validation. The message lists every problem. |
| 5xx | — | Our fault. Retry with the same Idempotency-Key. It's safe. |
Your responsibilities
- Consent and lawful basis. Call only candidates who applied, or who consented to be contacted about roles, and keep that evidence in your system. You are the data fiduciary for your candidates under the DPDP Act, 2023. We process their data on your instructions and delete it as described in Your data, not ours.
- Recording notice. Calls are recorded. Make sure your candidate-facing privacy notice says that screening calls may be recorded and handled by an AI agent.
- Do-not-call. Respect
do_not_call: trueacross all of your outreach, not just these calls. Push your own opt-outs to us withPOST /v1/suppressions. - Accuracy of the brief. The agent says what the brief says. A wrong budget in the brief becomes a wrong budget on the phone.
- Decisions. The verdict is a screening recommendation. A person on your side makes the hiring decision.
Questions or a key rotation: contact your HumanBit account manager.