These docs are a work in progress and may not be fully up to date. Some pages may contain internal notes for our team.
Skip to Content

Integration — External widget → Backend

The Svelte 5 widget never holds a JWT. Authentication against the backend is done entirely via the finder token embedded in the URL path or as a query parameter. This page covers every backend surface the widget calls at runtime, how each call is authenticated, rate-limiting constraints, how the backend resolves a finder from a key, caching behavior, and what the widget does when the backend is unreachable.

All widget network code lives in a single module: dropafinder-app-external/ext/2/source/src/lib/api.js.

For the broader runtime flow these calls fit into, see Data flow — end user. For CDN file layout and cache TTLs, see Storage and CDN. For how the widget initializes before any backend call is made, see External widget — init flow.


Authentication model

The widget has no concept of a logged-in user. It authenticates by presenting the finder token — a short opaque string — as part of each request. There is no Authorization header, no session cookie, and no JWT.

How the token reaches the backend varies by endpoint:

  • GET /api/ext/finders/details/{key} — token is a URL path segment (the {key} route parameter). Source: api.js:82.
  • GET /api/mapping/autocomplete and GET /api/mapping/places — token is a query parameter (finder_token). Source: api.js:116, api.js:143.
  • POST /api/analytics — token is a JSON body field (finderId). Source: AnalyticsController.php:17.

The backend never issues a challenge or returns a bearer token to the widget. The finder token is effectively a public capability token that grants read access to the specific finder’s data.


The four public endpoints the widget calls

1. GET /api/ext/finders/details/{key} — origin fallback for finder config

Route: routes/api.php:218 — no middleware beyond Laravel defaults (no auth:api, no throttle).

Purpose: Returns the full finder payload (config, settings, locations) from the origin. This is the final fallback in the three-level CDN read chain. In normal production operation the widget resolves via the CDN fast path and does not hit this endpoint. It is always called in development (before CDN infrastructure is live) and on CDN failure.

Widget call site: api.js:82

const response = await fetch(`${API_BASE}/ext/finders/details/${finderKey}`);

API_BASE is import.meta.env.VITE_API_APP_URL — a build-time environment variable, e.g. https://api.locationfinders.com/api.

Finder key resolution: The controller (ExternalController::getFinderDetails) looks up the finder with:

$finder = Finder::with('maps.sets.locations', 'maps.locations') ->where('token', $key) ->first();

(ExternalController.php:95–97)

If no finder matches, it returns {"message": "Finder not found"} with HTTP 403.

Domain authorization check: Before returning data, the controller checks the Origin (or Referer) request header against the finder’s authorized_urls list (ExternalController.php:93–121). Matching is hostname-exact — parse_url(..., PHP_URL_HOST) is compared on both sides. Two special cases apply:

  • If authorized_urls is empty, all origins are allowed.
  • localhost and 127.0.0.1 are always allowed regardless of the list.

An unauthorized origin returns {"error": "Unauthorized domain"} with HTTP 403.

Caching inside the controller: After authorization, the controller uses Laravel’s Cache facade with the key finder_details_{token} (ExternalController.php:123–129). If the cache is cold, buildPayload() assembles the response and stores it. No explicit TTL is set, so the cache uses the default driver TTL (application-configured). This cache is separate from the CDN layer — it is a server-side process cache, not a CDN edge cache.

Response shape:

{ "finder": { "token": "abc123", "settings": [...], "design": {...}, "authorized_urls": [...], "advanced_refinements": {...}, "custom_field_definitions": [...] }, "locations": [ { "id": 1, "title": "...", "address": { "street_address_1": "...", "city": "...", "state": "...", ... }, "latitude": 37.7749, "longitude": -122.4194, "phone": null, "website": null, "image": "...", "image_url": "...", "tags": [...], "custom_fields": [...] } ], "created": "2026-04-27T12:00:00Z" }

The CDN-published version of this payload also includes hash and published_at fields (added by syncFinderDetails before the R2 write). The origin fallback response does not include those fields. See Data flow — end user § Phase 4 for how the widget handles the two shapes.

