> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Risk API: unified cross-channel Trust & Fraud score

> Query a composite 0-100 fraud risk score for any destination before sending SMS or placing a call, fusing SMS-pumping, URL-reputation, and Verify signals.

# Risk API

The Risk API fuses the anti-fraud detectors already running across your Orbit account into
one composite verdict you can query **before** committing spend on an SMS send or a call. It
answers a single question — "is this destination safe to reach right now?" — without you
having to call each detector yourself and reconcile the scores.

* **Live SMS-pumping / artificial-traffic score** — always computed from destination-prefix
  patterns, recent send velocity, delivery-conversion rate, and geo-spread.
* **URL/link-reputation score** — computed automatically whenever you pass a `message_body`,
  by scanning every URL in it.
* **Verify Fraud Guard** and **Voice Biometrics** scores — optional pass-through inputs from
  your own session-level checks, folded into the composite when supplied.

The endpoint is **read-only and advisory**: it sends nothing, routes nothing, and enforces
nothing. The [Devotel softswitch](/voice/quickstart) remains the sole outbound path for calls
and SMS — Risk only tells you what it would recommend.

**Base path:** `/api/v1/risk`

**Authentication:** API key (`X-API-Key`) or session JWT.

**Rate limit:** 120 requests/minute per tenant.

| Method | Path                 | Purpose                                                       |
| ------ | -------------------- | ------------------------------------------------------------- |
| `POST` | `/api/v1/risk/score` | Composite cross-channel Trust & Fraud score for a destination |

## When to call it

Call `/api/v1/risk/score` at the point you'd otherwise commit spend:

* Before an outbound SMS/WhatsApp send to a destination you haven't messaged before.
* Before dialing a destination that has flagged in your delivery reports.
* Alongside a [Verify](/api-reference/verify) OTP send, passing along the Fraud Guard score
  you already computed for that session so it's folded into one number.
* Alongside a [Voice Biometrics](/api-reference/endpoints/voice-biometrics) challenge, passing
  its score the same way.

The response is a **recommendation, not an enforcement action** — your application decides
whether to allow, hold for review, or block, the same way you'd act on a credit-card AVS
result. Risk never sends, routes, or blocks anything on its own; wiring the recommendation
into your send flow (allow / hold / drop) is up to your integration.

## Request body

| Field                     | Type   | Default | Notes                                                                                                        |
| ------------------------- | ------ | ------- | ------------------------------------------------------------------------------------------------------------ |
| `destination`             | string | —       | **Required.** E.164 destination the score is requested for.                                                  |
| `channel`                 | string | `sms`   | One of `sms`, `whatsapp`, `voice`, `verify`. Drives the SMS-pumping velocity aggregate.                      |
| `message_body`            | string | —       | Optional, ≤5000 chars. Every URL in it is reputation-scanned and folded into the composite.                  |
| `verify_signal`           | object | —       | Optional pass-through `{ score, reasons[] }` from your own Verify Fraud Guard evaluation for this recipient. |
| `voice_biometrics_signal` | object | —       | Optional pass-through `{ score, reasons[] }` from your own Voice Biometrics challenge for this session.      |

`score` in either pass-through object is an integer 0-100; `reasons` is up to 20 short
strings (≤200 chars each), echoed back inside the channel breakdown.

## Scenario 1 — score-only lookup

