# Batch lookup (up to 100 IPs)

> Resolve 1 to 100 IPv4 and IPv6 addresses in a single POST request pinned to a consistent dataset snapshot.

```http
POST https://api.ipscope.io/v1/ip/batch
```

Authentication: API key (`X-API-Key` header or `Authorization: Bearer`). Plan: scope: batch · 1 credit / IP

Resolves between **1 and 100** IPv4 or IPv6 addresses in one HTTP round-trip (`POST /v1/ip/batch`). Requires an API key with the `batch` scope.

**Request body** (BatchLookupRequestDto)

- `ips` (string[], required): Array of `1` to `100` valid IPv4 or IPv6 address strings. Validated before any lookup credits are debited.

```json Example
{
  "ips": [
    "198.51.100.7",
    "203.0.113.42",
    "2606:4700:4700::1111"
  ]
}
```

## Code examples

```bash curl
curl -sS -X POST "https://api.ipscope.io/v1/ip/batch" \
  -H "Authorization: Bearer $IPSCOPE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ips":["198.51.100.7","203.0.113.42","2606:4700:4700::1111"]}'
```

```python Python
import asyncio, os
from ipscope import AsyncIpScopeClient

async def main() -> None:
    async with AsyncIpScopeClient(api_key=os.environ["IPSCOPE_KEY"]) as client:
        batch = await client.lookup_batch(["198.51.100.7", "203.0.113.42", "2606:4700:4700::1111"])
        for item in batch["results"]:
            print(item["ip"], item["geo"]["country_code"], item["security"]["is_vpn"])

asyncio.run(main())
```

```ts Node.js
import { IpScopeClient } from '@ipscope/sdk-node';

const client = new IpScopeClient({ apiKey: process.env.IPSCOPE_KEY });
const batch = await client.lookupBatch(['198.51.100.7', '203.0.113.42', '2606:4700:4700::1111']);
for (const item of batch.results) {
  console.log(item.ip, item.geo?.country_code, item.security?.is_vpn);
}
```

```go Go
client := ipscope.NewClient(ipscope.Config{APIKey: os.Getenv("IPSCOPE_KEY")})
defer client.Close()

batch, err := client.LookupBatch(ctx, []string{"198.51.100.7", "203.0.113.42", "2606:4700:4700::1111"})
if err != nil {
	return err
}
fmt.Println("Count:", batch.Count, "Snapshot:", batch.DatasetVersion)
```

```rust Rust
use ipscope::{AsyncIpScopeClient, ClientConfig};

let client = AsyncIpScopeClient::new(ClientConfig::new(std::env::var("IPSCOPE_KEY")?))?;
let ips = vec!["198.51.100.7".into(), "203.0.113.42".into(), "2606:4700:4700::1111".into()];
let batch = client.lookup_batch(&ips).await?;
println!("Count: {} Snapshot: {}", batch.count, batch.dataset_version);
```

```java Java
import java.util.List;
import io.ipscope.sdk.IpScopeClient;
import io.ipscope.sdk.IpScopeConfig;

IpScopeConfig config = IpScopeConfig.builder().apiKey(System.getenv("IPSCOPE_KEY")).build();
try (IpScopeClient client = new IpScopeClient(config)) {
    var batch = client.lookupBatch(List.of("198.51.100.7", "203.0.113.42", "2606:4700:4700::1111"));
    System.out.println("Resolved: " + batch.count());
}
```

```csharp .NET
using IpScope.Sdk;

using var client = new IpScopeClient(new IpScopeClientOptions {
    ApiKey = Environment.GetEnvironmentVariable("IPSCOPE_KEY")
});
var batch = await client.LookupBatchAsync(new[] { "198.51.100.7", "203.0.113.42", "2606:4700:4700::1111" });
Console.WriteLine($"Count={batch.Count} Version={batch.DatasetVersion}");
```

