# Fraud checks

Evaluate a shopper **before** you charge them, and report abuse you have confirmed
yourself. These endpoints are **advisory**: `POST /charges` runs the full fraud engine
again server-side, so a `check` that says `allow` never overrides a decline at charge
time, and a `check` you skip never weakens the real decision.

Use them to fail fast: stop a hopeless checkout before the shopper types a card, or
step up to 3DS on a borderline one.

**Integrator must-dos** (IP when you proxy, `card_bin`+`last4`, token is not pre-approval):
[Integration hard rules](./27-integration-hard-rules.md).

---

## `POST /v1/check`

Requires the `check:read` scope.

```bash
curl https://api.worthypayments.com/v1/check \
  -H "x-api-key: sk_test_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "shopper@example.com",
    "amount": 4999,
    "card_bin": "424242",
    "billing_country": "US",
    "fingerprint": "wp_fp_9f2c...",
    "transaction_type": "one_off"
  }'
```

| Field | Type | Req | Notes |
|---|---|---|---|
| `email` | string | yes | Hashed before it leaves the edge; the plaintext is never stored |
| `amount` | integer | | Cents. Send it when you know it; omit for a pre-cart check |
| `card_bin` | string | | First 6 to 8 digits. Never send the full PAN. (`bin` is accepted as a legacy alias; prefer `card_bin`) |
| `last4` | string | | Last 4 of the PAN. **Send with `card_bin`** so the [decision token](./26-decision-token.md) can bind the card; omit either and that binding is not enforced later |
| `billing_country` | string | | ISO-3166 alpha-2 |
| `fingerprint` | string | | Device fingerprint from the [SDK](./08-embedded-sdk.md) |
| `currency` | string | | ISO-4217, lowercase. Defaults to `usd`. Sent with `amount`, it is **bound into the decision token**. A token minted as `usd` cannot be presented for a `eur` charge |
| `ip` | string | yes¹ | **Buyer’s public IP.** ¹Nominally optional (we fall back to the request peer), but the peer is *your server*, so in practice this is required. See [Client IP](#client-ip-effectively-required) |
| `transaction_type` | string | | `PURCHASE` or `SUBSCRIPTION`. Advisory only. It is recorded, not scored |

### Client IP (effectively required)

IP is one of the strongest fraud signals (velocity, blocklist, geo, cascade). Wrong IP
makes the verdict useless or noisy.

**Always set `ip` yourself.** The endpoint does fall back to the request peer, but the
only key that can call it is a secret key on your server, so the peer is your egress
address, not the buyer's. Omitting `ip` scores your data centre.

| Who calls `/v1/check` | What happens |
|---|---|
| **Your backend** (secret key) | The supported path. **Set `ip` to the shopper’s real IP.** |
| Browser / publishable key | **Not possible.** A `pk_` is narrowed server-side to `payments:charge` and scope-`none` endpoints and can never hold `check:read`, so the call returns `403 permission_denied` regardless of what the body contains. Proxy it through your own server. |

Resolve the shopper IP the same way a well-configured proxy does: the hop **your**
load balancer or CDN appended (not the left-most `X-Forwarded-For` entry, which the
client can forge). Pass a single public IPv4/IPv6 string, e.g. `"203.0.113.42"`.

> **`POST /v1/charges` does not accept body `ip`.** Charge IP is never read from the body,
> so it cannot be spoofed by a caller. A charge built on your server records your egress
> address as your server's, never scored or blocklisted as a buyer's; send the buyer's
> [`device_session`](./24-device-data-collection.md#device-session-the-buyers-ip-and-browser)
> to score it on the buyer's IP. The [Payment Element](./08b-payment-element.md) does not
> need one: the browser confirms directly, so the peer is the buyer. Either way, use
> `/check` with an explicit `ip` for the advisory step.
> Details: [Charges → Client IP](./03-charges.md#client-ip-fraud-signal-not-a-body-field).

```json
{
  "object": "fraud_check",
  "status": "clean",
  "action": "allow",
  "requires_3ds": false,
  "reasons": [],
  "risk_score": 12,
  "check_id": "8300054a-bc4d-4746-861f-63804710d69d",
  "risk_decision_token": "cmR0MXxt….9f8e7d…"
}
```

| Field | Notes |
|---|---|
| `status` | `clean`, `flagged`, or `blocked` |
| `action` | `allow`, `review`, or `deny`. What we recommend you do |
| `requires_3ds` | Advisory: if `true`, expect a possible 3-D Secure challenge on the later charge. To run 3DS on that charge whatever your rules decide, send `three_ds.request` (`any`, `challenge` or `challenge_mandated`). When the charge returns `requires_action`, complete the challenge and resubmit with `three_ds.session_id` (see [3-D Secure](./07-3ds.md)). |
| `reasons` | Machine-readable reason codes behind the verdict |
| `risk_score` | 0 to 100, higher is riskier |
| `check_id` | Correlates this evaluation with the later charge in your dashboard. **An empty string means the engine was never reached** (see the fail-open note below) |
| `risk_decision_token` | Signed receipt for this evaluation. Echo it on the charge to tie the two together. See [Risk decision token](./26-decision-token.md). Absent when token signing is not configured |

> **This endpoint fails open, and you can detect it.** If the fraud engine is unreachable
> the call still returns `201` with `status: "clean"`, `action: "allow"` and `risk_score:
> 0`. An outage must not stop a merchant taking payments. The tell is **`check_id: ""`**
> (and no `risk_decision_token`): a real evaluation always carries an id. Treat that
> response as *unscreened*, not as approved. The charge itself is unaffected. It runs its
> own fraud pass server-side either way.

> **This endpoint is advisory.** `POST /charges` runs the full fraud engine again on the
> data the charge actually carries. Skipping `/check` never bypasses screening, and
> passing it never pre-approves a charge. What the token adds is provenance: a charge that
> drifts from the evaluation it claims (different amount, card or device) is *rejected*.

### Linked-account blocks

`BLOCKED_BYPASS_LINK` means an active device or IP link points to a customer who is
currently blocked in the same merchant account and environment. It returns
`status: "blocked"`, `action: "deny"`, and `risk_score: 99`. The link reason is a
fixed code, never a customer or anchor identifier. This check runs after the inline
Cascade check and before velocity; an earlier allowlist match or disabled fraud
screening still wins.

`/check` has no customer or card, so a buyer allowlisted only by customer or card
can still be denied by `/check` on a device/IP link that the charge then approves.

The Cascade **sync-block** switch controls both device and IP link checks. The
**device** switch additionally controls device links. The inline-IP switch does
not control IP links. Missing settings and NULL device/sync-block values keep link
protection on; stored false disables the corresponding protection.

Blocked attempts still record link evidence while these switches are off. Device
links last 30 days and IP links 7 days; re-enabling protection re-arms links that
have not expired and still point to a blocked customer.

## `POST /v1/check/recheck`

Identical request and response. Call it once the final amount is known. A cart that
grew after the first check is exactly the case a single check misses.

---

## `POST /v1/report`

Report abuse you have confirmed yourself, so it counts against future attempts.
Requires the `report:write` scope.

```bash
curl https://api.worthypayments.com/v1/report \
  -H "x-api-key: sk_test_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "email": "abuser@example.com", "action": "block", "reason": "confirmed chargeback fraud" }'
```

`action` is `block` or `flag`. `block` denies future attempts outright; `flag` marks
them for review without stopping them.

---

## `GET /v1/blocklist`

Read back what is currently blocked for your account. Requires `blocklist:read`.
Paginates like every other list. See [Pagination](./13-pagination.md).

```json
{
  "object": "list",
  "data": [
    {
      "object": "blocklist_entry",
      "id": "d2fb8af6-d015-4a27-ac59-f43ec314ae64",
      "type": "email",
      "value": "abuser@example.com",
      "reason": "confirmed chargeback fraud",
      "expires_at": null,
      "created_at": "2026-07-27T10:00:00Z",
      "is_global": false
    }
  ],
  "has_more": false
}
```

`is_global: true` marks a platform-wide entry. You cannot remove those, and they apply
across every merchant.

---

## 3DS and fraud controls from your own tools

If you keep your own admin tooling, these endpoints let it read and drive the same 3DS
and fraud levers as the dashboard, so you do not maintain two rule sets by hand.

| Lever | Scope of effect | API |
|---|---|---|
| Ask for 3DS on one payment | that payment | `three_ds.request` on `POST /v1/charges` and `POST /v1/payment_intents` |
| The customer's **Require 3DS** flag | every later card charge for that customer | `GET`, `POST`, `DELETE /v1/customers/{id}/three_ds` |
| The risk-score 3DS step-up (score 70–95) | every card charge in this mode | `GET`, `POST /v1/fraud/three_ds_policy` |
| Fraud rules | every charge they match | `GET /v1/fraud/rules` (read-only) |
| Payment Workflow routes, incl. **Attempt 3DS** | every charge the route wins | `GET /v1/payment_workflow/routes` (read-only) |

**A rule outranks a request.** You can always *start* 3DS — on one payment with
`three_ds.request`, or for a customer with the flag — but nothing you send can switch it
*off* when our side asks for it. If a fraud rule, a detector, the customer's flag or the
winning route asks for 3DS, the charge is stepped up and challenged even when the request
asked for something softer. Clearing the customer's flag lifts only the flag; the rules
and routes keep applying.

### The risk-score 3DS step-up

Every card charge gets a fraud risk score from 0 to 100. By default a charge scoring 70
to 95 is stepped up to 3DS, whatever your own rules decide. If you choose 3DS yourself
with `three_ds.request`, turn that off so your rules are the only trigger:

```bash
curl https://api.worthypayments.com/v1/fraud/three_ds_policy \
  -H "x-api-key: sk_test_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "risk_step_up": { "enabled": false } }'
```

```json
{
  "object": "three_ds_policy",
  "risk_step_up": { "enabled": false, "min_score": 70, "max_score": 95 },
  "livemode": false
}
```

- **The body sets the whole band.** To narrow it instead, send
  `{ "risk_step_up": { "enabled": true, "min_score": 85 } }`. A bound you leave out goes
  back to its default (70 or 95).
- **It applies from the next charge.** TEST and LIVE keep separate settings; your key
  decides which one you change. `GET /v1/fraud/three_ds_policy` reads it.
- **It lifts only the score band.** A charge is still stepped up when a fraud rule asks
  for it, the customer is marked Require 3DS, or the route has Attempt 3DS on, and a score
  above your decline threshold still declines.
- **Scopes:** reading needs `fraud:read`; changing needs `fraud:write`, which only secret
  and restricted keys can hold.

### The customer's Require 3DS flag

```bash
curl https://api.worthypayments.com/v1/customers/cus_5f0c1f7e-8d2a-4c1b-9a3e-2b7d6c5e4f10/three_ds \
  -H "x-api-key: sk_test_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Chargeback on file" }'
```

```json
{
  "object": "customer_three_ds",
  "customer": "cus_5f0c1f7e-8d2a-4c1b-9a3e-2b7d6c5e4f10",
  "require_3ds": true,
  "livemode": false
}
```

`POST` turns it on, `DELETE` clears it and `GET` reads it. It is the flag the dashboard
shows under **Restrictions** on the customer's page, so a change made in either place is
visible in the other. Both writes are safe to repeat and answer the flag as read back
after the write. `DELETE` clears the flag whoever set it, a fraud detector included —
exactly what **Turn off** does in the dashboard. A change made through the API is recorded
against your key, so it can be told apart from a dashboard click. TEST and LIVE keys
see separate flags. An unknown customer id is a `404`.

### Reading your rules and routes

`GET /v1/fraud/rules` returns your fraud rules in evaluation order with their
`conditions`, `action` and `enabled` state; `requires_3ds` marks a rule that steps the
charge up. `GET /v1/payment_workflow/routes` returns the Payment Workflow canvas route by
route, in the order it is evaluated, with `require_3ds` for the route's **Attempt 3DS**
toggle. Each route also keeps one fraud rule in step with it; that rule's `route` field
names the route.

Both are **read-only**. Creating, editing or disabling rules and routes stays in the
dashboard: a key that could rewrite them could also switch your fraud protection off or
send money to a different processor, and opening that up needs its own decision. Tell
us if your integration needs it.

---

## Scopes

These endpoints are **server-side only**. A publishable (`pk_`) key can never reach
them: the authority is the key, not the request body, so no browser call can grant
itself `check:read`, `report:write`, `fraud:read` or `fraud:write`. `fraud:read` covers
the three reads above; `fraud:write` covers setting and clearing the customer flag.

Related: [Authentication](./02-authentication.md) · [Errors](./10-errors.md) ·
[3-D Secure](./07-3ds.md)