The smallest useful call: a destination and a channel, nothing else. The SMS-pumping detector
is the only signal present unless you supply more, so the composite equals that detector's
score.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.orbit.devotel.io/api/v1/risk/score \
    -H "X-API-Key: dv_live_sk_your_key_here" \
    -H "Content-Type: application/json" \
    -d '{
      "destination": "+14155552671",
      "channel": "sms"
    }'
  ```

  ```python Python theme={null}
  import requests

  resp = requests.post(
      "https://api.orbit.devotel.io/api/v1/risk/score",
      headers={"X-API-Key": "dv_live_sk_your_key_here"},
      json={"destination": "+14155552671", "channel": "sms"},
      timeout=10,
  )
  resp.raise_for_status()
  print(resp.json()["data"]["band"])  # low | elevated | high | critical
  ```

  ```go Go theme={null}
  package main

  import (
  	"bytes"
  	"encoding/json"
  	"fmt"
  	"net/http"
  )

  func main() {
  	body, _ := json.Marshal(map[string]string{
  		"destination": "+14155552671",
  		"channel":     "sms",
  	})
  	req, _ := http.NewRequest(
  		"POST",
  		"https://api.orbit.devotel.io/api/v1/risk/score",
  		bytes.NewReader(body),
  	)
  	req.Header.Set("X-API-Key", "dv_live_sk_your_key_here")
  	req.Header.Set("Content-Type", "application/json")

  	resp, err := http.DefaultClient.Do(req)
  	if err != nil {
  		panic(err)
  	}
  	defer resp.Body.Close()

  	var out struct {
  		Data struct {
  			Score          int    `json:"score"`
  			Band           string `json:"band"`
  			Recommendation string `json:"recommendation"`
  		} `json:"data"`
  	}
  	json.NewDecoder(resp.Body).Decode(&out)
  	fmt.Println(out.Data.Band) // low | elevated | high | critical
  }
  ```
</CodeGroup>

```json theme={null}
{
  "data": {
    "destination": "+14155552671",
    "channel": "sms",
    "score": 8,
    "band": "low",
    "recommendation": "allow",
    "channels": [
      { "channel": "sms_pumping", "present": true, "score": 8, "reasons": [] },
      { "channel": "url_reputation", "present": false, "score": 0, "reasons": [] },
      { "channel": "verify_fraud", "present": false, "score": 0, "reasons": [] },
      { "channel": "voice_biometrics", "present": false, "score": 0, "reasons": [] }
    ]
  },
  "meta": {
    "request_id": "req_abc123",
    "timestamp": "2026-08-07T10:14:22Z"
  }
}
```

Walk the response field by field:

* **`score`** — 0-100 composite, always the *worst* present channel score (never averaged
  down, so one bad signal can't be diluted by clean ones). Here only `sms_pumping` was
  present, so the composite equals its 8.

* **`band`** — coarse read of the score. The cutoffs are the same ones the SMS-pumping
  scorer uses, so a band means the same thing on this composite as it does on the
  single-channel signals:

  | Score  | Band       | Recommendation |
  | ------ | ---------- | -------------- |
  | 0-24   | `low`      | `allow`        |
  | 25-49  | `elevated` | `allow`        |
  | 50-79  | `high`     | `review`       |
  | 80-100 | `critical` | `block`        |

* **`recommendation`** — advisory only: `allow`, `review`, or `block`. Only `high` and
  `critical` move it off `allow`.

* **`channels`** — full transparency breakdown, always in the same four-entry order. A
  channel with `present: false` contributed a `score` of `0` — it never invents risk for a
  signal you didn't supply.

* **`meta.request_id`** — echo this in support tickets; it ties the verdict to the exact
  signals that produced it.

## Scenario 2 — pre-send guard with Verify and Voice signals

When you've already run a Verify Fraud Guard check or a Voice Biometrics challenge for this
session, pass their scores through instead of reconciling four numbers yourself. The
composite takes the worst present signal — supply a high Verify score and the recommendation
flips.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.orbit.devotel.io/api/v1/risk/score \
    -H "X-API-Key: dv_live_sk_your_key_here" \
    -H "Content-Type: application/json" \
    -d '{
      "destination": "+14155552671",
      "channel": "verify",
      "verify_signal": { "score": 62, "reasons": ["geo_mismatch", "sim_swap_recent"] },
      "voice_biometrics_signal": { "score": 12, "reasons": [] }
    }'
  ```

  ```python Python theme={null}
  import requests

  resp = requests.post(
      "https://api.orbit.devotel.io/api/v1/risk/score",
      headers={"X-API-Key": "dv_live_sk_your_key_here"},
      json={
          "destination": "+14155552671",
          "channel": "verify",
          # From the riskScore on your POST /verify/send response:
          "verify_signal": {"score": 62, "reasons": ["geo_mismatch", "sim_swap_recent"]},
          # From your /verify/voice-biometrics/* challenge result:
          "voice_biometrics_signal": {"score": 12, "reasons": []},
      },
      timeout=10,
  )
  resp.raise_for_status()
  print(resp.json()["data"]["recommendation"])  # review
  ```

  ```go Go theme={null}
  package main

  import (
  	"bytes"
  	"encoding/json"
  	"fmt"
  	"net/http"
  )

  func main() {
  	body, _ := json.Marshal(map[string]interface{}{
  		"destination": "+14155552671",
  		"channel":     "verify",
  		"verify_signal": map[string]interface{}{
  			"score":   62,
  			"reasons": []string{"geo_mismatch", "sim_swap_recent"},
  		},
  		"voice_biometrics_signal": map[string]interface{}{
  			"score":   12,
  			"reasons": []string{},
  		},
  	})
  	req, _ := http.NewRequest(
  		"POST",
  		"https://api.orbit.devotel.io/api/v1/risk/score",
  		bytes.NewReader(body),
  	)
  	req.Header.Set("X-API-Key", "dv_live_sk_your_key_here")
  	req.Header.Set("Content-Type", "application/json")

  	resp, err := http.DefaultClient.Do(req)
  	if err != nil {
  		panic(err)
  	}
  	defer resp.Body.Close()

  	var out struct {
  		Data struct {
  			Score          int    `json:"score"`
  			Recommendation string `json:"recommendation"`
  		} `json:"data"`
  	}
  	json.NewDecoder(resp.Body).Decode(&out)
  	fmt.Println(out.Data.Recommendation) // review
  }
  ```
