Naver Local Search API (via Naver API Hub)
Status: Reference
Status: Active Part of: External APIs index Last verified: 2026-07-07 (live pilot on public + private courts)
Role
Powers the "🗺️ 코트 찾기" venue picker and — going forward — the private-court identity leg of the venue-metadata ingest (academies / clubs / indoor centers that government open-data doesn't cover). Complements Kakao Local (which additionally returns phone).
In test mode (jest with mock adapters), a static list of 15 Seoul courts is returned with zero API calls.
⚠️ Migrated to Naver API Hub (2026-07)
The legacy developers.naver.com open API (endpoint openapi.naver.com/v1/search/local.json, auth X-Naver-Client-Id/X-Naver-Client-Secret) is deprecated. The search APIs now live on the Naver API Hub, which runs on NCP's API Gateway with NCP-style auth. The old scheme returns 024 인증 실패.
| Legacy (dead) | Naver API Hub (current) | |
|---|---|---|
| Endpoint | openapi.naver.com/v1/search/local.json | naverapihub.apigw.ntruss.com/search/v1/local |
| Auth headers | X-Naver-Client-Id / X-Naver-Client-Secret | X-NCP-APIGW-API-KEY-ID / X-NCP-APIGW-API-KEY |
| Console | developers.naver.com | Naver API Hub (overview · API guide) |
| Pricing | 25k/day free tier | Pay-as-you-go |
The search-venues edge function still calls the legacy endpoint/scheme — it must be migrated to the above when Naver is wired into the ingest.
Endpoint
GET https://naverapihub.apigw.ntruss.com/search/v1/localCredentials
| Env var | Header sent | Where to get |
|---|---|---|
EXPO_PUBLIC_NAVER_API_HUB_CLIENT_ID | X-NCP-APIGW-API-KEY-ID | Naver API Hub console → your app → 인증정보 |
EXPO_PUBLIC_NAVER_API_HUB_CLIENT_SECRET | X-NCP-APIGW-API-KEY | Same app page |
Do not confuse with NCP Maps.
EXPO_PUBLIC_NAVER_MAPS_CLIENT_ID/SECRET(console: ncloud.com) is a separate product powering the static-map + geocoding edge functions. Both happen to use theX-NCP-APIGW-*header names but are issued by different consoles and are not interchangeable.
Values are sent as HTTP request headers; treat as server-side secrets (set via npx supabase secrets set for the edge function).
Console setup
- Naver Cloud → API Hub → 앱 등록.
- Enable the APIs you need: 검색 → 지역 (venue identity). (Also available on the same key: 이미지·블로그·웹문서·뉴스·카페, and DataLab 검색어트렌드 — none needed for the venue ingest.)
- Newly-applied apps/APIs may need a short approval/propagation window before the key authenticates.
- Copy the Client ID / Secret into
.env(EXPO_PUBLIC_NAVER_API_HUB_CLIENT_ID/SECRET).
Request format
GET https://naverapihub.apigw.ntruss.com/search/v1/local
?query=양재시민의숲 테니스장
&display=5
&sort=random
&format=json
X-NCP-APIGW-API-KEY-ID: {EXPO_PUBLIC_NAVER_API_HUB_CLIENT_ID}
X-NCP-APIGW-API-KEY: {EXPO_PUBLIC_NAVER_API_HUB_CLIENT_SECRET}Query parameters
| Param | Value | Notes |
|---|---|---|
query | {name or 테니스 {area}} | Prefix vague area queries with 테니스 so results are court-specific. |
display | 5 | Hard max is 5 (values >5 are silently clamped). |
sort | random | random = relevance, comment = review count. |
format | json |
Response shape
{
"total": 5, // ⚠️ NOT a real match count — tracks the (5-capped) display value. Use items.length.
"items": [
{
"title": "매헌시민의숲<b>테니스장</b>", // strip HTML with stripHtml()
"link": "", // business homepage if any (Instagram / booking.naver / own domain) — NOT a Naver Place URL
"category": "자연공원>부속시설", // mostly the generic "스포츠,오락" — weak public/private signal
"description": "", // ⚠️ always empty for tennis venues
"telephone": "", // ⚠️ deprecated — always empty. Use Kakao Local for phone.
"address": "서울특별시 서초구 매헌동 산 145",
"roadAddress": "서울특별시 서초구 매헌로 99",
"mapx": "1270369770", // longitude × 1e7
"mapy": "374730498", // latitude × 1e7
},
],
}Coordinate conversion
const lng = parseInt(item.mapx, 10) / 1e7; // 1270369770 → 127.0369770
const lat = parseInt(item.mapy, 10) / 1e7; // 374730498 → 37.4730498What it actually brings (2026-07-07 pilot, public + private courts)
- Fields:
title,category,address,roadAddress,mapx/mapyare ~100%.telephoneanddescriptionare dead (always empty);linkis a self-registered homepage (present for ~87% of private, ~27% of public), never a Naver Place URL. - Coverage: private courts ~100%, public courts ~73% (some public misses are naming failures — retry with a shorter name).
- Category is mostly the generic
스포츠,오락(83%); only자연공원>부속시설and장소대여>공유누리 개방자원are public-exclusive — so category alone is a weakvenue_typeclassifier. - Verdict: a strong, geocoded private-court directory + a patchy public existence check. At the API level it brings identity only (name/category/address/coords) — no phone, hours, fees, or amenities.
Enrichment via the Naver Place page (for the ingest, not the picker)
The Local Search API gives no amenities/fees, but the Naver Place page does — reachable via a 2-hop resolution (the documented link / /place/{id} guesses do not work; map.naver.com internal APIs CAPTCHA/IP-block non-browser traffic):
GET https://search.naver.com/search.naver?query={name}— classic web search, server-renders a place snippet containing amap.naver.com/p/entry/place/{ID}link (uncaptcha'd).GET https://m.place.naver.com/place/{ID}/home— ~600KB page withwindow.__APOLLO_STATE__/window.__PLACE_STATE__fully populated.
From the Apollo state (pilot, 11 venues): amenities/conveniences reliable (~10/11: 주차·화장실·예약·wifi), rating + review count reliable, fees opportunistic (3/11 — only where the owner filled the SmartPlace 메뉴 panel: e.g. 탄천 ₩12,000/면, TS CLUB 평일 ₩46,500), photos present (count + URLs, license unresolved). Hours are genuinely unfillable (0/11 — listings unclaimed + academies run by reservation, not fixed hours). Kakao's place page is a pure SPA shell — not scrapeable without a headless browser.
Quotas & cost
Naver API Hub is pay-as-you-go (per-call billing; no fixed free tier). 지역 search returns ≤5 results per call. Keep the debounce + result caching (below) to bound call volume. See the API Hub pricing/overview.
Adapter
| File | Purpose |
|---|---|
packages/app/src/ports/services/venue-search.service.port.ts | VenueSearchServicePort interface |
packages/app/src/adapters/mock/venue-search.mock.ts | Static 15-court dataset, instant response |
packages/app/src/adapters/naver/venue-search.naver.ts | Live Naver calls (⚠️ still on the legacy endpoint — migrate to API Hub) |
supabase/functions/search-venues/index.ts | Server proxy (⚠️ still on the legacy openapi.naver.com + X-Naver-Client-Id scheme) |
Switching between mock and live is handled by src/registry.ts.
Error handling
| HTTP status | Meaning | Adapter response |
|---|---|---|
200 | Success | Return mapped SearchedVenue[] |
401 / 024 | Invalid credentials / wrong auth scheme (e.g. legacy headers on the Hub endpoint) | console.error, return [] |
429 | Rate/quota exceeded | console.error, return [] |
| Network failure | Offline / timeout | console.error, return [] |
The UI treats all error states as "no results" and shows an empty state — never a raw API error.
Cost control measures
| Layer | Mechanism | Saves |
|---|---|---|
VenuePickerModal | 400ms debounce on TextInput | Eliminates keystroke-per-call |
| Adapter | display=5 cap | Limits response payload (5 is also the max) |
| Component state | Results cached until modal closes | No repeat calls for same query |
| Mock mode | All dev work hits zero real calls | Full quota preserved for prod testing |