# FieldSwap API

Identity-data API. Send one piece of identity (email, phone, LinkedIn URL, IP,
or full name), ask for another field back (phone, employment, social, address,
etc.). On a cache miss FieldSwap cascades through configured third-party data
vendors and persists the result, so the same lookup is free next time within
the field's TTL.

| | |
|---|---|
| **Base URL** | `https://api.fieldswap.founderscale.com` |
| **Content type** | `application/json` |

All product endpoints live under `/v1/`. Breaking changes ship under a new
major version; backwards-compatible additions (new optional fields, new
`output` types) ship within `/v1/`.

---

## Authentication

Every `/v1/*` request requires an API key. Generate one in the **API keys**
tab of the dashboard. Keys are shown once on creation — store them in a secret
manager, not in source.

You can authenticate two ways. They are equivalent; pick whichever your stack
prefers:

```
Authorization: Bearer fs_live_<your_key>
# or
X-Api-Key: fs_live_<your_key>
```

Keys are scoped to a single workspace (tenant). Treat them like passwords:
rotate periodically, revoke any key that may have leaked, and use a separate
key per environment or service so you can revoke without coordinated downtime.

> **Never expose API keys in client-side code.** Browser JS, mobile apps, and
> public repositories are not safe for `fs_live_` keys — proxy through your
> own backend instead.

---

## Lookup

**`POST /v1/lookup`**

Resolve a single output field for one identity input. Each call costs **1
credit** regardless of whether the result was served from cache or freshly
fetched from a vendor. If the cascade returns no data the call still costs 1
credit (this matches the underlying vendor billing model — you pay per query,
not per match).

### Request body

```json
{
  "input":  { "type": "<field-type>", "value": "<string>" },
  "output": "<field-type>"
}
```