```ruby Ruby
require "ipscope"

client = IpScope::Client.new(api_key: ENV.fetch("IPSCOPE_KEY"))
batch = client.lookup_batch(["198.51.100.7", "203.0.113.42", "2606:4700:4700::1111"])
puts "Resolved #{batch.count} IPs on snapshot #{batch.dataset_version}"
```

```php PHP
<?php
use IpScope\Sdk\IpScopeClient;

$client = new IpScopeClient(apiKey: getenv('IPSCOPE_KEY') ?: '');
$batch = $client->lookupBatch(['198.51.100.7', '203.0.113.42', '2606:4700:4700::1111']);
echo "Resolved: " . $batch->count . PHP_EOL;
```

## Responses

### 200 `BatchLookupResponse`: Batch lookup results pinned to a single consistent dataset_version.

- `dataset_version` (string, required): Snapshot version that served all lookups in this batch.
- `count` (integer, required): Total number of resolved IP records returned in `results`.
- `results` (IpLookupResponse[], required): Ordered array of `IpLookupResponse` objects projected to your API key’s `licensedFamilies`.
  - `ip` (string, required): Canonical normalized representation of the queried IPv4 or IPv6 address.
  - `ip_version` (integer, required): Protocol family (`4` for IPv4, `6` for IPv6).
  - `matched_network` (string, optional, nullable): Longest-matching routed CIDR prefix in the active Radix/LPM snapshot.
  - `geo` (GeoLocation, optional): Country and city geolocation record (`geo_country` and `geo_city` families).
    - `country_code` (string, optional, nullable): Effective ISO 3166-1 alpha-2 country code (`"US"`, `"DE"`, `"TR"`).
    - `declared_country_code` (string, optional, nullable): Country code registered in RIR WHOIS/RDAP allocation records or RFC 8805 geofeed.
    - `physical_country_code` (string, optional, nullable): Country code where active BGP routing and latency triangulation place the infrastructure.
    - `is_virtual_location` (boolean, required): `true` when `declared_country_code` differs from `physical_country_code` (cross-border routed or geofeed-relocated prefix).
    - `location_type` (string, required): How the effective location was resolved by the lookup engine.
    - `confidence` (number, optional, nullable): Calibrated geolocation confidence score.
    - `subdivision` (string, optional, nullable): First-level administrative region or state. Included when `geo_city` is licensed.
    - `city` (string, optional, nullable): Resolved city name. Included when `geo_city` is licensed.
    - `accuracy_radius_km` (integer, optional, nullable): Estimated accuracy radius in kilometres around the city centroid. Included when `geo_city` is licensed.
    - `timezone` (string, optional, nullable): IANA time zone identifier. Included when `geo_city` is licensed.
  - `network` (NetworkInfo, optional): BGP origin autonomous system metadata (`network` family).
    - `asn` (integer, optional, nullable): Origin Autonomous System Number without the `AS` prefix.
    - `organization` (string, optional, nullable): Canonical autonomous system or operator organization name.
  - `connection` (ConnectionInfo, optional): Access network classification (`connection` and `hosting` families).
    - `connection_type` (string, required): Primary access network classification.
    - `is_residential` (boolean, required): `true` for consumer FTTH, cable, and DSL broadband ISPs.
    - `is_mobile` (boolean, required): `true` for cellular carrier gateways (4G/5G CGNAT pools).
    - `is_business` (boolean, required): `true` for enterprise leased lines and corporate autonomous systems.
    - `is_hosting` (boolean, required): `true` for cloud providers, colocation facilities, and VPS networks.
  - `security` (SecurityInfo, optional): VPN, proxy, Tor, and hosting anonymizer classification (`anonymity` and `hosting` families).
    - `is_vpn` (boolean, required): `true` when the IP is an active commercial or self-hosted VPN exit node.
    - `is_hosting` (boolean, required): `true` when routed by a datacenter, cloud, or hosting ASN.
    - `is_tor_exit` (boolean, required): `true` when listed in the active Tor Project exit consensus.
    - `is_proxy` (boolean, required): `true` for open HTTP/SOCKS proxies and residential proxy relays.
    - `anonymizer_type` (string, required): Primary anonymizer classification.
    - `confidence` (number, optional, nullable): Calibrated anonymity confidence score.
    - `reason_codes` (string[], required): Deterministic evidence codes explaining why the IP was flagged.
  - `security_extensions` (SecurityExtensions, optional): Extended threat telemetry and scanner recency (`security_extensions` family).
    - `abuse_velocity_score` (number, optional, nullable): Normalized abuse velocity score from active honeypot and darknet observations.
    - `is_known_scanner` (boolean, optional, nullable): `true` when the IP has been observed performing internet-wide port or vulnerability scanning.
    - `observation_recency_hours` (integer, optional, nullable): Hours elapsed since the most recent active security or anonymizer observation.
  - `dataset_version` (string, required): Version identifier of the compiled IPScope dataset snapshot serving the response.
  - `updated_at` (string, optional, nullable): ISO 8601 UTC timestamp when the active dataset snapshot was published.

