# Search workflow

Create a SearchIn job, then poll until it completes. FaceCheck and OSINT Industries are async.

URL: https://osintverse.com/docs/api/search

Every search returns a job object with `id`, `status`, `cost_usd`, and `balance_after`.

## Status values

| Status      | Meaning                                                         |
| ----------- | --------------------------------------------------------------- |
| `completed` | Finished. Results are in `result.matches` and `result.sources`. |
| `running`   | Still processing. Poll `GET /v1/search/:id` every \~2 seconds.  |
| `failed`    | Insufficient balance or request rejected before charging.       |
| `refunded`  | Upstream provider failed after billing. Balance was restored.   |

## Synchronous providers

LeakRadar, Predicta Search, DeHashed, Snusbase, Whoxy, WhoisXML, SecurityTrails, Shodan, BuiltWith, Picarta, and LeakOSINTbot typically complete in the `POST /v1/search` response. Check `status` anyway — treat `running` as a signal to poll.

Hunter.io Domain Search and Email Finder are usually synchronous. Email Verifier can return `running` while Hunter finishes SMTP checks — poll `GET /v1/search/:id` the same way as FaceCheck.

Hunter.io **name** queries must include a company domain: `Alexis Ohanian reddit.com` (or `Jane Doe @ example.com`).

## Async polling (OSINT Industries)

OSINT Industries lookups return `running` immediately with `poll_url`. Keep calling `GET /v1/search/:id` until `completed`, `failed`, or `refunded`. `result.matches` grows as each module finishes. A clean stream end that simply omitted slow modules is still `completed`.

Stop an in-flight OSINT Industries job with `POST /v1/search/:id/cancel`. Modules already returned stay on the job; the original charge is kept. Jobs that are already `completed`, `failed`, or `refunded` are a no-op.

```json title="Running OSINT Industries response"
{
  "id": "550e8400-e29b-41d4-a716-446655440002",
  "status": "running",
  "provider": "osint-industries",
  "input_type": "email",
  "query": "user@example.com",
  "cost_usd": "0.60",
  "balance_after": "9.40",
  "result": {
    "sources": [{ "name": "osint-industries" }],
    "matches": []
  },
  "error": null,
  "poll_url": "/v1/search/550e8400-e29b-41d4-a716-446655440002"
}
```

