Kaminari Ad

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.

ParameterDefaultNotes
country_coderequiredISO 3166-1 alpha-2, case-insensitive.
proxy_typeresidentialresidential or mobile.
regionnoneNarrows 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:

typecodeField
unsupported_proxy_regionscanning.unsupported_proxy_regionproxy.region
unsupported_proxy_cityscanning.unsupported_proxy_cityproxy.city
unsupported_proxy_ispscanning.unsupported_proxy_ispproxy.isp
unsupported_country_codescanning.unsupported_country_codecountry_code
unknown_emulator_idscanning.unknown_emulator_idemulator_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.

← All documentation