Leads API
Captures homepage contact form submissions in the database and automatically pushes them to Pipedrive’s Leads Inbox via a backend job.
Why This Exists
Section titled “Why This Exists”Previously, the homepage contact form called the Pipedrive API directly from the frontend with a hardcoded API token. Lead capture was moved server-side to:
- Remove the exposed API token from client-side code
- Store consent timestamps in the database (GDPR compliance)
- Push to Pipedrive through a queued, retryable backend job (see Pipedrive Sync below)
Endpoint
Section titled “Endpoint”POST /api/leads
Section titled “POST /api/leads”Public endpoint (no authentication required). Rate-limited to 5 requests per minute per IP.
Request: See StoreLeadRequest for validation rules.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Contact name (max 255) |
email | string | Yes | Valid email (max 255) |
phone | string | No | Phone number (max 50) |
region | string | No | Free-text region summary (max 255). Legacy path only — the current homepage form does not send this. |
destinations | array | No | Structured list of selected regions/countries. See shape below. Max 100 items. |
preferred_call_time | string | No | Preferred callback time (max 255) |
consent | boolean | Yes | Must be true (accepted) |
destinations shape
Section titled “destinations shape”Each item must be an object with:
| Field | Type | Required | Description |
|---|---|---|---|
type | string | Yes | region (entire region selected) or country (specific country selected) |
id | string | No | Stable identifier (e.g., CMS slug) up to 100 chars |
label | string | Yes | Human-readable name shown in the admin (max 100) |
Example payload:
{ "name": "María García", "email": "maria@example.com", "phone": "+34 612 345 678", "destinations": [ {"type": "region", "id": "centroamerica", "label": "Centroamérica"}, {"type": "country", "id": "tailandia", "label": "Tailandia"} ], "preferred_call_time": "Mañana (9:00 – 13:00)", "consent": true}Response: 201 Created
{ "success": true, "message": "Lead received successfully."}Errors:
| Code | Cause |
|---|---|
| 422 | Validation failure (missing name/email, invalid email, consent not accepted, invalid destinations.*.type) |
| 429 | Rate limit exceeded (5 requests/minute) |
Admin Panel
Section titled “Admin Panel”Leads appear in the Filament admin under Customers > Leads (read-only resource, no create/edit). Admins can view details and bulk-delete.
Destinos display
Section titled “Destinos display”The Destinos section on the lead view page renders the structured destinations array with visual differentiation:
- Regiones completas — primary-coloured badges with a globe icon for items where
type = region. - Países específicos — gray badges with a map-pin icon for items where
type = country. - Texto original — fallback plain text, shown only for legacy leads that stored a raw
regionstring with no structureddestinations.
The leads list table shows the same two badge columns (Regiones, Países) instead of a single joined string, so admins can scan which specific destinations a lead mentioned.
Permissions: view_lead, delete_lead (no create/update since leads come from the public form).
Source: backend/app/Filament/Resources/Leads/LeadResource.php
Frontend Integration
Section titled “Frontend Integration”The HomeContactClosing React component on the home page renders the “Agendar cita” side modal. Destinations are picked via a single DestinationMultiSelect component that combines regions and countries into one multi-select with a region-group accordion. Selecting a whole region disables the individual country checkboxes within it to avoid redundant payloads.
On submit, HomeContactClosing calls submitContactInquiry() from frontend/src/services/leadApi.ts, which POSTs to /api/leads using the apiUrl prop (resolved from API_URL env var). The service maps Spanish form field names to the API contract (e.g., nombre to name) and translates the picker’s selection into the structured destinations array described above. The free-text region field is no longer populated by the frontend; it remains accepted by the API for legacy/manual callers.
Preferred call time label
Section titled “Preferred call time label”The callback-time select is grouped under disabled LUNES-VIERNES / SÁBADOS headers, so the visible option label is time-only and ambiguous once stripped from the UI (e.g. 12:30h-14h is weekday, 12:30h-15h is Saturday). Before submitting, formatHorarioLabel() prefixes the chosen value with Lun-Vie or Sábado, so sales sees unambiguous context in Pipedrive and the admin records (e.g. Lun-Vie 12:30h-14h).
Sources:
frontend/src/components/home/HomeContactClosing/HomeContactClosing.react.tsxfrontend/src/components/DestinationMultiSelect/DestinationMultiSelect.tsxfrontend/src/services/leadApi.ts
Pipedrive Sync
Section titled “Pipedrive Sync”New Leads are pushed to Pipedrive’s native Leads Inbox automatically on creation. The sync is one-way (outbound) and create-only: once a Lead has a Pipedrive Lead ID, the job short-circuits. Leads are meant to be triaged (promoted to Deals) in Pipedrive, not edited from Volare, and no inbound webhook handler exists for Leads. Inbound handlers in app/Jobs/Pipedrive/Inbound/ cover Deal, Person, Note, and Activity only.
How it works
Section titled “How it works”When a Lead is created, PipedriveSyncObserver dispatches SyncLeadToPipedriveJob on the pipedrive-sync queue (configurable via the PIPEDRIVE_QUEUE env var, default pipedrive-sync). The job:
- Resolves a Pipedrive Person for the Lead’s email, in this order:
- Reuse the Person linked to a local
Clientwith a matching email, if that Client is already synced. - Otherwise, call
GET /persons/search?term={email}&fields=email&exact_match=trueand reuse the first match. - Otherwise,
POST /personsto create a new Person with name, email, and phone.
- Reuse the Person linked to a local
- Creates the Lead via
POST /leadswithtitle,person_id, and the custom fields below. The returned Pipedrive Lead UUID is persisted on thepipedrive_syncs.pipedrive_idrow for this Lead.
title format: "{name} - {destinations}" when destinations exist, otherwise "Web inquiry from {name}".
Custom fields
Section titled “Custom fields”Four Lead-specific custom fields are attached to the Pipedrive Lead:
| Volare key | Pipedrive label | Source |
|---|---|---|
volare_lead_id | Volare Lead ID | Lead.id |
lead_source_name | Volare Lead Source | Lead.source (e.g. homepage_contact) |
lead_destinations | Volare Lead Destinations | Comma-separated region + country labels from Lead.destinations |
preferred_call_time | Preferred Call Time | Lead.preferred_call_time |
Pipedrive has no /leadFields endpoint — Leads share the Deal custom-field schema because a Lead converts to a Deal when qualified. These four fields are therefore registered under /dealFields by pipedrive:setup-custom-fields, and the hash keys carry over automatically on lead-to-deal conversion.
Concurrency guard
Section titled “Concurrency guard”SyncLeadToPipedriveJob implements ShouldBeUnique (unique id = Lead.id, uniqueFor = 600s). This prevents duplicate POST /leads calls when a second dispatch races in during the ~1s window between reading pipedrive_id == null and writing it back. The filled($pipedrive_id) short-circuit remains as a secondary guard.
--force is refused for Leads
Section titled “--force is refused for Leads”pipedrive:sync-all --force is loudly downgraded to non-force mode for create-only models (currently Lead). Re-dispatching a synced Lead is a no-op because the job short-circuits, so --force cannot repair data drift on these records — it would silently enqueue dead jobs.
Operational requirements
Section titled “Operational requirements”PIPEDRIVE_API_TOKENmust be set. If it’s empty,PipedriveSyncObserversilently skips dispatch (there’s no error).- A queue worker must consume the
pipedrive-syncqueue (or whateverPIPEDRIVE_QUEUEis set to). - Run
php artisan pipedrive:setup-custom-fieldsonce per environment to register the custom fields in Pipedrive and store their hash keys inpipedrive_settings. The command is idempotent: already-registered fields are skipped by key lookup.
Monitoring
Section titled “Monitoring”The Filament page at /admin/pipedrive-sync-monitor shows per-record sync status (model_type, model_id, pipedrive_id, status, last error) across all synced models, with a re-dispatch action for failed rows.
Source Files
Section titled “Source Files”| Component | File |
|---|---|
| Model | backend/app/Models/Lead.php |
| Controller | backend/app/Http/Controllers/Api/LeadController.php |
| Form Request | backend/app/Http/Requests/Api/StoreLeadRequest.php |
| Migrations | backend/database/migrations/2026_04_16_141305_create_leads_table.php, ..._add_destinations_to_leads_table.php, ..._drop_budget_from_leads_table.php |
| Factory | backend/database/factories/LeadFactory.php |
| Filament Resource | backend/app/Filament/Resources/Leads/LeadResource.php |
| Pipedrive Sync Job | backend/app/Jobs/Pipedrive/SyncLeadToPipedriveJob.php |
| Pipedrive Observer | backend/app/Observers/PipedriveSyncObserver.php (registered in AppServiceProvider::boot()) |
| Pipedrive Registry | backend/app/Services/Pipedrive/PipedriveSyncRegistry.php ('lead' entry, endpoint /leads) |
| Custom Fields Setup | backend/app/Console/Commands/PipedriveSetupCustomFieldsCommand.php |
| Bulk Sync Command | backend/app/Console/Commands/PipedriveSyncAllCommand.php |
| Sync Monitor | backend/app/Filament/Pages/PipedriveSyncMonitor.php |
| Frontend Form | frontend/src/components/home/HomeContactClosing/HomeContactClosing.react.tsx |
| Frontend Picker | frontend/src/components/DestinationMultiSelect/DestinationMultiSelect.tsx |
| Frontend Service | frontend/src/services/leadApi.ts |