In the [Python SDK](https://osintverse.com/docs/python), pass `wait=True` on `client.search.create(...)` or call `client.search.wait(id)`. Without `wait`, poll yourself and read growing `result.matches`.

## Async polling (FaceCheck)

Facecheck.id image searches often return `running` with `poll_url`. Keep calling `GET /v1/search/:id` until `completed`, `failed`, or `refunded`. FaceCheck `result` is typically null until the job completes. In the [Python SDK](https://osintverse.com/docs/python), pass `wait=True` on `client.search.create(...)` or call `client.search.wait(id)`.

```json title="Running response"
{
  "id": "550e8400-e29b-41d4-a716-446655440001",
  "status": "running",
  "provider": "facecheck",
  "input_type": "image",
  "query": "https://example.com/photo.jpg",
  "cost_usd": "0.50",
  "balance_after": "9.45",
  "result": null,
  "error": null,
  "poll_url": "/v1/search/550e8400-e29b-41d4-a716-446655440001"
}
```

Completed FaceCheck matches use `score`, `url`, and `thumbnail` (often a data URI).

> **Do not busy-loop.** Poll about every two seconds. Daily key rate limits still apply to poll requests.

## Result shape

`result.sources` describes what the adapter fetched (counts, paging, lookup type). `result.matches` is the normalized list your UI or pipeline should consume. Provider-specific fields stay on each match — see [provider guides](https://osintverse.com/docs/providers).

## LeakRadar unlocks

A LeakRadar search charges **$1.00** and auto-unlocks the first **1,000** credentials. Remaining matches unlock from the same job with `POST /v1/search/:id/unlock` at **$0.50 per 1,000** (billed in 1,000-result blocks). The SearchIn UI shows leftover count and cost before you confirm.

## OSINT Industries premium modules

An OSINT Industries search charges **$0.60** for the basic module scan. Premium modules are **excluded by default*&#x2A;. Optional premium modules (Snapchat, TikTok, and similar) add **$1.80**.

* At create time, send `"premium": true` on `POST /v1/search` (or on bulk, where it applies only to OSINT Industries jobs). Combined cost is **$2.40**.
* After a completed basic search, call `POST /v1/search/:id/premium` with `{ "premium": true }`. The extra $1.80 is charged then; new modules merge into the stored result.
* Already-premium searches return **200** with no extra charge. Insufficient balance returns **402**. Upstream failure refunds the extra $1.80 only.

The SearchIn UI offers a checkbox before you run, and a result-panel button after a basic search completes.

## Bulk search

`POST /v1/search/bulk` runs a cartesian product of `queries[]` × `providers[]` in one request.

| Limit                            | Value |
| -------------------------------- | ----- |
| Max queries                      | 50    |
| Max jobs (`queries × providers`) | 100   |

```json title="Bulk request"
{
  "input_type": "email",
  "queries": ["alice@example.com", "bob@example.com"],
  "providers": ["leakradar", "dehashed"]
}
```

The response includes `batch_id`, `estimated_cost_usd`, `summary`, and a `searches[]` array (one job per pair, queries outer / providers inner). Partial success is normal — inspect each job’s `status` / `error`.

Poll any `running` job with `GET /v1/search/:id` the same way as a single search. The bulk call counts once toward the daily API key rate limit; each poll after it counts once too.

## Webhooks

Pass `callback_url` on `POST /v1/search` or `POST /v1/search/bulk` (one event per job) to get a signed `POST` when the search finishes, instead of polling. The event says what happened, not what was found: fetch the result with `GET /v1/search/:id` and your key.

```json title="Request"
{
  "provider": "facecheck",
  "input_type": "image",
  "query": "https://example.com/photo.jpg",
  "callback_url": "https://hooks.example.com/osintverse"
}
```

```json title="Event body"
{
  "type": "search.completed",
  "timestamp": 1792040551,
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "batch_id": "550e8400-e29b-41d4-a716-446655440000",
    "status": "completed",
    "provider": "facecheck",
    "input_type": "image",
    "cost_usd": "1.00",
    "refunded": false,
    "url": "/v1/search/550e8400-e29b-41d4-a716-446655440000"
  }
}
```

`type` is `search.completed`, `search.failed`, or `search.refunded`. The event never contains the query or the results.

**Rules for `callback_url`:** `https` on port 443, no credentials in the URL, and a public host. Internal names and private, loopback, or link-local addresses are refused when you create the search, and again on every delivery attempt. Redirects are not followed.

**Delivery:** three attempts (at once, after 5 seconds, after 30 seconds). Answer with any `2xx`. A `4xx` other than 408, 409, 425, or 429 stops the retries. Delivery is best effort, so keep `GET /v1/search/:id` as the source of truth.

### Verify the signature

Events follow [Standard Webhooks](https://www.standardwebhooks.com). Get your secret once with `GET /v1/webhooks/secret` (any key; it never charges). It is one secret per wallet: a team key gets the team's secret.

Each request carries `webhook-id`, `webhook-timestamp`, and `webhook-signature`. The signature is `v1,` followed by the base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the base64-decoded part of the secret after `whsec_`. Reject timestamps more than five minutes old, and use `webhook-id` (stable per search and outcome) to drop duplicates.

```python title="Python"
import base64, hashlib, hmac, time

def verify(secret: str, headers: dict, body: bytes) -> bool:
    msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"]
    if abs(time.time() - int(ts)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    expected = base64.b64encode(
        hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()
    ).decode()
    sent = [part.split(",", 1)[1] for part in headers["webhook-signature"].split() if part.startswith("v1,")]
    return any(hmac.compare_digest(expected, value) for value in sent)
```

```ts title="Node"
import { createHmac, timingSafeEqual } from "node:crypto"

export function verify(secret: string, headers: Record<string, string>, body: string) {
  const id = headers["webhook-id"]
  const ts = headers["webhook-timestamp"]
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64")
  const expected = createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest()
  return headers["webhook-signature"]
    .split(" ")
    .filter((part) => part.startsWith("v1,"))
    .some((part) => {
      const sent = Buffer.from(part.slice(3), "base64")
      return sent.length === expected.length && timingSafeEqual(sent, expected)
    })
}
```

The official Standard Webhooks libraries (`standardwebhooks` on PyPI and npm) verify these events too.
