Skip to content

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.

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.

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)

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.invalid and similar typos now 422 instead of being stored and then bouncing in ActiveCampaign.
  • example.com no longer validates. It publishes a Null MX record (RFC 7505, “this domain accepts no mail”), so every @example.com address fails. Tests and manual QA use @byvolare.com.
  • The rule is scoped to this endpoint only. Leads and magic-link auth keep the plain email rule — 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.

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: wasRecentlyCreated is true. The controller dispatches both CRM jobs — SyncNewsletterSubscriberToActiveCampaignJob and SyncNewsletterSubscriberToPipedriveJob — and returns 201 without waiting for either.
  • Repeat submit (same email), no new phone: returns 200 with 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 still 200.

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.

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:

  1. Subscriber gone → log at info and return.
  2. pipedrive.api_token blank → log at info and return. This job is dispatched directly from the controller, not by PipedriveSyncObserver, so the observer’s token guard never runs; the guard lives on the job instead. Local and CI environments therefore never accumulate failed jobs.
  3. getOrCreatePipedriveSync() — the pipedrive_syncs tracking row for this subscriber.
  4. Status Ignored → return, no resurrection.
  5. pipedrive_id already set and no local phone → re-stamp Synced and return.
  6. 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_subscribers row 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.

A new Pipedrive Person is created with:

name: NewsletterSubscriber.name
email: [{ 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.

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.

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.

The controller dispatches SyncNewsletterSubscriberToActiveCampaignJob with the subscriber id onto the activecampaign-sync queue, alongside the Pipedrive job.

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
  1. Loads the subscriber by id. If the row was deleted in the meantime, it logs at info and returns.
  2. Checks ActiveCampaignClient::isConfigured() — base URL and API token and list id. Environments without credentials (local, CI) log at info and return, so they never accumulate failed jobs.
  3. POST /contact/sync with a contact wrapper holding email, firstName, lastName, and fieldValues (the consent custom fields). The form captures a single name, split on the first space; marketing only personalises on firstName, so compound first names are an accepted imprecision.
  4. POST /contactLists with a contactList wrapper holding the configured list, the contact id, and status: 1 (subscribed). List membership is what triggers marketing’s welcome automation.
  5. POST /contactTags with the contact id and the resolved id of the market tagskipped entirely when the row has no market.
  6. Persists the returned contact.id to newsletter_subscribers.activecampaign_contact_idonly after every step succeeded, so a partial failure (contact created but list or tag rejected) leaves the id null and 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.

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).

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.

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 tag match in PHP before accepting a hit, otherwise searching for es could 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.

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.

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).

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

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
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.

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.

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.

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