Speaker enrollment and verification
Eidos · Speaker verification answers one question: is this the same speaker who was enrolled? You enroll a speaker with one or more recordings, then compare new recordings against that enrollment.
| Endpoint | Scope | Cost |
|---|---|---|
POST /v1/speakers/enroll |
speakers:enroll |
50 credits |
POST /v1/speakers/verify |
speakers:verify |
50 credits |
POST /v1/speakers/verify with detect_deepfake=true |
speakers:verify and deepfake:detect |
100 credits (50 + 50) |
GET /v1/speakers |
speakers:read |
free |
GET /v1/speakers/{speaker_id} |
speakers:read |
free |
DELETE /v1/speakers/{speaker_id} |
speakers:delete |
free |
The model
The model is eidos-redimnet2, based on ReDimNet2. The current version is redimnet2-b6-vox2-lm-v1.0.0. The version can change; each response names the version that produced it in model.version, so read it from there rather than hard-coding it.
Phone-call audio. Verification is less reliable on telephone audio: 8 kHz narrowband recordings and audio that has passed through lossy voice codecs. Expect more wrong decisions on that audio than on clean wideband recordings, and plan a fallback, such as a second factor or human review, for calls where the decision matters.
Speaker IDs
speaker_id is your identifier for a person, such as a customer number. It must match ^[A-Za-z0-9_.:-]{1,64}$ and is unique within your account. Use an opaque identifier rather than a name, email address or phone number.
Speakers are private to your account. An ID that does not exist in your account returns 404 speaker_not_found, whether or not another account uses the same ID.
Enroll a speaker
POST /v1/speakers/enrollMultipart fields:
| Field | Required | Description |
|---|---|---|
file |
yes | One recording of the speaker: 5 to 60 seconds after decoding, with at least 3 seconds of detected speech. We recommend 10 seconds or more of clean speech. |
speaker_id |
yes | Your identifier for the speaker. |
display_name |
no | A label shown in the dashboard. |
The first call for a speaker_id creates the speaker. Each later call adds a sample, up to 5 active samples per speaker. The speaker's voice template is the normalised mean of the sample embeddings for the current model version. Recordings from different sessions, devices or days give the template more variety than one long recording.
curl https://platform.detectif.ai/v1/speakers/enroll \
-H "Authorization: Bearer $DETECTIF_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-F "speaker_id=customer-1042" \
-F "display_name=Customer 1042" \
-F "file=@enrollment-1.wav"import os
import uuid
import requests
API = "https://platform.detectif.ai/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['DETECTIF_API_KEY']}"}
def enroll(speaker_id: str, path: str, display_name: str | None = None) -> dict:
fields = {"speaker_id": speaker_id}
if display_name:
fields["display_name"] = display_name
with open(path, "rb") as f:
resp = requests.post(
f"{API}/speakers/enroll",
headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())},
data=fields,
files={"file": (os.path.basename(path), f)},
timeout=120,
)
body = resp.json()
if not resp.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']} ({body['request_id']})")
return body
body = enroll("customer-1042", "enrollment-1.wav", "Customer 1042")
print(body["speaker"]["samples"], body["quality"]["speech_seconds"])import { readFile } from "node:fs/promises";
import { basename } from "node:path";
import { randomUUID } from "node:crypto";
const API = "https://platform.detectif.ai/v1";
async function enroll(speakerId, path, displayName) {
const form = new FormData();
form.append("speaker_id", speakerId);
if (displayName) form.append("display_name", displayName);
form.append("file", new Blob([await readFile(path)]), basename(path));
const res = await fetch(`${API}/speakers/enroll`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DETECTIF_API_KEY}`,
"Idempotency-Key": randomUUID(),
},
body: form,
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message} (${body.request_id})`);
return body;
}
const body = await enroll("customer-1042", "enrollment-1.wav", "Customer 1042");
console.log(body.speaker.samples, body.quality.speech_seconds);Example response (values are illustrative):
{
"request_id": "req_71c0e2a4f9d31a170e74983d",
"operation_id": "op_3e9d4a02b7c127b87803b74dd556a75b",
"object": "speaker.enrollment",
"speaker": {
"speaker_id": "customer-1042",
"display_name": "Customer 1042",
"samples": 1,
"created_at": "2026-10-12T09:14:03Z",
"updated_at": "2026-10-12T09:14:03Z"
},
"quality": { "speech_seconds": 11.8, "duration_seconds": 14.2 },
"model": { "name": "eidos-redimnet2", "version": "redimnet2-b6-vox2-lm-v1.0.0" },
"credits": { "charged": 50, "balance": 99850 },
"latency_ms": 296.4,
"idempotent_replay": false
}quality.speech_seconds is the amount of speech detected in the recording. Enrollment needs at least 3 seconds; 10 seconds or more of clean speech is recommended. Enroll 2 to 3 samples per speaker.
Verify a speaker
POST /v1/speakers/verifyMultipart fields:
| Field | Required | Description |
|---|---|---|
file |
yes | The recording to check: 3 to 60 seconds after decoding, with at least 2 seconds of detected speech. |
speaker_id |
yes | The enrolled speaker to compare against. |
detect_deepfake |
no | true to also run deepfake detection on the same recording. Costs 50 more credits and needs the deepfake:detect scope. |
curl https://platform.detectif.ai/v1/speakers/verify \
-H "Authorization: Bearer $DETECTIF_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-F "speaker_id=customer-1042" \
-F "detect_deepfake=true" \
-F "file=@caller.wav"import os
import uuid
import requests
def verify(speaker_id: str, path: str, detect_deepfake: bool = False) -> dict:
fields = {"speaker_id": speaker_id}
if detect_deepfake:
fields["detect_deepfake"] = "true"
with open(path, "rb") as f:
resp = requests.post(
"https://platform.detectif.ai/v1/speakers/verify",
headers={
"Authorization": f"Bearer {os.environ['DETECTIF_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
data=fields,
files={"file": (os.path.basename(path), f)},
timeout=120,
)
body = resp.json()
if not resp.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']} ({body['request_id']})")
return body
body = verify("customer-1042", "caller.wav", detect_deepfake=True)
r = body["result"]
print(f"{r['decision']} similarity={r['similarity']:.3f} threshold={r['threshold']}")
df = body["deepfake"]
if df and "error" in df:
print("deepfake check failed:", df["error"]["code"])
elif df:
print("deepfake:", df["label"], df["score"])
print("charged:", body["credits"]["charged"])import { readFile } from "node:fs/promises";
import { basename } from "node:path";
import { randomUUID } from "node:crypto";
async function verify(speakerId, path, detectDeepfake = false) {
const form = new FormData();
form.append("speaker_id", speakerId);
if (detectDeepfake) form.append("detect_deepfake", "true");
form.append("file", new Blob([await readFile(path)]), basename(path));
const res = await fetch("https://platform.detectif.ai/v1/speakers/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DETECTIF_API_KEY}`,
"Idempotency-Key": randomUUID(),
},
body: form,
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message} (${body.request_id})`);
return body;
}
const body = await verify("customer-1042", "caller.wav", true);
const r = body.result;
console.log(`${r.decision} similarity=${r.similarity.toFixed(3)} threshold=${r.threshold}`);
if (body.deepfake?.error) console.log("deepfake check failed:", body.deepfake.error.code);
else if (body.deepfake) console.log("deepfake:", body.deepfake.label, body.deepfake.score);
console.log("charged:", body.credits.charged);Example response with detect_deepfake=true (values are illustrative):
{
"request_id": "req_0b8e5d13c6fac3aa890b2a12",
"operation_id": "op_c41a7e9f2d054febd9b806806d7caada",
"object": "speaker.verification",
"speaker_id": "customer-1042",
"result": {
"decision": "match",
"similarity": 0.71,
"threshold": 0.53,
"similarity_semantics": "cosine similarity between embeddings; not a probability",
"duration_seconds": 6.3
},
"deepfake": {
"label": "genuine",
"is_synthetic": false,
"score": 0.05,
"threshold": 0.62,
"score_semantics": "model output probability of synthetic speech; not calibrated to your traffic",
"duration_seconds": 6.3,
"segments": [
{ "start": 0.0, "end": 4.0, "score": 0.04 },
{ "start": 2.0, "end": 6.3, "score": 0.06 }
],
"operation_id": "op_f7b2093ac1e4ae025abcf34a4243019e",
"credits_charged": 50,
"model": { "name": "aletheia-wavlm", "version": "wavlm-finetune-n-m43-moredata-ep063" }
},
"model": { "name": "eidos-redimnet2", "version": "redimnet2-b6-vox2-lm-v1.0.0" },
"credits": { "charged": 100, "balance": 99750 },
"latency_ms": 534.9,
"idempotent_replay": false
}| Field | Description |
|---|---|
result.similarity |
Cosine similarity between the recording's voice embedding and the speaker's template, from -1 to 1. Higher means more similar. |
result.threshold |
The cosine similarity threshold applied for this request. |
result.decision |
match when similarity reaches threshold, otherwise no_match. |
result.similarity_semantics |
A plain statement of what the similarity means. |
deepfake |
null without detect_deepfake=true. Otherwise the deepfake result (same fields as deepfake detection's result, plus its own operation_id, credits_charged and model), or, if that part failed, {"error": {...}, "operation_id": "...", "credits_charged": 0}. |
model |
The speaker model and version used. Templates belong to a model version. |
credits.charged |
50, or 100 when the deepfake part also succeeded. 0 for an idempotent replay, which also has credits.original_charge (the amount charged for the original request). |
Reading the result
- Similarity is not a probability. A similarity of 0.71 does not mean a 71% chance that it is the same person. It is a cosine similarity between two embeddings. Compare it with the
thresholdreturned in the same response. - Read the threshold and model version from each response. The default is 0.53 and can change when the model changes. Do not hard-code it. Store
model.versionwith each decision. See The decision threshold. - Telephone audio is harder. On 8 kHz or codec-compressed phone audio, verification is less reliable (team-reported). Enroll and verify with audio from the same kind of channel when you can.
- A match is a separate finding from authenticity. A cloned voice can be similar to the enrolled speaker. Use
detect_deepfake=true, or a separate detection call, when that matters. no_matchis a result, not an error. It is charged likematch. Decide in your own flow what happens next, for example a second factor or human review.
The decision threshold
result.decision is match when result.similarity is at or above result.threshold. The default threshold is 0.53 (cosine similarity). It was chosen for a low false-accept rate on a held-out split of Svarah (Indian-accented English), where the team measured:
| Default threshold 0.53 on Svarah (held-out split) | Rate |
|---|---|
False accepts (a different speaker decided as match) |
0.12% |
False rejects (the same speaker decided as no_match) |
2.75% |
The trade-off: a threshold set for few false accepts rejects more genuine speakers when the conditions differ. When the enrollment and the verification recordings come from different sessions and conditions, false rejects rise. In the team's VoxCeleb test they were about 10% for full utterances and about 22% for 3 second clips. On phone and codec-compressed audio, verification is less reliable still.
To reduce false rejects:
- enroll 2 to 3 samples per speaker, each with 10 seconds or more of clean speech, from different sessions;
- verify with longer recordings where you can;
- treat
no_matchas a reason for another check, not as proof of a different speaker.
You can also apply your own threshold to result.similarity. A lower threshold rejects fewer genuine speakers and accepts more impostors; a higher one does the opposite. Measure on recordings from your own channels before you change it.
The combined verify and deepfake option
With detect_deepfake=true, one request runs two operations on the same recording: a speaker verification and a deepfake detection. They are accounted independently:
- Each operation reserves 50 credits, so you need at least 100 available credits. With fewer, the request fails with
402 insufficient_creditsand nothing is charged. - It counts as two model operations toward the per-account rate limit.
- If both succeed, you are charged 100 credits.
- If the deepfake part fails (for example a timeout),
deepfakecontains anerrorobject with acodeandmessage, its reservation is released, and you are charged 50 credits for the verification. - Each part has its own
operation_id. Both stored results can be fetched withGET /v1/operations/{operation_id}.
List, read and delete speakers
These calls are free.
# List speakers, 50 at a time
curl "https://platform.detectif.ai/v1/speakers?limit=50" \
-H "Authorization: Bearer $DETECTIF_API_KEY"
# Read one speaker
curl https://platform.detectif.ai/v1/speakers/customer-1042 \
-H "Authorization: Bearer $DETECTIF_API_KEY"
# Delete a speaker and its stored voice embeddings
curl -X DELETE https://platform.detectif.ai/v1/speakers/customer-1042 \
-H "Authorization: Bearer $DETECTIF_API_KEY"import os
import requests
API = "https://platform.detectif.ai/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['DETECTIF_API_KEY']}"
def all_speakers():
cursor = None
while True:
params = {"limit": 50}
if cursor:
params["cursor"] = cursor
page = session.get(f"{API}/speakers", params=params, timeout=30).json()
yield from page["data"]
cursor = page.get("next_cursor")
if not cursor:
break
for speaker in all_speakers():
print(speaker["speaker_id"], speaker["samples"])
print(session.get(f"{API}/speakers/customer-1042", timeout=30).json())
resp = session.delete(f"{API}/speakers/customer-1042", timeout=30)
print(resp.status_code)const API = "https://platform.detectif.ai/v1";
const headers = { Authorization: `Bearer ${process.env.DETECTIF_API_KEY}` };
async function* allSpeakers() {
let cursor;
do {
const url = new URL(`${API}/speakers`);
url.searchParams.set("limit", "50");
if (cursor) url.searchParams.set("cursor", cursor);
const page = await (await fetch(url, { headers })).json();
yield* page.data;
cursor = page.next_cursor;
} while (cursor);
}
for await (const speaker of allSpeakers()) console.log(speaker.speaker_id, speaker.samples);
console.log(await (await fetch(`${API}/speakers/customer-1042`, { headers })).json());
const del = await fetch(`${API}/speakers/customer-1042`, { method: "DELETE", headers });
console.log(del.status);GET /v1/speakers returns speakers newest first as {"object": "list", "data": [...], "has_more": bool, "next_cursor": string | null}. limit defaults to 20 and can be 1 to 100. Pass next_cursor back as cursor to read the next page. Each speaker has speaker_id, display_name, samples (active samples for the current model version), created_at and updated_at.
Deleting a speaker removes its stored voice embeddings and returns {"object": "speaker.deleted", "speaker_id": "...", "deleted": true}. To enroll the same speaker_id again later, start a new enrollment.
Errors
Requests that fail are not charged. See Errors for the full list.
| HTTP | error.code |
What to do |
|---|---|---|
| 400 | invalid_speaker_id |
Use 1 to 64 characters from A-Z a-z 0-9 _ . : -. |
| 404 | speaker_not_found |
The speaker_id is not enrolled in your account. |
| 409 | too_many_samples |
The speaker already has 5 active samples. Delete the speaker and enroll again to replace them. |
| 409 | speaker_reenrollment_required |
The speaker has no samples for the current speaker model version. Enroll the speaker again. |
| 422 | audio_too_short, audio_too_long |
Enrollment: 5 to 60 seconds. Verification: 3 to 60 seconds. |
| 422 | insufficient_speech |
Too little speech was detected: at least 3 seconds to enroll, 2 seconds to verify. |
| 422 | silent_audio |
The recording contains no signal. |
| 403 | insufficient_scope |
With detect_deepfake=true the key needs both speakers:verify and deepfake:detect. |
| 402 | insufficient_credits |
Not enough available credits for the operations requested. |