Skip to content

Leads API

Captures “Agendar cita” 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 frontend 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)
source string No homepage_contact or appointment_page (LeadSource). Defaults to homepage_contact when omitted.
travel_month / travel_year integer No Requested travel month (1–12) and year (current year to +2). Each requires the other; a month already past in the current year is rejected.
trip_duration string No up_to_8, 9_to_11, 12_to_15, over_15 (LeadTripDuration)
travellers integer No 1–20
budget_min / budget_max integer No EUR per person (0–1,000,000). budget_max must be ≥ budget_min; a null budget_max means open-ended — Lead::budgetLabel() words it “Más de X €” up to the fixed 8.000 € tier and “Desde X €” above it (a tier the page relabelled from a pricier country’s from-price).
contact_channel string No call, whatsapp, video_call (LeadContactChannel)
heard_about string No LeadHeardAbout value (google, instagram, recommendation, …)
comments string No Free text (max 2000)
newsletter_opt_in boolean No Also subscribes the email to the newsletter (see below)
market string No es, de, us, uk, ca
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": "Lun-Vie 12:30h-14h",
"travel_month": 5,
"travel_year": 2027,
"trip_duration": "12_to_15",
"travellers": 2,
"budget_min": 5800,
"budget_max": 6500,
"contact_channel": "call",
"newsletter_opt_in": true,
"market": "es",
"source": "appointment_page",
"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 enum value, past travel month, budget_max below budget_min)
429 Rate limit exceeded (5 requests/minute)

When newsletter_opt_in is true, the controller also subscribes the email through NewsletterSubscriptionService — the same service the footer form uses (see Newsletter Subscriptions). An existing subscriber keeps its original consent; only a missing phone is filled in. The signup is a side effect: if it throws, the error is logged and the lead is still acknowledged with 201.

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.

The view page adds Trip (travel month, duration, travellers, budget per person, comments) and Contact preferences (contact channel, preferred call time, heard about us, newsletter opt-in, market) sections. The list adds budget, contact channel, travel month, duration and source columns, with filters for source, contact channel, trip duration and a minimum budget per person (budget_min ≥ value).

Permissions: view_lead, delete_lead (no create/update since leads come from the public form).

Source: backend/app/Filament/Resources/Leads/LeadResource.php

The “Agendar cita” page (/es/agendar-cita, /{market}/book-appointment elsewhere) is the only frontend caller. It is a standalone noindex page with the AppointmentForm React island; the “Habla con un especialista” closing band on the home, region, country and trip pages links to it.

  • Destinations: countries come from GET cms/appointment-countries and are sent as type: country items. Their cheapest “desde” price narrows the budget tiers offered. If the request fails the page still renders, without the destination question.
  • Call slots: built from the market’s customer-service hours (see Markets); the question is hidden when the market has none. formatContactHorarioLabel() prefixes the slot with Lun-Vie, Sábado or Domingo so sales sees an unambiguous value (e.g. Lun-Vie 12:30h-14h).
  • Payload: buildAppointmentPayload() sends source: appointment_page, the market, and omits optional answers left blank. Required fields are enforced client-side; the API keeps them optional.
  • Errors: 422 field errors are mapped back onto the form; 429, 5xx and network failures keep the answers filled for a retry.

Sources:

  • frontend/src/pages/[market]/book-appointment.astro
  • frontend/src/features/appointment/components/AppointmentForm.tsx
  • frontend/src/features/appointment/appointmentForm.ts (options, budget tiers, payload builder)
  • frontend/src/features/appointment/services/appointmentApi.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}".

Lead-specific custom fields attached to the Pipedrive Lead (empty values are skipped):

Volāre key Pipedrive label Source
volare_lead_id Volare Lead ID Lead.id
lead_source_name Volare Lead Source Lead.source (appointment_page or 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
lead_travel_month Volare Lead Travel Month First day of the travel month (date)
lead_trip_duration Volare Lead Trip Duration Spanish label, e.g. 12 a 15 días
lead_travellers Volare Lead Travellers Lead.travellers (number)
lead_budget Volare Lead Budget per Person Lead::budgetLabel(), e.g. 5.800 – 6.500 €, Más de 8.000 €, Desde 13.700 € (relabelled open-ended tier), Hasta 3.000 €
lead_budget_min Volare Lead Budget Min (EUR) Lead.budget_min (number, for range filters)
lead_contact_channel Volare Lead Contact Channel Spanish label, e.g. Videollamada
lead_heard_about Volare Lead Heard About Spanish label
lead_comments Volare Lead Comments Lead.comments
lead_newsletter_opt_in Volare Lead Newsletter Opt-in Sí; No only for appointment_page leads (the homepage form had no checkbox, so it sends nothing)

Pipedrive has no /leadFields endpoint — Leads share the Deal custom-field schema because a Lead converts to a Deal when qualified. These 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 Lead and NewsletterSubscriber. Re-dispatching a synced Lead is a no-op because the job short-circuits, so --force cannot repair Lead data drift. Newsletter subscribers have a narrow empty-phone backfill exception, not general profile updates.

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 registered by pipedrive:setup-custom-fields. Provisioning migrations call it on deploy; the “Agendar cita” fields (lead_travel_month … lead_newsletter_opt_in) are created by 2026_10_30_133559_provision_pipedrive_appointment_lead_custom_fields. If that migration ran while Pipedrive was unreachable (it logs a warning and continues), re-run the command by hand: until the fields are registered the job silently drops those values (a field with no registered key is skipped). The command is idempotent (already-registered fields are skipped by key lookup), so re-running it is safe.

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
Enums backend/app/Enums/LeadSource.php, LeadTripDuration.php, LeadContactChannel.php, LeadHeardAbout.php
Newsletter Service backend/app/Services/NewsletterSubscriptionService.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, 2026_10_30_122224_add_appointment_details_to_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
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 Page frontend/src/pages/[market]/book-appointment.astro
Frontend Form frontend/src/features/appointment/components/AppointmentForm.tsx
Frontend Service frontend/src/features/appointment/services/appointmentApi.ts