Leads API
Captures “Agendar cita” 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 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. |
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": "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) |
Newsletter opt-in
Section titled “Newsletter opt-in”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.
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.
Appointment details
Section titled “Appointment details”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
Frontend Integration
Section titled “Frontend Integration”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-countriesand are sent astype: countryitems. 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 withLun-Vie,SábadoorDomingoso sales sees an unambiguous value (e.g.Lun-Vie 12:30h-14h). - Payload:
buildAppointmentPayload()sendssource: 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.astrofrontend/src/features/appointment/components/AppointmentForm.tsxfrontend/src/features/appointment/appointmentForm.ts(options, budget tiers, payload builder)frontend/src/features/appointment/services/appointmentApi.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 Volāre, 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”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.
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 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.
Records deleted by hand in Pipedrive
Section titled “Records deleted by hand in Pipedrive”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
failedretries for this reason, runpipedrive:ignore-deleted(idempotent;--dry-runreports 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
ignoredtopending. If it is still deleted, the next sync re-ignores it.
Operational requirements
Section titled “Operational requirements”PIPEDRIVE_API_TOKENmust be set. If it’s empty,PipedriveSyncObserversilently 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-syncqueue (or whateverPIPEDRIVE_QUEUEis 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 by2026_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.
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 and a Reactivate action for rows ignored after a manual deletion in Pipedrive.
Source Files
Section titled “Source Files”| 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 |