Skip to content

Newsletter Subscriptions API

Captures footer newsletter signups in the database with a local audit trail for GDPR Article 7, deduplicates by email, and mirrors each subscriber into Pipedrive as a Person.

The public newsletter form previously POSTed straight to Pipedrive from the browser with a hardcoded API token. Two problems followed:

  • No local proof of consent. Pipedrive doesn’t record when, under which policy version, or from which IP the user agreed.
  • Resubmissions created duplicate Pipedrive Persons because Pipedrive does not deduplicate by email out of the box.

The flow is now server-side. A newsletter_subscribers table with UNIQUE(email) is the source of truth for both the consent audit and dedup; Pipedrive is a downstream mirror.

Public endpoint (no authentication required). Rate-limited to 5 requests per minute per IP.

Request: See StoreNewsletterSubscriptionRequest for validation rules.

Field Type Required Description
name string Yes Subscriber name (max 255, trimmed)
email string Yes Valid email (max 255, trimmed)
consent boolean No Optional (sometimes, boolean). The footer form no longer renders a consent checkbox — subscribing implies consent, which the controller timestamps server-side. Still accepted when sent by older callers. Either way the row is stamped with consent_given_at, privacy_policy_version (from config/privacy.php), and consent_ip.

Example payload:

{
"name": "María García",
"email": "maria@example.com",
"consent": true
}

Responses:

Status When Body
201 Created First subscription for this email { "success": true, "message": "¡Gracias por suscribirte!" }
200 OK Email already exists in newsletter_subscribers { "success": true, "message": "Ya estás suscrito. ¡Gracias por tu interés!" }

Errors:

Code Cause
422 Validation failure (missing/blank name or email, invalid email)
429 Rate limit exceeded (5 requests/minute)

Inside NewsletterSubscriptionController::store() the row is created with NewsletterSubscriber::firstOrCreate(['email' => $email], [consent fields]). Dedup is enforced at the database level by UNIQUE(email) on newsletter_subscribers.

  • First submit: wasRecentlyCreated is true. The controller calls POST /persons on Pipedrive, stores the returned id on pipedrive_person_id, and returns 201.
  • Repeat submit (same email): wasRecentlyCreated is false. The controller returns 200 with the “Ya estás suscrito” message and does not call Pipedrive. Consent timestamps from the first subscription are preserved; resubmitting does not refresh them.

The original consent (consent_given_at, privacy_policy_version, consent_ip) therefore reflects the first time the user opted in. To re-consent under a new policy version, delete the row first.

Sync is synchronous and runs inside the request. It is best-effort: a failure to reach Pipedrive after the local row was already inserted is logged and the API still returns 201 with the success message.

The controller catches three failure modes explicitly and one fallthrough:

Exception Log level Notes
PipedriveAuthenticationException error Token misconfigured. pipedrive_person_id stays null.
PipedriveRateLimitException warning Includes the API’s retry-after delay. Row needs reconciliation later.
Throwable (any other) error Network errors, validation errors from Pipedrive, etc.

In every failure case the local row remains and the user sees a success message. This is intentional: the GDPR audit record must not be lost because a CRM call failed.

A new Pipedrive Person is created with:

name: NewsletterSubscriber.name
email: [{ value: NewsletterSubscriber.email, primary: true }]

Plus the three Person-level custom fields registered by pipedrive:setup-custom-fields:

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

These are the same three fields attached to a newly-created Pipedrive Person by the Lead sync job. See the Custom fields section in the Leads API doc for the registration mechanism and the shared BuildsPipedriveCustomFields trait that translates each logical key into its Pipedrive hash id.

config/privacy.php exposes policy_version, overridable via the PRIVACY_POLICY_VERSION env var (default v1.0). Every consent row — newsletter, lead, and any future consent surface — stamps this value on insert so we can prove which version of the policy the user accepted.

Bump this value whenever the published privacy policy changes in a way that requires fresh consent. Existing rows keep the version they were stamped with.

The newsletter_subscribers table:

Column Type Notes
id bigint PK
email string UNIQUE — enforces dedup
name string nullable
consent_given_at timestamptz Set to now() on insert
privacy_policy_version string From config('privacy.policy_version') at insert time
consent_ip string nullable From $request->ip()
pipedrive_person_id unsignedBigInteger nullable null if Pipedrive call failed or hasn’t run yet
created_at, updated_at timestamptz

There is no Filament resource — subscribers are write-only from the public form and read via the database or Pipedrive.

The footer newsletter form (FooterNewsletterModule.react.tsx) calls subscribeToNewsletter() from frontend/src/services/pipedrive.ts, which POSTs to /api/newsletter/subscribe using the apiUrl resolved from the API_URL server env var. The Pipedrive token never reaches the browser.

The service surfaces three failure messages to the user:

  • 422 — Spanish-language validation message from the first error in errors.
  • 429 — “Demasiadas solicitudes. Por favor, espera un momento.”
  • Network / timeout — generic connection error.

Both 200 and 201 responses are treated as success and the API-provided message is shown.

Component File
Model backend/app/Models/NewsletterSubscriber.php
Controller backend/app/Http/Controllers/Api/NewsletterSubscriptionController.php
Form Request backend/app/Http/Requests/Api/StoreNewsletterSubscriptionRequest.php
Migration backend/database/migrations/2026_05_11_065421_create_newsletter_subscribers_table.php
Custom Fields Trait backend/app/Traits/BuildsPipedriveCustomFields.php
Custom Fields Setup backend/app/Console/Commands/PipedriveSetupCustomFieldsCommand.php
Consent Provisioning Migration backend/database/migrations/2026_05_11_082935_provision_pipedrive_consent_custom_fields.php
Privacy Config backend/config/privacy.php (env PRIVACY_POLICY_VERSION)
Route backend/routes/api.php (api.newsletter.subscribe)
Frontend Service frontend/src/services/pipedrive.ts
Frontend Form frontend/src/components/Footer/FooterNewsletterModule.react.tsx