# Veqen documentation


# Quickstart: your first check in 2 minutes

Veqen tells you whether a signup is real. You send an email address, an IP address, a phone number or a domain. You get back a **verdict** (`admit`, `review` or `refuse`), a **score** from 0 to 100, and the **reasons** in plain English.

## 1. Get an API key

1. [Create a free account](https://veqen-app.com/signup) and confirm your email. That unlocks 1,000 free credits every month.
2. Open [API keys](https://veqen-app.com/app/keys) and click **Create key**.
3. Copy the key. It starts with `vq_live_` and is shown **only once**.

Store it as an environment variable so it never ends up in your code:

```bash
export VEQEN_API_KEY="vq_live_..."
```

## 2. Check an email address

```bash [curl]
curl https://veqen-app.com/v1/email/check \
  -H "Authorization: Bearer $VEQEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "jane@mailinator.com"}'
```

```python [Python]
import os, requests

response = requests.post(
    "https://veqen-app.com/v1/email/check",
    headers={"Authorization": f"Bearer {os.environ['VEQEN_API_KEY']}"},
    json={"email": "jane@mailinator.com"},
    timeout=10,
)
result = response.json()
print(result["risk"]["verdict"])  # "refuse"
```

```javascript [JavaScript]
const response = await fetch("https://veqen-app.com/v1/email/check", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.VEQEN_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ email: "jane@mailinator.com" }),
});
const result = await response.json();
console.log(result.risk.verdict); // "refuse"
```

## 3. Read the answer

The part you act on is always `risk`:

```json
{
  "email": "jane@mailinator.com",
  "disposable": true,
  "did_you_mean": null,
  "risk": {
    "score": 90,
    "level": "high",
    "verdict": "refuse",
    "reasons": [
      {
        "code": "disposable_domain",
        "impact": 90,
        "message": "mailinator.com hands out throwaway inboxes."
      }
    ]
  }
}
```

| Field | What it means |
|---|---|
| `risk.verdict` | What we suggest you do: `admit`, `review` or `refuse`. |
| `risk.score` | 0 means nothing wrong, 100 means certainly fake or unusable. |
| `risk.reasons` | Every problem we found, strongest first. Each has a stable `code` and a human `message`. |

## 4. Act on the verdict

| Verdict | Score | What to do |
|---|---|---|
| `admit` | under 30 | Let them in. |
| `review` | 30 to 69 | Let them in with friction: confirm the email, verify the phone, or hold free credits until they do. |
| `refuse` | 70 and up | Block, and ask for a different email or phone with a friendly message. |

> **Tip:** show people the `message`, not the `code`. "Please use a permanent email address" works better than "Invalid email".

## What next?

- Checking a whole signup at once? Use the [signup check](/docs/signup-check). It combines email, IP and phone and catches mismatches between them.
- Building an AI agent? Connect the [MCP server](/docs/mcp).
- Want to know what every reason means? See [risk scores and reasons](/docs/reasons).


# Authentication

Every request needs an API key. Send it in the `Authorization` header:

```http
Authorization: Bearer vq_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```

If your HTTP client makes custom `Authorization` headers awkward, you can send the same key as `X-API-Key: vq_live_...` instead.

## Creating keys

Create keys in the dashboard under [API keys](https://veqen-app.com/app/keys). Give each one a name you'll recognise later, like "Production signup" or "Staging".

- **A key is shown once.** We store only a fingerprint of it, so we can't show it again. If you lose it, create a new one and revoke the old one.
- **You can have up to 20 active keys.** Use one per app or environment, so you can revoke one without breaking the others.
- **All keys on an account share one credit balance.**

## Keeping keys safe

- Call the API **from your server**, never from a browser or mobile app. Anyone can read a key that ships in client-side code.
- Keep keys in environment variables or a secrets manager, not in your repository.
- If a key leaks, [revoke it](https://veqen-app.com/app/keys) right away. Revoked keys stop working instantly.

## Errors

| HTTP | `error.type` | What happened | Fix |
|---|---|---|---|
| 401 | `missing_api_key` | No key in the request. | Add `Authorization: Bearer vq_live_...`. |
| 401 | `invalid_api_key` | The key is malformed, unknown or revoked. | Copy the key again, or create a new one. |
| 403 | `account_suspended` | The account is suspended. | [Contact us](https://veqen-app.com/contact). |

## AI agents and OAuth

MCP clients such as Claude can connect with OAuth instead of a key: you sign in and approve access, and no key is copied anywhere. See [MCP server](/docs/mcp).


# Credits and billing

You pay per check, in credits. There is no subscription.

## What each check costs

| Check | Endpoint | Credits |
|---|---|---|
| Email | `POST /v1/email/check` | 1 |
| Email list | `POST /v1/email/batch` | 1 per address |
| IP address | `POST /v1/ip/check` | 1 |
| Phone number | `POST /v1/phone/check` | 1 |
| Domain | `POST /v1/domain/check` | 1 |
| Whole signup | `POST /v1/signup/check` | 3 |
| Mailbox verification | add `"verify_mailbox": true` | +5 |
| Balance and usage | `GET /v1/account`, `GET /v1/usage` | free |

You're charged only when a check returns a result. Invalid requests, rate-limited requests and server errors cost nothing. The mailbox add-on is charged only when the mail server is actually asked.

## Free credits

Every account gets **1,000 free credits each month**, once the email address is confirmed. They reset on the 1st of the month (UTC). Unused free credits don't roll over. Free credits are spent before prepaid ones.

## Prepaid packs

Buy packs in [Billing](https://veqen-app.com/app/billing). Prepaid credits **never expire**.

| Pack | Credits | Price | Per 1,000 |
|---|---|---|---|
| Starter | 10,000 | $10 | $1.00 |
| Builder | 50,000 | $40 | $0.80 |
| Scale | 250,000 | $150 | $0.60 |
| Volume | 1,000,000 | $500 | $0.50 |

Payments go through Stripe Checkout. Receipts and invoices are emailed, and you can download them in the billing portal.

## Auto top-up

Tick **Save my card for auto top-up** when you buy a pack. Then turn on auto top-up in Billing and choose:

- **When below**: the balance that triggers a refill, such as 2,000 credits.
- **Buy**: which pack to buy.

We charge the saved card at most once every 10 minutes. If a payment fails, we switch auto top-up off and email you, so we never retry a declined card in a loop.

## Checking your balance from code

```bash
curl https://veqen-app.com/v1/account -H "Authorization: Bearer $VEQEN_API_KEY"
```

```json
{
  "email": "you@company.com",
  "balance": { "free": 640, "paid": 50000, "total": 50640 },
  "free_monthly_credits": 1000,
  "free_resets_at": "2026-11-01T00:00:00Z",
  "costs": { "email": 1, "ip": 1, "phone": 1, "domain": 1, "signup": 3, "mailbox": 5 },
  "rate_limit_per_second": 50
}
```

Every check response also carries two headers, so you don't need extra calls:

| Header | Meaning |
|---|---|
| `X-Credits-Charged` | Credits this request cost. |
| `X-Credits-Remaining` | Your balance after this request. |

## When you run out

Requests return **HTTP 402** with `error.type` set to `insufficient_credits`. Nothing else changes: add credits and the same request works. If your app must never block a signup because of billing, treat a 402 like `review` and let the person through with email verification.

## Refunds

Unused prepaid credits can be refunded within 30 days of purchase. [Email us](https://veqen-app.com/contact) with your account email.


# Errors

When something goes wrong, you get a non-2xx HTTP status and this body:

```json
{
  "error": {
    "type": "insufficient_credits",
    "message": "This call needs 3 credits and you have 1. Buy credits at /app/billing.",
    "docs": "https://veqen-app.com/docs/errors#insufficient_credits"
  }
}
```

- `type` is stable. Branch on it in your code.
- `message` is written for humans. Log it, or show it to a developer.
- Every response also has an `X-Request-Id` header. Include it if you contact support.

Failed requests are **never charged**.

## Error types

### missing_api_key

**HTTP 401.** The request had no API key. Add the header `Authorization: Bearer vq_live_...`.

### invalid_api_key

**HTTP 401.** The key is malformed, doesn't exist, or was revoked. Copy it again carefully, or create a new key in the dashboard.

### account_suspended

**HTTP 403.** The account is suspended, usually after a payment dispute. [Contact us](https://veqen-app.com/contact).

### insufficient_credits

**HTTP 402.** Your balance doesn't cover this call. Buy credits, turn on auto top-up, or wait for your free credits to reset on the 1st. Unconfirmed accounts get no free credits: confirm your email first.

### invalid_request

**HTTP 422.** The JSON body is missing a field or has the wrong type. The message names the field, for example `email: Field required`.

### idempotency_key_reused

**HTTP 422 or 409.** You reused an `Idempotency-Key` with a different request body (422), or sent the same key twice at the same moment (409). Use a fresh key for each distinct request. See [idempotency](/docs/idempotency).

### rate_limited

**HTTP 429.** Too many requests per second. Wait for the number of seconds in the `Retry-After` header, then retry. See [rate limits](/docs/rate-limits).

### server_error

**HTTP 500.** Something broke on our side. Retry with exponential backoff. If you send an `Idempotency-Key`, retries are always safe.

## A good retry policy

Retry on **429** and **5xx** only. Never retry 4xx errors other than 429: the same request will fail the same way.

```python [Python]
import time, requests


def check(payload, key, attempts=4):
    for attempt in range(attempts):
        r = requests.post(
            "https://veqen-app.com/v1/signup/check",
            headers={"Authorization": f"Bearer {key}", "Idempotency-Key": payload["request_id"]},
            json={k: v for k, v in payload.items() if k != "request_id"},
            timeout=10,
        )
        if r.status_code == 429 or r.status_code >= 500:
            time.sleep(float(r.headers.get("Retry-After", 2**attempt)))
            continue
        return r
    return r
```

```javascript [JavaScript]
async function check(body, key, requestId, attempts = 4) {
  let res;
  for (let attempt = 0; attempt < attempts; attempt++) {
    res = await fetch("https://veqen-app.com/v1/signup/check", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${key}`,
        "Content-Type": "application/json",
        "Idempotency-Key": requestId,
      },
      body: JSON.stringify(body),
    });
    if (res.status !== 429 && res.status < 500) return res;
    const wait = Number(res.headers.get("Retry-After")) || 2 ** attempt;
    await new Promise((r) => setTimeout(r, wait * 1000));
  }
  return res;
}
```

## Fail open, not closed

If Veqen is unreachable, don't block real people. Treat a timeout or 5xx like a `review` verdict: let them in, and require email confirmation.


# Rate limits

| Account | Requests per second | Burst |
|---|---|---|
| Free credits only | 5 | 10 |
| Has prepaid credits | 50 | 100 |

Limits apply per account, across all of its keys. Need more? [Contact us](https://veqen-app.com/contact).

## Headers

Every check response tells you where you are:

| Header | Meaning |
|---|---|
| `RateLimit-Limit` | Requests per second allowed. |
| `RateLimit-Remaining` | Requests left in the current burst. |
| `Retry-After` | On a 429 only: seconds to wait before retrying. |

## Handling a 429

Wait for `Retry-After` seconds and send the same request again. Rate-limited requests are never charged.

For bulk work, such as cleaning an existing list, use [`/v1/email/batch`](/docs/email-batch). It checks up to 100 addresses in one request.


# Idempotency (safe retries)

Networks fail. If a request times out, you can't tell whether we received it. Send an **`Idempotency-Key`** header and retrying is always safe:

- The **first** request with a key runs the check and charges credits.
- Any **retry** with the same key and the same body, within 24 hours, returns the original response and **charges nothing**. The response has the header `Idempotent-Replayed: true`.
- The same key with a **different body** returns `idempotency_key_reused` (HTTP 422).

```bash
curl https://veqen-app.com/v1/signup/check \
  -H "Authorization: Bearer $VEQEN_API_KEY" \
  -H "Idempotency-Key: signup-7f3e2a91" \
  -H "Content-Type: application/json" \
  -d '{"email": "jane@example.com", "ip": "203.0.113.7"}'
```

## Choosing a key

Use something unique to the action, such as your signup's database ID or a UUID you generate once and reuse for every retry of that action. Keys can be up to 255 characters.

> We keep idempotent responses for 24 hours so retries can be answered. Requests without a key aren't stored.


# Signup check

`POST /v1/signup/check` · **3 credits** (+5 with `verify_mailbox`)

This is the one to use on a signup form. It runs the email, IP and phone checks together, adds checks that only make sense in combination, and gives you **one verdict** for the person.

## Request

Send any combination of the fields. At least one is required.

| Field | Type | Description |
|---|---|---|
| `email` | string | The email they signed up with. |
| `ip` | string | Their IP address, as your server sees it. See [which IP to send](/docs/ip-check#which-ip-to-send). |
| `phone` | string | Their phone number, if you collect one. |
| `phone_country` | string | Two-letter country, for numbers typed without `+country`. |
| `verify_mailbox` | boolean | Also verify the mailbox over SMTP (+5 credits). |

```bash [curl]
curl https://veqen-app.com/v1/signup/check \
  -H "Authorization: Bearer $VEQEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "jane@mailinator.com", "ip": "185.220.101.1", "phone": "+14155552671"}'
```

```python [Python]
def screen_signup(email, ip, phone=None):
    r = requests.post(
        "https://veqen-app.com/v1/signup/check",
        headers={"Authorization": f"Bearer {VEQEN_API_KEY}"},
        json={"email": email, "ip": ip, "phone": phone},
        timeout=10,
    )
    r.raise_for_status()
    return r.json()["risk"]
```

```javascript [JavaScript]
async function screenSignup({ email, ip, phone }) {
  const r = await fetch("https://veqen-app.com/v1/signup/check", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.VEQEN_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({ email, ip, phone }),
  });
  if (!r.ok) throw new Error(`Veqen ${r.status}`);
  return (await r.json()).risk;
}
```

## Response

The response holds the full result of each individual check (`email`, `ip`, `phone`, or `null` when you didn't send that field) plus the combined `risk`:

```json
{
  "email": { "email": "jane@mailinator.com", "disposable": true, "risk": { "...": "..." } },
  "ip": { "ip": "185.220.101.1", "flags": { "tor": true, "hosting": false }, "risk": { "...": "..." } },
  "phone": { "e164": "+14155552671", "line_type": "fixed_line_or_mobile", "risk": { "...": "..." } },
  "risk": {
    "score": 99,
    "level": "high",
    "verdict": "refuse",
    "reasons": [
      { "code": "disposable_domain", "impact": 90, "source": "email", "message": "mailinator.com hands out throwaway inboxes." },
      { "code": "tor_exit_node", "impact": 85, "source": "ip", "message": "This address is a Tor exit node." },
      { "code": "automation_pattern", "impact": 40, "source": "signup", "message": "A throwaway inbox from a server or Tor network is the classic bot-signup pattern." },
      { "code": "phone_ip_country_mismatch", "impact": 15, "source": "signup", "message": "The phone number is from US but the visitor is connecting from DE." }
    ]
  }
}
```

Each reason says which input it came from in `source`: `email`, `ip`, `phone`, or `signup` for the cross-checks.

### Cross-checks

| Code | Impact | When |
|---|---|---|
| `automation_pattern` | 40 | A throwaway email arriving from a datacenter or Tor network. |
| `phone_ip_country_mismatch` | 15 | The phone number's country differs from the IP's country. Common for travellers and VPN users, so it's a nudge, not a block. |

## How the score is combined

Signals combine like independent odds, so two medium signals add up to a stronger one, but never jump straight to 100:

```
score = 100 × (1 − (1 − a/100) × (1 − b/100) × …)
```

Two 50-point signals give 75. A 90-point signal alone gives 90.

## Recommended handling

```javascript
const risk = await screenSignup({ email, ip, phone });
if (risk.verdict === "refuse") {
  return res.status(400).json({ error: "We couldn't verify these details. Please use a permanent email address." });
}
const user = await createUser({ email, needsVerification: risk.verdict === "review" });
```

- **Don't** tell people which signal tripped. It helps abusers tune around you.
- **Do** log `risk.reasons` with the user record. It makes support and later reviews easy.
- **Fail open.** If the call errors or times out, create the account and require email confirmation.


# Email check

`POST /v1/email/check` · **1 credit** (+5 with `verify_mailbox`)

## Request

| Field | Type | Required | Description |
|---|---|---|---|
| `email` | string | yes | The address to check. |
| `verify_mailbox` | boolean | no | Also ask the domain's mail server whether this mailbox exists. Adds up to a few seconds and 5 credits. Charged only if the server was actually asked. Default `false`. |

```bash [curl]
curl https://veqen-app.com/v1/email/check \
  -H "Authorization: Bearer $VEQEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email": "jane.doe+promo@gmial.com"}'
```

```python [Python]
r = requests.post(
    "https://veqen-app.com/v1/email/check",
    headers={"Authorization": f"Bearer {VEQEN_API_KEY}"},
    json={"email": "jane.doe+promo@gmial.com"},
    timeout=10,
)
```

```javascript [JavaScript]
const r = await fetch("https://veqen-app.com/v1/email/check", {
  method: "POST",
  headers: { Authorization: `Bearer ${VEQEN_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({ email: "jane.doe+promo@gmial.com" }),
});
```

You can also use `GET /v1/email/check?email=...` for quick tests. Prefer `POST` in production so addresses don't end up in URL logs.

## Response

Typo domains like this are often registered by throwaway services, so this one is both a typo and disposable:

```json
{
  "email": "jane.doe+promo@gmial.com",
  "valid_syntax": true,
  "normalized": "jane.doe+promo@gmial.com",
  "canonical": "jane.doe@gmial.com",
  "local_part": "jane.doe+promo",
  "domain": "gmial.com",
  "disposable": true,
  "free_provider": false,
  "role_account": false,
  "plus_addressed": true,
  "did_you_mean": "jane.doe+promo@gmail.com",
  "domain_info": {
    "exists": true,
    "accepts_mail": true,
    "mx": [],
    "null_mx": false,
    "spf": false,
    "dmarc": false,
    "registered_at": "2003-05-12",
    "age_days": 8562,
    "registrar": "GoDaddy.com, LLC"
  },
  "mailbox": null,
  "risk": {
    "score": 93,
    "level": "high",
    "verdict": "refuse",
    "reasons": [
      { "code": "disposable_domain", "impact": 90, "message": "gmial.com hands out throwaway inboxes." },
      { "code": "possible_typo", "impact": 30, "message": "Looks like a typo of gmail.com." },
      { "code": "plus_addressed", "impact": 5, "message": "Uses a +tag, so one inbox can sign up many times." }
    ]
  }
}
```

### Fields

| Field | What it tells you |
|---|---|
| `valid_syntax` | `false` means it isn't an email address at all. `syntax_error` then explains why. |
| `normalized` | The address with a lower-cased, standard domain. |
| `canonical` | The inbox it really lands in: `+tags` removed, and for Gmail, dots removed too. **Compare this to spot one person making many accounts.** |
| `disposable` | The domain hands out throwaway inboxes (Mailinator, 10 Minute Mail and thousands more). |
| `free_provider` | A consumer mailbox like Gmail or Outlook. Normal for people, unusual for a B2B signup. |
| `role_account` | A shared inbox such as `info@` or `support@`. |
| `plus_addressed` | The address has a `+tag`. |
| `did_you_mean` | A suggested fix for a likely typo. Show it: "Did you mean jane@gmail.com?" |
| `domain_info.accepts_mail` | Whether mail can be delivered to the domain: it has MX records, or (as the email standard allows) an address record to fall back on. `false` means mail can never arrive. `null` means DNS couldn't be reached. |
| `domain_info.mx` | Mail servers, most preferred first. |
| `domain_info.age_days` | Days since the domain was registered, when the registry publishes it. |
| `mailbox` | Only when `verify_mailbox` was `true`. `result` is `deliverable`, `undeliverable`, `catch_all` (the server accepts any address) or `unknown`. |
| `risk` | Score, level, verdict and reasons. See [risk scores and reasons](/docs/reasons). |

## Typical handling

```python
risk = result["risk"]
if result.get("did_you_mean"):
    ask_user(f"Did you mean {result['did_you_mean']}?")
elif risk["verdict"] == "refuse":
    reject("Please use a permanent email address you can receive mail at.")
elif risk["verdict"] == "review":
    require_email_confirmation()
else:
    accept()
```

## Frequently asked questions

### Does the email check send an email?

No. We look at the address and its domain's DNS records. Even with `verify_mailbox`, we only start an SMTP conversation and stop before any message is sent.

### Why is mailbox verification optional?

It's slower (up to a few seconds), some mail providers always answer "yes" (catch-all), and large providers like Gmail don't reveal whether a mailbox exists. For signup forms, the default check plus email confirmation is usually the best mix. Mailbox verification shines for cleaning lists of business addresses.


# Email list (batch)

`POST /v1/email/batch` · **1 credit per address** (+5 each with `verify_mailbox`)

Use it to clean an existing list or to check a CSV import. Each address gets exactly the same result as [`/v1/email/check`](/docs/email-check), in the order you sent them.

## Request

| Field | Type | Required | Description |
|---|---|---|---|
| `emails` | array of strings | yes | 1 to 100 addresses. |
| `verify_mailbox` | boolean | no | Verify each mailbox over SMTP. Default `false`. |

```bash [curl]
curl https://veqen-app.com/v1/email/batch \
  -H "Authorization: Bearer $VEQEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"emails": ["ana@gmail.com", "bot@mailinator.com", "sales@acme.com"]}'
```

```python [Python]
import requests


def clean(addresses, key):
    keep = []
    for start in range(0, len(addresses), 100):
        chunk = addresses[start : start + 100]
        r = requests.post(
            "https://veqen-app.com/v1/email/batch",
            headers={"Authorization": f"Bearer {key}"},
            json={"emails": chunk},
            timeout=60,
        )
        r.raise_for_status()
        for item in r.json()["results"]:
            if item["risk"]["verdict"] != "refuse":
                keep.append(item.get("normalized") or item["email"])
    return keep
```

```javascript [JavaScript]
const res = await fetch("https://veqen-app.com/v1/email/batch", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.VEQEN_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({ emails: ["ana@gmail.com", "bot@mailinator.com"] }),
});
const { results } = await res.json();
const keep = results.filter((r) => r.risk.verdict !== "refuse");
```

## Response

```json
{
  "count": 3,
  "results": [
    { "email": "ana@gmail.com", "disposable": false, "risk": { "score": 0, "verdict": "admit", "...": "..." } },
    { "email": "bot@mailinator.com", "disposable": true, "risk": { "score": 90, "verdict": "refuse", "...": "..." } },
    { "email": "sales@acme.com", "role_account": true, "risk": { "score": 15, "verdict": "admit", "...": "..." } }
  ]
}
```

Your balance must cover the whole batch before it runs (the number of addresses × the cost per address). Otherwise you get `insufficient_credits` and nothing is charged.


# IP check

`POST /v1/ip/check` · **1 credit**

## Request

| Field | Type | Required | Description |
|---|---|---|---|
| `ip` | string | yes | An IPv4 or IPv6 address. |

```bash [curl]
curl https://veqen-app.com/v1/ip/check \
  -H "Authorization: Bearer $VEQEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ip": "185.220.101.1"}'
```

```python [Python]
r = requests.post(
    "https://veqen-app.com/v1/ip/check",
    headers={"Authorization": f"Bearer {VEQEN_API_KEY}"},
    json={"ip": visitor_ip},
    timeout=10,
)
```

```javascript [JavaScript]
const r = await fetch("https://veqen-app.com/v1/ip/check", {
  method: "POST",
  headers: { Authorization: `Bearer ${VEQEN_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({ ip: visitorIp }),
});
```

`GET /v1/ip/check?ip=...` works too. Leave out `ip` on the GET form to check the address the request came from.

## Which IP to send

Send the **visitor's** IP as your server sees it, never a value the browser reports.

- No proxy in front of your app: use the connection's remote address.
- Behind a load balancer or CDN: use the left-most address in `X-Forwarded-For` **that your own proxy added**. Cloudflare also gives you `CF-Connecting-IP`.

## Response

```json
{
  "ip": "185.220.101.1",
  "valid": true,
  "version": 4,
  "type": "public",
  "location": {
    "country_code": "DE",
    "country": "Germany",
    "in_eu": true,
    "region": "Hamburg",
    "city": "Hamburg (Hamburg-Nord)",
    "continent": "EU",
    "latitude": 53.5735,
    "longitude": 10.0548
  },
  "network": {
    "asn": 60729,
    "organization": "Stiftung Erneuerbare Freiheit",
    "kind": "isp",
    "kind_label": "Internet service provider",
    "known_network": null
  },
  "flags": { "hosting": false, "vpn_likely": false, "relay": false, "tor": true },
  "risk": {
    "score": 85,
    "level": "high",
    "verdict": "refuse",
    "reasons": [
      { "code": "tor_exit_node", "impact": 85, "message": "This address is a Tor exit node." }
    ]
  }
}
```

### Fields

| Field | What it tells you |
|---|---|
| `type` | `public` for normal internet addresses. `private`, `loopback` and others mean you're sending an internal address, probably your own proxy's. |
| `location` | Country, region and city. Country is reliable; city is a best guess. |
| `network.asn` / `organization` | Who owns the network the address belongs to. |
| `network.kind` | `isp` (home, business or mobile provider), `cloud`, `hosting`, `vpn_hosting` (hosting popular with commercial VPNs), `cdn` (CDN or privacy relay) or `unknown`. |
| `flags.hosting` | The address is a server, not a person's connection. |
| `flags.vpn_likely` | The network is mostly used by VPN services. |
| `flags.relay` | Traffic comes through a CDN or privacy relay such as Cloudflare WARP. Low risk on its own. |
| `flags.tor` | The address is a current Tor exit node (list refreshed hourly). |

## Frequently asked questions

### Should I block datacenter IPs?

Not outright. Real people use VPNs. Treat it as one signal: a datacenter IP **plus** a throwaway email is a bot pattern; a datacenter IP with a company email is usually a person on a VPN. The [signup check](/docs/signup-check) weighs these together for you.

### Where does the location data come from?

From DB-IP's free Lite databases (licensed CC BY 4.0), refreshed monthly. Tor exits come from the Tor Project and refresh hourly.


# Phone check

`POST /v1/phone/check` · **1 credit**

## Request

| Field | Type | Required | Description |
|---|---|---|---|
| `phone` | string | yes | The number as the person typed it. International format (`+44 7400 123456`) needs nothing else. |
| `country` | string | no | Two-letter country code (ISO 3166, e.g. `GB`) to read numbers typed in national format, like `07400 123456`. |

```bash [curl]
curl https://veqen-app.com/v1/phone/check \
  -H "Authorization: Bearer $VEQEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"phone": "07400 123456", "country": "GB"}'
```

```python [Python]
r = requests.post(
    "https://veqen-app.com/v1/phone/check",
    headers={"Authorization": f"Bearer {VEQEN_API_KEY}"},
    json={"phone": "07400 123456", "country": "GB"},
    timeout=10,
)
store(r.json()["e164"])  # "+447400123456"
```

```javascript [JavaScript]
const r = await fetch("https://veqen-app.com/v1/phone/check", {
  method: "POST",
  headers: { Authorization: `Bearer ${VEQEN_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({ phone: "07400 123456", country: "GB" }),
});
```

## Response

```json
{
  "input": "07400 123456",
  "valid": true,
  "possible": true,
  "e164": "+447400123456",
  "international": "+44 7400 123456",
  "national": "07400 123456",
  "calling_code": 44,
  "country_code": "GB",
  "country": "United Kingdom",
  "location": "United Kingdom",
  "line_type": "mobile",
  "line_type_label": "Mobile",
  "carrier": "Three",
  "time_zones": ["Europe/Guernsey", "Europe/Isle_of_Man", "Europe/Jersey", "Europe/London"],
  "risk": { "score": 0, "level": "low", "verdict": "admit", "reasons": [] }
}
```

### Fields

| Field | What it tells you |
|---|---|
| `valid` | A real, assignable number for its country. Stronger than `possible`. |
| `possible` | The right length for the country, but maybe not an assigned range. |
| `e164` | **Store this.** The international standard form every SMS provider accepts. |
| `line_type` | `mobile`, `fixed_line`, `fixed_line_or_mobile` (some countries, like the US, don't separate them), `voip`, `toll_free`, `premium_rate`, `shared_cost`, `personal_number`, `pager`, `uan`, `voicemail` or `unknown`. |
| `carrier` | The carrier the number range was first assigned to. Numbers can be ported, so treat it as a hint. |
| `time_zones` | Where the number's range is used, handy for calling hours. |

### Risk reasons

| Code | Impact | Why |
|---|---|---|
| `unparseable_number` | 100 | Not a phone number, or missing the country code. |
| `invalid_number` | 85–95 | Wrong length, or a range no carrier uses. |
| `premium_rate_number` | 80 | Costs the caller money; never a personal phone. |
| `toll_free_number` | 60 | Belongs to a business. |
| `voip_number` | 50 | Cheap to create in bulk; common in fake signups. |
| `country_mismatch` | 15 | The number's country differs from the `country` you passed. |

## Frequently asked questions

### Does this send an SMS?

No. Validation uses numbering-plan data only (Google's libphonenumber). The number is never contacted.

### Can it detect every VoIP number?

It detects numbers in ranges assigned to VoIP. In countries without separate VoIP ranges, such as the US, VoIP numbers look like ordinary ones. Use SMS verification when it matters.


# Domain check

`POST /v1/domain/check` · **1 credit**

Good for B2B signups ("is acme-corp.io a real company?"), lead qualification, and vetting a website before you trust it.

## Request

| Field | Type | Required | Description |
|---|---|---|---|
| `domain` | string | yes | A domain (`acme.com`), a URL (`https://www.acme.com/pricing`) or an email address. We extract the domain. |

```bash [curl]
curl https://veqen-app.com/v1/domain/check \
  -H "Authorization: Bearer $VEQEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"domain": "https://www.example.com/about"}'
```

```python [Python]
r = requests.post(
    "https://veqen-app.com/v1/domain/check",
    headers={"Authorization": f"Bearer {VEQEN_API_KEY}"},
    json={"domain": "example.com"},
    timeout=10,
)
```

```javascript [JavaScript]
const r = await fetch("https://veqen-app.com/v1/domain/check", {
  method: "POST",
  headers: { Authorization: `Bearer ${VEQEN_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({ domain: "example.com" }),
});
```

## Response

```json
{
  "input": "https://www.example.com/about",
  "valid": true,
  "domain": "example.com",
  "disposable": false,
  "free_provider": false,
  "exists": true,
  "accepts_mail": false,
  "mx": [],
  "null_mx": true,
  "spf": true,
  "dmarc": true,
  "registered_at": "1995-08-14",
  "age_days": 11390,
  "registrar": "RESERVED-Internet Assigned Numbers Authority",
  "risk": {
    "score": 60,
    "level": "medium",
    "verdict": "review",
    "reasons": [
      { "code": "domain_refuses_mail", "impact": 60, "message": "example.com accepts no email." }
    ]
  }
}
```

### Fields

| Field | What it tells you |
|---|---|
| `exists` | `false` when the domain isn't registered. |
| `accepts_mail` | The domain has somewhere to deliver email. |
| `null_mx` | The domain says it never accepts email (RFC 7505). |
| `spf`, `dmarc` | Email authentication records. Established businesses almost always publish them. |
| `registered_at`, `age_days` | From RDAP, the modern WHOIS. Some country registries don't publish it, so it can be `null`. |
| `disposable`, `free_provider` | Same meaning as in the [email check](/docs/email-check). |


# MCP server for AI agents

Veqen runs a remote [Model Context Protocol](https://modelcontextprotocol.io) server. Any MCP client can call the same checks as the REST API, using the same credits.

**Server URL:** `https://veqen-app.com/mcp` (streamable HTTP)

## Connect with OAuth (Claude, ChatGPT and other apps)

1. In your client, add a **custom connector** (or "remote MCP server") with the URL above.
2. When asked, sign in to Veqen and click **Allow access**.
3. Done. No key is copied anywhere. You can disconnect it later in **Settings → Connected apps**.

The server supports OAuth 2.1 with dynamic client registration and PKCE, as the MCP spec requires. Discovery documents live at `/.well-known/oauth-protected-resource/mcp` and `/.well-known/oauth-authorization-server`.

## Connect with an API key (Claude Code, Cursor, scripts)

Send your API key as a Bearer token.

```bash [Claude Code]
claude mcp add --transport http veqen https://veqen-app.com/mcp \
  --header "Authorization: Bearer $VEQEN_API_KEY"
```

```json [Cursor]
{
  "mcpServers": {
    "veqen": {
      "url": "https://veqen-app.com/mcp",
      "headers": { "Authorization": "Bearer vq_live_..." }
    }
  }
}
```

```json [Other clients]
{
  "mcpServers": {
    "veqen": {
      "type": "http",
      "url": "https://veqen-app.com/mcp",
      "headers": { "Authorization": "Bearer vq_live_..." }
    }
  }
}
```

## Tools

| Tool | Use it when | Credits |
|---|---|---|
| `check_email_address` | Someone gives an email and you need to know if it's real, throwaway or a typo. Optional `verify_mailbox`. | 1 (+5) |
| `check_ip_address` | You have a visitor's IP and need location, network owner, or datacenter/VPN/Tor status. | 1 |
| `check_phone_number` | You need to validate or format a number, or know if it's mobile, VoIP or toll-free. | 1 |
| `check_domain_name` | You need to know if a company domain exists, receives mail, and how old it is. | 1 |
| `check_signup_risk` | You're deciding whether to accept a signup or lead and have several details about the person. | 3 (+5) |
| `get_balance` | You want to know how many credits are left. | 0 |

Check tools spend account credits, so they are marked as metered operations rather than read-only. `get_balance` is read-only. Agents cannot change account settings, buy credit packs or see API keys. Results are the same JSON as the REST API, plus a `credits` object with what was charged and what remains. If you enable auto top-up, spending credits can trigger the automatic payment you configured.

## Example prompts

- "Check these 20 new signups from today's export and list the ones you'd refuse, with reasons."
- "Before you add this lead to the CRM, check that the email and company domain are real."
- "Is +44 56 1234 5678 a mobile number? Format it for our SMS provider."

## Errors

Tool errors come back as MCP tool errors with the same `type` as the REST API, for example `insufficient_credits: This call needs 3 credits and you have 0.` Agents can read them and tell you what to do.


# SDKs and OpenAPI

The API is plain JSON over HTTPS, so any HTTP client works. These make it shorter.

The clients are open source under the MIT license in [Keyveve/veqen-sdks](https://github.com/Keyveve/veqen-sdks). That repository also includes an attributed, daily-refreshed mirror of the public CC0 disposable-email blocklist.

## Python

```bash
pip install veqen==0.1.0
```

```python
from veqen import Veqen

client = Veqen()  # reads VEQEN_API_KEY from the environment
result = client.signup(email="jane@mailinator.com", ip="185.220.101.1")
if result["risk"]["verdict"] == "refuse":
    print([r["message"] for r in result["risk"]["reasons"]])
```

Methods: `email(address, verify_mailbox=False)`, `emails(list)`, `ip(address)`, `phone(number, country=None)`, `domain(name)`, `signup(email=, ip=, phone=, phone_country=)`, `account()`, `usage(days=30)`. Errors raise `VeqenError` with `.type`, `.status` and `.message`. Retries on 429 and 5xx are built in.

## JavaScript / TypeScript

```bash
npm install https://veqen-app.com/static/sdk/veqen-sdk-0.1.0.tgz
```

```javascript
import { Veqen } from "@veqen/sdk";

const veqen = new Veqen(); // reads VEQEN_API_KEY
const result = await veqen.signup({ email: "jane@mailinator.com", ip: "185.220.101.1" });
console.log(result.risk.verdict);
```

Works in Node 20+ and server runtimes with `fetch` and `crypto.randomUUID`. Full TypeScript types included. Keep your API key on your server, never in browser code.

The downloads above are hosted by Veqen; registry publication on PyPI and npm is pending. Each SDK generates an idempotency key for a check and reuses it for retries, preventing duplicate credit charges. This means check responses are retained for up to 24 hours as described in the [idempotency docs](/docs/idempotency).

## OpenAPI

The machine-readable spec is at [`https://veqen-app.com/openapi.json`](https://veqen-app.com/openapi.json) (OpenAPI 3.1). Import it into Postman, Insomnia, or a code generator.

## Docs for AI assistants

- [`/llms.txt`](https://veqen-app.com/llms.txt): a short index of these docs for language models.
- [`/llms-full.txt`](https://veqen-app.com/llms-full.txt): every docs page in one Markdown file. Paste it into your coding assistant's context.
- Add `.md` to any docs URL to get it as Markdown, for example [`/docs/signup-check.md`](/docs/signup-check.md).


# Pay per call with x402

> This option is for autonomous agents that hold a wallet. Most people should use an [API key](/docs/authentication) or the [MCP server](/docs/mcp).

[x402](https://x402.org) lets a client pay for one HTTP request at a time. Veqen exposes the single checks at these URLs:

| Endpoint | Query parameter |
|---|---|
| `GET https://veqen-app.com/x402/v1/email/check` | `email` |
| `GET https://veqen-app.com/x402/v1/ip/check` | `ip` |
| `GET https://veqen-app.com/x402/v1/phone/check` | `phone` (and optional `country`) |
| `GET https://veqen-app.com/x402/v1/domain/check` | `domain` |

## How it works

1. Call the endpoint with no payment. You get **HTTP 402** with the price, the network (Base) and the address to pay, in the standard x402 format.
2. Your x402 client signs a USDC payment and repeats the request with the payment header.
3. The payment settles and you get the normal JSON result.

Any x402 client library handles steps 1–3 automatically. The endpoints are also listed in the x402 Bazaar, so agents can discover them.

The response body is identical to the REST API's. If x402 isn't enabled on this server, these URLs return 404.


# Risk scores and reasons

Every check returns the same `risk` block:

```json
"risk": {
  "score": 64,
  "level": "medium",
  "verdict": "review",
  "reasons": [
    { "code": "domain_brand_new", "impact": 55, "message": "acme-trial.io was registered 3 days ago." },
    { "code": "role_account", "impact": 15, "message": "info@ is usually a shared team inbox, not a person." }
  ]
}
```

## Score, level and verdict

| Score | Level | Verdict | Suggested action |
|---|---|---|---|
| 0–29 | `low` | `admit` | Let them in. |
| 30–69 | `medium` | `review` | Let them in with friction: email or phone verification, or hold rewards until verified. |
| 70–100 | `high` | `refuse` | Block, or ask for different details. |

**Impact** is how strongly one reason, on its own, points to a fake or unusable signup. Reasons combine like independent odds: `score = 100 × (1 − Π(1 − impact/100))`.

Codes are stable and safe to branch on. New codes may be added over time, so treat unknown codes by their `impact`.

## Email reasons

| Code | Impact | Meaning |
|---|---|---|
| `invalid_syntax` | 100 | Not a valid email address. |
| `domain_not_found` | 95 | The domain doesn't exist. |
| `domain_refuses_mail` | 95 | The domain publishes a null MX: it accepts no email. |
| `mailbox_not_found` | 95 | The mail server says this mailbox doesn't exist (mailbox verification only). |
| `disposable_domain` | 90 | A throwaway inbox provider. |
| `no_mail_server` | 90 | The domain has no mail server. |
| `mx_shared_with_disposable` | 75 | The domain's mail server also serves known throwaway domains. Catches new throwaway brands. |
| `domain_brand_new` | 55 | Registered less than 7 days ago. |
| `domain_new` | 35 | Registered less than 30 days ago. |
| `possible_typo` | 30 | Looks like a misspelling of a popular provider, such as gmial.com. |
| `catch_all_domain` | 20 | The server accepts mail for any address, so the mailbox can't be confirmed. |
| `role_account` | 15 | A shared inbox, like info@ or support@. |
| `random_local_part` | 12 | The part before the @ looks machine-generated. |
| `dns_unavailable` | 10 | DNS couldn't be reached, so mail servers are unknown. |
| `no_email_authentication` | 8 | The domain publishes neither SPF nor DMARC. |
| `plus_addressed` | 5 | The address uses a +tag. |

## IP reasons

| Code | Impact | Meaning |
|---|---|---|
| `invalid_ip` | 100 | Not a valid IPv4 or IPv6 address. |
| `tor_exit_node` | 85 | A current Tor exit node. |
| `vpn_network` | 60 | A hosting network mainly used by commercial VPNs. |
| `datacenter_ip` | 45–55 | A cloud or hosting provider, not a home or mobile network. |
| `non_public_ip` | 40 | A private or reserved address: you're probably sending your own proxy's IP. |
| `privacy_relay` | 20 | Traffic relayed through a CDN or privacy service. |

## Phone reasons

| Code | Impact | Meaning |
|---|---|---|
| `unparseable_number` | 100 | Not a phone number, or missing its country code. |
| `invalid_number` | 85–95 | Wrong length, or an unassigned range. |
| `premium_rate_number` | 80 | Premium-rate line. |
| `toll_free_number` | 60 | Toll-free business line. |
| `pager_number`, `voicemail_number` | 60 | Can't receive SMS codes. |
| `voip_number` | 50 | VoIP range. |
| `shared_cost_number` | 40 | Business line. |
| `personal_number`, `uan_number` | 30 | Forwarding or switchboard number. |
| `country_mismatch` | 15 | The number's country differs from the one you passed. |

## Domain reasons

The domain check uses the same codes as the email check, with lower impact for "doesn't receive mail" (a website domain may legitimately not use email): `domain_refuses_mail` 60, `no_mail_server` 50.

## Signup cross-checks

| Code | Impact | Meaning |
|---|---|---|
| `automation_pattern` | 40 | Throwaway email from a datacenter or Tor network. |
| `phone_ip_country_mismatch` | 15 | Phone country differs from IP country. |


# Balance and usage

## GET /v1/account

Your balance, monthly allowance and per-check costs. **Free.**

```bash
curl https://veqen-app.com/v1/account -H "Authorization: Bearer $VEQEN_API_KEY"
```

```json
{
  "email": "you@company.com",
  "balance": { "free": 640, "paid": 50000, "total": 50640 },
  "free_monthly_credits": 1000,
  "free_resets_at": "2026-11-01T00:00:00Z",
  "costs": { "email": 1, "ip": 1, "phone": 1, "domain": 1, "signup": 3, "mailbox": 5 },
  "rate_limit_per_second": 50
}
```

## GET /v1/usage

Credits and calls per day. **Free.**

| Query parameter | Default | Description |
|---|---|---|
| `days` | 30 | How many days back, 1 to 365. |

```bash
curl "https://veqen-app.com/v1/usage?days=7" -H "Authorization: Bearer $VEQEN_API_KEY"
```

```json
{
  "days": [
    { "date": "2026-10-19", "credits": 412, "calls": 260, "by_check": { "email": 180, "signup": 76, "ip": 4 } },
    { "date": "2026-10-20", "credits": 128, "calls": 95, "by_check": { "email": 70, "signup": 15, "phone": 10 } }
  ],
  "total_credits": 540,
  "total_calls": 355
}
```

Days with no usage are left out. Dates are in UTC.
