DEV Community

Luca Siviero
Luca Siviero

Posted on

Syncing Webround and Brevo newsletter subscribers on Cloudflare Workers

When a client migrated their store to Webround, they already had an active Brevo account with about 3,000 newsletter contacts built up over multiple years.

However, their original integration had stopped working and had started accumulating a significant discrepancy between the contacts in their e-commerce and those registered on Brevo.

The requirement was straightforward: keep Webround and Brevo in sync without losing the existing list, add all the contacts accumulated over time that hadn't been synced, and make sure that an unsubscribe on either side reflects on the other.

This meant three things: a one-time bulk import of existing Webround subscribers into Brevo, a webhook from Webround to Brevo for new subscribes and unsubscribes, and a webhook from Brevo back to Webround for unsubscribes and contact deletions.

All of it runs on a single Cloudflare Worker.

The bulk import

Before setting up the real-time sync, the existing list needed to be aligned. Webround exposes a paginated /store-customers endpoint with an includeNewsletter=true filter, so the import script fetches all subscribers page by page and pushes them to Brevo in batches of 150, which stays within Brevo's import API limits.

async function fetchAllNewsletterContacts() {
    const contacts = [];
    let page = 1;
    let totalPages = 1;

    while (page <= totalPages) {
        const res = await fetch(
            `${WR_API_URL}/store-customers?page=${page}&limit=100&includeNewsletter=true&includeDeleted=false`,
            { headers: wrHeaders }
        );
        const data = await res.json();
        totalPages = data.pagination.totalPages;

        for (const c of data.data) {
            if (c.email) contacts.push({ email: c.email, displayName: c.displayName });
        }
        page++;
    }

    return contacts;
}

