Newsletter Subscriptions API
Captures popup and footer newsletter signups in the database with a local audit trail for GDPR Article 7, deduplicates by email, and mirrors each subscriber into two downstream systems: Pipedrive as a Person and ActiveCampaign as a list contact. Both mirrors are queued jobs with retries — the request cycle performs no CRM HTTP at all. The popup additionally captures an optional phone number; existing subscribers may add a missing phone without replacing their original consent or reactivating an ActiveCampaign subscription.
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.
Marketing then needed the same contacts in ActiveCampaign to run campaigns — Pipedrive is the sales CRM and has no campaign tooling. One ActiveCampaign list holds all newsletter subscribers, and marketing’s welcome automation triggers off membership in that list.
Both mirrors are now queued. The Pipedrive call originally ran inline inside the HTTP request, best-effort, with failures only logged — so a Pipedrive outage stranded the lead with no retry and no record that it had been missed. Moving it into the Pipedrive sync job family gives it the same retry budget, pipedrive_syncs tracking row, and admin monitor as every other synced model, and lets the signup return immediately.
Because there is a single list, segmentation has to come from somewhere else: the market the visitor was browsing travels from the page URL into the request, onto the row, and out as an ActiveCampaign contact tag. The three consent values are mirrored as ActiveCampaign contact custom fields, matching the labels already used on the Pipedrive Person, so a GDPR request can be answered from either system.
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). Validated with email:rfc,dns — see Email validation |
phone |
string | No | Nullable, max 50 characters, containing 7–15 digits with an optional leading +, spaces, parentheses, dots or hyphens. Omitted/blank input stays null. Stored locally and used only to fill an empty phone in either CRM. |
consent |
boolean | No | Optional (sometimes, boolean). The row is stamped with consent_given_at, privacy_policy_version (from config/privacy.php), and consent_ip regardless of what is sent — the server is the source of truth for the consent audit. See the caution below. |
market |
string | No | Optional (sometimes, nullable). Must be one of es, de, us, uk, ca — anything else is a 422. The list is hardcoded in the Form Request, so adding a market means editing the rule. Persisted on the row on create and mirrored to ActiveCampaign as a market tag. Both public forms send it (derived from the page URL); callers that omit it leave the row null. |
Example payload:
{ "name": "María García", "email": "maria@byvolare.com", "phone": "+34 600 123 456", "consent": true, "market": "es"}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, malformed email or phone, email domain with no mail DNS, unrecognised market) |
| 429 | Rate limit exceeded (5 requests/minute) |
Email Validation
Section titled “Email Validation”The email rule is email:rfc,dns: the address must be RFC-valid and its domain must resolve to a mail host.
This was chosen over double opt-in (the other option on the ticket). Double opt-in would have needed a confirmation-email flow, a pending state on the row, and a token endpoint; a DNS check rejects the bulk of typo’d and throwaway domains at the door for none of that cost, at the price of a live DNS lookup inside the request.
Consequences worth knowing:
foo@gmail.con,foo@foo.invalidand similar typos now422instead of being stored and then bouncing in ActiveCampaign.example.comno longer validates. It publishes a Null MX record (RFC 7505, “this domain accepts no mail”), so every@example.comaddress fails. Tests and manual QA use@byvolare.com.- The rule is scoped to this endpoint only. Leads and magic-link auth keep the plain
emailrule — they are not marketing-list surfaces, and a DNS lookup on a checkout-adjacent request is a worse trade. - A DNS outage would surface as spurious
422s on signup. Accepted: the failure is visible and transient, and no subscriber is silently lost.
Idempotency and Dedup
Section titled “Idempotency and Dedup”Inside NewsletterSubscriptionController::store() the row is created with NewsletterSubscriber::firstOrCreate() keyed on ['email' => $email], with name, phone, market, and the three consent columns in the create attributes. Dedup is enforced at the database level by UNIQUE(email) on newsletter_subscribers.
- First submit:
wasRecentlyCreatedis true. The controller dispatches both CRM jobs —SyncNewsletterSubscriberToActiveCampaignJobandSyncNewsletterSubscriberToPipedriveJob— and returns201without waiting for either. - Repeat submit (same email), no new phone: returns
200with the “Ya estás suscrito” message and queues neither job. Existing phone, name, market and consent metadata remain unchanged. - Repeat submit adding the first phone: an atomic update fills the local phone only while it is
null, then dispatches both jobs in phone-only mode. They fill an empty phone on an existing CRM contact; they do not create a second contact, replace an existing CRM phone, or replay ActiveCampaign list membership, tags or welcome automation. The response is still200.
Each dispatch has its own try/catch, so one queue failure does not prevent attempting the other mirror. A dispatch failure is logged at error with the subscriber id and job class, and swallowed — the local subscriber must not be lost because the queue was down.
The original consent (consent_given_at, privacy_policy_version, consent_ip) therefore reflects the first submission. This endpoint does not implement renewed consent under a new policy version.
Pipedrive Sync Behaviour
Section titled “Pipedrive Sync Behaviour”SyncNewsletterSubscriberToPipedriveJob extends AbstractPipedriveSyncJob, so it inherits the family’s queue (pipedrive-sync, configurable via PIPEDRIVE_QUEUE), tries = 3, timeout = 120, and backoff = [30, 60, 180]. It implements ShouldBeUnique (uniqueFor = 600), keyed by subscriber id, with a separate :phone suffix for phone-only jobs. A pending initial delivery therefore does not swallow a later phone backfill. Phone-only jobs never create a Person.
handle() is fully overridden rather than using the base class’s build-payload-and-POST path, because resolving the Person needs a search call first. The flow:
- Subscriber gone → log at
infoand return. pipedrive.api_tokenblank → log atinfoand return. This job is dispatched directly from the controller, not byPipedriveSyncObserver, so the observer’s token guard never runs; the guard lives on the job instead. Local and CI environments therefore never accumulate failed jobs.getOrCreatePipedriveSync()— thepipedrive_syncstracking row for this subscriber.- Status
Ignored→ return, no resurrection. pipedrive_idalready set and no local phone → re-stampSyncedand return.- Resolve the linked Person or search by email (below), saving its id locally before phone enrichment so a failed read/update can retry without creating another Person. If this run created the Person with the phone already in the payload, skip the extra phone read/update. Otherwise, refresh the local subscriber and fill the CRM phone only if it is empty; this also captures a phone submitted while creation was in flight. Only complete delivery marks the sync row
Synced.
Person dedupe: search first, create second
Section titled “Person dedupe: search first, create second”Ticket criterion: duplicate emails must not create duplicate contacts in either tool. ActiveCampaign gets this for free — POST /contact/sync upserts by email. Pipedrive’s POST /persons has no upsert, so the job searches first:
GET /persons/search { term: <email>, fields: 'email', exact_match: 'true' }- Hit → the first match’s id is linked. Existing name, email, consent fields and non-empty phones are preserved; only an empty phone can be filled from the subscriber. The local
newsletter_subscribersrow remains the authoritative consent audit record. - Miss → initial delivery creates a Person with
POST /persons; only this branch attaches the consent custom fields. Phone-only delivery returns without creating anything, leaving initial delivery/retries responsible for creation.
Pipedrive Person payload
Section titled “Pipedrive Person payload”A new Pipedrive Person is created with:
name: NewsletterSubscriber.nameemail: [{ value: NewsletterSubscriber.email, primary: true }]phone: [{ value: NewsletterSubscriber.phone, primary: true }] (when present)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.
Failure fates and sync-row states
Section titled “Failure fates and sync-row states”Failures land on the pipedrive_syncs row, which is what makes them visible and recoverable:
| Failure | Sync row status | Job behaviour |
|---|---|---|
429 rate limit (PipedriveRateLimitException) |
Pending (with the error message) |
release()d for the API’s retry-after delay — not failed, so it does not burn a retry |
Connection error / timeout (PipedriveTimeoutException) |
Failed |
Retryable — rethrown so the queue applies the backoff schedule |
| 401/403 auth, 400/422 validation, and any other non-2xx including 5xx | Failed |
Not retryable — report() to Sentry/Nightwatch, then fail(). No retry |
Any other Throwable |
Failed |
Rethrown |
| All retries exhausted | Failed |
The base class’s failed() logs at error and re-marks the row |
newsletter_subscribers.pipedrive_person_id staying null is the quick marker that a subscriber never reached Pipedrive; the pipedrive_syncs row says why.
Recovery
Section titled “Recovery”Because the model is registered in PipedriveSyncRegistry::MODELS as newsletter_subscriber, the whole Pipedrive tooling works on it with no per-model code:
| Tool | Use |
|---|---|
| Pipedrive Sync Monitor (System → Pipedrive Sync) | See status, error, and pipedrive_id per subscriber; Retry resets a Failed row to Pending (reset only — an Artisan run then re-dispatches it, since there is no observer) |
pipedrive:retry-failed --model=newsletter_subscriber |
Re-dispatch Failed rows. --status defaults to failed, so add --status=pending (or all) to also pick up rows the 429 path parked as Pending |
pipedrive:sync-all --model=newsletter_subscriber |
Dispatch for unsynced rows — this doubles as the backfill for subscribers created before the Pipedrive mirror existed |
pipedrive:reconcile |
Compare local rows against the live account |
--force is still refused for this model: NewsletterSubscriber remains in PipedriveSyncAllCommand::CREATE_ONLY_MODELS, so --force is loudly downgraded. Explicit phone backfills are a narrow exception to the historical create-only policy; they do not enable general replacement of CRM data.
ActiveCampaign Sync Behaviour
Section titled “ActiveCampaign Sync Behaviour”The controller dispatches SyncNewsletterSubscriberToActiveCampaignJob with the subscriber id onto the activecampaign-sync queue, alongside the Pipedrive job.
How the two mirrors differ
Section titled “How the two mirrors differ”Both are queued jobs with tries = 3 and a 30s/60s/180s backoff. What still differs is how each one dedupes and how a failure is recovered:
| Pipedrive | ActiveCampaign | |
|---|---|---|
| Queue | pipedrive-sync (env-configurable via PIPEDRIVE_QUEUE) |
activecampaign-sync (job constant, not env-configurable) |
| Dedupe | No upsert on POST /persons — the job searches by email first and links any hit |
POST /contact/sync upserts by email server-side |
| Failure tracking | A pipedrive_syncs row per subscriber (status, attempts, last error) surfaced in the admin monitor |
No tracking table — a null activecampaign_contact_id marks incomplete initial delivery; phone-only failures require queue/log inspection |
| Re-delivery | pipedrive:retry-failed / pipedrive:sync-all --model=newsletter_subscriber |
activecampaign:sync-subscribers — re-dispatches rows whose contact id is still null, up to --limit (see Backfill and re-delivery) |
| Scope | Create/link a Person; fill an empty phone without replacing existing data | Initial delivery upserts the contact, list membership and tag; phone-only delivery updates an empty phone without changing subscription state |
What the job does
Section titled “What the job does”- Loads the subscriber by id. If the row was deleted in the meantime, it logs at
infoand returns. - Checks
ActiveCampaignClient::isConfigured()— base URL and API token and list id. Environments without credentials (local, CI) log atinfoand return, so they never accumulate failed jobs. POST /contact/syncwith acontactwrapper holdingemail,firstName,lastName, andfieldValues(the consent custom fields). The form captures a singlename, split on the first space; marketing only personalises onfirstName, so compound first names are an accepted imprecision.POST /contactListswith acontactListwrapper holding the configuredlist, thecontactid, andstatus: 1(subscribed). List membership is what triggers marketing’s welcome automation.POST /contactTagswith the contact id and the resolved id of the market tag — skipped entirely when the row has no market.- Persists the returned
contact.idtonewsletter_subscribers.activecampaign_contact_id— only after every step succeeded, so a partial failure (contact created but list or tag rejected) leaves the idnulland the subscriber eligible for the backfill command.
When the subscriber has a phone, initial delivery also reads the resolved
contact and uses PUT /contacts/{id} to fill an empty phone. A pre-existing
CRM phone is never replaced. Phone-only delivery resolves the existing id
(or searches /contacts by exact email when the local id is missing), then
performs only that guarded phone update. It never creates a contact, writes
consent/name fields, adds tags or changes list membership, and it does not
mark an incomplete initial delivery as complete. This prevents a repeated
signup from reactivating someone who unsubscribed in ActiveCampaign.
Every endpoint takes its payload nested under a wrapper key (contact, contactList, contactTag), not flat.
All three calls are idempotent on the ActiveCampaign side — contact/sync upserts by email, and re-posting an existing list membership or tag association is a no-op — so retries and re-runs never create duplicate contacts, memberships, or tags.
Steady-state cost without a phone: 3 API calls per subscriber. A supplied phone adds a contact read and, only if empty, an update. The schema lookups behind the consent fields and the market tag are cached for a week, so they amortize to roughly one lookup per field/tag per week across all subscribers rather than per signup.
Consent custom fields
Section titled “Consent custom fields”The same three values stamped on the row and on the Pipedrive Person are mirrored to ActiveCampaign as contact custom fields, under the same labels:
| ActiveCampaign field title | AC field type | Value sent |
|---|---|---|
| Consent Given At | date |
consent_given_at as YYYY-MM-DD |
| Privacy Policy Version | text |
privacy_policy_version |
| Consent IP | text |
consent_ip (cast to string; empty when the IP was never captured) |
The titles live in a CONSENT_FIELDS constant on the job. They are titles, not ids — the ids are account-specific and resolved at runtime (see below).
Market tag
Section titled “Market tag”The subscriber’s market is applied to the contact as a plain contact tag named after the market code (es, de, us, uk, ca). One list plus a per-market tag is what lets marketing segment campaigns without provisioning five lists.
When market is null the contact is left untagged and no contactTags call is made. This is deliberate — there is no es fallback. A tag is an assertion about where the visitor came from; inventing one for an unknown market would quietly pollute a segment that marketing sends real campaigns to. An untagged contact is visibly “market unknown” instead.
In practice a footer signup always carries a market: PublicLayout.astro resolves it through normalizeMarket(), which falls back to DEFAULT_MARKET (es) for an unrecognised URL segment. So null rows come from non-footer callers, not from public-page traffic.
Rows that predate the column are not part of that untagged set: the migration that adds market also stamps every existing row es in the same up(), because the newsletter’s whole history was collected during the ES-only launch era. That runs at deploy time, so it is necessarily ordered before any backfill can dispatch — the historical import reaches ActiveCampaign correctly tagged. Only a post-deploy caller that omits market produces an untagged contact.
Schema resolution
Section titled “Schema resolution”ActiveCampaign’s API takes numeric ids for tags and custom fields, and those ids are account-specific — the trial account and production do not share them. ActiveCampaignSchemaResolver resolves name → id at runtime and creates the object when it is missing, so the same code runs against any account with no env vars and no manual provisioning:
| Method | Lookup | Create when missing |
|---|---|---|
tagId(name) |
GET /tags?filters[search][eq]={name} |
POST /tags with tagType: contact |
fieldId(title, type) |
GET /fields?limit=100 |
POST /fields with {type, title, visible: 1} |
Two details that matter:
- The tag lookup is a search filter, so ActiveCampaign can return near-matches. The resolver re-checks for an exact
tagmatch in PHP before accepting a hit, otherwise searching forescould bind to an unrelated tag that merely contains it. - The field lookup pages through the first 100 fields only. An account with more than 100 contact custom fields could miss an existing field and create a duplicate. Fine at three fields; revisit if the account grows one.
A 2xx response carrying no usable id raises the base ActiveCampaignException (non-retryable — the payload is structurally wrong).
Caching. Results go into the default Laravel cache store (database in deployed environments, array under phpunit.xml) for 7 days, under activecampaign:tag-id:{name} and activecampaign:field-id:{title}. Ids never change once created, so the TTL is not about freshness — it is a safety net: a tag or field deleted by hand in ActiveCampaign self-heals on the next re-resolution within a week, instead of failing every sync until someone clears the cache.
Job configuration: tries = 3, timeout = 120, backoff = [30, 60, 180], queue activecampaign-sync.
The queue name is a QUEUE_NAME constant on the job, not an env var. The worker --queue= lists in compose.yaml and queue-worker.sh are hardcoded, so an env override would route jobs to a queue no worker consumes.
Failure handling
Section titled “Failure handling”ActiveCampaignClient maps error responses onto typed exceptions so the job reasons about retryability instead of status codes:
| Response | Exception | Job behaviour |
|---|---|---|
| 429 | ActiveCampaignRateLimitException |
release()d with the API’s Retry-After delay (floored at 1s; 10s when the header is absent) instead of the $backoff schedule |
| 401 / 403 | ActiveCampaignAuthenticationException |
Not retryable — report() then fail() |
| 400 / 422 | ActiveCampaignValidationException |
Not retryable — report() then fail(). Carries ActiveCampaign’s errors array |
| 5xx | ActiveCampaignServerException |
Retryable — rethrown so the queue applies $backoff |
| Connection error / timeout | ActiveCampaignTimeoutException |
Retryable — rethrown |
| Any other non-2xx (404, 409, …) | ActiveCampaignException (base) |
Base class is not retryable — report() then fail() |
The job also throws the base ActiveCampaignException when contact/sync returns a 2xx with no usable contact.id, which lands on the same non-retryable path.
A non-retryable error means the payload is structurally wrong, so the job calls report() before fail(): fail() alone parks the job silently in failed_jobs, and this should surface on the exception channels (Sentry / Nightwatch) instead. When retries are exhausted, failed() logs at error with the exception class.
activecampaign_contact_id staying null is the marker that a subscriber never completed ActiveCampaign delivery (contact + list + tag) — whether the environment was unconfigured, the job failed outright, or it failed partway after the contact upsert. The id is written only once all steps succeed.
A failed phone-only update does not clear an existing contact id. Inspect queue failures and retry the original phone-only job; the backfill command below does not select already-linked subscribers, and replacing a phone-only retry with full delivery would replay list membership.
Backfill and re-delivery
Section titled “Backfill and re-delivery”activecampaign:sync-subscribers dispatches SyncNewsletterSubscriberToActiveCampaignJob for every subscriber whose activecampaign_contact_id is still null, lowest id first.
| Option | Default | Purpose |
|---|---|---|
--limit= |
500 |
Maximum jobs dispatched in one run. The reported total counts all pending rows regardless of the limit, so a capped run still shows how many are left |
--dry-run |
off | Reports the count and what it would dispatch, then exits without queueing anything |
One command covers both jobs: the one-off import of the pre-integration subscriber history at go-live, and later stragglers — a job that failed, or signups taken while the credentials were missing. In a healthy steady state it reports 0 subscribers.
Operator-invoked only. It is deliberately not scheduled — the ActiveCampaign mirror is expected to succeed on dispatch, and a recurring sweep would hide a systematic failure instead of surfacing it.
Jobs are queued with an incremental one-second delay (SPACING_SECONDS). Initial delivery usually makes three calls without a phone and up to five with one, excluding schema lookups. The spacing staggers availability rather than enforcing an account-wide rate limit; worker concurrency, other API traffic and retries affect actual throughput. A 500-job run schedules its last job about eight minutes after dispatch starts, but may take longer to drain.
The credential check is the last of three guards, in this order: --dry-run reports and exits 0; an empty pending set exits 0; only then does a failed ActiveCampaignClient::isConfigured() print an error and exit 1, dispatching nothing rather than queueing jobs that would each skip. So a machine with no credentials can still dry-run, and a run with nothing pending succeeds quietly there instead of failing.
The Pipedrive mirror’s equivalents remain pipedrive:sync-all --model=newsletter_subscriber and pipedrive:retry-failed --model=newsletter_subscriber (see Recovery).
Configuration
Section titled “Configuration”| Env var | Purpose |
|---|---|
ACTIVECAMPAIGN_API_URL |
Account root, e.g. https://<account>.api-us1.com. The client appends the /api/3 prefix itself — don’t include it |
ACTIVECAMPAIGN_API_TOKEN |
Sent as the Api-Token header. Nothing logs it in the clear — the client’s failure log lines carry the masked form from ActiveCampaignAuthService::getSanitizedHeaders() |
ACTIVECAMPAIGN_LIST_ID |
Numeric id of the list subscribers are added to |
All three come from ActiveCampaign → Settings → Developer. These are the only ActiveCampaign env vars. Tags and custom fields are resolved and created by name at runtime, so there is nothing else to configure and no provisioning step before pointing the integration at a fresh account.
Config file: backend/config/activecampaign.php, which also pins the HTTP timeout to 30s. It deliberately exposes no retry or queue-name knob: retries belong to the queued job (which reacts to the error type), and an inner HTTP retry would hammer an already rate-limited API.
ActiveCampaignClient exposes only get() (schema lookups) and post() (everything else); both go through one request() path so the typed-exception mapping is identical for either verb.
phpunit.xml pins ACTIVECAMPAIGN_API_TOKEN to an empty string, mirroring PIPEDRIVE_API_TOKEN, so a developer’s real credentials can never leak into a test run. With no token, isConfigured() is false and the job quietly skips.
Related: Queue System — ActiveCampaign Sync Queue
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 | |
phone |
string(50) nullable | Optional phone; repeated signup may fill null but cannot replace a stored number |
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 | Written by SyncNewsletterSubscriberToPipedriveJob on success. null = never mirrored (unconfigured environment, job still queued, or it failed) — check the pipedrive_syncs row for the reason |
activecampaign_contact_id |
unsignedBigInteger nullable | ActiveCampaign contact id. null = never mirrored (unconfigured environment, or the job failed) |
market |
string(5) nullable | Market the subscriber signed up from (es, de, us, uk, ca), from the page URL. Every row that predates the column was stamped es by the migration that adds it (ES-only launch era), so null now only arises from a post-deploy caller that sends no market. null means unknown, not es — such contacts reach ActiveCampaign with no market tag |
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. Their Pipedrive delivery state is visible in Arkana, though: the Pipedrive Sync Monitor lists NewsletterSubscriber rows alongside every other synced model.
Frontend Integration
Section titled “Frontend Integration”The footer newsletter form (FooterNewsletterModule.react.tsx) and NewsletterPopup share subscribeToNewsletter(name, email, consent, apiUrl, market?, phone?) from frontend/src/services/pipedrive.ts. It POSTs to /api/newsletter/subscribe using the apiUrl resolved from the API_URL server env var. The popup supplies the optional phone; the footer continues to send only name, email, consent and market. Blank phones are omitted from the JSON body. CRM tokens never reach the browser.
PublicLayout.astro mounts the popup only on eligible public pages. Its
timing and suppression rules share a
browser preference with the footer: any successful subscription hides the
popup on the current page and suppresses future appearances in that browser.
Only preference flags/timestamps are stored, never the submitted details.
How the market reaches the payload
Section titled “How the market reaches the payload”URL segment → PublicLayout.astro (normalizeMarket) → Footer.astro market={market} → FooterNewsletterModule (React island) → subscribeToNewsletter(..., market) → POST body { name, email, consent, market }market is optional at every hop in the types, and the service only adds the key to the JSON body when it has a truthy value — so an omitted market is a body without the field, never market: null. At runtime, though, PublicLayout always supplies one (normalizeMarket defaults to es), so public-page signups are never marketless.
The other caller, features/teaser/components/TeaserPage.tsx, passes nothing (its routes are 301-redirected now), and any subscriber it produces keeps market = null.
The market is a plain string on FooterNewsletterModuleProps, not the Market union: the value has already been normalized upstream, and the backend re-validates it against its own hardcoded list anyway.
The service maps outcomes onto distinct user-facing messages (all hardcoded Spanish except the 422 case):
| Outcome | Message |
|---|---|
consent argument falsy |
“Debes aceptar la política de privacidad para continuar.” — returned before any fetch |
2xx |
The API’s own message, falling back to “¡Gracias por suscribirte!” |
422 |
The first entry in the response’s errors — i.e. Laravel’s message, not a frontend string — falling back to “Por favor, revisa los datos del formulario.” |
429 |
“Demasiadas solicitudes. Por favor, espera un momento.” |
| Any other non-2xx | “Ha ocurrido un error. Por favor, inténtalo de nuevo.” |
Timeout (AbortError) |
“La solicitud tardó demasiado. Por favor, inténtalo de nuevo.” |
| Network error | “Error de conexión. Por favor, inténtalo de nuevo.” |
Timeout and network failure are separate messages. The request is aborted after REQUEST_TIMEOUT_MS (10s) via AbortController.
Both 200 and 201 are treated as success. The footer shows the API-provided message; the popup closes and shows its five-second “Gracias por suscribirte. / Pronto recibirás nuestras recomendaciones.” toast. Existing subscribers therefore also follow the popup’s success/suppression path.
Because the 422 message comes straight from Laravel, the new DNS rule surfaces to the user as the framework’s own English string, “The email field must be a valid email address.” APP_LOCALE is en and lang/*/validation.php only defines app-specific namespaced keys (no override for the standard email rule), so a Spanish-speaking visitor who mistypes their domain sees English copy. Pre-existing behaviour for every validation error on this form, not something the DNS rule introduced — but the DNS rule makes it easier to hit.
Source Files
Section titled “Source Files”| Component | File |
|---|---|
| Model | backend/app/Models/NewsletterSubscriber.php |
| Controller | backend/app/Http/Controllers/Api/NewsletterSubscriptionController.php (persists the row, dispatches both CRM jobs, returns — no CRM HTTP) |
| Form Request | backend/app/Http/Requests/Api/StoreNewsletterSubscriptionRequest.php |
| Factory | backend/database/factories/NewsletterSubscriberFactory.php (state: syncedToActiveCampaign()) |
| Migration | backend/database/migrations/2026_05_11_065421_create_newsletter_subscribers_table.php |
| ActiveCampaign Columns Migration | backend/database/migrations/2026_08_28_100116_add_activecampaign_columns_to_newsletter_subscribers_table.php (adds activecampaign_contact_id + market, and stamps every pre-existing row market = es) |
| Phone Migration | backend/database/migrations/2026_09_07_144751_add_phone_to_newsletter_subscribers_table.php |
| ActiveCampaign Sync Job | backend/app/Jobs/ActiveCampaign/SyncNewsletterSubscriberToActiveCampaignJob.php |
| ActiveCampaign Backfill Command | backend/app/Console/Commands/ActiveCampaignSyncSubscribersCommand.php (activecampaign:sync-subscribers) |
| ActiveCampaign HTTP Client | backend/app/Services/ActiveCampaign/ActiveCampaignClient.php |
| ActiveCampaign Schema Resolver | backend/app/Services/ActiveCampaign/ActiveCampaignSchemaResolver.php (tag/field name → id, auto-create, 7-day cache) |
| ActiveCampaign Auth Service | backend/app/Services/ActiveCampaign/ActiveCampaignAuthService.php |
| ActiveCampaign Exceptions | backend/app/Exceptions/Services/ActiveCampaign/ |
| ActiveCampaign Config | backend/config/activecampaign.php |
| Pipedrive Sync Job | backend/app/Jobs/Pipedrive/SyncNewsletterSubscriberToPipedriveJob.php |
| Pipedrive Sync Registry | backend/app/Services/Pipedrive/PipedriveSyncRegistry.php (key newsletter_subscriber) |
| 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 |
| Frontend Popup | frontend/src/components/NewsletterPopup/ |
| Browser Preferences | frontend/src/services/newsletterPreferences.ts |
| Frontend Market Wiring | frontend/src/components/Footer/Footer.astro, Footer.types.ts |
| Endpoint Tests | backend/tests/Feature/Api/NewsletterSubscriptionControllerTest.php |
| ActiveCampaign Job Tests | backend/tests/Feature/Jobs/ActiveCampaign/SyncNewsletterSubscriberToActiveCampaignJobTest.php |
| ActiveCampaign Backfill Command Tests | backend/tests/Feature/Console/Commands/ActiveCampaignSyncSubscribersCommandTest.php |
| Pipedrive Job Tests | backend/tests/Feature/Jobs/Pipedrive/SyncNewsletterSubscriberToPipedriveJobTest.php |
| Client Tests | backend/tests/Feature/Services/ActiveCampaign/ActiveCampaignClientTest.php |
| Schema Resolver Tests | backend/tests/Feature/Services/ActiveCampaign/ActiveCampaignSchemaResolverTest.php |