Integration — Backend → third-party
All outbound calls the Laravel backend makes to external services. For the full data-flow context see data-flow-builder.md and storage-and-cdn.md (session 6 ★). For the customer-facing autocomplete configuration UI, see ../integrations/autocomplete-providers.md.
1. Paddle
Paddle is the merchant of record for all subscription billing. The backend talks to Paddle in two directions: inbound webhook and outbound REST.
Webhook receiver
POST /api/webhooks/paddle → BillingController::webhook() (app/Http/Controllers/BillingController.php:185)
Paddle delivers a signed POST to this endpoint for every billing lifecycle event. The controller verifies the HMAC-SHA256 Paddle-Signature header (5-minute timestamp window) before processing. Duplicate events are dropped by checking the paddle_webhook_events table for the event_id.
Handled event types (BillingController.php:527–545):
| Group | Events |
|---|---|
| Subscription | subscription.created, subscription.trialing, subscription.activated, subscription.updated, subscription.past_due, subscription.paused, subscription.resumed, subscription.canceled |
| Transaction | transaction.paid, transaction.completed, transaction.payment_failed |
Every processed event is written to paddle_webhook_events with event_id, event_type, occurred_at, processed_at, user_id, and a payload_summary JSON column.
Outbound calls
All outbound calls are made by BillingController::paddleRequest() (BillingController.php:607), which sets the base URL, bearer token, and Paddle-Version: 1 header.
| Method | Operation | Triggered by |
|---|---|---|
POST /transactions | Create checkout transaction | BillingController::checkout() — user initiates subscription upgrade |
GET /subscriptions/{id} | Fetch subscription state | BillingController::sync() and portal() |
GET /subscriptions | List subscriptions for a customer | BillingController::findSubscriptionForUser() |
GET /customers | Resolve customer by email | BillingController::resolveCustomerIdForUser() |
GET /transactions | List invoices for a customer | BillingController::invoices() |
GET /transactions/{id} | Fetch a single transaction | BillingController::invoicePdf() |
GET /transactions/{id}/invoice | Get invoice PDF URL | BillingController::invoicePdf() |
Environment variables
| Variable | Purpose |
|---|---|
PADDLE_ENVIRONMENT | sandbox or production (default sandbox) |
PADDLE_API_KEY | Bearer token for Paddle REST API |
PADDLE_CLIENT_TOKEN | Public token used by the frontend Paddle.js checkout |
PADDLE_WEBHOOK_SECRET | HMAC secret for webhook signature verification |
PADDLE_BRONZE_PRICE_ID | Paddle price ID for the Bronze plan |
PADDLE_PREMIUM_PRICE_ID | Paddle price ID for the Premium plan |
PADDLE_API_BASE_URL | API base (default https://api.paddle.com) |
Source: config/services.php:60–68.
2. Cloudflare R2 / S3
Disk configuration
The r2 disk is defined in config/filesystems.php:61–70. It uses the S3 driver pointed at the Cloudflare R2 S3-compatible endpoint:
driver: s3
region: auto
use_path_style_endpoint: true
throw: trueThe s3 disk (lines 48–58) is the standard AWS S3 configuration and is available as a fallback but is not used by default. The active upload disk is selected via FILESYSTEM_DISK (default local in development; set to r2 in production).
What is stored
| Object path | Content | Who uploads |
|---|---|---|
finders/{key}/v/{hash}.json | Immutable versioned finder payload; CacheControl: public, max-age=31536000, immutable | ExternalController::syncFinderDetails() on publish |
finders/{key}/config.json | Pointer file containing hash and published_at; CacheControl: public, max-age=60 | Same publish call |
workspaces/{id}/locations/{id}/{uuid}.{ext} | Location image (jpg/png/webp/gif, max 2 MB) | LocationController::uploadImage() (LocationController.php:271) |
avatars/{user_id}/{uuid}.{ext} | User avatar (jpg/png/webp, max 1 MB) | AccountSecurityController::uploadAvatar() (AccountSecurityController.php:36) |
The CDN base URL is assembled from config('services.cdn.base_url') (env CDN_BASE_URL, default https://cdn.locationfinders.com). All public object URLs are constructed as CDN_BASE_URL/{key} — the R2 bucket is not exposed directly.
Finder payload publish flow
On POST /ext/finders/details/{token}/sync, ExternalController::syncFinderDetails() (ExternalController.php:191–202):
- Builds the full finder JSON payload.
- sha1-hashes the payload (excluding
createdandhashkeys) and takes the first 12 characters ashash. - Writes the versioned file at
finders/{key}/v/{hash}.json(immutable, 1-year TTL). - Writes the pointer file at
finders/{key}/config.json(60-second TTL).
See data-flow-builder.md for the full publish sequence diagram.
Environment variables
| Variable | Purpose |
|---|---|
R2_ACCESS_KEY_ID | R2 API key ID |
R2_SECRET_ACCESS_KEY | R2 API secret |
R2_BUCKET | Bucket name |
R2_ENDPOINT | R2 S3-compatible endpoint URL (e.g. https://<account>.r2.cloudflarestorage.com) |
CDN_BASE_URL | Public CDN domain in front of the bucket (default https://cdn.locationfinders.com) |
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_DEFAULT_REGION / AWS_BUCKET / AWS_ENDPOINT | Standard S3 disk — not used in default production config |
Source: config/filesystems.php and config/services.php:40–42.
3. Autocomplete providers
The backend proxies all autocomplete search requests on behalf of the widget so that BYOK keys are never exposed in the browser. The entry point is MappingController (app/Http/Controllers/MappingController.php).
Request flow
- Widget calls
GET /api/mapping/autocomplete?input=…&finder_token=…&latitude=…&longitude=…&sessionToken=…. MappingController::resolveProvider()looks up the finder by token (cached 60 seconds; only provider slug + finder ID are cached — never the plaintext key).- Reads the
settings.autocompleteProvidersetting from the finder’ssettingsJSON column to get the provider slug. - If a BYOK key is configured, retrieves
FinderIntegration.api_keyfresh on every request (decrypted, never cached). Source:MappingController.php:95–99. - Calls
AutocompleteProviderFactory::make($slug, $apiKey)(app/Services/Autocomplete/AutocompleteProviderFactory.php:9) to get a provider instance. - Calls
$provider->search(…)and returns normalized predictions.
For Google (the only provider requiring a round-trip for coordinates), the widget then calls GET /api/mapping/places?place_id=…&finder_token=… which proxies GoogleProvider::placeDetails().
Provider classes
All six providers implement AutocompleteProviderInterface (app/Services/Autocomplete/AutocompleteProviderInterface.php), which defines search() and placeDetails().
| Slug | Class | Upstream endpoint | Returns coords in search()? | placeDetails() needed? |
|---|---|---|---|---|
google | GoogleProvider | maps.googleapis.com/maps/api/place/autocomplete/json | No — lat/lng are null | Yes — place/details/json |
mapbox | MapboxProvider | api.mapbox.com/geocoding/v5/mapbox.places/{input}.json | Yes | No — returns null |
here | HereProvider | autocomplete.search.hereapi.com/v1/autocomplete | Yes (via position) | No |
radar | RadarProvider | api.radar.io/v1/search/autocomplete | Yes (via latitude/longitude) | No |
geoapify | GeoapifyProvider | api.geoapify.com/v1/geocode/autocomplete | Yes | No |
dropaSearch (default) | DropaSearchProvider | nominatim.openstreetmap.org/search | Yes | No |
DropaSearchProvider is the no-key fallback. It hits the public Nominatim OSM API with a User-Agent: Dropafinder/1.0 header (source: DropaSearchProvider.php:9). No API key is required.
All providers use Http::timeout(5) for the upstream call. Partial failures return an empty array — the widget receives {"predictions": []}.
BYOK key storage
Customer-supplied API keys are stored encrypted in the finder_integrations table (model: FinderIntegration, controller: FinderIntegrationController). The allowed BYOK providers are google, radar, mapbox, here, geoapify — dropaSearch (Nominatim) never has a key (FinderIntegrationController.php:14).
Keys are managed via PUT /api/finders/{id}/integrations/{provider} and DELETE /api/finders/{id}/integrations/{provider}. Saving or deleting a key busts the autocomplete_finder_{token} cache entry.
The server-side GOOGLE_API_KEY (from config/services.php:43) and RADAR_API_KEY (config/services.php:47) are system-level keys used by the AI import enrichment flow (see section 4), not by the autocomplete proxy.
4. AI service (Gemini)
Provider
Google Gemini via the REST generateContent API. The model is configurable; the default is gemini-2.5-flash-lite (config/services.php:51).
Endpoint pattern: https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={GEMINI_API_KEY}
Usage
The AI service is called in two flows:
Location import (AiLocationImportController::requestGeminiDraft(), AiLocationImportController.php:463):
- Called from
GenerateAiLocationImportJob(queue:aigen). - Takes user-supplied answers (brand name, business type, geographic scope, etc.) and workspace custom-field definitions.
- Returns a JSON array of
locationswith name, address, tags, custom fields, and optional coordinates. - Temperature: 0.2;
responseMimeType: application/json. - After Gemini drafts locations,
enrichDraftPayloadWithGoogle()optionally calls Google Places text search + details to enrich addresses, phone numbers, websites, opening hours, and coordinates.
Location improvement (AiLocationImprovementController::requestGeminiImprovement(), AiLocationImprovementController.php:511):
- Called from
GenerateAiLocationImprovementJob(queue:aigen). - Takes the full workspace location dataset (serialized to JSON) plus active map context.
- Returns
location_updates(per-location field suggestions) andgroup_recommendations(tag merges, new categories, splits, etc.). - Temperature: 0.2;
responseMimeType: application/json. - After Gemini suggestions are normalized,
geocodePayload()optionally calls Google Geocoding API to fill missing coordinates.
Both flows share identical generation config and a 90-second HTTP timeout with a 10-second connect timeout. Failures throw ValidationException to be caught by the job’s failed() handler.
Token pricing
config/services.php:52–57 stores per-million token pricing for gemini-2.5-flash-lite (default: $0.10 input / $0.40 output). The usage_metadata JSON column on ai_location_import_batches and ai_location_improvement_batches captures promptTokenCount, candidatesTokenCount, totalTokenCount, and thoughtsTokenCount from the Gemini response.
Environment variables
| Variable | Purpose |
|---|---|
GEMINI_API_KEY | Google Gemini API key |
GEMINI_MODEL | Model name (default gemini-2.5-flash-lite) |
GEMINI_2_5_FLASH_LITE_INPUT_PER_MILLION | Input token cost override (default 0.10) |
GEMINI_2_5_FLASH_LITE_OUTPUT_PER_MILLION | Output token cost override (default 0.40) |
GOOGLE_API_KEY | Google Places + Geocoding key used for AI import/improvement enrichment |
Source: config/services.php:49–58 and config/services.php:43.
5. Mail
Transport
Laravel’s mailer. The default transport in .env.example is log (development). The production transport is not hardcoded — it is controlled by MAIL_MAILER. config/mail.php ships with drivers for smtp, ses, postmark, resend, sendmail, and log. Separate tokens (POSTMARK_TOKEN, RESEND_KEY) are wired in config/services.php:17–29 for Postmark and Resend respectively.
🔴 [NEEDS CLARIFICATION: Confirm which mail transport (smtp, postmark, resend, or ses) is active in the production environment.]
What triggers email
| Trigger | Mailable / mechanism | Source |
|---|---|---|
| Workspace member invitation | InviteUserMail | InviteController::sendInvite() (InviteController.php:20) |
No other Mail:: calls or additional Mailable classes were found in the codebase at the time of writing. Password-reset and email-verification flows are not present in the custom AuthController — Laravel’s built-in notification system may handle these, or they may be absent.
🔴 [NEEDS CLARIFICATION: Are password-reset and email-verification emails sent? If so, which Notification class or SMTP provider handles them?]
Environment variables
| Variable | Purpose |
|---|---|
MAIL_MAILER | Active transport (smtp, postmark, resend, ses, log, etc.) |
MAIL_HOST | SMTP host (default 127.0.0.1) |
MAIL_PORT | SMTP port (default 2525) |
MAIL_USERNAME | SMTP username |
MAIL_PASSWORD | SMTP password |
MAIL_ENCRYPTION | TLS/SSL (tls recommended) |
MAIL_FROM_ADDRESS | Sender address |
MAIL_FROM_NAME | Sender display name |
POSTMARK_TOKEN | API token for Postmark transport |
RESEND_KEY | API key for Resend transport |
Source: config/mail.php and config/services.php:17–29.
6. ipinfo.io
ipinfo.io is not called by the Laravel backend. It is called client-side by the embedded widget (Svelte/snippet.js) to resolve the visitor’s approximate latitude/longitude for geolocation-biased autocomplete and analytics context. The call is:
fetch("https://ipinfo.io/json?token=<IPINFO_TOKEN>")The token is embedded in the compiled widget bundle. The loc field of the response ("lat,lng") is stored in a Svelte writable store and passed to the backend autocomplete proxy as the optional latitude/longitude query parameters.
⚠️ Warning: The ipinfo.io token is visible in the public JavaScript bundle. It should be rate-limited to the production domain and scoped to the minimum required API access.
The backend has no IPINFO_* env vars and no ipinfo references in any PHP source file. If a server-side geolocation lookup is ever needed, the token and call should be moved to a dedicated backend endpoint.
See data-flow-end-user.md for the full widget initialization sequence including the ipinfo.io phase.
Summary table
| Service | Direction | Trigger | Key class(es) |
|---|---|---|---|
| Paddle | Inbound webhook + outbound REST | Subscription lifecycle events; user-initiated checkout | BillingController |
| Cloudflare R2 | Outbound upload | Finder publish, location image upload, avatar upload | ExternalController, LocationController, AccountSecurityController |
| Google Places / Geocoding | Outbound proxy (autocomplete) + outbound enrichment (AI import) | Widget autocomplete + AI import/improvement geocoding | GoogleProvider, AiLocationImportController, AiLocationImprovementController |
| Mapbox | Outbound proxy | Widget autocomplete (BYOK) | MapboxProvider |
| HERE | Outbound proxy | Widget autocomplete (BYOK) | HereProvider |
| Radar | Outbound proxy | Widget autocomplete (BYOK) | RadarProvider |
| Geoapify | Outbound proxy | Widget autocomplete (BYOK) | GeoapifyProvider |
| DropASearch (Nominatim) | Outbound proxy | Widget autocomplete (default / no key) | DropaSearchProvider |
| Google Gemini | Outbound REST | AI location import generation, AI location improvement generation | AiLocationImportController, AiLocationImprovementController |
| Mail transport | Outbound | Workspace member invite | InviteController + InviteUserMail |
| ipinfo.io | Widget-side (not backend) | Widget visitor geolocation | n/a (external snippet) |
See also:
- ./index.md — integration points overview
- ../../architecture/storage-and-cdn.md — R2 bucket layout and CDN caching (session 6 ★)
- ../../architecture/deployments.md — environment variable provisioning (session 6 ★)
- ../../../integrations/autocomplete-providers.md — customer-facing autocomplete setup guide