```json 200 BatchLookupResponse
{
  "dataset_version": "2026-10-27T00:00:00Z.crawl-1793088000000",
  "count": 3,
  "results": [
    {
      "ip": "198.51.100.7",
      "ip_version": 4,
      "matched_network": "198.51.100.0/24",
      "geo": {
        "country_code": "US",
        "declared_country_code": "US",
        "physical_country_code": "US",
        "is_virtual_location": false,
        "location_type": "physical",
        "confidence": 0.94,
        "subdivision": "Virginia",
        "city": "Ashburn",
        "postal_code": "20147",
        "latitude": 39.0438,
        "longitude": -77.4874,
        "accuracy_radius_km": 10,
        "timezone": "America/New_York"
      },
      "network": {
        "asn": 64501,
        "organization": "Example Cloud LLC"
      },
      "connection": {
        "connection_type": "hosting",
        "is_hosting": true,
        "is_residential": false,
        "is_mobile": false,
        "is_business": false
      },
      "security": {
        "is_vpn": true,
        "is_hosting": true,
        "is_tor_exit": false,
        "is_proxy": false,
        "anonymizer_type": "vpn",
        "confidence": 0.96,
        "reason_codes": [
          "vpn_handshake_confirmed",
          "datacenter_asn_range"
        ]
      },
      "security_extensions": {
        "abuse_velocity_score": 18,
        "is_known_scanner": false,
        "observation_recency_hours": 12,
        "anonymizer_reason": "vpn_handshake_confirmed",
        "threat_level": "medium",
        "abuse_velocity": "low",
        "confidence": 0.96
      },
      "dataset_version": "2026-10-27T00:00:00Z.crawl-1793088000000",
      "updated_at": "2026-10-27T00:00:00Z"
    },
    {
      "ip": "203.0.113.42",
      "ip_version": 4,
      "matched_network": "203.0.113.0/24",
      "geo": {
        "country_code": "DE",
        "declared_country_code": "DE",
        "physical_country_code": "DE",
        "is_virtual_location": false,
        "location_type": "physical",
        "confidence": 0.92,
        "subdivision": "Hesse",
        "city": "Frankfurt am Main",
        "accuracy_radius_km": 10,
        "timezone": "Europe/Berlin"
      },
      "network": {
        "asn": 24940,
        "organization": "Hetzner Online GmbH"
      },
      "connection": {
        "connection_type": "hosting",
        "is_residential": false,
        "is_mobile": false,
        "is_business": false,
        "is_hosting": true
      },
      "security": {
        "is_vpn": false,
        "is_hosting": true,
        "is_tor_exit": false,
        "is_proxy": false,
        "anonymizer_type": "none",
        "confidence": 0.91,
        "reason_codes": [
          "datacenter_asn_range"
        ]
      },
      "dataset_version": "2026-10-27T00:00:00Z.crawl-1793088000000",
      "updated_at": "2026-10-27T00:00:00Z"
    }
  ]
}
```

