AnswerReel docs: the REST API and webhooks
Send interview invites and read scores from your own systems, and get a webhook when a candidate submits, is scored or gets a label.
By The AnswerReel team. Updated .
Before you start
The API and webhooks are included on the Starter, Team and Agency plans, from $39 a month (see pricing). On Free, every request gets a 403 with plan_required, and no webhooks are sent. Your keys and webhook settings stay put if you move down a plan.
Only the Owner of an organization can create API keys. In AnswerReel, open Settings, then API and webhooks, and click Create key. The key is shown once, so copy it straight into your secrets manager. You can have 5 keys at a time and revoke any of them on the same page; requests with a revoked key get a 401. Revoking a key doesn't remove the webhooks it subscribed, so turn those off in Settings too.
Every request goes to https://answerreel.com/api/v1 over HTTPS and sends the key as a bearer token. Keys start with ar_live_.
curl https://answerreel.com/api/v1/jobs \
-H "Authorization: Bearer ar_live_YOUR_API_KEY"Requests, errors and limits
Request and response bodies are JSON. When something goes wrong you get an error code and a sentence written for people:
{
"error": "job_closed",
"message": "This job is closed. Copy it to start a new one."
}Check error in your code. The codes stay the same; the messages may be reworded.
Idempotency keys
Every POST and DELETE needs an Idempotency-Key header: a string of up to 200 characters that you make up, such as a UUID. Send the same key when you retry, and the action happens once. For 24 hours a repeat gets the first request's status code back.
- We keep only the status code and the ID of what was created, and build the response again from current data. A repeat can show newer values than the first response did, and
dataisnullif the candidate has since been deleted. - Idempotency keys are shared by all of your organization's API keys. Using one for a different request (another method, path or body) returns
422 idempotency_key_reused. - If the first request is still running, a repeat gets
409 request_in_progress. Try again a few seconds later. - If the first request got a 4xx, repeats get the same status with
replayed_error. Fix the request and send it with a new key. - If it got a
500, retry with the same key after a minute. If the action went through, you get its result; if not, it runs again.
Rate limit and pagination
Each API key can make 60 requests per clock minute. Past that you get 429 rate_limited with a Retry-After header in seconds. Nothing is paginated: the job list comes back whole.
Error codes
| Code and status | What it means |
|---|---|
invalid_json400The body isn't a JSON object. | The body isn't a JSON object. |
idempotency_key_required400A POST or DELETE came without an Idempotency-Key header, or with one over 200 characters. | A POST or DELETE came without an Idempotency-Key header, or with one over 200 characters. |
invalid_key401The API key is missing, mistyped or revoked. | The API key is missing, mistyped or revoked. |
plan_required403Your plan doesn't include the API. | Your plan doesn't include the API. |
quota_answers_used403You've used this month's answers. | You've used this month's answers. |
quota_period_invites403You've reached this month's limit on new invites. | You've reached this month's limit on new invites. |
quota_daily_invites403You've reached today's limit on new invites. | You've reached today's limit on new invites. |
quota_open_invites403Too many invites are still open. | Too many invites are still open. |
job_not_found404No such job in your organization. | No such job in your organization. |
candidate_not_found404No such candidate in your organization, or the candidate was deleted. | No such candidate in your organization, or the candidate was deleted. |
webhook_not_found404No such webhook, or it was added in Settings rather than through the API. | No such webhook, or it was added in Settings rather than through the API. |
job_closed409The job is closed. | The job is closed. |
job_needs_a_question409The job has no questions yet. | The job has no questions yet. |
publish_not_checked409The job is a draft whose questions haven't passed our checks. Open it in AnswerReel and save it. | The job is a draft whose questions haven't passed our checks. Open it in AnswerReel and save it. |
request_in_progress409A request with the same Idempotency-Key is still running. | A request with the same Idempotency-Key is still running. |
invalid_email422The email address isn't valid. | The email address isn't valid. |
invalid_name422The name is longer than 120 characters. | The name is longer than 120 characters. |
invalid_event422The event isn't one of the four listed under Webhooks. | The event isn't one of the four listed under Webhooks. |
invalid_url422The webhook address breaks a rule. The message says which. | The webhook address breaks a rule. The message says which. |
idempotency_key_reused422This Idempotency-Key was already used for a different request. | This Idempotency-Key was already used for a different request. |
replayed_error4xxA repeat of a request that failed. The status is the one the first request got. | A repeat of a request that failed. The status is the one the first request got. |
rate_limited429More than 60 requests this minute. Wait the number of seconds in Retry-After. | More than 60 requests this minute. Wait the number of seconds in Retry-After. |
server_error500Something failed on our side. Retry with the same Idempotency-Key after a minute. | Something failed on our side. Retry with the same Idempotency-Key after a minute. |
Endpoints
List jobs
GET /api/v1/jobs
Every job except drafts, newest first. status is published or closed, and client is the client's name or null.
{
"data": [
{
"id": "c2a4e6f8-1b3d-4f5a-8c7e-9d0b2a4c6e81",
"title": "Customer support lead",
"client": "Harbor Logistics",
"status": "published",
"created_at": "2026-10-20T09:12:44.31+00:00"
}
]
}Send an invite
POST /api/v1/jobs/{job_id}/invites
Send email (required) and name (optional). It works like inviting one person from the job page: the same limits apply, it counts toward your new invites, and we email the candidate their own link. If the job is still a draft, the first invite publishes it, as it does in the app.
If that email already has an invite for this job that hasn't expired, bounced or been revoked, no second email goes out. You get 200, "existing": true and that candidate. A new invite returns 201. Its status starts as pending_send and becomes invited once the email is sent.
curl -X POST \
https://answerreel.com/api/v1/jobs/c2a4e6f8-1b3d-4f5a-8c7e-9d0b2a4c6e81/invites \
-H "Authorization: Bearer ar_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: invite-maya-ruiz-support-lead" \
-d '{"email": "maya.ruiz@example.com", "name": "Maya Ruiz"}'{
"data": {
"job": {
"id": "c2a4e6f8-1b3d-4f5a-8c7e-9d0b2a4c6e81",
"title": "Customer support lead"
},
"name": "Maya Ruiz",
"email": "maya.ruiz@example.com",
"label": null,
"client": {
"id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"name": "Harbor Logistics"
},
"status": "pending_send",
"ai_status": "none",
"ai_version": 0,
"total_score": null,
"candidate_id": "3d1c7a52-9e64-4b8f-a0c2-7f5e1b9d4a26",
"data_version": 1,
"submitted_at": null,
"scored_questions": null,
"scorable_questions": null,
"review_url": "https://answerreel.com/app/jobs/c2a4e6f8-1b3d-4f5a-8c7e-9d0b2a4c6e81?candidate=3d1c7a52-9e64-4b8f-a0c2-7f5e1b9d4a26"
},
"existing": false
}Get a candidate
GET /api/v1/candidates/{candidate_id}
Status, scores and label for one candidate, in the shape described under the candidate object. A deleted candidate returns 404 candidate_not_found.
curl https://answerreel.com/api/v1/candidates/3d1c7a52-9e64-4b8f-a0c2-7f5e1b9d4a26 \
-H "Authorization: Bearer ar_live_YOUR_API_KEY"{
"data": {
"job": {
"id": "c2a4e6f8-1b3d-4f5a-8c7e-9d0b2a4c6e81",
"title": "Customer support lead"
},
"name": "Maya Ruiz",
"email": "maya.ruiz@example.com",
"label": "next_round",
"client": {
"id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"name": "Harbor Logistics"
},
"status": "submitted",
"ai_status": "done",
"ai_version": 1,
"total_score": 4.2,
"candidate_id": "3d1c7a52-9e64-4b8f-a0c2-7f5e1b9d4a26",
"data_version": 9,
"submitted_at": "2026-10-29T14:05:12.48+00:00",
"scored_questions": 2,
"scorable_questions": 2,
"review_url": "https://answerreel.com/app/jobs/c2a4e6f8-1b3d-4f5a-8c7e-9d0b2a4c6e81?candidate=3d1c7a52-9e64-4b8f-a0c2-7f5e1b9d4a26"
}
}Subscribe a webhook
POST /api/v1/webhooks
Send a url and one event. For several events, subscribe once per event. The response has the webhook's ID and its signing secret. The secret only comes back here, and on a repeat with the same Idempotency-Key, so store it. The address rules are under Webhooks; an address that breaks one gets 422 invalid_url.
curl -X POST https://answerreel.com/api/v1/webhooks \
-H "Authorization: Bearer ar_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: subscribe-scores-ready-1" \
-d '{"url": "https://example.com/hooks/answerreel", "event": "candidate.ai_completed"}'{
"data": {
"id": "5b0d3c1a-7f2e-4a9b-8c6d-1e3f5a7b9c20",
"url": "https://example.com/hooks/answerreel",
"event": "candidate.ai_completed"
},
"secret": "whsec_YOUR_SIGNING_SECRET"
}Delete a webhook
DELETE /api/v1/webhooks/{webhook_id}
Removes a webhook that was added through the API, along with any deliveries still waiting for it. Webhooks added in Settings are turned off there instead; this endpoint returns 404 webhook_not_found for them. It needs an Idempotency-Key like any other DELETE.
curl -X DELETE https://answerreel.com/api/v1/webhooks/5b0d3c1a-7f2e-4a9b-8c6d-1e3f5a7b9c20 \
-H "Authorization: Bearer ar_live_YOUR_API_KEY" \
-H "Idempotency-Key: unsubscribe-5b0d3c1a-7f2e-4a9b-8c6d-1e3f5a7b9c20"{
"data": {
"id": "5b0d3c1a-7f2e-4a9b-8c6d-1e3f5a7b9c20",
"deleted": true
}
}The candidate object
Invites, candidate lookups and webhooks all describe a candidate the same way. It never includes links to videos, audio or files.
| Field | What it holds |
|---|---|
candidate_idstringThe candidate's ID. | The candidate's ID. |
namestring or nullThe name from the invite or the apply form, if one was given. | The name from the invite or the apply form, if one was given. |
emailstringAlways lowercase. | Always lowercase. |
statusstringWhere the invite stands. The values are listed below. | Where the invite stands. The values are listed below. |
submitted_atstring or nullWhen the candidate clicked Submit, as an ISO 8601 timestamp. | When the candidate clicked Submit, as an ISO 8601 timestamp. |
jobobjectThe job's id and title. | The job's id and title. |
clientobject or nullThe id and name of the client the job is for, if you've set one. | The id and name of the client the job is for, if you've set one. |
total_scorenumber or nullFrom 1.0 to 5.0, with one decimal: the average of the questions that got a score (scored_questions). A question that couldn't be scored is left out, not counted as zero. null until scoring is done, or when no question got a score. | From 1.0 to 5.0, with one decimal: the average of the questions that got a score (scored_questions). A question that couldn't be scored is left out, not counted as zero. null until scoring is done, or when no question got a score. |
scored_questionsinteger or nullHow many questions got a score. | How many questions got a score. |
scorable_questionsinteger or nullHow many questions could have been scored. Short factual questions and file uploads are never scored. | How many questions could have been scored. Short factual questions and file uploads are never scored. |
ai_statusstringHow far the AI has got. The values are listed below. | How far the AI has got. The values are listed below. |
ai_versionintegerGoes up each time the AI results are replaced, for example after you retry a recording that failed. | Goes up each time the AI results are replaced, for example after you retry a recording that failed. |
labelstring or nullYour team's label: next_round, maybe or not_fit. | Your team's label: next_round, maybe or not_fit. |
data_versionintegerGoes up whenever anything in this object changes. Keep whichever copy has the higher number. | Goes up whenever anything in this object changes. Keep whichever copy has the higher number. |
review_urlstringOpens the candidate in AnswerReel. Only people in your organization can open it. | Opens the candidate in AnswerReel. Only people in your organization can open it. |
status
pending_sendThe invite exists, and the email hasn't gone out yet. | The invite exists, and the email hasn't gone out yet. |
|---|---|
send_failedThe email couldn't be sent after several tries. | The email couldn't be sent after several tries. |
undeliverableThe first invite email bounced. | The first invite email bounced. |
invitedThe email went out. The deadline counts from here. | The email went out. The deadline counts from here. |
in_progressThe candidate has started. | The candidate has started. |
graceThe deadline passed while an upload or a follow-up the candidate started in time was unfinished. Lasts up to an hour. | The deadline passed while an upload or a follow-up the candidate started in time was unfinished. Lasts up to an hour. |
submittingThe candidate clicked Submit and some recordings are still processing. | The candidate clicked Submit and some recordings are still processing. |
submittedSubmitted, with every answer saved. | Submitted, with every answer saved. |
expiredThe deadline passed before they submitted. | The deadline passed before they submitted. |
revokedSomeone on your team revoked the invite. | Someone on your team revoked the invite. |
ai_status
noneNothing to process yet: the candidate hasn't submitted. | Nothing to process yet: the candidate hasn't submitted. |
|---|---|
waiting or processingTranscribing, summarizing and scoring. | Transcribing, summarizing and scoring. |
doneFinished. | Finished. |
partialFinished, but at least one recording couldn't be transcribed. You can retry it from the candidate's page. | Finished, but at least one recording couldn't be transcribed. You can retry it from the candidate's page. |
failedProcessing failed. You can retry it from the candidate's page. | Processing failed. You can retry it from the candidate's page. |
offThe AI isn't used for this candidate: they asked for a person to review them, or AI is off for the job. | The AI isn't used for this candidate: they asked for a person to review them, or AI is off for the job. |
Webhooks
Add a webhook in Settings, under API and webhooks, where you can pick several events at once, or through the API above. Either way the address has to use https on port 443, must not contain a user name or password, and has to point at a public address on the internet. We look the domain up again before every delivery and connect to the address we checked.
| Event | When it's sent |
|---|---|
candidate.submittedA candidate submits and all of their answers are saved. | A candidate submits and all of their answers are saved. |
candidate.ai_completedThe AI has finished with a candidate's answers: transcripts, summary and, if the job is scored, scores. Sent again with a higher ai_version when the results are redone. | The AI has finished with a candidate's answers: transcripts, summary and, if the job is scored, scores. Sent again with a higher ai_version when the results are redone. |
candidate.label_changedSomeone on your team sets, changes or clears a candidate's label. | Someone on your team sets, changes or clears a candidate's label. |
candidate.deletedA candidate's data was deleted. data carries only candidate_id and deleted: true. | A candidate's data was deleted. data carries only candidate_id and deleted: true. |
What a delivery looks like
Each delivery is a POST with a JSON body. data is the candidate object.
{
"id": "8f6f9a3e-5b1c-4f0e-9d7a-2c4b6e8a0f13",
"type": "candidate.ai_completed",
"sequence": 3,
"data": {
"job": {
"id": "c2a4e6f8-1b3d-4f5a-8c7e-9d0b2a4c6e81",
"title": "Customer support lead"
},
"name": "Maya Ruiz",
"email": "maya.ruiz@example.com",
"label": "next_round",
"client": {
"id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"name": "Harbor Logistics"
},
"status": "submitted",
"ai_status": "done",
"ai_version": 1,
"total_score": 4.2,
"candidate_id": "3d1c7a52-9e64-4b8f-a0c2-7f5e1b9d4a26",
"data_version": 9,
"submitted_at": "2026-10-29T14:05:12.48+00:00",
"scored_questions": 2,
"scorable_questions": 2,
"review_url": "https://answerreel.com/app/jobs/c2a4e6f8-1b3d-4f5a-8c7e-9d0b2a4c6e81?candidate=3d1c7a52-9e64-4b8f-a0c2-7f5e1b9d4a26"
}
}Content-Type: application/json
User-Agent: AnswerReel-Webhooks/1.0
AnswerReel-Event-Id: 8f6f9a3e-5b1c-4f0e-9d7a-2c4b6e8a0f13
AnswerReel-Event-Type: candidate.ai_completed
AnswerReel-Signature: t=1793282800,v1=<64 hex characters>idis the event ID. The same event can reach you more than once, because we retry whenever we don't get a 2xx in time, so skip IDs you've already handled. A delivery you resend by hand from Settings gets a new ID.sequenceputs one candidate's events in order: a later event has a higher number. It can skip numbers.- A webhook tells you the current state, not a snapshot. We build
datawhen we send it, so a retry can carry newer data than the first try did. Comparedata.data_versionwith the copy you have and keep the higher one, whatever order deliveries arrive in. - Once a candidate is deleted, their earlier events that hadn't gone out yet are dropped and only
candidate.deletedis sent. Deleting a candidate in AnswerReel doesn't delete copies you've made elsewhere; this event is your cue to do that.
Timing and retries
Deliveries go out once a minute. Reply with any 2xx within 10 seconds and do slow work after that. A different status, a timeout or a redirect counts as a failure; we don't follow redirects.
After a failure we try again 1 minute later, then after 10 minutes, after an hour, and then every 6 hours. If a webhook keeps failing for 3 days, we turn it off, drop what's still waiting and email the Owner. When you turn it back on in Settings, only new events are sent. The 50 most recent deliveries are listed there, and you can resend any of them.
Checking signatures
Every delivery is signed with the webhook's secret. Webhooks added in Settings show the secret once, when you add them; API subscriptions return it in the response. Check the signature before you trust a delivery:
- Read the raw request body as it arrived. Parsing and re-serializing the JSON changes the bytes, and the signature won't match.
- Split the
AnswerReel-Signatureheader intot, the Unix time in seconds when we signed it, andv1, the signature. - Compute the HMAC-SHA256 of
t, a period and the raw body, using the whole secret (includingwhsec_) as the key, and hex-encode it. - Compare it with v1 in constant time, and reject timestamps more than 5 minutes from your clock.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody is the request body exactly as it arrived, before any JSON parsing
export function verifyAnswerReelSignature(rawBody, header, secret) {
const parts = Object.fromEntries(String(header ?? "").split(",").map((part) => part.split("=", 2)));
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
const received = Buffer.from(parts.v1 ?? "", "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}import hashlib
import hmac
import time
def verify_answerreel_signature(raw_body: bytes, header: str, secret: str) -> bool:
"""raw_body is the request body exactly as it arrived, before any JSON parsing."""
try:
parts = dict(part.split("=", 1) for part in (header or "").split(","))
timestamp = int(parts["t"])
if abs(time.time() - timestamp) > 300:
return False
except (KeyError, ValueError, OverflowError):
return False
signed = str(timestamp).encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), parts.get("v1", "").encode())Zapier and help
The AnswerReel Zapier integration runs on this same API: its three triggers are the first three events above, and its action sends an invite. If something here doesn't match what you see, write to support@answerreel.com.
Set up a job, then connect it
Start from our screening interview questions, or build a scorecard first. API keys, webhooks and Zapier come with Starter, $39 a month, and every plan above it.