Proxy targeting
Discover which regions, cities, and ISPs a scan may target, and read the validation errors when a value is rejected.
A scan can be routed through a proxy in a specific place on a specific
kind of connection. country_code picks the country; the optional
proxy block narrows it further:
{
"url": "https://example.com/",
"country_code": "US",
"emulator_id": "samsung_galaxy_a15_android16",
"proxy": {
"proxy_type": "residential",
"region": "california",
"city": "los angeles",
"isp": "comcast cable"
}
}
Every value inside proxy comes from a catalogue our upstream network
provider publishes, so the accepted set is not something we can express
as a fixed enum in the schema — it differs per country, per connection
type, and it changes as the provider's pool changes. Read it from the
catalogue endpoint below rather than guessing.
Reading the catalogue
GET /api/v1/proxy/targeting?country_code=US returns everything
accepted for one country:
{
"country_code": "US",
"proxy_type": "residential",
"regions": ["florida", "texas", "california"],
"cities": ["philadelphia", "brooklyn", "chicago"],
"isps": ["comcast cable", "spectrum", "at&t internet"],
"refreshed_at": "2026-08-20T18:00:00Z",
"ttl_seconds": 86400
}
Arrays are ordered by pool size, largest first. Prefer values near the front: a scan targeting a large pool is more likely to get an exit node.
| Parameter | Default | Notes |
|---|---|---|
country_code | required | ISO 3166-1 alpha-2, case-insensitive. |
proxy_type | residential | residential or mobile. |
region | none | Narrows cities to that region. |
Pass proxy_type explicitly if you scan on mobile. The two networks
are different pools with different catalogues, not two views of one. In
the US the residential catalogue lists over 1500 ISPs while the mobile
one lists around a dozen carriers, so a value taken from the residential
response will usually be rejected for a mobile scan.
Region and city must come from the same response. Without region,
cities is the whole country's list, which is the right set to pick
from when you only target a country. Once you target a region, re-read
the endpoint with region= and pick a city from that narrower list —
a region and a city that do not belong together pass validation but
leave the provider with nothing to route through.
A country we do not support, or do not recognise, returns 200 with
empty arrays rather than 404. The useful answer to "can I target
here" is a list, possibly an empty one.
Caching
The response carries an ETag and a Cache-Control with max-age
equal to ttl_seconds. Send the ETag back as If-None-Match and you
get 304 Not Modified when nothing changed:
curl -H "Authorization: Bearer $KEY" \
-H 'If-None-Match: W/"9f2b…"' \
"https://kaminari.ad/api/v1/proxy/targeting?country_code=US"
refreshed_at and the ETag answer different questions. refreshed_at
is when we last verified the lists against the provider, and it moves on
every check whether or not anything changed — it is a freshness stamp,
not a change detector. The ETag changes only when the contents change,
so that is the one to compare against. refreshed_at is null for a
country we have not synced yet, which is different from empty arrays
with a timestamp: the first means "no data yet", the second means "no
targeting available here".
What the guarantee covers
Any value in this response is accepted by POST /api/v1/scans, in any
case — the endpoint and the validator read the same catalogue.
It does not promise the scan will find an exit node. The provider's pool
is real consumer connections and it moves continuously, so a narrow
target can be valid at submission and unroutable minutes later. When
that happens the scan fails with a proxy: NODE_NOT_FOUND error rather
than being rejected at creation. Targeting fewer dimensions, or picking
higher-ranked values, makes it less likely.
Validation errors
A value outside the catalogue is rejected with 422 and the standard
HTTPValidationError body, so you can tell exactly which field was
refused and retry without it instead of dropping the whole proxy
block:
{
"detail": [
{
"loc": ["body", "proxy", "region"],
"msg": "Unsupported proxy region 'US' for country US",
"type": "unsupported_proxy_region",
"ctx": {
"given": "US",
"country_codes": ["US"],
"valid_sample": ["alabama", "alaska", "arizona"],
"valid_total": 51
}
}
],
"code": "scanning.unsupported_proxy_region"
}
loc names the field, ctx.given echoes what you sent, and
ctx.valid_sample shows a few accepted values (valid_total is how
many there are in all — the sample is capped so an error stays small).
The same shape covers the other two fields that are checked against a runtime vocabulary rather than a fixed enum:
type | code | Field |
|---|---|---|
unsupported_proxy_region | scanning.unsupported_proxy_region | proxy.region |
unsupported_proxy_city | scanning.unsupported_proxy_city | proxy.city |
unsupported_proxy_isp | scanning.unsupported_proxy_isp | proxy.isp |
unsupported_country_code | scanning.unsupported_country_code | country_code |
unknown_emulator_id | scanning.unknown_emulator_id | emulator_id |
Errors that are not about a single field — an exhausted quota, an
archived campaign — keep detail as a plain string, since there is no
field for you to retry without.
Choosing values from your own targeting
If you already have targeting on your side and want to mirror it, take the intersection rather than sending your value blind: read the catalogue for the country, keep the values that appear in it, and send the highest-ranked survivor. Where nothing survives, send the scan without that dimension — a country-level scan is better than a rejected one.