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.

FieldTypeRequiredDescription
namestringYesSubscriber name (max 255, trimmed)
emailstringYesValid email (max 255, trimmed)
consentbooleanYesMust be true (accepted). Persists consent_given_at, privacy_policy_version (from config/privacy.php), and consent_ip on the row.

Example payload:

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

Responses:

StatusWhenBody
201 CreatedFirst subscription for this email{ "success": true, "message": "¡Gracias por suscribirte!" }
200 OKEmail already exists in newsletter_subscribers{ "success": true, "message": "Ya estás suscrito. ¡Gracias por tu interés!" }

Errors:

CodeCause
422Validation failure (missing/blank name or email, invalid email, consent not accepted)
429Rate 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:

ExceptionLog levelNotes
PipedriveAuthenticationExceptionerrorToken misconfigured. pipedrive_person_id stays null.
PipedriveRateLimitExceptionwarningIncludes the API’s retry-after delay. Row needs reconciliation later.
Throwable (any other)errorNetwork 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:

Volare keyPipedrive labelField typeSource
consent_given_atConsent Given AtdateNewsletterSubscriber.consent_given_at (as YYYY-MM-DD)
privacy_policy_versionPrivacy Policy VersionvarcharNewsletterSubscriber.privacy_policy_version
consent_ipConsent IPvarcharNewsletterSubscriber.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:

ColumnTypeNotes
idbigint PK
emailstringUNIQUE — enforces dedup
namestring nullable
consent_given_attimestamptzSet to now() on insert
privacy_policy_versionstringFrom config('privacy.policy_version') at insert time
consent_ipstring nullableFrom $request->ip()
pipedrive_person_idunsignedBigInteger nullablenull if Pipedrive call failed or hasn’t run yet
created_at, updated_attimestamptz

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.

ComponentFile
Modelbackend/app/Models/NewsletterSubscriber.php
Controllerbackend/app/Http/Controllers/Api/NewsletterSubscriptionController.php
Form Requestbackend/app/Http/Requests/Api/StoreNewsletterSubscriptionRequest.php
Migrationbackend/database/migrations/2026_05_11_065421_create_newsletter_subscribers_table.php
Custom Fields Traitbackend/app/Traits/BuildsPipedriveCustomFields.php
Custom Fields Setupbackend/app/Console/Commands/PipedriveSetupCustomFieldsCommand.php
Consent Provisioning Migrationbackend/database/migrations/2026_05_11_082935_provision_pipedrive_consent_custom_fields.php
Privacy Configbackend/config/privacy.php (env PRIVACY_POLICY_VERSION)
Routebackend/routes/api.php (api.newsletter.subscribe)
Frontend Servicefrontend/src/services/pipedrive.ts
Frontend Formfrontend/src/components/Footer/FooterNewsletterModule.react.tsx