</CodeGroup>

The same destination, with and without the pass-through signals — the Verify Fraud Guard
score (62) now drives the verdict from `allow` to `review`:

**Without pass-through signals** (`destination` + `channel` only):

```json theme={null}
{
  "data": {
    "destination": "+14155552671",
    "channel": "verify",
    "score": 8,
    "band": "low",
    "recommendation": "allow",
    "channels": [
      { "channel": "sms_pumping", "present": true, "score": 8, "reasons": [] },
      { "channel": "url_reputation", "present": false, "score": 0, "reasons": [] },
      { "channel": "verify_fraud", "present": false, "score": 0, "reasons": [] },
      { "channel": "voice_biometrics", "present": false, "score": 0, "reasons": [] }
    ]
  },
  "meta": { "request_id": "req_def456", "timestamp": "2026-08-07T10:16:40Z" }
}
```

**With `verify_signal` and `voice_biometrics_signal` supplied:**

```json theme={null}
{
  "data": {
    "destination": "+14155552671",
    "channel": "verify",
    "score": 62,
    "band": "high",
    "recommendation": "review",
    "channels": [
      { "channel": "sms_pumping", "present": true, "score": 8, "reasons": [] },
      { "channel": "url_reputation", "present": false, "score": 0, "reasons": [] },
      { "channel": "verify_fraud", "present": true, "score": 62, "reasons": ["geo_mismatch", "sim_swap_recent"] },
      { "channel": "voice_biometrics", "present": true, "score": 12, "reasons": [] }
    ]
  },
  "meta": { "request_id": "req_ghi789", "timestamp": "2026-08-07T10:16:41Z" }
}
```

Two things to notice: the composite is `max(channel scores)`, not an average — the low
`voice_biometrics` 12 doesn't pull the 62 down — and your `reasons` strings come back
verbatim on the channel entry, so drill-down views can attribute the flip to Fraud Guard
without a second call.

## Scenario 3 — message-body URL scan