⚠️ Warning: One setting receives special handling at response time. If design.map.tileSet is "tomtom", the controller strips any customer-supplied design.map.apiKey entry from the settings array and injects the server-side TomTom key from config('services.tomtom.api_key') in its place (ExternalController.php:132–138). This means the TomTom API key is never sent from the widget; it is injected server-side on each request.


2. GET /api/mapping/autocomplete — address autocomplete proxy

Route: routes/api.php:212 — wrapped in Route::middleware('throttle:30,1').

Purpose: Proxies address autocomplete requests to the per-finder autocomplete provider. The backend hides all third-party API keys; the widget never sees them.

Widget call site: api.js:97–133 (fetchAutocompleteSuggestions)

Query parameters sent by the widget:

ParameterRequiredSource
inputYesUser-typed string
sessionTokenNoUUID generated per search session (groups billing on Google Places)
latitudeNoViewer’s resolved location (IP or GPS)
longitudeNoViewer’s resolved location (IP or GPS)
finder_tokenNoFinder key (passed so backend can resolve provider)

Finder key use: The finder_token query param is not a hard auth check — it is used only to resolve which autocomplete provider (and which API key) the finder is configured to use. The controller caches [providerSlug, finderId] under the key autocomplete_finder_{token} for 60 seconds (MappingController.php:76–89). The decrypted third-party API key is fetched fresh on every request and never stored in cache (MappingController.php:96–98).

If finder_token is absent or no matching finder exists, the controller falls back to the dropaSearch provider (MappingController.php:70–73, 91–93).

Response shape:

{ "predictions": [ ... ] }

On error (network or validation failure) the widget catches and returns an empty array — the search bar degrades silently (api.js:122–126, Search.svelte:208–209).


3. GET /api/mapping/places — place details by ID

Route: routes/api.php:213 — same throttle:30,1 middleware group as autocomplete.

Purpose: Resolves a provider-specific place ID to coordinates and address detail. Called after the user selects an autocomplete suggestion.

Widget call site: api.js:135–160 (fetchPlaceDetails)

Query parameters sent:

ParameterRequiredSource
place_idYesPlace ID returned by the autocomplete prediction
sessionTokenNoSame session token used for the matching autocomplete call
finder_tokenNoSame finder key; same provider resolution logic as autocomplete

Response shape:

{ "result": { ... } }

result may be null — the comment in MappingController.php:65 notes this signals the widget to fall back to inline coordinates from the autocomplete prediction. On a non-OK HTTP status, fetchPlaceDetails throws an Error with the message from errorData.error || errorData.message (api.js:149–155). The Search.svelte caller catches and swallows the error, clearing the selection silently (Search.svelte:388).


4. POST /api/analytics — visitor event ingestion

Route: routes/api.php:210 — no throttle middleware, no auth:api.

Purpose: Receives batched visitor interaction events (loads, searches, clicks, filter applications) for storage in the analytics table.

This endpoint is not in the ext/ prefix group but is equally public — the widget calls it unauthenticated. The finderId body field performs implicit finder resolution via the exists:finders,token validation rule (AnalyticsController.php:17).

Widget call site: src/lib/analytics.js (called from App.svelte and interaction handlers).

For a full description of the analytics event schema, see the Analytics embedding guide.


Rate limiting

The throttle middleware applied to the mapping endpoints is declared in routes/api.php:211:

Route::middleware('throttle:30,1')->group(function () { Route::get('/mapping/autocomplete', ...); Route::get('/mapping/places', ...); });

throttle:30,1 is Laravel’s built-in ThrottleRequests middleware — 30 requests per 1 minute, keyed by IP address. This is a global limit across all finders and all users sharing an IP; it is not per-finder or per-tenant.

💡 Tip: The 30 req/min limit applies per visitor IP, not per finder. A user who types quickly in the search bar (autocomplete debounces at 250 ms in Search.svelte:224) will typically produce 1–3 requests per search interaction. Rate limit exhaustion is unlikely for normal usage but could be reached by automated scanning.

