Skip to content

Leads API

Captures homepage contact form submissions in the database and automatically pushes them to Pipedrive’s Leads Inbox via a backend job.

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)

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). Persists consent_given_at, privacy_policy_version (from config/privacy.php), and consent_ip on the row as the local GDPR audit trail.

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)

Leads appear in the Filament admin under Customers > Leads (read-only resource, no create/edit). Admins can view details and bulk-delete.

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 region string with no structured destinations.

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

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.

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.tsx
  • frontend/src/components/DestinationMultiSelect/DestinationMultiSelect.tsx
  • frontend/src/services/leadApi.ts

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 Volāre, and no inbound webhook handler exists for Leads. Inbound handlers in app/Jobs/Pipedrive/Inbound/ cover Deal, Person, Note, and Activity only.

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:

  1. Resolves a Pipedrive Person for the Lead’s email, in this order:
    1. Reuse the Person linked to a local Client with a matching email, if that Client is already synced.
    2. Otherwise, call GET /persons/search?term={email}&fields=email&exact_match=true and reuse the first match.
    3. Otherwise, POST /persons to create a new Person with name, email, and phone.
  2. Creates the Lead via POST /leads with title, person_id, and the custom fields below. The returned Pipedrive Lead UUID is persisted on the pipedrive_syncs.pipedrive_id row for this Lead.

title format: "{name} - {destinations}" when destinations exist, otherwise "Web inquiry from {name}".

Four Lead-specific custom fields are attached to the Pipedrive Lead:

Volāre 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.

Three additional Person-level custom fields are attached only when SyncLeadToPipedriveJob creates a new Pipedrive Person (step 3 of resolvePersonId):

Volāre key Pipedrive label Field type Source
consent_given_at Consent Given At date Lead.consent_given_at (as YYYY-MM-DD)
privacy_policy_version Privacy Policy Version varchar Lead.privacy_policy_version
consent_ip Consent IP varchar Lead.consent_ip

When the job resolves an existing Person (steps 1 and 2 — reuse from a synced Client or from /persons/search), it intentionally does not patch these fields onto the Person. The local leads row remains the authoritative GDPR audit record; missing fields on an existing Pipedrive Person are a CRM visibility gap, not a compliance one.

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.

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.

The sync is one-way (Volāre → Pipedrive). If an operator deletes a deal or person by hand in Pipedrive, Volāre isn’t notified and keeps trying to update it, so Pipedrive rejects the update with 400 "Entity is deleted. You must first restore it before you can edit". To stop the retry loop and the Sentry/Nightwatch noise, the sync job detects this case (PipedriveValidationException::isDeletedEntity()) and parks the record in the ignored status instead of failing: no retry, no exception report. Because every re-dispatcher (sync-all, retry-failed, reconciliation, the observer) only acts on pending/failed records, an ignored record is left alone and never resurrected — matching the one-way design where a deletion in Pipedrive is treated as intentional.

  • To clear a backlog of records already stuck in failed retries for this reason, run pipedrive:ignore-deleted (idempotent; --dry-run reports the count without writing).
  • To bring a record back, restore it from Pipedrive’s recycle bin, then use the Reactivate action in the sync monitor to move it from ignored to pending. If it is still deleted, the next sync re-ignores it.
  • PIPEDRIVE_API_TOKEN must be set. If it’s empty, PipedriveSyncObserver silently skips dispatch (there’s no error) and the consent-fields provisioning migration logs a warning and returns early.
  • A queue worker must consume the pipedrive-sync queue (or whatever PIPEDRIVE_QUEUE is set to).
  • Custom fields are provisioned automatically by migrations that call pipedrive:setup-custom-fields. The command is idempotent (already-registered fields are skipped by key lookup), so re-running migrations or invoking it manually is safe. No manual ops step is needed on deploy.

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 and a Reactivate action for rows ignored after a manual deletion in Pipedrive.

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, 2026_05_11_065550_add_privacy_columns_to_leads_table.php, 2026_05_11_082935_provision_pipedrive_consent_custom_fields.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
Custom Fields Trait backend/app/Traits/BuildsPipedriveCustomFields.php
Privacy Config backend/config/privacy.php (env PRIVACY_POLICY_VERSION)
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