async function importToBrevo(contacts) {
    const BATCH = 150;

    for (let i = 0; i < contacts.length; i += BATCH) {
        const batch = contacts.slice(i, i + BATCH);

        await fetch('https://api.brevo.com/v3/contacts/import', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', 'api-key': BREVO_API_KEY },
            body: JSON.stringify({
                listIds: [BREVO_LIST_ID],
                updateExistingContacts: true,
                jsonBody: batch.map(c => ({
                    email: c.email,
                    attributes: { FIRSTNAME: c.displayName },
                })),
            }),
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

The updateExistingContacts: true flag handles deduplication natively: if a contact already exists on Brevo, Brevo updates it instead of creating a duplicate. No need to diff the lists manually.

The real-time sync

To avoid the original problem of having two out-of-sync contact lists, webhooks are essential. They let us listen to events instead of running periodic polling against both platforms.

The Worker listens on two endpoints: /webhook/wr for Webround events and /webhook/brevo for Brevo events.

Webround -> Brevo

Webround fires store.customer.newsletter.subscribed and store.customer.newsletter.unsubscribed events. The Worker validates the HMAC signature with a timestamp check to reject stale requests, then calls the Brevo import API to add the contact or the list remove endpoint to remove it. This way, all users who actively subscribe or unsubscribe on the storefront are kept in sync with Brevo.

Webround signs every webhook payload with HMAC SHA-256 using a shared secret, combined with a timestamp to prevent replay attacks:

export async function verifyWebroundWebhook(
    request: Request,
    secret: string
): Promise<{ valid: boolean; body: string }> {
    const signature = request.headers.get("X-Webhook-Signature");
    const timestamp = request.headers.get("X-Webhook-Timestamp");

    const ts = Number(timestamp);
    if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 30_000) {
        return { valid: false, body: "" };
    }

    const body = await request.text();
    const key = await crypto.subtle.importKey(
        "raw",
        new TextEncoder().encode(secret),
        { name: "HMAC", hash: "SHA-256" },
        false,
        ["sign"]
    );
    const mac = await crypto.subtle.sign(
        "HMAC",
        key,
        new TextEncoder().encode(`${timestamp}.${body}`)
    );
    const expected = [...new Uint8Array(mac)]
        .map(b => b.toString(16).padStart(2, "0"))
        .join("");

    let diff = 0;
    for (let i = 0; i < expected.length; i++) {
        diff |= expected.charCodeAt(i) ^ signature!.charCodeAt(i);
    }

    return { valid: diff === 0, body };
}
Enter fullscreen mode Exit fullscreen mode

The comparison uses a constant-time loop to avoid timing attacks.

Brevo -> Webround

Brevo doesn't sign payloads with HMAC. Instead, it sends webhooks from a fixed set of IP ranges and supports an optional shared secret header. The Worker validates both:

const BREVO_WEBHOOK_CIDRS = [
    { base: ip4ToInt("1.179.112.0"), mask: 0xFFFFF000 },
    { base: ip4ToInt("172.246.240.0"), mask: 0xFFFFF000 },
];

export function validateBrevoWebhook(request: Request, env: Env): boolean {
    const ip = request.headers.get("CF-Connecting-IP") ?? "";
    const secret = request.headers.get("x-brevo-secret") ?? "";
    return isBrevoIp(ip) && secret === env.BREVO_WEBHOOK_SECRET;
}
Enter fullscreen mode Exit fullscreen mode

When Brevo fires an unsubscribe or contactDeleted event, the Worker looks up the customer on Webround by email and sets their newsletter field to false, so anyone who unsubscribes directly from an email link gets updated on Webround too:

export async function handleBrevoWebhook(
    body: { event?: string; email?: string },
    env: Env
): Promise<Response> {
    const { event, email } = body;
    if (!email) return new Response("Missing email", { status: 400 });

    switch (event) {
        case "unsubscribe":
        case "contactDeleted":
            return setNewsletterOnWebround(email, false, env);
        default:
            return new Response("ok", { status: 200 });
    }
}
Enter fullscreen mode Exit fullscreen mode

The full Worker routing

export default {
    async fetch(request: Request, env: Env): Promise<Response> {
        const url = new URL(request.url);
        const path = url.pathname.includes("/apps/")
            ? "/" + url.pathname.split("/").slice(4).join("/")
            : url.pathname;

        if (path === "/webhook/wr" && request.method === "POST") {
            const { valid, body } = await verifyWebroundWebhook(request, env.WR_WEBHOOK_SECRET);
            if (!valid) return new Response("Unauthorized", { status: 401 });

            const envelope = JSON.parse(body) as {
                eventType: string;
                data: { email: string; displayName?: string };
            };

            if (envelope.eventType === "store.customer.newsletter.subscribed") {
                await addContacts(env, [{ email: envelope.data.email, displayName: envelope.data.displayName ?? envelope.data.email }]);
            }
            if (envelope.eventType === "store.customer.newsletter.unsubscribed") {
                await removeContacts(env, [envelope.data.email]);
            }

            return new Response("ok", { status: 200 });
        }

        if (path === "/webhook/brevo" && request.method === "POST") {
            if (!validateBrevoWebhook(request, env)) {
                return new Response("Unauthorized", { status: 401 });
            }
            const body = await request.json() as { event?: string; email?: string };
            return handleBrevoWebhook(body, env);
        }

        return new Response("Not found", { status: 404 });
    },
};
Enter fullscreen mode Exit fullscreen mode

The path normalization at the top handles Webround App Extensions, which prefix the URL with /apps/{id}/... when the Worker is registered as an extension rather than called directly. In a standard Cloudflare Workers deployment, the URL format is different, but it's handled by the same normalization.

What this covers

A customer subscribes on the storefront: the Worker adds them to the Brevo list in real time. A customer unsubscribes from a Brevo email: the Worker sets their newsletter flag to false on Webround. A contact is deleted on Brevo: same result. The two systems stay consistent without any manual intervention or periodic batch jobs.

The full source is on GitHub: https://github.com/WebroundAdmin/wr-brevo-integration

Built on Webround, an API-first e-commerce platform: webround.com

Top comments (0)