| Field | Type | Description |
|---|---|---|
| `input.type` | FieldType | Kind of identity supplied. See [Field types](#field-types). |
| `input.value` | string (1–500) | Raw value. Emails are lowercased, phones parsed to E.164, LinkedIn normalized to canonical URL. |
| `output` | FieldType | Field you want returned. Can equal `input.type` (useful for canonicalization). |

### Examples

**curl**

```bash
curl -X POST https://api.fieldswap.founderscale.com/v1/lookup \
  -H "Authorization: Bearer fs_live_<your_key>" \
  -H "Content-Type: application/json" \
  -d '{
    "input":  { "type": "email", "value": "ada@example.com" },
    "output": "phone"
  }'
```

**Node**

```js
import fetch from "node-fetch";

const res = await fetch("https://api.fieldswap.founderscale.com/v1/lookup", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.FIELDSWAP_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    input:  { type: "email", value: "ada@example.com" },
    output: "phone",
  }),
});

if (!res.ok) throw new Error(`fieldswap ${res.status}: ${await res.text()}`);
const data = await res.json();
console.log(data.output?.value); // { phone_e164: "+14155551234" }
```

**Python**

```python
import os, httpx

r = httpx.post(
    "https://api.fieldswap.founderscale.com/v1/lookup",
    headers={"Authorization": f"Bearer {os.environ['FIELDSWAP_KEY']}"},
    json={
        "input":  {"type": "email", "value": "ada@example.com"},
        "output": "phone",
    },
    timeout=10.0,
)
r.raise_for_status()
data = r.json()
print(data["output"]["value"])  # {'phone_e164': '+14155551234'}
```

### Response

```json
{
  "request_id": "8f1d2c20-…",
  "person_id":  "0a4e5b66-…",
  "input":  { "type": "email", "value": "ada@example.com" },
  "output": {
    "type": "phone",
    "value": { "phone_e164": "+14155551234" },
    "fetched_at": "2026-05-08T17:40:21.118Z",
    "source_vendor": "exampledata",
    "fresh": true
  },
  "hit_source": "vendor",
  "credits_charged": 1,
  "credits_remaining": 9847,
  "status": "ok"
}
```

| Field | Description |
|---|---|
| `request_id` | UUID. Echoed in `x-request-id` response header. Quote when filing support. |
| `person_id` | Stable identifier for the resolved identity in your workspace. `null` if no person was matched or created. |
| `output` | The resolved field, or `null` if `status` is `not_found` / `opt_out`. |
| `output.value` | Field-specific shape. See [Field types](#field-types). |
| `output.fresh` | `true` if served from cache within TTL or freshly fetched. |
| `hit_source` | `cache`, `vendor`, or `miss`. Use to gauge cache effectiveness. |
| `status` | `ok`, `not_found`, or `opt_out`. `200 OK` is returned for all three — check this field. |

> **Status semantics.** `not_found` means we know who the person is but no
> vendor returned the requested field. `opt_out` means the person has invoked
> their right to deletion or opted out of enrichment — your code should treat
> both identically (no value, no retry needed for some hours).

---

## Field types

Both `input.type` and `output` use the same enum. Some fields are only
meaningful as inputs (`ip`) or as outputs (`employment`, `social`) — supplying
them in the wrong direction returns `400`.

Output types come in two flavors. **Bundle** types (`email`, `phone`, …) return
every populated sub-field for the domain in one call. **Leaf** types
(`email_deliverability_score`, `phone_carrier`, …) return a single field —
useful when you only want one specific datum and don't want to parse a wider
object. A leaf-type response served from cache after a sibling leaf populated
the row costs the usual 1 credit but does not trigger another vendor call.

### Bundles & identity

| Type | Direction | Value shape |
|---|---|---|
| `email` | in / out | `{ "email": "...", "verified": true, "deliverability_state": "deliverable", "deliverability_score": 100, "deliverability_reason": "accepted_email", "disposable": false, "role": false, "free": true, "accept_all": null, "mx_record": "...", "smtp_provider": "google", "did_you_mean": null }` |
| `phone` | in / out | `{ "phone_e164": "+14155551234", "country": "US", "line_type": "mobile", "carrier": "Verizon Wireless", "caller_name": "Ada Lovelace", "caller_type": "CONSUMER" }` |
| `linkedin` | in / out | `{ "url": "https://linkedin.com/in/ada" }` |
| `first_name` | out | `{ "first": "Ada", "last": null, "full": "Ada Lovelace" }` |
| `last_name` | out | `{ "first": null, "last": "Lovelace", "full": "Ada Lovelace" }` |
| `full_name` | in / out | `{ "first": "Ada", "last": "Lovelace", "full": "Ada Lovelace" }` |
| `birthdate` | out | `{ "birthdate": "1815-12-10", "precision": "full" }` |
| `address` | out | `{ "line1": "...", "line2": null, "city": "...", "region": "CA", "postal": "94110", "country": "US" }` |
| `company` | out | `{ "company": "Analytical Engines Ltd", "title": "...", "start_date": "2024-01-01", "end_date": null, "is_current": true }` |
| `title` | out | same as `company` |
| `employment` | out | same as `company` |
| `social` | out | `{ "platform": "twitter", "handle": "ada", "url": "https://twitter.com/ada" }` |
| `ip` | in only | input only — resolves to company / employment |

### `email_*` leaf types (sourced from Emailable)

| Type | Value shape | Notes |
|---|---|---|
| `email_verified` | `{ "verified": true }` | True iff `state == "deliverable"`. |
| `email_deliverability_state` | `{ "state": "deliverable" }` | One of `deliverable`, `undeliverable`, `risky`, `unknown`. |
| `email_deliverability_score` | `{ "score": 100 }` | Integer 0–100. |
| `email_deliverability_reason` | `{ "reason": "accepted_email" }` | E.g. `accepted_email`, `low_quality`, `invalid_smtp`, `timeout`. |
| `email_disposable` | `{ "disposable": false }` | Temp-mail providers (mailinator, etc.). |
| `email_role` | `{ "role": false }` | Role addresses (`support@`, `info@`, `admin@`). |
| `email_free` | `{ "free": true }` | Free providers (gmail, yahoo, …). |
| `email_accept_all` | `{ "accept_all": null }` | Domain catch-all; `null` if undetermined. |
| `email_mx_record` | `{ "mx_record": "aspmx.l.google.com" }` | Resolved MX host. |
| `email_smtp_provider` | `{ "smtp_provider": "google" }` | E.g. `google`, `outlook`, `null`. |
| `email_did_you_mean` | `{ "did_you_mean": null }` | Suggested correction (e.g. `gmial.com` → `gmail.com`); `null` when none. |

### `phone_*` leaf types (sourced from Twilio Lookup v2)

| Type | Value shape | Notes |
|---|---|---|
| `phone_line_type` | `{ "line_type": "mobile" }` | One of `mobile`, `landline`, `fixedvoip`, `nonfixedvoip`, `personal`, `tollfree`, `premium`, `sharedcost`, `uan`, `voicemail`, `pager`. |
| `phone_carrier` | `{ "carrier": "Verizon Wireless" }` | Carrier name from line-type intelligence. |
| `phone_country_code` | `{ "country_code": "US" }` | ISO-3166-1 alpha-2. |
| `phone_caller_name` | `{ "caller_name": "Ada Lovelace" }` | CNAM lookup; US numbers only. |
| `phone_caller_type` | `{ "caller_type": "CONSUMER" }` | `CONSUMER` or `BUSINESS`. |

**Normalization.** Inputs are always normalized before lookup, so
`Ada@Example.com` and `ada@example.com` hit the same cache row. Phones are
parsed to E.164; if parsing fails we return `400` with
`{"detail":"could not parse phone"}`.

---

## Errors

Errors are returned with an HTTP status code and a JSON body of the shape
`{ "detail": "<message>" }`. The `x-request-id` header is set on every
response — quote it when contacting support.

| Status | Code | When |
|---|---|---|
| `400` | `bad_request` | Input could not be parsed (e.g. invalid email or phone number). |
| `401` | `unauthorized` | Missing, malformed, or revoked API key. |
| `402` | `payment_required` | Insufficient credits, or workspace daily provider cap reached. |
| `403` | `forbidden` | Authenticated, but not allowed (e.g. dashboard-only endpoint hit with an API key). |
| `404` | `not_found` | Resource does not exist or is not visible to your tenant. |
| `429` | `rate_limited` | Per-workspace request rate limit exceeded. `Retry-After` header included. |
| `5xx` | `server_error` | Transient failure. Safe to retry with exponential backoff. |

**Retry guidance.** Lookups are not idempotent (each call costs a credit), but
retries on `429` and `5xx` are safe — a re-issued lookup within the cache TTL
hits the cache and skips vendor work. Always honor `Retry-After` on `429`.

---

## Rate limits

Limits are enforced per workspace. The default is **60 requests / minute**
shared across every API key in the workspace; contact us to raise it. Every
response includes:

```
X-RateLimit-Limit:     60
X-RateLimit-Remaining: 47
X-RateLimit-Reset:     1715192461   # unix seconds
Retry-After:           23           # only on 429
```

On top of the per-workspace request rate limit there is a per-workspace **daily provider spend
cap** — once your configured cap is hit, lookups return `402 payment_required`
until the next UTC day. Configure the cap in the Billing dashboard.

---

## Logs & usage

Every request is logged with input/output type, hit source, latency, status,
and the person it resolved to. View them in the Logs tab, or pull them
programmatically:

```
GET /logs?limit=100&cursor=2026-05-08T00:00:00Z
GET /billing/usage      # current period credits + request count
GET /billing/ledger     # credit ledger entries
```

These dashboard endpoints require a **JWT session**, not an API key — they
exist for the FieldSwap UI. If you want programmatic access to usage data,
request a reporting key (separate scope) via support.

---

## Privacy endpoints

Two **unauthenticated** endpoints exist so end users (data subjects) can
invoke their privacy rights without going through your support team. You do
not call these from your backend; you link to them from your privacy policy.

### Submit a DSAR

**`POST /v1/dsar/submit`**

```json
{
  "requester_email": "ada@example.com",
  "type":            "access" | "delete",
  "identity_type":   "email",
  "identity_value":  "ada@example.com",
  "note":            "optional free text"
}
```

### Opt out

**`POST /v1/opt-out`**

```json
{
  "identity_type":  "email",
  "identity_value": "ada@example.com",
  "reason":         "optional"
}
```

Opt-out is permanent for the identity until reversed by support. Subsequent
lookups for an opted-out person return `status: opt_out` with no `output` and
zero credits charged.

---

## No-code (Zapier & Make)

You don't need to write code to call FieldSwap. Revenue-ops teams can wire
`/v1/lookup` into a Zapier Zap or a Make scenario using each tool's generic HTTP
step — no official app required. A typical flow: a new lead lands in your CRM,
FieldSwap resolves their email into a phone number, and the result is written
back to the record.

Step-by-step guides for each tool:

- [Using FieldSwap with Zapier](/docs/zapier)
- [Using FieldSwap with Make.com](/docs/make)

These cover the enrichment (action) direction only. A FieldSwap-as-a-trigger
integration isn't available yet.

---

## SDKs & samples

The API is small enough that an SDK isn't required — any HTTP client works.
Official client libraries for Node and Python are on the roadmap.

For interactive testing without writing code, use the Playground in the
dashboard. It runs against your live workspace and deducts real credits.

Found a bug or have a question? Email
[support@fieldswap.io](mailto:support@fieldswap.io) with the failing
`request_id` from `x-request-id`.
