# 14-Jun-2026 — Brevo Member Sync: How the Information Flow Changes [#168](https://github.com/nbarrett/ngx-ramblers/issues/168)

_____

This article describes how member and consent information moves between NGX Ramblers, Brevo and Salesforce, and how that flow changes with the Brevo SDK v5 upgrade and the move to incremental member synchronisation. It is a companion to the email architecture article and focuses on one question: when a member changes, what travels where, and when.

## Why this changed

Three forces drove the change:

1. **Brevo retires the Bulk Contact Update endpoint on 30 October 2026.** The member-to-Brevo list sync relied on that endpoint, so it had to move to the Contact Import API.
2. **The sync was brute-force.** Before sending any email, the platform pulled the entire Brevo contact list and reconciled it from scratch, on every send, whether or not anything had changed.
3. **Consent did not reach Brevo.** A member whose marketing consent was withdrawn was excluded from NGX-composed sends, but stayed on the Brevo lists, so the two systems could disagree.

## At a glance

| Aspect | Before | After |
|--------|--------|-------|
| Brevo SDK | `@getbrevo/brevo` v2.0.0-beta.4 (pre-release, deprecated `request` library) | v5.0.4 (single `BrevoClient`, native fetch, typed errors) |
| Bulk list update | `updateBatchContacts` (`POST /v3/contacts/batch`, retiring 30 Oct 2026) | Contact Import API (`importContacts`) |
| Pre-send sync | Full contact-list pull and reconcile on every send | No-op when nothing changed since the last sync |
| Member edits | Reach Brevo only on the next send | Pushed to Brevo immediately on save |
| Consent withdrawal | Excluded from NGX sends only; member stayed on Brevo lists | Removes the member from Brevo lists as well |
| Salesforce consent | Stored locally, never propagated to Brevo | Withdrawal propagates through to Brevo list removal |

## The information flow before

Member changes only reached Brevo when someone sent an email. At that point the platform read the whole Brevo contact list, compared it against the membership, and pushed the differences back using the bulk update endpoint. Consent lived on the member record but only affected who NGX chose to email; it never changed Brevo list membership.

```mermaid
flowchart LR
    ADMIN@{ icon: "ngx:user", label: "Group admin", pos: "b", h: 48 }
    MEMBERS@{ icon: "ngx:mongodb", label: "Member records", pos: "b", h: 48 }
    ADMIN -->|"edit member"| MEMBERS

    subgraph ngx["NGX Ramblers"]
        SEND["Send an email"]
        RECON["Full reconcile<br/>pull ALL contacts, diff, push"]
        SEND --> RECON
    end

    MEMBERS -.->|"only at next send"| SEND
    RECON -->|"updateBatchContacts<br/>POST /v3/contacts/batch"| BREVO@{ icon: "ngx:brevo", label: "Brevo lists", pos: "b", h: 48 }
    RECON -->|"reads entire list every time"| BREVO

    CONSENT["Consent withheld"]
    MEMBERS --- CONSENT
    CONSENT -.->|"excluded from NGX send only"| SEND

    style ngx fill:#FBE9E7,stroke:#E0A89B,stroke-width:2px,rx:12,ry:12,color:#404143
```

## The information flow after

Each member now carries a small **signature** of exactly the fields that go to Brevo (email, names, member attributes, the lists they are eligible for, and whether marketing consent is withheld). That signature is the pivot for the whole flow:

- **On save**, an admin edit to a member or a member's own change to their contact preferences pushes that one member to Brevo straight away, using single-contact operations rather than a bulk pass.
- **Before a send**, the platform checks whether any member in the audience differs from its stored signature. If none do, it does nothing and contacts Brevo not at all.
- **Bulk and seed** operations (the first full load, and the periodic safety net) use the Contact Import API instead of the retired bulk endpoint.
- **Consent** is now part of eligibility: a member whose relevant consent is withheld is eligible for no Brevo lists, so the same machinery that adds and updates contacts also removes them.

