Score

A number from 0 to 1 for a call, before you answer it, and where we would draw the line on it. The line is yours to move.

Score a call

GET/v1/scoreany key

A dry run. It runs the same checks a real call runs, counts the call you are asking about as if it had happened, and writes nothing down. Asking does not change the answer you get next time. How scoring works says what the number means; the short version is that 0.5 is doubt and 0.8 is refusal, and both come back with every answer.

Query parameters

callerstringrequired
Who is calling, E.164. On an outbound call, who you are about to dial.
numberstringrequired
Your own number they are calling. One of your protected numbers.
direction"inbound" | "outbound"default "inbound"
Pass outbound to ask about a number you are about to dial, the pre-dial check for a web form.

Response

scorenumber
0 to 1. How bad this looks. The answer.
action"allow" | "review" | "reject"
Where we would draw the line: what we would do at thresholds. Take it as it is, or draw your own.
thresholdsobject
Where action changes: review at 0.5, reject at 0.8. Fixed and public, so a line you draw on the score keeps its meaning.
reasonsstring[]
Short stable labels for what drove the score. Display them; don't branch on them.
blockobject | null
The active block that applies, if there is one: pattern, type, until, remaining_s, violations.
checksobject | null
The counts behind the score, one entry per check: what was counted in the window, the threshold in force for your protection level, and whether it passed. null when the caller is on your trusted list.
levelstring
The protection level in force for this number, as you configured it.
dry_runtrue
Always true here. Nothing was recorded.

Errors

invalid_caller400
The caller number could not be parsed as E.164.
invalid_number400
Your own number could not be parsed as E.164.
invalid_direction400
Something other than inbound or outbound.
unauthorized401
Missing or unknown key.

Drawing your own line

The whole integration, in the place where you decide whether to answer. Two numbers are yours: where you refuse, and where you would rather ask a question first.

Where you answer the callNode
// Where we would draw them. Move either; a sales line
// can afford to be stricter than a support line.
const REFUSE_AT = 0.8;
const VERIFY_AT = 0.5;

const q = new URLSearchParams({ caller, number: yours });
const r = await fetch(`https://api.murusai.com/v1/score?${q}`, {
  headers: { Authorization: `Bearer ${process.env.MURUSAI_KEY}` },
  signal: AbortSignal.timeout(800),
});

// Fail open. If we are slow or down, answer the call: a missed
// customer costs more than a spam call, and the alert path is
// still behind you.
if (!r.ok) return connect();

const { score } = await r.json();
if (score >= REFUSE_AT) return hangUp();
if (score >= VERIFY_AT) return connect({ verifyFirst: true });
return connect();

If you would rather not think about it, action is the same decision at the defaults: if (action === "reject") return hangUp(). The two agree exactly when your numbers are ours.

Fail open
Give it a short timeout and connect the call if the answer does not come. Your caller is listening to silence while you wait, and no score is better than a dropped customer. We would rather you miss a block than miss a sale.

Before you dial

With direction=outbound the question turns around: you are asking about a number somebody typed into your web form, before you spend a minute dialling it. The answer has the same shape.caller is the number you are about to dial; number is the one you will dial from.

GET /v1/score?caller=%2B15125550142&number=%2B14155550100&direction=outbound

Then tell us what happened

A score on its own teaches us nothing. The calls you actually connect should be reported to events: that is what builds the history behind bad_history, and what puts a caller on the network for everybody else. A call.started answers with this same shape, so if you report every call you need not ask separately at all.

Its first name

This endpoint began life as GET /v1/verdict, and still answers there, identically. Nothing built against that name needs to change; new code should use /v1/score.