Pass `message_body` and every URL in it is reputation-scanned live — the body-level score is
the worst-scoring URL, and that score enters the composite as a fourth channel. A body with
two links scans both; the clean one can't dilute the bad one.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.orbit.devotel.io/api/v1/risk/score \
    -H "X-API-Key: dv_live_sk_your_key_here" \
    -H "Content-Type: application/json" \
    -d '{
      "destination": "+14155552671",
      "channel": "sms",
      "message_body": "Your delivery is held: verify customs at http://secure-royalmail.delivery-claim.top/confirm — or track at https://www.royalmail.com/track"
    }'
  ```

  ```python Python theme={null}
  import requests

  resp = requests.post(
      "https://api.orbit.devotel.io/api/v1/risk/score",
      headers={"X-API-Key": "dv_live_sk_your_key_here"},
      json={
          "destination": "+14155552671",
          "channel": "sms",
          "message_body": (
              "Your delivery is held: verify customs at "
              "http://secure-royalmail.delivery-claim.top/confirm "
              "— or track at https://www.royalmail.com/track"
          ),
      },
      timeout=10,
  )
  resp.raise_for_status()
  url_channel = next(
      c for c in resp.json()["data"]["channels"]
      if c["channel"] == "url_reputation"
  )
  print(url_channel["score"])  # worst-URL score
  ```

  ```go Go theme={null}
  package main

  import (
  	"bytes"
  	"encoding/json"
  	"fmt"
  	"net/http"
  )

  func main() {
  	body, _ := json.Marshal(map[string]string{
  		"destination":  "+14155552671",
  		"channel":      "sms",
  		"message_body": "Your delivery is held: verify customs at http://secure-royalmail.delivery-claim.top/confirm — or track at https://www.royalmail.com/track",
  	})
  	req, _ := http.NewRequest(
  		"POST",
  		"https://api.orbit.devotel.io/api/v1/risk/score",
  		bytes.NewReader(body),
  	)
  	req.Header.Set("X-API-Key", "dv_live_sk_your_key_here")
  	req.Header.Set("Content-Type", "application/json")

  	resp, err := http.DefaultClient.Do(req)
  	if err != nil {
  		panic(err)
  	}
  	defer resp.Body.Close()

  	var out struct {
  		Data struct {
  			Channels []struct {
  				Channel string `json:"channel"`
  				Score   int    `json:"score"`
  			} `json:"channels"`
  		} `json:"data"`
  	}
  	json.NewDecoder(resp.Body).Decode(&out)
  	for _, c := range out.Data.Channels {
  		if c.Channel == "url_reputation" {
  			fmt.Println(c.Score) // worst-URL score
  		}
  	}
  }
  ```
</CodeGroup>

```json theme={null}
{
  "data": {
    "destination": "+14155552671",
    "channel": "sms",
    "score": 85,
    "band": "critical",
    "recommendation": "block",
    "channels": [
      { "channel": "sms_pumping", "present": true, "score": 8, "reasons": [] },
      { "channel": "url_reputation", "present": true, "score": 85, "reasons": ["brand_impersonation", "abused_tld", "no_tls", "lure_keywords"] },
      { "channel": "verify_fraud", "present": false, "score": 0, "reasons": [] },
      { "channel": "voice_biometrics", "present": false, "score": 0, "reasons": [] }
    ]
  },
  "meta": { "request_id": "req_jkl012", "timestamp": "2026-08-07T10:19:03Z" }
}
```

The `url_reputation` entry carries the per-URL view: `score` is the worst URL's score and
`reasons` is that URL's finding codes — here the scanner fired on a brand token
(`royalmail`) that isn't the URL's actual registrable domain, a commonly-abused `.top` TLD,
plain `http://`, and smishing lure keywords (`verify`, `confirm`, `customs`, `delivery`).
At 85 the composite crosses the 80+ `critical` cutoff and the recommendation becomes
`block`. The second, legitimate `royalmail.com` link scored clean — both URLs were scanned,
and the worst one wins.

Bodies cap at 25 distinct URLs per scan and every URL in the body counts; URL-scoring
has weight against phishing/smishing patterns specifically, so run this check whenever your
send template includes user-supplied links.

## Acting on a `band: hold` or `block` recommendation

The verdict is yours to enforce — Risk returns a recommendation only. Treat `recommendation:
"block"` (80+, or `high` if you prefer caution) as "hold this send / call for review" in your
own send flow. Common follow-ups:

* **Hold and re-check later** — SMS-pumping velocity spikes decay; re-query the same
  destination after a quieter window rather than dropping the recipient permanently.
* **Route through [Frequency Caps](/api-reference/frequency-caps)** — your tenant-owned
  per-recipient send caps keep review-queue destinations from receiving anything until you
  release them.
* **Add the destination to [Message Suppression](/api-reference/message-suppression)** —
  your tenant-owned opt-out/block list, when a recipient should stop receiving traffic
  entirely.
* **Check [Number Intelligence](/api-reference/network-apis)** — per-number
  reputation/fraud lookups outside the send path, when you want more context than the
  composite gives.

## See also

* [Verify API](/api-reference/verify) — OTP fraud-guard scoring you can pass in as `verify_signal`
* [Voice Biometrics](/api-reference/endpoints/voice-biometrics) — caller-verification scoring you can pass in as `voice_biometrics_signal`
* [Number Intelligence](/api-reference/network-apis) — per-number reputation/fraud lookups outside the send path