### 400 `ApiError`: Empty ips[] array, more than 100 IPs, or at least one malformed IP address (0 credits charged).

- `statusCode` (integer, required): HTTP status code returned by the API gateway.
- `message` (string, required): Human-readable diagnostic message describing the error condition.
- `error` (string, required): Canonical HTTP error classification.

```json 400 ApiError
{
  "statusCode": 400,
  "message": "Batch lookup requires between 1 and 100 IP addresses",
  "error": "Bad Request"
}
```

### 402 `ApiError`: Insufficient prepaid credit reservation for ips.length units.

- `statusCode` (integer, required): HTTP status code returned by the API gateway.
- `message` (string, required): Human-readable diagnostic message describing the error condition.
- `error` (string, required): Canonical HTTP error classification.

```json 402 ApiError
{
  "statusCode": 402,
  "message": "Prepaid credit reservation exhausted. Please top up your wallet.",
  "error": "Payment Required"
}
```

### 403 `ApiError`: API key does not have the required batch scope.

- `statusCode` (integer, required): HTTP status code returned by the API gateway.
- `message` (string, required): Human-readable diagnostic message describing the error condition.
- `error` (string, required): Canonical HTTP error classification.

```json 403 ApiError
{
  "statusCode": 403,
  "message": "API key does not have required scope: batch",
  "error": "Forbidden"
}
```

### 429 `ApiError`: Per-user rate limit exceeded (rl:user:<userId>).

- `statusCode` (integer, required): HTTP status code returned by the API gateway.
- `message` (string, required): Human-readable diagnostic message describing the error condition.
- `error` (string, required): Canonical HTTP error classification.

```json 429 ApiError
{
  "statusCode": 429,
  "message": "Rate limit exceeded (600 req/min). Retry after 14s.",
  "error": "Too Many Requests"
}
```

## Snapshot consistency & atomic validation

- **Pre-debit validation**: Every address in `ips` is validated via `tryParseIp(ip)` before any credits are debited. If even one address in the array is invalid, the entire request fails with `400 Bad Request` and **0 credits** are charged.
- **Pinned snapshot reference**: `LookupController` captures a single `SnapshotLoaderService` snapshot reference at the start of the batch (`const snapshot = this.snapshotLoader.getCurrentSnapshot()`), guaranteeing that all items in `results[]` are evaluated against the exact same `dataset_version` even if an atomic snapshot reload occurs mid-request.
- **Order preservation**: `results[]` is returned in the exact index order of the input `ips[]` array.

## Streaming 10,000+ IPs with Official Server SDKs

When enriching large log files, SIEM queues, or backfill datasets with thousands of IPs, use `lookupBatchChunked` or `lookupBatchStream` in any [official server SDK](https://ipscope.io/docs/sdks). The SDK automatically deduplicates addresses, checks its local O(1) LRU cache first, and streams 100-IP chunks with bounded concurrency.

```ts Node.js
import { IpScopeClient } from '@ipscope/sdk-node';

const client = new IpScopeClient({ apiKey: process.env.IPSCOPE_KEY });

for await (const chunk of client.lookupBatchStream(tenThousandIps, { concurrency: 4 })) {
  for (const record of chunk.results) {
    console.log(record.ip, record.geo?.country_code, record.security?.is_vpn);
  }
}
```

```python Python
import asyncio, os
from ipscope import AsyncIpScopeClient

async def enrich_logs(ips: list[str]) -> None:
    async with AsyncIpScopeClient(api_key=os.environ["IPSCOPE_KEY"]) as client:
        async for record in client.lookup_batch_stream(ips, max_concurrency=4):
            print(record["ip"], record["geo"]["country_code"])
```
