# Fraud Signals

> One risk score from SIM-swap recency, BVN demographic changes and watchlist status. Built for account-takeover and onboarding fraud.

Combines SIM-swap recency, recent BVN demographic changes and the industry watchlist into one risk score from 0 to 100. Built for account takeover and onboarding fraud.

`POST /v1/signals`

- **Availability:** Sandbox and live
- **Indicative price:** ₦200.00 per completed check
- **Consent:** Your attestation (`client_attested`).
- **MCP tool:** `check_signals`

## Request

**Body parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `phone` | string | No | Format: Nigerian mobile number, `0803…` or `+234803…`. |
| `bvn` | string | No | Format: 11 digits. |
| `reason` | string | Yes | Purpose of the check. One of the 38 NIMC purpose codes. See the [purpose code list](https://speedmap-delta.vercel.app/docs/compliance#purpose-codes). |
| `consent` | object | Yes | Your attestation that you obtained the data subject's consent for this purpose. |
| `consent.obtained` | boolean | Yes | Always `true`. |

- Provide `phone`, `bvn`, or both. The phone drives SIM-swap signals; the BVN drives demographic changes and the watchlist.
- `match` is always `null`: a risk score is not a match. Read `summary.risk_score` and `summary.risk_level`.
- Weights: watchlisted 60, SIM swap within 7 days 35 (within 30 days 15), BVN name change within 30 days 20, BVN phone change within 30 days 10. The score is capped at 100; `high` from 60, `medium` from 30.

Send an `Idempotency-Key` header so a retried request is never billed twice. See [Idempotency](https://speedmap-delta.vercel.app/docs/idempotency).

_cURL_

```bash
curl -X POST http://localhost:4000/v1/signals \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "phone": "08099990000",
  "bvn": "22222222225",
  "reason": "financialProducts",
  "consent": {
    "obtained": true
  }
}'
```

_Node.js_

```javascript
const res = await fetch("http://localhost:4000/v1/signals", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SPEEDMAP_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "phone": "08099990000",
    "bvn": "22222222225",
    "reason": "financialProducts",
    "consent": {
      "obtained": true
    }
  }),
});

const verification = await res.json();
if (!res.ok) throw new Error(verification.error.message);
console.log(verification.status, verification.match);
```

_Python_

```python
import os, uuid, requests

res = requests.post(
    "http://localhost:4000/v1/signals",
    headers={
        "Authorization": f"Bearer {os.environ['SPEEDMAP_API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "phone": "08099990000",
        "bvn": "22222222225",
        "reason": "financialProducts",
        "consent": {
            "obtained": True
        }
    },
    timeout=30,
)
res.raise_for_status()
verification = res.json()
print(verification["status"], verification["match"])
```

## Response

A completed check returns `200` with a [verification object](https://speedmap-delta.vercel.app/docs/verifications). `status` is `verified` or `not_found`. If the provider has not answered in time you get `202` with `status: "pending"`; [retrieve it later](https://speedmap-delta.vercel.app/docs/verifications#pending-verifications) or wait for the `verification.completed` [webhook](https://speedmap-delta.vercel.app/docs/webhooks).

**200 OK · assertion**

```json
{
  "reference_id": "spm_8171f3a87cbe221341f7185f",
  "object": "verification",
  "product": "signals",
  "method": "risk_signals",
  "status": "verified",
  "match": null,
  "field_matches": null,
  "field_confidence": null,
  "mode": "sandbox",
  "reason": "financialProducts",
  "subject": "*******2225",
  "summary": {
    "risk_score": 100,
    "risk_level": "high",
    "factors": [
      {
        "code": "watchlisted",
        "weight": 60,
        "detail": "BVN is on the industry watchlist"
      },
      {
        "code": "sim_swap_7d",
        "weight": 35,
        "detail": "SIM swapped 2 day(s) ago"
      },
      {
        "code": "bvn_name_change_30d",
        "weight": 20,
        "detail": "Name changed on the BVN in the last 30 days"
      },
      {
        "code": "bvn_phone_change_30d",
        "weight": 10,
        "detail": "Phone number changed on the BVN in the last 30 days"
      }
    ],
    "sim_swap_recent": true,
    "days_since_sim_swap": 2,
    "bvn_demographic_changes": 2,
    "bvn_demographic_changes_30d": 2,
    "watchlisted": true
  },
  "consent": {
    "mechanism": "client_attested",
    "consent_id": null,
    "timestamp": "2026-10-19T09:41:07.512Z"
  },
  "source": "api",
  "error": null,
  "billing": {
    "amount_kobo": 0,
    "currency": "NGN"
  },
  "latency_ms": 520,
  "created_at": "2026-10-19T09:41:07.512Z",
  "updated_at": "2026-10-19T09:41:07.874Z"
}
```

**summary**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `risk_score` | integer | No | Sum of factor weights, 0 to 100. |
| `risk_level` | string | No | `low`, `medium` or `high`. |
| `factors` | array | No | Each factor: `code`, `weight` and a readable `detail`. |
| `sim_swap_recent` | boolean \| null | No | SIM swapped within 30 days. `null` without a phone. |
| `days_since_sim_swap` | integer \| null | No | Days since the last SIM swap. |
| `bvn_demographic_changes` | integer \| null | No | Number of demographic changes on the BVN. `null` without a BVN. |
| `bvn_demographic_changes_30d` | integer \| null | No | Changes in the last 30 days. |
| `watchlisted` | boolean \| null | No | BVN is on the industry watchlist. |

Full-tier accounts calling the REST API also receive `provider`, `provider_reference` and `data`, the normalized record. MCP, webhooks, lists and bulk results never include it. See [Response shaping](https://speedmap-delta.vercel.app/docs/response-shaping).

**Full tier only**

```json
{
  "provider": "sandbox",
  "provider_reference": "SBX6CB753DB2C",
  "data": {
    "risk_score": 100,
    "risk_level": "high",
    "factors": [
      "…"
    ],
    "sim_swap": {
      "days_since_swap": 2
    },
    "bvn_changes": [
      "…"
    ]
  }
}
```

## Test values

Send these bodies with a sandbox key. More outcome triggers are on the [Sandbox](https://speedmap-delta.vercel.app/docs/sandbox) page.

**High risk**

```json
{
  "phone": "08099990000",
  "bvn": "22222222225",
  "reason": "financialProducts",
  "consent": {
    "obtained": true
  }
}
```

**Medium risk (recent SIM swap)**

```json
{
  "phone": "08187654321",
  "bvn": "22222222226",
  "reason": "financialProducts",
  "consent": {
    "obtained": true
  }
}
```

**Low risk**

```json
{
  "phone": "08031234567",
  "bvn": "22222222221",
  "reason": "financialProducts",
  "consent": {
    "obtained": true
  }
}
```

## Errors

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `invalid_request` | The body failed validation. `details` lists each field. |
| 401 | `unauthorized` | Missing or invalid API key. |
| 402 | `insufficient_balance` | Live only: wallet balance is below the check price. |
| 403 | `not_entitled` | Your workspace is not enabled for this product or method. |
| 409 | `idempotency_key_reused` | The `Idempotency-Key` was used with a different body. |
| 422 | `provider_rejected_request` | The upstream registry rejected the request. |
| 503 | `verification_unavailable` | Temporary failure. Retry, with the same or a new `Idempotency-Key`. |

All errors share one shape. See [Errors](https://speedmap-delta.vercel.app/docs/errors) for the full list.

---

Source: https://speedmap-delta.vercel.app/docs/products/signals