When the limit is exceeded the backend returns HTTP 429. fetchAutocompleteSuggestions catches any network-level error and returns []; it does not explicitly catch 429 — a non-OK status causes the function to also return [] (api.js:127–128). The widget search bar therefore shows no suggestions without surfacing an error to the user.

The GET /api/ext/finders/details/{key} origin fallback and POST /api/analytics have no throttle middleware applied.


CDN caching behavior and the origin fallback relationship

For production payloads, the widget does not call GET /api/ext/finders/details/{key} under normal conditions. It uses a two-file CDN pattern:

  1. Pointer filehttps://cdn.locationfinders.com/finders/{key}/config.json — CDN TTL 60 seconds (max-age=60). Contains { hash, published_at }.
  2. Versioned payload filehttps://cdn.locationfinders.com/finders/{key}/v/{hash}.json — CDN TTL 1 year (max-age=31536000, immutable). Contains the full finder payload.

These files are written by ExternalController::syncFinderDetails when the dashboard operator publishes a finder. The widget also checks localStorage (key lf-cdn-v2:{token}) before hitting the CDN, so a repeat visit with no intervening publish skips all network calls for the payload.

The origin API endpoint (/api/ext/finders/details/{key}) is the fallback of last resort: called when both the pointer fetch and the versioned payload fetch fail, and always in development.

For the full three-level read sequence with a decision diagram, see Data flow — end user § Phase 4. For CDN file layout and TTL rationale, see Storage and CDN.


Error handling when the backend is unreachable

Each function in api.js handles backend unavailability differently:

fetchFinderPointer — silent null return

try { const res = await fetch(`${CDN_BASE}/finders/${finderKey}/config.json`); if (!res.ok) return null; ... } catch { return null; }

Any fetch error or non-OK response returns null. The caller (fetchFinderPayload) treats a null pointer as a signal to skip straight to the origin API fallback. (api.js:40–50)

fetchFinderPayload — throws on total failure

The function works through the three levels silently (CDN + localStorage errors are caught internally) but throws if the final origin API call fails:

const response = await fetch(`${API_BASE}/ext/finders/details/${finderKey}`); if (!response.ok) { ... throw new Error(message); }

(api.js:82–94)

The caller in App.svelte::boot() catches the thrown error and writes its message to the error reactive variable (App.svelte:247–248):

} catch (err) { error = err instanceof Error ? err.message : 'Failed to load finder.'; } finally { loading = false; }

When error is non-empty, App.svelte:375 renders a .finderRuntimePlaceholder.errorState container with a .messageCard.error card displaying the error string. The widget never renders its interactive UI — the host page sees a placeholder with an error message. (App.svelte:375–424)

fetchAutocompleteSuggestions — silent empty array

Network errors return [] via a top-level try/catch (api.js:122–126). Non-OK responses also return [] (api.js:127–128). The search bar shows no dropdown without any error state.

fetchPlaceDetails — throws, caught by Search.svelte

Non-OK responses throw an Error. Search.svelte:388 catches the error and swallows it — no visible feedback to the visitor.

fetchIpLocation — graceful geolocation degradation

If IP geolocation fails, App.svelte calls setUserLocation with { lat: null, lng: null, error: 'Unable to determine viewer location.' } (App.svelte:151–156). The widget renders normally; distance-based sorting and map centering are disabled.


Summary table

EndpointAuthThrottleOn error
GET /ext/finders/details/{key}Token in URL path; domain check via Origin/Referer headerNoneWidget throws → renders error card
GET /mapping/autocompletefinder_token query param (provider resolution only)30 req/min per IP (throttle:30,1)Returns []; silent
GET /mapping/placesfinder_token query param (provider resolution only)30 req/min per IP (throttle:30,1)Throws; caught silently by Search.svelte
POST /analyticsfinderId body field (token validated via exists:finders,token)None🔴 [NEEDS CLARIFICATION: does the widget retry failed analytics events or discard them?]