Ordering ad providers by GeoIP region without the location permission
When the ad providers an app can use differ by region, the app has to know which region the user is in. The information it needs is not coordinates. A country, or an area broader than that, is enough. Asking for precise location access just to pick an ad is a heavy cost for both the user and the app.
Below is a design that does not use the OS location permission. It decides the order of providers from a region that the server estimates from the IP address. It explains what each signal means and where it can be wrong. This post has no record of observing or measuring a real failure, and it does not give the versions of the SDKs and services. Only the example function that decides the order was run, and the result is in the section “What running the example function showed.”
What the official documentation explains directly is how the location permission, the CDN location headers, and the UMP gate each behave. Looking up the region after consent, the per-process cache, the split between the resolver and the loader, the provider chain, and the expected values in tests are not requirements of the documentation. They are my own design choices made on top of it.
Four signals that hint at a region
Signals that look like a region mean different things.
- The app language is a preference for a language that is comfortable to read.
- The country in the Locale is a setting the user chose.
- OS location services give a value measured by the device, and they come with permissions and the handling of sensitive data.
- GeoIP is a region estimated from an IP address observed from outside.
Using Korean is not enough to choose a domestic provider. While traveling, or when the setting was never changed, the Locale and the current place can differ. GeoIP can also be wrong or empty because of a VPN, a relay service, or a carrier gateway. And the server that does the lookup sees the IP address.
So the signals are considered from the least precise up. The Locale is used only as a default for language and policy, not as the basis for whether a provider is available in a region. GeoIP is requested separately, only when a regional branch is needed, and when there is not enough to decide, the result stays unknown. For a feature that really needs location, the context and the minimum permission level are weighed against Apple’s guide to location authorization and Android’s guide to minimizing permission requests.
Consent first, region second
The order at app start is as follows.
- Refresh the stored consent state.
- Handle the consent UI if it is needed.
- Check the gate that says ads may be requested.
- Resolve the region once per process and keep it in memory.
- Build the provider array for the region bucket.
- Request in the order of the array.
- If everything fails, end with in-house content.
An example of the gate is canRequestAds in Google UMP. Being allowed to request ads and being able to trust the region estimate are separate matters.
When consent has not been confirmed, or external ads are blocked, the region lookup is not made at all. What to show in that case is decided by the product and its policy, but external ads must not be called through a side path.
Initialization being called twice has to be prevented as well. Initialization can be entered both right after the consent refresh and from the completion callback of the consent UI. Reusing the region task in progress, or the plan already built, reduces duplicate requests and races.
The server returns only a bucket
The location headers that a CDN adds are based on the IP address the server observed. CloudFront request headers are one example. The city value can be missing, and names that are not ASCII can arrive encoded. If every app parses raw city values and keeps its own policy, the work is duplicated, so the server builds the region bucket and sends that down.
There are three buckets. regionalMarket is a candidate for trying the regional provider first, otherMarket is the target of the global provider, and unknown means the region could not be classified. The response does not include coordinates, the IP address, or the city name. If branching by city is truly required, only an identifier used by the business rule is sent. A missing header, a decoding failure, or a validation failure gives unknown.
The CDN cache needs attention too. If the response varies by region but the header is not in the cache key, the result for one region can be reused for another. Conversely, splitting the key too finely lowers the cache hit rate. The policy states either that the response is not cached, or that it is cached by the coarse bucket the server built.
Separate region resolution from ad loading
The region resolver has no dependency on an ad SDK, and the ad loader has no dependency on the IP address or the Locale. The two are connected only through enums.
This example checks the consent state first and returns an array of provider candidates for the region bucket. The function does not load ads. It only builds the array.
enum ConsentState {
case pending
case adsAllowed
case externalAdsBlocked
}
enum AdRegion {
case regionalMarket
case otherMarket
case unknown
}
enum AdProvider {
case regional
case global
case inHouse
}
func makeAdPlan(
consent: ConsentState,
resolveRegion: () async -> AdRegion
) async -> [AdProvider] {
switch consent {
case .pending, .externalAdsBlocked:
return [.inHouse]
case .adsAllowed:
break
}
switch await resolveRegion() {
case .regionalMarket:
return [.regional, .global, .inHouse]
case .otherMarket, .unknown:
return [.global, .inHouse]
}
}
The example turns one policy into code. Returning [.inHouse] when consent is not finished or is blocked, and trying the global provider for unknown, are not general rules. Whether to try the global provider for unknown depends on the regions the service supports and on the consent conditions. That choice is not hidden inside the resolver. It is made visible in the chain policy.
The function itself has no guard against running twice, no network timeout, and no loader. Running once and caching the result is implemented by the caller, or by the resolver reusing its task. The loader tries the array in order and stops at the first success. It does not call a failed provider again right away, and it does not restart the whole chain. inHouse is the last option that keeps the slot from being empty when every external request fails. When a provider is added, only the chain changes and the resolver is left alone.
iOS, Android, and the web can share the contract of buckets and priority. The implementation modules do not have to be merged. Each platform uses its own SDK and keeps only the contract ConsentState → AdRegion → [AdProvider].
What running the example function showed
I compiled makeAdPlan above as it is and ran every combination of the three consent states and the three region buckets. It ran on macOS 27.0.1 with Swift 6.4, on October 4, 2026. The run also prints how many times the closure that returns the region was called.
pending + regionalMarket -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
pending + otherMarket -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
pending + unknown -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
adsAllowed + regionalMarket -> [geodemo.AdProvider.regional, geodemo.AdProvider.global, geodemo.AdProvider.inHouse] resolveRegion calls=1
adsAllowed + otherMarket -> [geodemo.AdProvider.global, geodemo.AdProvider.inHouse] resolveRegion calls=1
adsAllowed + unknown -> [geodemo.AdProvider.global, geodemo.AdProvider.inHouse] resolveRegion calls=1
externalAdsBlocked + regionalMarket -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
externalAdsBlocked + otherMarket -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
externalAdsBlocked + unknown -> [geodemo.AdProvider.inHouse] resolveRegion calls=0
geodemo is the module name of the executable. With consent pending, or with external ads blocked, the region lookup never happened and only the in-house provider came out. The lookup happened once only when ads were allowed, and the regional provider came first only for the regional bucket.
What this run confirmed stops at the function that decides the order. A real GeoIP lookup, an ad SDK, the loader that tries providers in order, the timeout, and the cache were not run.
Keep going when the lookup fails
The network request for the region lookup is restricted.
- Only the specified HTTPS target is allowed, and unexpected redirects are blocked.
- The timeout is a few seconds at most, and the request is not repeated while the screen is rendering.
- The response size is limited, and only bucket values on an allow list are accepted.
- A response that does not fit is treated as
unknown. - The result and the request in progress are reused within the process.
- The raw IP address and city are not stored on disk and not written to analytics logs.
An HTTP error, a timeout, being offline, and a parsing error are all handled as ordinary failure values. Concrete numbers for the timeout and the response size are not set here.
The in-memory cache has a limit. After the network changes, the previous bucket can still be used. I judged this acceptable for a coarse priority hint, and the region is evaluated again when the process next starts. No measurement that backs this judgment is included here. Until measurement shows that a long-lived cache is needed, permanent storage and expiry handling are not added.
No permission does not mean anonymous
Without the OS location API, the location permission prompt does not appear. But the GeoIP server and the CDN still process the IP address. Describing “no permission needed” as “anonymous” or “no location data is handled” is not accurate.
The purpose, the retention, the transfer to third parties, and the relation to consent have to be reviewed against the real data flow. The app receives only the bucket, the server does not keep the IP address and location headers longer than needed, and the value used for routing is not used for a long-term profile. Collecting less and complying with the law or store policy are separate things. Apple’s privacy guidelines are a reference from this point of view.
What to pin with tests
Calling the real GeoIP service and ad SDK can give a different result on each run because of VPNs, network state, and ad inventory. Unit tests inject the region function and the loader and pin the expected values below.
- If consent is
pending, there are 0 region lookups and in-house is chosen. - If ads are allowed and the bucket is regional, the regional provider comes first.
- If the regional provider fails, the chain moves to global once.
- On a timeout or a response error, global is chosen through the
unknownpath. - If every external request fails, the chain ends with in-house.
- If the consent completion callback arrives twice, the lookup and the plan are each made once.
In an integrated environment, the conditions to check are offline, a slow response, a missing header, an encoded city value, a VPN, and Private Relay. The pass criteria are that only allowed provider combinations appear, that the flow ends within the time limit, and that raw sensitive values do not show up in logs. How often the real location is guessed correctly is not a pass condition.
The goal of this design is that the app does not stop even when GeoIP is wrong. The expected values and the integration conditions above are the criteria for checking that goal. Of these, what a run confirmed is that the function that builds the plan produces only allowed combinations. Results that confirm a real lookup ends within the time limit, and how the loader behaves, are not in this post.
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.