URL: /api-reference/introduction

---
title: 'API Reference'
description: 'FormShield API documentation'
---

{/* TODO(api-rewrite): This page + api-reference/openapi.json describe the legacy v1 API.
    The OpenAPI spec will be regenerated from the rewritten API (per-signal endpoints +
    aggregate /v1/check with a calibrated 0-1 score, decision enum, and categories).
    Do not hand-edit openapi.json heavily — regenerate via api-reference/sync-openapi.ts
    once api.formshield.dev exposes the new /openapi.json. */}

The FormShield API validates form submissions and scores traffic using multiple signals. Every result carries a `score` from `0.0` to `1.0` and a `decision` of `allow`, `review`, or `block`.

<Note>
  This reference is being regenerated from the current API. The scoring model is a calibrated `0.0`–`1.0` score with an `allow` / `review` / `block` decision — not the legacy `0`–`10` `ham` / `spam` model. Some examples below still show legacy fields; the score and decision language above is authoritative. To record pageviews, see the [pageview tracking guide](/guides/pageview-tracking); to capture crawlers, see [server reporting](/guides/server-reporting).
</Note>

## Base URL

```
https://api.formshield.dev
```

## Authentication

All requests require a Bearer token in the Authorization header:

```bash
curl -X POST https://api.formshield.dev/v1/check \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json"
```

## Endpoints

### POST /v1/check

Check a form submission for spam.

**Request Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `form_id` | string | Yes | Identifier for your form |
| `form_data` | object | No | User-submitted form fields |
| `metadata` | object | No | System metadata (ip, email, etc.) |

At least one of `metadata.email`, `metadata.ip`, or `form_data` must be provided.

**Metadata fields:**

| Field | Type | Description |
|-------|------|-------------|
| `email` | string | User's email address |
| `ip` | string | User's IP address |
| `user_agent` | string | Browser user agent |
| `honeypot_field` | string | Hidden honeypot field value |
| `form_loaded_at` | number | Timestamp when form was loaded |
| `turnstile_token` | string | Turnstile/CAPTCHA token |

**Example Request:**

```json
{
  "form_id": "contact-form",
  "form_data": {
    "name": "John Smith",
    "message": "Hello, I'm interested in your product"
  },
  "metadata": {
    "email": "john@example.com",
    "ip": "203.0.113.42",
    "user_agent": "Mozilla/5.0..."
  }
}
```