```mermaid
flowchart LR
    ADMIN@{ icon: "ngx:user", label: "Group admin / member", pos: "b", h: 48 }
    MEMBERS@{ icon: "ngx:mongodb", label: "Member records<br/>+ sync signature", pos: "b", h: 48 }
    ADMIN -->|"edit member / preferences"| MEMBERS

    subgraph ngx["NGX Ramblers"]
        RT["Real-time push<br/>changed member only"]
        CHECK["Pre-send check<br/>signature dirty?"]
        SEED["Seed / periodic reconcile"]
    end

    MEMBERS -->|"on save"| RT
    MEMBERS -->|"on send"| CHECK
    CHECK -->|"nothing changed"| NOOP["No Brevo call"]
    CHECK -->|"changed members only"| RT

    RT -->|"create / update / add / remove"| BREVO@{ icon: "ngx:brevo", label: "Brevo lists", pos: "b", h: 48 }
    SEED -->|"importContacts<br/>Contact Import API"| BREVO

    CONSENT["Consent withheld<br/>Head Office or group"]
    MEMBERS --- CONSENT
    CONSENT -->|"remove from lists"| RT

    style ngx fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
```

## The three directions of consent and membership

It helps to separate the directions information now travels.

**Outbound — NGX to Brevo (membership and attributes).** Member edits flow to Brevo on save and, as a backstop, at send time and via the periodic reconcile. New and changed members are created or updated; members who become ineligible (unsubscribed or consent withheld) are removed.

**Inbound — Brevo to NGX, then NGX to Salesforce (unsubscribes).** When a contact unsubscribes or is blocked at Brevo, that comes back through the Brevo webhook and the scheduled blocklist sync, which records the block on the member and flips their subscriptions. If that takes the member to a full opt-out, NGX writes the consent change back to Salesforce. This direction is unchanged by this work, but it now sits alongside the outbound consent path below.

**New — Salesforce to Brevo (consent).** Previously a consent withdrawal arriving from Salesforce changed only the local member record. Now, because consent is part of Brevo eligibility, that withdrawal removes the member from their Brevo lists on the next sync.

```mermaid
flowchart TB
    SF@{ icon: "ngx:ramblers-hq", label: "Salesforce / Central Office", pos: "b", h: 48 }
    MEMBERS@{ icon: "ngx:mongodb", label: "NGX member records", pos: "b", h: 48 }
    BREVO@{ icon: "ngx:brevo", label: "Brevo", pos: "b", h: 48 }
    MEM@{ icon: "ngx:user", label: "Member inbox", pos: "b", h: 48 }

    SF -->|"member + consent import"| MEMBERS
    MEMBERS -->|"full opt-out writeback"| SF
    MEMBERS -->|"membership, attributes and consent<br/>on save / send / reconcile"| BREVO
    BREVO -->|"unsubscribe / block webhook"| MEMBERS
    BREVO -->|"newsletters and notifications"| MEM
```

## How consent is interpreted

Consent suppression mirrors the rule already used at send time, so Brevo membership and the recipient list now agree.

| Mode | Setting | Gate that removes a member from Brevo |
|------|---------|----------------------------------------|
| Insight Hub parity (default) | granular consent off | Head Office consent: `emailMarketingConsent` is `false` and `respectHeadOfficeConsent` is on |
| Granular consent | granular consent on | Group-level consent: `groupMarketingConsent` is `false` |

Head Office consent represents consent to receive Central Office marketing, which is not the same as consent to hear from a member's own group. That is why some groups turn `respectHeadOfficeConsent` off, and why, once granular consent is available, group-level consent takes over as the gate. The area and other granular flags are not used for a group's own site. The decision is driven by the member's own consent fields, so it does not depend on a member-facing page reading the administrator-only Salesforce configuration.

## What stays the same

- The full reconcile still exists. It is now the initial seed and the periodic backstop rather than the everyday mechanism, and it catches anything missed by the real-time path or changed directly inside Brevo.
- Proactive rate limiting is retained. The platform still paces calls to Brevo rather than relying only on the SDK's built-in retry.
- The inbound unsubscribe path and the Salesforce consent writeback on full opt-out are unchanged.

## Operational notes

- The first send after this ships still does a full reconcile, because no member has a stored signature yet. It is quiet from then on.
- A brand-new member created through the real-time path does not capture a Brevo contact id back from the import, so a later list removal for that member waits for the next full reconcile to record the id. In normal operation the reconcile fills this in.
- Salesforce-sourced changes reach Brevo through the reconcile rather than the real-time path, since a Salesforce import is a batch operation rather than an admin edit.
