# Response format

Every check and lookup returns the same typed JSON envelope with `verdict`, `p_scam`, `reason_codes`, `classification`, `entities` and `signals`. Entities carry typed facts and the datasets checked. Signals carry their source class, weight, dataset and coverage. A `verdict` of `unknown` means the evidence was not enough or a source failed. Code must never treat `unknown` as `safe`. Related domains, assess, watches and events have their own typed objects in the OpenAPI spec.

Updated 2026-10-30.

## The envelope

Every check and lookup answers with this object (related domains, assess, watches and events have their own types in the [OpenAPI spec](https://api.bewary.app/v1/openapi.json)). Lookups fill `entities` with the value you sent and anything it leads to, such as a link's domain.

| Field | Type | Meaning |
|---|---|---|
| `schema_version` | string | The contract version, `1.8.0` today. |
| `request_id` | string | `req_` and a ULID. Quote it in support requests. |
| `checked_at` | timestamp | When the verdict was computed, RFC 3339 in UTC. |
| `engine` | string | The engine version that answered. |
| `models` | object | Only the models actually called for this answer, plus `calibration`. A lookup calls none. |
| `input` | object | `kind` (`lookup` since 1.7.0), `channel`, `language`, `region`. |
| `verdict` | enum | `scam`, `suspicious`, `safe` or `unknown`. |
| `p_scam` | number 0 to 1 | The probability that the input is part of a scam. |
| `risk_score` | integer 0 to 100 | `p_scam` times 100, rounded, for simple rules. |
| `confidence` | number 0 to 1 | How settled the decision is, from 0 for `unknown` to 1. |
| `classification` | null | Filled only by the message and screenshot checks retired in 1.7.0. Kept so a 1.x parser keeps working. |
| `reason_codes` | array | The codes that decided the verdict, strongest first, at most 5. |
| `entities` | array | Everything found and checked, each with its own verdict and facts. |
| `signals` | array | Every signal, attached to an entity. |
| `degraded` | boolean | True when a source or model was skipped or failed. |
| `degraded_reasons` | array | Why, from a fixed list. |
| `cached` | boolean | Served from cache. |
| `explanation` | object or null | Only with `explain=true`. Plain-language text, not part of the contract. |

## Entities

| Field | Meaning |
|---|---|
| `id` | `e1`, `e2` and so on, unique in the response. |
| `type` | `url`, `domain`, `ip`, `phone`, `shortcode`, `email`, `wallet`, `iban`, `app` or `payment_handle`. |
| `value`, `normalized` | As sent, and in canonical form: lowercase host, E.164 number. |
| `role` | How it relates to the value you sent: `link_target` for a link and its domain, `redirect_hop` for where a short link leads, `payee` for a wallet, IBAN or payment handle, `mentioned` for the rest. `sender`, `callback_number`, `reply_to` and `hosting` stay in the schema from the retired message checks. |
| `verdict`, `p_scam`, `risk_score`, `reason_codes` | The entity's own judgment. |
| `facts` | Typed facts for its type. Every documented key is always present, `null` when unknown. |
| `related` | Ids of linked entities: a link and an email address point to their domain. |
| `datasets` | Every [dataset](/data) checked for this entity, with its geography. `in_coverage` false means the dataset does not cover it, such as a US-only list for a German number. |

A link is judged together with everything in its `related` list, so a link to a scam domain is a scam link. In TypeScript, `Entity` is a union on `type`, so `if (e.type === "domain")` narrows `e.facts` to `DomainFacts` without a cast. The facts for each type are listed on its page: [link](/docs/url), [domain](/docs/domain), [phone](/docs/phone), [short code](/docs/shortcode), [email](/docs/email), [IP](/docs/ip), [wallet](/docs/wallet), [IBAN](/docs/iban), [app](/docs/app), [payment handle](/docs/payment-handle).

## Signals

| Field | Meaning |
|---|---|
| `code` | A stable [reason code](/docs/reason-codes). |
| `entity` | The entity id it is about. |
| `severity` | `info`, `low`, `medium`, `high` or `critical`. |
| `weight` | Its signed contribution. Positive pushes toward scam, negative reassures. |
| `source_class` | `confirmed` (a list or report), `first_party` (our own data), `heuristic`, `model` or `attacker_content` (read from the scammer's own page or image). |
| `p` | The model's probability when `source_class` is `model`, otherwise `null`. |
| `dataset` | The [dataset](/data) the signal came from. |
| `coverage` | That dataset's geography: `WW` for worldwide, or a country code such as `US` or `DE`. |
| `evidence` | Fixed keys per code, such as the brand a domain imitates. |

## How the verdict is decided

The engine weighs every signal, then picks one of four verdicts. `p_scam` always sits in its verdict's band:

| Verdict | `p_scam` | Means |
|---|---|---|
| `scam` | 0.80 to 1 | Clear evidence: a scam-list hit, a brand imitated on an abused domain, a known scam pattern. |
| `suspicious` | 0.50 up to, not including, 0.80 | Real risk that falls short of proof. |
| `safe` | 0 up to, not including, 0.20 | Nothing that should worry the reader. |
| `unknown` | 0.20 up to, not including, 0.50 | Not enough evidence either way, or a source failed (`degraded` is true). |

`signals` lists everything found, including signals the decision outweighed: a real 2FA text can carry a medium `TEXT_SCAM_WORDING` signal and still be `safe`. `reason_codes` lists only what decided the verdict. A check that would have been safe but lost a source answers `unknown`, never `safe`.

Until the calibrated model ships, `models.calibration` reads `interim-band-v0`: `p_scam` follows the engine's score inside the verdict's band and is not yet a measured probability. One part is calibrated already: a domain's `site.p_scam_site`, our verdict on a page our worker read, is fitted on labelled hosts and published at `GET /v1/calibration/site`.

## degraded_reasons

`source_timeout`, `source_unavailable`, `budget_limit`, `redirect_unresolved`, `input_truncated`, `model_unavailable`, `page_unavailable`.

## Versioning

The contract was frozen at `1.0.0` and is `1.8.0` today. New fields, new enum values and new reason codes are added in minor versions, so ignore fields you do not know and treat an unknown enum value as `unknown`. Nothing is renamed or removed within `/v1`.