**Response (200):** wrapped in the standard [envelope](#response-envelope). The
score is a calibrated probability in `[0,1]` with a `decision` enum (not the legacy
0–10 sum + verdict/action).

```json
{
  "version": "1",
  "data": {
    "score": 0.04,
    "confidence": 0.74,
    "decision": "allow",
    "categories": { "spam": 0.04, "fraud": 0.01, "cold_email": 0.02 },
    "signals": {
      "ip": { "risk": 0, "country_code": "US", "is_vpn": false, "is_datacenter": false },
      "email": { "risk": 0, "disposable": false, "domain": "example.com" },
      "content": { "risk": 0.05, "spam_probability": 0.05, "language": "en", "flags": [] }
    },
    "rule_matches": [],
    "fusion": { "method": "weighted_sum", "model_version": "v2" }
  },
  "error": null,
  "metadata": { "request_id": "req_abc123def456", "processing_time_ms": 234 }
}
```

## Response Envelope

Every response is wrapped in a standard envelope so the boundary between the
**answer** and **request metadata** is always explicit and identical across
endpoints:

```json
{
  "version": "1",
  "data": { },
  "error": null,
  "metadata": {
    "request_id": "req_abc123def456",
    "processing_time_ms": 12
  }
}
```

- `version` — envelope schema version (string). Bumped only on a breaking change to the envelope itself.
- `data` — the endpoint payload on success, otherwise `null`.
- `error` — `{ code, message }` on failure, otherwise `null`. Exactly one of `data` / `error` is non-null.
- `metadata` — request/response info, never the answer: `request_id`, `processing_time_ms`, plus any endpoint provenance (e.g. `source`, `built_at`).

Example — `GET /v1/ip/{ip}` for a Tor exit node:

```json
{
  "version": "1",
  "data": {
    "ip": "185.220.101.1",
    "ip_version": 4,
    "network": {
      "asn": 60729,
      "org": "TORSERVERS-NET",
      "hostname": null,
      "hostname_verified": null,
      "cloud_provider": null,
      "cloud_region": null
    },
    "location": { "country": "DE" },
    "type": { "datacenter": true, "hosting": true, "isp": false, "mobile": false, "cloud": false },
    "risk": { "proxy": true, "vpn": true, "tor": true, "residential_proxy": false, "scanner": true },
    "flags": ["datacenter", "hosting", "proxy", "vpn", "tor", "scanner"]
  },
  "error": null,
  "metadata": {
    "request_id": "req_04e3c0cc2ffd",
    "processing_time_ms": 1,
    "source": "ipatlas",
    "built_at": "2026-06-01T22:18:11+00:00",
    "enrichment": { "requested": [], "resolved": [], "credits": 1 }
  }
}
```

<Note>Every endpoint returns this envelope.</Note>

### Enrichment (`?include=`)

`GET /v1/ip/{ip}` is a fast base lookup by default. Opt into extra datasets with a comma-separated
`?include=` (or `include=all`) — each adds fields and may cost an extra credit. Unknown names → `400`.

| dataset | adds | cost |
|---------|------|------|
| `region` | `network.cloud_provider` + `network.cloud_region` (e.g. `aws` / `us-west-1`) | included |
| `company` | a `company` block: `{ name, website, country, category, confidence }` (by ASN) | included |
| `rdns` | `network.hostname` (reverse DNS / PTR) + `network.hostname_verified` (forward-confirmed) | +1 credit on a cache miss |

`metadata.enrichment` reports `{ requested, resolved, skipped?, cache?, credits }` so the cost and
coverage are explicit. Example — `GET /v1/ip/3.5.1.1?include=region,company,rdns`:

```json
{
  "data": {
    "ip": "3.5.1.1",
    "ip_version": 4,
    "network": {
      "asn": 16509,
      "org": "AMAZON-02",
      "hostname": "s3-us-east-1.amazonaws.com",
      "hostname_verified": true,
      "cloud_provider": "aws",
      "cloud_region": "us-east-1"
    },
    "company": {
      "name": "Amazon.com",
      "website": "https://www.amazon.com",
      "country": "US",
      "category": "Hosting and Cloud Provider",
      "confidence": "high"
    },
    "location": { "country": "US" },
    "type": { "datacenter": true, "hosting": true, "isp": false, "mobile": false, "cloud": true },
    "risk": { "proxy": false, "vpn": false, "tor": false, "residential_proxy": false, "scanner": false },
    "flags": ["datacenter", "hosting", "cloud"]
  },
  "error": null,
  "metadata": {
    "source": "ipatlas",
    "enrichment": {
      "requested": ["region", "company", "rdns"],
      "resolved": ["region", "company", "rdns"],
      "cache": { "rdns": "hit" },
      "credits": 1
    }
  }
}
```

## Error Responses

Inside the envelope, a failure populates `error` (and `data` is `null`):

```json
{
  "version": "1",
  "data": null,
  "error": { "code": "VALIDATION_ERROR", "message": "ip is not a valid IPv4 or IPv6 address" },
  "metadata": { "request_id": "req_abc123def456", "processing_time_ms": 3 }
}
```

**HTTP Status Codes:**

| Status | Code | Description |
|--------|------|-------------|
| 400 | `VALIDATION_ERROR` | Invalid request format |
| 401 | `MISSING_KEY` | No Authorization header |
| 401 | `INVALID_KEY` | Invalid API key |
| 401 | `EXPIRED_KEY` | API key has expired |
| 403 | `KEY_DISABLED` | API key is disabled |
| 422 | `MISSING_REQUIRED_DATA` | No analyzable data provided |
| 429 | `QUOTA_EXCEEDED` | Rate limit exceeded |
| 500 | `INTERNAL_ERROR` | Server error |

**Rate Limit Response (429):** the rate-limit detail rides in `metadata`.

```json
{
  "version": "1",
  "data": null,
  "error": { "code": "QUOTA_EXCEEDED", "message": "Monthly request quota exceeded" },
  "metadata": {
    "request_id": "req_abc123def456",
    "processing_time_ms": 1,
    "ratelimit": { "limit": 1000, "remaining": 0, "reset": 1704067200000, "reset_in_seconds": 86400 }
  }
}
```
