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.
Why This Exists
Section titled “Why This Exists”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.
Endpoint
Section titled “Endpoint”POST /api/newsletter/subscribe
Section titled “POST /api/newsletter/subscribe”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 | Yes | Must 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:
| 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, consent not accepted) |
| 429 | Rate limit exceeded (5 requests/minute) |
Idempotency and Dedup
Section titled “Idempotency and Dedup”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:
wasRecentlyCreatedis true. The controller callsPOST /personson Pipedrive, stores the returnedidonpipedrive_person_id, and returns201. - Repeat submit (same email):
wasRecentlyCreatedis false. The controller returns200with 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.
Pipedrive Sync Behaviour
Section titled “Pipedrive Sync Behaviour”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.
Pipedrive Person payload
Section titled “Pipedrive Person payload”A new Pipedrive Person is created with:
name: NewsletterSubscriber.nameemail: [{ value: NewsletterSubscriber.email, primary: true }]Plus the three Person-level custom fields registered by pipedrive:setup-custom-fields:
| Volare 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.
Privacy Policy Version
Section titled “Privacy Policy Version”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.
Database
Section titled “Database”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.
Frontend Integration
Section titled “Frontend Integration”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 inerrors.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.
Source Files
Section titled “Source Files”| 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 |