<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Luca Siviero</title>
    <description>The latest articles on DEV Community by Luca Siviero (@luca_at_webround).</description>
    <link>https://dev.to/luca_at_webround</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4018102%2Fffb344d5-78fc-4f27-9752-e3b22f1ca9d5.png</url>
      <title>DEV Community: Luca Siviero</title>
      <link>https://dev.to/luca_at_webround</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/luca_at_webround"/>
    <language>en</language>
    <item>
      <title>I built an AI business assistant that knows your Webround store.</title>
      <dc:creator>Luca Siviero</dc:creator>
      <pubDate>Sun, 06 Sep 2026 11:19:52 +0000</pubDate>
      <link>https://dev.to/luca_at_webround/i-built-an-ai-business-assistant-that-knows-your-webround-store-33i9</link>
      <guid>https://dev.to/luca_at_webround/i-built-an-ai-business-assistant-that-knows-your-webround-store-33i9</guid>
      <description>&lt;h1&gt;
  
  
  &lt;strong&gt;Webround Agent: an AI business assistant that already knows your store&lt;/strong&gt;
&lt;/h1&gt;

&lt;p&gt;When running an e-commerce store, you always end up asking the same questions: what was my best month, season, or year so far? Which product category is worth investing in more? What's the right strategy for my ad campaigns, and how much budget should I allocate? Which products show up most often in abandoned carts?&lt;/p&gt;

&lt;p&gt;These questions seem simple, but the answers behind them are worth a lot to any merchant.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What usually happens&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;On traditional platforms, you're stuck with preset, limited dashboards that give you a general overview but make it hard to dig into the details. Knowing exactly which category the products abandoned most often in carts belong to is a crucial piece of information, but if the platform doesn't surface it, getting a quick answer is nearly impossible.&lt;/p&gt;

&lt;p&gt;That's why business intelligence tools exist: to query that data. But they come with two common problems: &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You need to rely on an external service that increases your costs.&lt;/li&gt;
&lt;li&gt;You need to export your data in a compatible format. &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Whether it's a lack of skills to do a proper export or a desire to avoid paying for external consultants, merchants often end up relying on tools that only see part of the picture, compromising both the quality and quantity of answers they can get.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The problem grows with the store&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;When an e-commerce store has a short history or only a handful of products, keeping track of everything is relatively simple: you have a precise overview because there isn't much data, and there's no need to dig into details because there's no real pressure to.&lt;/p&gt;

&lt;p&gt;But when a store grows into something large, with thousands of products, years of history, hundreds of categories and subcategories, and thousands of euros in ad budget, the need to understand what's happening across the parts of the catalog you don't monitor every day becomes increasingly urgent. At that point, a data analyst stops being a luxury and starts being a necessity.&lt;/p&gt;

&lt;p&gt;So the question becomes obvious: what if your data analyst lived inside your e-commerce platform?&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Webround Agent&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Webround Agent approaches the problem of data analysis differently. It's an AI business assistant that lives directly inside Webround Commerce, with full knowledge of your store's data: catalog, sales, stock, categories, carts and more. It answers questions in plain language, with no technical knowledge required, no configuration needed, no external tools to integrate, and no need to know how to query a database.&lt;/p&gt;

&lt;p&gt;It handles any question of a commercial nature: which products are performing best this month, which products have high stock but low sales, how sales are distributed across price ranges and categories, or how results compare to the previous month.&lt;/p&gt;

&lt;p&gt;These are the questions every merchant asks constantly, and with Webround there's now a tool that answers them in seconds instead of making you spend hours navigating panels, filters, and exports.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;How it works&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;At the moment, the agent is "just" a data analyst: it runs SQL queries against your data to surface the information it needs, but it doesn't publish anything, doesn't modify data, and doesn't manage your store. It's an assistant, not an automator, &lt;strong&gt;for now.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It doesn't have access to your live database or catalog: it works against a remote copy that syncs every hour. This means all data older than one hour is available and is completely read-only. The model never touches Webround's core infrastructure; it only accesses a copy of that specific merchant's data.&lt;/p&gt;

&lt;p&gt;This might look like a limitation, but it's actually a strength: this is still an LLM, subject to hallucinations and prompt engineering. Even if it somehow decided to wipe an entire database, it would cause no damage at all, at least until the next sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Costs and credits&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Webround Agent is built on a latest-generation open-source model with low costs and strong performance.&lt;/p&gt;

&lt;p&gt;Usage is billed through a credit system with a small minimum per request, but every account starts with 2,000 free credits, enough for hundreds of questions.&lt;/p&gt;

&lt;p&gt;As the system evolves, credits remain the central mechanism for additional usage. At the moment, there's no definitive plan and no way to purchase credits independently, but the price is around €0.50 per 1,000 credits, and consumption depends on the model used. Right now, a heavy request involving dozens of SQL queries costs no more than around thirty credits.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;How to access it&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Webround Agent is available directly inside Webround Commerce, on any plan, with no additional setup. On the very first request, the system may say there's no data available: don't worry, it's just the initial sync cycle running before your data becomes available for the first time. The conversation is already contextualized on your store, so there's nothing fundamental to explain and no service to connect.&lt;/p&gt;

&lt;p&gt;That said, it's good practice to provide some context: for example, explaining how many ad campaigns are currently running, what the goals are for the next measurable period, the metrics you care about most, and some details about suppliers, costs, and margins.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Closing thoughts&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Anyone who knows Webround knows I removed the AI builder from the editor: it was a tool with genuine potential that couldn't deliver reliability, and whose use case was easily covered by external tools like ChatGPT or Claude, which we all use anyway.&lt;/p&gt;

&lt;p&gt;Webround Agent is something entirely different because it doesn't overlap with the store's logic and doesn't get between the merchant and the platform. It uses data that already exists to answer questions a store owner would ask regardless, just faster and with more precision.&lt;/p&gt;

&lt;p&gt;Want to try it? Sign up at &lt;a href="https://webround.com/en" rel="noopener noreferrer"&gt;&lt;strong&gt;webround.com&lt;/strong&gt;&lt;/a&gt;, create a draft, and enable a store: you'll have 2,000 credits ready from day one.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>ecommerce</category>
      <category>analytics</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I built an AI website builder for Webround. Then I killed it.</title>
      <dc:creator>Luca Siviero</dc:creator>
      <pubDate>Sun, 06 Sep 2026 11:05:51 +0000</pubDate>
      <link>https://dev.to/luca_at_webround/i-built-an-ai-website-builder-for-webround-then-i-killed-it-4na</link>
      <guid>https://dev.to/luca_at_webround/i-built-an-ai-website-builder-for-webround-then-i-killed-it-4na</guid>
      <description>&lt;h1&gt;
  
  
  &lt;strong&gt;I built an AI website builder for Webround. Then I killed it.&lt;/strong&gt;
&lt;/h1&gt;

&lt;p&gt;Since the early days of building Webround, I always imagined that at some point I would integrate an AI assistant capable of building an entire site from scratch, from a single prompt. Many companies today run marketing specifically around this kind of natively integrated feature, so the desire to introduce something similar in Webround kept growing.&lt;/p&gt;

&lt;p&gt;I took advantage of the quieter summer weeks to build an AI builder that, in the end, I didn't release and decided to put on hold.&lt;/p&gt;

&lt;p&gt;There was no unsolvable problem behind this decision. But, as I used the builder, some fundamental issues kept surfacing that made me question the entire integration. Webround is a headless platform with public APIs and a full React environment, so anyone can open Claude or ChatGPT, describe what they want, and get working code without me adding anything. &lt;/p&gt;

&lt;p&gt;And since the integration was built on the same mainstream LLMs everyone already uses, the question became obvious: why use a proprietary integration that costs more when you can just use your own chat?&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;How it worked&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Webround supports both a visual no-code editor for people who do not write code and a full React environment with Tailwind v4 and NPM support.&lt;/p&gt;

&lt;p&gt;The idea behind the builder was simple but ambitious: build an AI service that analyzed the user's request and autonomously decided what to do, how to combine no-code and React, and how to use the &lt;code&gt;wr&lt;/code&gt; prop of React components in Webround.&lt;/p&gt;

&lt;p&gt;Worried about building an integration with a massive context, I decided to split the planning phase from the execution phase. &lt;/p&gt;

&lt;p&gt;A first prompt collected all the data and requirements (copy, structure, color palette, what to change, what to add) and generated a multi-step flow to execute. &lt;/p&gt;

&lt;p&gt;A second prompt received each step separately with the operations clearly defined. With strong typing and the right context files, the AI knew exactly what to do to apply each change in the Webround editor.&lt;/p&gt;

&lt;p&gt;This approach gave me, in theory, two advantages: the user approves changes before the AI starts working, and each step was isolated and bound to a single prompt at a time, so the AI never ended up with a massive context trying to do everything at once. The results, though, were not what I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;No-code generation with AI produces mediocre results&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Webround's no-code components are simple, typed, rigid structures. An image component takes a URL, an alt text, and some style properties. In practice, a component is generic React code that knows how to interpret the JSON tied to its configuration. &lt;/p&gt;

&lt;p&gt;While it is nothing extremely complex, it still is not free-form HTML generation: the model has to produce output that respects the structure, otherwise every error translates into a missing style or a component that fails to load.&lt;/p&gt;

&lt;p&gt;Through the UI this never happens because the user is constrained to editing interfaces that prevent mistakes: you cannot insert a number where a string is expected because the interface simply does not let you do so.&lt;/p&gt;

&lt;p&gt;To make the AI understand all of this, you have to inject the entire component type system into the context, otherwise the model would just guess. In most cases the AI did not produce invalid output, and it was not a major problem when it did because I had built a validation system that discarded the output if the model did not respect the expected JSON schema.&lt;/p&gt;

&lt;p&gt;The real problem was the visual quality of what got built. It was rarely acceptable because I kept ending up with layouts that made little sense, missed multi-device configurations, and styles that were visually ineffective. It was as if the AI was dropping pieces along the way.&lt;/p&gt;

&lt;p&gt;So I started relying more heavily on generating sections entirely in custom React code.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;A completely different story with React&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;When I told the AI to write React code, I realized how capable current models actually are at frontend development. Some of the output genuinely surprised me: beautiful landing pages, balanced colors, fonts, and copy coherent with the site's goal. We all know there is a difference between generating a nice landing page and building a solid frontend, but it was clear that site generation with React code worked well.&lt;/p&gt;

&lt;p&gt;It is a task the AI has been trained on extensively, and I got exactly what I expected: correct components, good visual output, correct configurations. I had some occasional issues with unexpected characters breaking JSON formatting in the output validator, but those were identifiable and fixable problems.&lt;/p&gt;

&lt;p&gt;At that point I thought the model was limiting the no-code section generation.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The models I used and the cost problem&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;For this integration I used two models: Gemini 3.5 Flash and Gemini 3.1 Pro Preview. I would have liked to integrate Claude, but I already had a complete Gemini integration ready and decided to reuse it. Honestly, the quality difference was barely noticeable: Gemini 3.5 Flash cost less than Gemini 3.1 Pro and performed just as well.&lt;/p&gt;

&lt;p&gt;But, as I kept using the builder, two problems became hard to ignore.&lt;/p&gt;

&lt;p&gt;First, output costs were high and I could not simplify them: the planning phase was cheap, but the actual code generation was a real issue. In most cases I was dealing with costs that made the whole thing extremely inefficient. Generating an entire site, which still produced output that required a significant amount of cleanup afterwards, would easily consume one or two euros in Gemini 3.5 Flash credits, depending on the level of detail in the prompt. Factor in the re-iteration a user would need to get exactly what they wanted and those costs could easily double.&lt;/p&gt;

&lt;p&gt;Second, no-code generation was unsatisfying and highly inconsistent. Sometimes no-code sections came out well, other times they did not. The more reliable strategy was to rely on React, which is also more flexible and powerful. But then the question becomes obvious: why would anyone use a tool built into Webround, at a premium cost, to generate React code that Claude can generate perfectly well with a bit of Webround context provided?&lt;/p&gt;

&lt;p&gt;The further I went, the clearer it became that I was building a system that, using the same models, would cost more and produce results roughly equivalent to what the user can already do on their own. Anyone using a website builder, even if they cannot code, probably already has access to Claude, Gemini, or ChatGPT. If no-code is unreliable and React is the sensible path, why not just give them the right context files and get out of the way?&lt;/p&gt;

&lt;p&gt;I was building an AI website builder integrated into Webround that cost more to produce results equal to or worse than what users could get themselves. Not only is that indefensible as a product position, but it also meant maintaining an integration that adds to technical debt without adding real value.&lt;/p&gt;

&lt;p&gt;I did not want to walk away empty-handed, though.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What I did instead&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Without overthinking it, my questions had clear answers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does it cost more? Yes.&lt;/li&gt;
&lt;li&gt;Is it better? Not necessarily.&lt;/li&gt;
&lt;li&gt;Is it essential? No.&lt;/li&gt;
&lt;li&gt;Can it be built later, or differently, from outside? Yes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the decision was straightforward: suspend the AI builder, but run one test with Claude.&lt;/p&gt;

&lt;p&gt;I created a project, loaded a few context files, gave it clear instructions, and tried building something.&lt;/p&gt;

&lt;p&gt;It went exactly as expected. Claude generates clean, good-looking, correct, working React code at a fraction of what it would cost making individual API calls to Gemini, Claude, or OpenAI.&lt;/p&gt;

&lt;p&gt;So I wrote three Markdown files designed exactly for this purpose. They contain the information needed to point an AI in the right direction, prevent common mistakes, direct it to the official documentation, and explain the small details of the Webround ecosystem.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;webround.md&lt;/strong&gt; covers the core platform concepts: the difference between draft and store, the runtime architecture, and all the extensibility tools, Checkout Hooks, Edge Hooks, Webhooks and App Extensions, with a clear description of what each one does and when to use it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;webround-apis.md&lt;/strong&gt; covers the four Webround API systems, Core, Commerce, Catalog and Checkout, with the authentication methods supported by each, the differences between JWT and API key authentication, specific use cases and the differences between the various API systems. It also includes the detail that &lt;code&gt;draftId&lt;/code&gt; and &lt;code&gt;storeId&lt;/code&gt; are always the same value: obvious to someone who knows the platform, but exactly the kind of thing that trips up a model that does not know the entire platform.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;webround-frontend.md&lt;/strong&gt; covers everything needed to write custom React components: how to structure a component with the &lt;code&gt;wr&lt;/code&gt; prop, how to handle internal navigation in the shadow DOM, how to use Tailwind v4 and how to handle some basic functions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I was starting to understand something simple: you do not need a proprietary AI editor system. You just need the right context to make the AI work correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What I took away from this&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The appeal of an AI website builder is real, especially when you see others building them and the marketing around these features keeps getting louder. But when a feature does not add real value beyond convenience at the expense of control and quality, it becomes clear that investing in it, at least right now, does not make sense.&lt;/p&gt;

&lt;p&gt;The platform is already open enough that it does not need a proprietary AI intermediary: I was adding non-essential complexity in exchange for a credit system or cost premium that was not always justifiable.&lt;/p&gt;

&lt;p&gt;At the moment, I have put development on hold without archiving the idea. There will probably be iterations on this in the coming months with more specific, more targeted details and more granular integrations. Part of me feels this feature is important, but maybe that is just FOMO. Another part knows I would have gained almost nothing from it except a slightly more convenient workflow. &lt;/p&gt;

&lt;p&gt;For now, the context files are enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Download the context files for Webround&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If you are building on Webround with an AI assistant, these files are ready to use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.webround.com/ai/webround" rel="noopener noreferrer"&gt;&lt;strong&gt;webround.md&lt;/strong&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.webround.com/ai/webround-apis" rel="noopener noreferrer"&gt;&lt;strong&gt;webround-apis.md&lt;/strong&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.webround.com/ai/webround-frontend" rel="noopener noreferrer"&gt;&lt;strong&gt;webround-frontend.md&lt;/strong&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need the full API specs or the complete frontend type definitions, these files are available too. I would not recommend pasting them directly into a chat message since you would burn through an enormous number of tokens. I use them as a knowledge base inside a Claude Project, which retrieves specific information without consuming too many tokens thanks to the tool use and the RAG it builds automatically over project files.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.webround.com/wr.d.ts" rel="noopener noreferrer"&gt;&lt;strong&gt;https://docs.webround.com/wr.d.ts&lt;/strong&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.webround.com/core-api.json" rel="noopener noreferrer"&gt;&lt;strong&gt;https://docs.webround.com/core-api.json&lt;/strong&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.webround.com/commerce-api.json" rel="noopener noreferrer"&gt;&lt;strong&gt;https://docs.webround.com/commerce-api.json&lt;/strong&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.webround.com/catalog-api.json" rel="noopener noreferrer"&gt;&lt;strong&gt;https://docs.webround.com/catalog-api.json&lt;/strong&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.webround.com/checkout-api.json" rel="noopener noreferrer"&gt;&lt;strong&gt;https://docs.webround.com/checkout-api.json&lt;/strong&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>website</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>I migrated a 4 years old PrestaShop store. Here's how it went.</title>
      <dc:creator>Luca Siviero</dc:creator>
      <pubDate>Tue, 25 Aug 2026 19:44:30 +0000</pubDate>
      <link>https://dev.to/luca_at_webround/i-migrated-a-4-years-old-prestashop-store-heres-how-it-went-426</link>
      <guid>https://dev.to/luca_at_webround/i-migrated-a-4-years-old-prestashop-store-heres-how-it-went-426</guid>
      <description>&lt;p&gt;While looking for new clients for Webround, I reached out to a contact referred to me by word of mouth from a client I had already built a site for.&lt;/p&gt;

&lt;p&gt;The situation was interesting: an e-commerce store with 4 years of order history, more than 4,000 product variants, and a solid organic SEO ranking backed by active advertising.&lt;/p&gt;

&lt;p&gt;On paper, the store wasn't in bad shape because the site was running and sales were coming in. The problem was everything it couldn't do.&lt;/p&gt;

&lt;p&gt;Over those 4 years of selling online, the client had gone through three marketing agencies, each with their own ideas and customizations. The developer who had originally built the store had long since left the project, and nobody inside the company could touch anything. Between plugins that couldn't be updated, a site that was starting to show its age and looking a bit rough around the edges, they were essentially stuck with a 2010-era interface and very little room to extend anything easily.&lt;/p&gt;

&lt;p&gt;And the hosting didn't help: a server costing 250 euros a year doing everything at once. Between the site, the database, email, static assets with no CDN, and 20 GB of storage, they were running into unexpected errors and running out of space every three months.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The client didn't know what to do&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;His request was focused on the site itself, not the system behind it. He knew PrestaShop wasn't the best solution, but a basic interface refresh would have been enough for him.&lt;/p&gt;

&lt;p&gt;The problem is that the real issues kept surfacing as we talked. Marketplace sync had broken because of abandoned plugins, changing any business logic was always complicated, analytics were a mess with no clear or organized metrics. In short, it was obvious the problem wasn't just cosmetic.&lt;/p&gt;

&lt;p&gt;Customization on PrestaShop is one of those things that takes three times as long as it would on a normal system. Not only were things pretty limited in version 1.7, but the newer versions haven't made great strides either. Updates are tedious, between the unusual templating languages and plugins that inject themselves directly into the platform core, introducing bugs, vulnerabilities, and enormous technical debt.&lt;/p&gt;

&lt;p&gt;PrestaShop 1.7 had also accumulated a backlog of missing security patches over time and, with the traffic the site was handling, including solid organic rankings and active Google Ads campaigns, it was impossible to ignore that the infrastructure wasn't holding up its end.&lt;/p&gt;

&lt;p&gt;At that point, the reasoning was straightforward: either I upgrade everything, you buy a new theme, &lt;strong&gt;rebuy&lt;/strong&gt; the plugins you already had, and we make some visual adjustments, or &lt;strong&gt;you migrate to Webround&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Worth noting: we're now on PrestaShop 9. Version 1.7 dates back to 2021 and isn't directly compatible with a one-click upgrade path. In practice, the migration was going to happen one way or another and, at that point, it made sense to do it on something better.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The server problem&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;This isn't a minor detail, because the machine running the site was responsible for the database, company email, all the store's images, and every plugin installation.&lt;/p&gt;

&lt;p&gt;It sounds extreme, but it's not unusual: most PrestaShop installations are untouchable monoliths. One thing breaks and you risk taking everything down with it. A classic Single Point of Failure.&lt;/p&gt;

&lt;p&gt;Webround is serverless for most of its operation. Its infrastructure is built so that no merchant ever has to manage a single machine to operate the core of the platform. On top of having a native CDN, the site itself runs on Cloudflare's edge network, so there's no loading latency for anyone visiting the site from anywhere.&lt;/p&gt;

&lt;p&gt;With the current subscription, the client pays 32.50 € per month, but can you put a price on never having to update your e-commerce engine version again with the risk of losing data, losing access to company email, and losing all your site data? With full freedom to manage the site's appearance and functionality through React code and fully-documented APIs?&lt;/p&gt;

&lt;p&gt;When the client found out that a complex feature was just an NPM package and a few hundred lines of code away, his eyes went wide.&lt;/p&gt;

&lt;p&gt;That's when we decided to migrate to Webround.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The export problem&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The first technical obstacle came before I even understood the full picture: I had no SSH access, and no way to enable it from the cPanel I had been given. For a bulk export, direct database access is a huge advantage but it wasn't an option here. So I had to improvise.&lt;/p&gt;

&lt;p&gt;The solution was a PHP script uploaded via File Manager, executed once from the browser, and deleted immediately after. The script queries the PrestaShop database directly, collects all relevant tables into a single JSON file, and writes it outside &lt;code&gt;public_html,&lt;/code&gt;so it's never publicly accessible. The file is then downloaded via FTP or the file manager interface and converted to a local SQLite database with a Node script.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The actual migration&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;I had already migrated some WooCommerce and PrestaShop sites before, so the pattern wasn't new to me. Classic ETL flow. But I used this as an opportunity to build something reliable and repeatable for all future PrestaShop migrations. Unfortunately, every PrestaShop installation is unique: different database names, custom table prefixes, and countless small differences that make a one-size-fits-all tool impractical to use directly. For the core data, though, there's always a standard way to extract what you need.&lt;/p&gt;

&lt;p&gt;I built a series of scripts &lt;strong&gt;whose order is not arbitrary&lt;/strong&gt;, designed to extract data from PrestaShop and import it into Webround via the APIs, in exactly the order and format that Webround requires.&lt;/p&gt;

&lt;p&gt;The full sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tags and options&lt;/strong&gt;: PrestaShop attribute groups become tags and options on Webround. Options reuse tags to define how products branch into variants, which PrestaShop calls combinations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collections&lt;/strong&gt;: Categories are migrated level by level in a hierarchical fashion, preserving the structure and the filters that identify which products belong to each collection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Products&lt;/strong&gt;: Created in batch with specs, SEO details, shipping dimensions, and category and brand tag assignments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Variants&lt;/strong&gt;: Each Webround variant maps to a PrestaShop combination, with a unique slug and SKU, price, and stock level. Worth noting: in PrestaShop, combinations carry a price expressed as a delta from the base product price. In Webround, products don't have a price, only variants do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Download images&lt;/strong&gt;: Downloaded from the original server and saved in the environment running the scripts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upload images&lt;/strong&gt;: Images shared across all variants of a product go on the product, while images specific to a single combination go on the variant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDFs&lt;/strong&gt;: Attachments follow the same pattern as variants.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upload PDFs&lt;/strong&gt;: Exactly like images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tax zones&lt;/strong&gt;: Each product is linked to the correct Webround tax zone based on its PrestaShop tax rules group.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shipping&lt;/strong&gt;: Shipping zones and methods applied to all products.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customers&lt;/strong&gt;: Deduplicated by email and imported as standard Webround customers. Login credentials are not transferred.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Promotions&lt;/strong&gt;: PrestaShop cart rules become Webround promotions with discount type, usage limits, and expiry dates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orders&lt;/strong&gt;: Full order history with line items, status mapping, and promotion usage tracking to prevent customers who have already used single-use promotions from reusing them after the migration.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every script is &lt;strong&gt;idempotent&lt;/strong&gt;: if a run is interrupted, the next one picks up from the saved state without duplicating anything. The one rule: don't delete the state files.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The 301 redirects&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The site still ranks first on several relevant Google search queries today, thanks to a well-maintained, extensive catalog fully synced with Google Merchant Center, which also feeds the Google Shopping campaigns.&lt;/p&gt;

&lt;p&gt;The migration scripts automatically generate a &lt;code&gt;redirects.json&lt;/code&gt; file mapping every legacy PrestaShop URL to its Webround equivalent: this lets Webround serve the correct response on all previously indexed URLs without losing search ranking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important&lt;/strong&gt;: this is not an optional step when the site has a meaningful Google history, otherwise rankings drop and Google is forced to re-crawl and re-index the entire site from scratch. To protect the SEO of your PrestaShop store after a migration, make sure all products remain reachable at their previous URLs. Returning a permanent 301 redirect to Google ensures a clean migration with no ranking loss.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Customers and passwords&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Password migration is always tricky: every system has its own hashing mechanism and authentication strategy. With Webround, authentication is clearly possible, but the process is designed for customers, with an email verification and JWT authentication flow that isn't practical to replicate inside a migration script. The client and I agreed that the cleanest approach was to reset all credentials and add a notice on the login page. It's also a good opportunity to re-engage customers who haven't logged in for a while with the news of the new site, driving reliable traffic from day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Orders&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Order history and line items are handled directly by the orders migration script. Thanks to Webround's authentication model, if a customer registers after having placed orders as a guest using the same email address, all previously associated orders are linked to the newly authenticated account. This guarantees that a returning customer, after registering, finds their full order history already there when they access their personal area.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The result&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The client is entirely satisfied. His words show that the effort put into building a headless, React-based, API-first system pays off: "You're much faster than anyone who has ever touched this site." That might sound like a small thing, but it isn't.&lt;/p&gt;

&lt;p&gt;At the same skill level, on PrestaShop, the same work would have taken three times as long, if not more. The project went on to require several additional integrations and business logic customizations, but the speed and freedom of working with Webround quickly turned into new requests for integrations and business flows, adding customer retention mechanisms, promotional advantages, and front-end personalizations that would have taken far longer to build on a legacy on-premise system that would have carried all the usual fragility that comes with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Resources&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If you're also evaluating a migration from PrestaShop to Webround, or just want to see what I built, all the code is here.&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/WebroundAdmin/wr-prestashop-migration" rel="noopener noreferrer"&gt;&lt;strong&gt;https://github.com/WebroundAdmin/wr-prestashop-migration&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ecommerce</category>
      <category>api</category>
      <category>backend</category>
    </item>
    <item>
      <title>Syncing Webround and Brevo newsletter subscribers on Cloudflare Workers</title>
      <dc:creator>Luca Siviero</dc:creator>
      <pubDate>Wed, 19 Aug 2026 13:03:35 +0000</pubDate>
      <link>https://dev.to/luca_at_webround/syncing-webround-and-brevo-newsletter-subscribers-on-cloudflare-workers-3pbl</link>
      <guid>https://dev.to/luca_at_webround/syncing-webround-and-brevo-newsletter-subscribers-on-cloudflare-workers-3pbl</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;All of it runs on a single &lt;strong&gt;Cloudflare Worker&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The bulk import&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Before setting up the real-time sync, the existing list needed to be aligned. Webround exposes a paginated &lt;code&gt;/store-customers&lt;/code&gt; endpoint with an &lt;code&gt;includeNewsletter=true&lt;/code&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchAllNewsletterContacts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contacts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;totalPages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;totalPages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;WR_API_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/store-customers?page=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;limit=100&amp;amp;includeNewsletter=true&amp;amp;includeDeleted=false`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;wrHeaders&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nx"&gt;totalPages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pagination&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totalPages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;displayName&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;importToBrevo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BATCH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;BATCH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;BATCH&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.brevo.com/v3/contacts/import&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;api-key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BREVO_API_KEY&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                &lt;span class="na"&gt;listIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;BREVO_LIST_ID&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="na"&gt;updateExistingContacts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="na"&gt;jsonBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
                    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="na"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;FIRSTNAME&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;displayName&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="p"&gt;})),&lt;/span&gt;
            &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;updateExistingContacts: true&lt;/code&gt; flag handles &lt;strong&gt;deduplication&lt;/strong&gt; natively: if a contact already exists on Brevo, Brevo updates it instead of creating a duplicate. No need to diff the lists manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The real-time sync&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The Worker listens on two endpoints: &lt;code&gt;/webhook/wr&lt;/code&gt; for Webround events and &lt;code&gt;/webhook/brevo&lt;/code&gt; for Brevo events.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Webround -&amp;gt; Brevo&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Webround fires &lt;code&gt;store.customer.newsletter.subscribed&lt;/code&gt; and &lt;code&gt;store.customer.newsletter.unsubscribed&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Webround signs every webhook payload with HMAC SHA-256 using a shared secret, combined with a timestamp to prevent replay attacks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyWebroundWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Webhook-Signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Webhook-Timestamp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;importKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HMAC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sign&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HMAC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mac&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;|=&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comparison uses a constant-time loop to avoid timing attacks.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Brevo -&amp;gt; Webround&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BREVO_WEBHOOK_CIDRS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;ip4ToInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1.179.112.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;0xFFFFF000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;ip4ToInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;172.246.240.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;0xFFFFF000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateBrevoWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CF-Connecting-IP&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-brevo-secret&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;isBrevoIp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BREVO_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleBrevoWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;event&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;email&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Missing email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unsubscribe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contactDeleted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;setNewsletterOnWebround&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  &lt;strong&gt;The full Worker routing&lt;/strong&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/apps/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhook/wr&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifyWebroundWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WR_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unauthorized&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;envelope&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="na"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
            &lt;span class="p"&gt;};&lt;/span&gt;

            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;store.customer.newsletter.subscribed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;addContacts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;displayName&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="p"&gt;}]);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;store.customer.newsletter.unsubscribed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;removeContacts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhook/brevo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;validateBrevoWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unauthorized&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;email&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;handleBrevoWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not found&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The path normalization at the top handles Webround App Extensions, which prefix the URL with &lt;code&gt;/apps/{id}/...&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What this covers&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;newsletter&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The full source is on GitHub: &lt;a href="https://github.com/WebroundAdmin/wr-brevo-integration" rel="noopener noreferrer"&gt;&lt;strong&gt;https://github.com/WebroundAdmin/wr-brevo-integration&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Built on &lt;strong&gt;Webround&lt;/strong&gt;, an API-first e-commerce platform: &lt;a href="https://webround.com/en" rel="noopener noreferrer"&gt;&lt;strong&gt;webround.com&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>marketing</category>
      <category>webdev</category>
      <category>api</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Why I built a full PayPal checkout on Cloudflare Workers instead of using Stripe</title>
      <dc:creator>Luca Siviero</dc:creator>
      <pubDate>Tue, 18 Aug 2026 16:37:15 +0000</pubDate>
      <link>https://dev.to/luca_at_webround/why-i-built-a-full-paypal-checkout-on-cloudflare-workers-instead-of-using-stripe-5b06</link>
      <guid>https://dev.to/luca_at_webround/why-i-built-a-full-paypal-checkout-on-cloudflare-workers-instead-of-using-stripe-5b06</guid>
      <description>&lt;p&gt;When I migrated a 4-year-old e-commerce store to Webround, the client had one non-negotiable requirement: PayPal had to work. Their customers were used to it and removing it would have meant losing conversions from day one.&lt;/p&gt;

&lt;p&gt;I assumed it would be straightforward, but it wasn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The Stripe Connect dead end&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Webround uses Stripe Connect in standard mode: the platform takes a commission, but the merchant keeps their own Stripe account and receives payments directly. It's a clean architecture, keeps Webround out of non-technical matters and causes no issues.&lt;/p&gt;

&lt;p&gt;Except with PayPal.&lt;/p&gt;

&lt;p&gt;Stripe Connect in standard mode doesn't support PayPal unless you're a &lt;strong&gt;merchant of record,&lt;/strong&gt; meaning you collect the money on behalf of your merchants and redistribute it yourself. It's a completely different business model, implying a whole set of responsibilities. It seemed strange enough that I had to confirm it with Stripe support. After a &lt;strong&gt;phone call&lt;/strong&gt; (yes, Stripe calls you, wherever you are), I got confirmation that what my client needed required a change in Webround's internal responsibility model.&lt;/p&gt;

&lt;p&gt;It wasn't a viable path, but fortunately, with Webround on my side, reaching a workaround wasn't difficult.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The PayPal equivalent&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The second idea I had was to integrate PayPal's equivalent of Stripe Connect (&lt;strong&gt;PayPal Marketplace&lt;/strong&gt;) at the platform level, so the merchant could connect their own PayPal account natively. In theory it made sense, in practice a bit less: there's an approval process, you need to talk to someone on the sales team, reach a minimum volume threshold, and go through a partner onboarding process that takes weeks. Not viable.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The standalone approach&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Third option: build a standalone PayPal checkout flow that bypasses both Stripe and any Connect model entirely. The merchant uses their own PayPal account directly. In practice, a perfectly normal PayPal payment process to orchestrate correctly.&lt;/p&gt;

&lt;p&gt;I've been relying on &lt;strong&gt;Cloudflare Workers&lt;/strong&gt; for this kind of thing for a few years now: lightweight, distributed at the edge, perfect for stateless serverless integrations.&lt;/p&gt;

&lt;p&gt;I published the full source on GitHub: &lt;a href="https://github.com/WebroundAdmin/wr-paypal-integration" rel="noopener noreferrer"&gt;github.com/WebroundAdmin/wr-paypal-integration&lt;/a&gt;. Feel free to use it as a starting point.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;How the flow works&lt;/strong&gt;
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The customer clicks PayPal: the Worker validates the cart via the Webround API. It never trusts what the frontend says.&lt;/li&gt;
&lt;li&gt;The Worker creates the Webround order (status: &lt;code&gt;created&lt;/code&gt;) and the PayPal order.&lt;/li&gt;
&lt;li&gt;The PayPal order ID goes back to the frontend, which opens the payment page.&lt;/li&gt;
&lt;li&gt;The customer approves and the frontend captures the payment. We don't trust the frontend for the order status change. We use webhooks!&lt;/li&gt;
&lt;li&gt;PayPal sends the signed webhook and the Worker verifies the HMAC signature.&lt;/li&gt;
&lt;li&gt;The Worker updates the Webround order to &lt;code&gt;paid&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Webround fires the &lt;code&gt;order.paid&lt;/code&gt; event to your existing integrations.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Step 1. Validate the cart server-side&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Never trust prices submitted by the client, because any request can manipulate them. When the customer initiates checkout, the Worker fetches the real net price, tax rate, and stock availability for every item directly from the Webround API.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verifiedItems&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;unitNet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;taxRate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;packageWeight&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchVariantData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="c1"&gt;// Method that calls the Webround API&lt;/span&gt;
      &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;variantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;currencyCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;shippingAddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;countryCode&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;netAmount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unitNet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;taxRate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;packageWeight&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  &lt;strong&gt;Step 2. Calculate the total (and the rounding trap)&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;This is where things get interesting.&lt;/p&gt;

&lt;p&gt;Stripe and PayPal calculate order totals differently:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stripe:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unitGross = round(netAmount × (1 + taxRate/100) × 100) / 100
lineTotal = unitGross × quantity
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;PayPal:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lineTotal = round(netAmount × quantity × (1 + taxRate/100) × 100) / 100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is an &lt;strong&gt;implementation detail&lt;/strong&gt;: you could just send the total directly to the Stripe checkout session, but in Webround's native Stripe integration, the payment page shows the full cart breakdown: images, name, unit price, and line total. So Stripe, which works in cents when paying in EUR, needs to know the tax-inclusive total for a single unit before it can show the total across all quantities purchased.&lt;/p&gt;

&lt;p&gt;On a single-unit purchase, the difference is zero. On a cart with mixed quantities and decimal prices, the totals diverge.&lt;/p&gt;

&lt;p&gt;I discovered this in production. Not because a customer complained... the checkout worked fine from their perspective. I found it because the order export was recalculating totals server-side and the numbers weren't matching what PayPal had actually charged. Silent discrepancies in the accounting export.&lt;/p&gt;

&lt;p&gt;Here's an example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Take a product with a net price of €8.17 and a 22% VAT rate:
Exact gross: 8.17 × 1.22 = €9.9674

PayPal method: multiply first, round after:
9.9674 × 3 = 29.9022 → rounded: €29.90

Stripe method: round first, multiply after:
9.9674 → rounded to 2 decimals: €9.97
9.97 × 3 = €29.91

A difference of €0.01 per order. Silent, systematic, and impossible to catch in happy-path testing.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; align PayPal's calculation to Stripe's method, which is Webround's native integration, so PayPal adapts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;grandTotal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;verifiedItems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Stripe-compatible rounding: round unit gross first, then multiply by quantity&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unitGrossCents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;netAmount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taxRate&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unitGrossCents&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply the same method when creating order items:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unitGrossCents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;netAmount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taxRate&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unitGross&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;unitGrossCents&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unitNet&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taxRate&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;unitGross&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taxRate&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unitGross&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unitTax&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;unitGross&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;unitNet&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick one method and apply it consistently everywhere. The mismatch is exactly what causes the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Step 3. Create the Webround order and the PayPal order in parallel&lt;/strong&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;wrCustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paypalToken&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nf"&gt;ensureCustomer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;customerEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;billingAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;getPayPalToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;wrOrder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paypalOrder&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nf"&gt;createOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wrCustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;customerEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currencyCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;grandTotalStr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;shippingCostStr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;billingAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;shippingAddress&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;createPayPalOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paypalToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;grandTotalStr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currencyCode&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We save the mapping between the PayPal order ID and the Webround order ID in a Cloudflare D1 table: we'll need it when the webhook arrives.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nf"&gt;createOrderItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wrOrder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;verifiedItems&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;saveOrderMapping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paypalOrder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wrOrder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Worker returns the PayPal order ID to the frontend, which passes it to the PayPal JS SDK to open the payment page.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Step 4. Handle the webhook&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;After the customer approves and the frontend captures the payment, PayPal sends a signed webhook event. This is the authoritative signal that the payment succeeded. Never trust what happens on the client!&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getPayPalToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifyWebhookSignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unauthorized&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wrStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;WEBHOOK_STATUS_MAP&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="c1"&gt;// PAYMENT.CAPTURE.COMPLETED → "paid"&lt;/span&gt;
&lt;span class="c1"&gt;// PAYMENT.CAPTURE.DENIED    → "failed"&lt;/span&gt;
&lt;span class="c1"&gt;// CHECKOUT.ORDER.CANCELLED  → "canceled"&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mapping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getOrderMapping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paypalOrderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nf"&gt;updateMappingStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paypalOrderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wrStatus&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;updateOrderStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wr_order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wrStatus&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the Webround order status becomes &lt;code&gt;paid&lt;/code&gt;, Webround fires the &lt;code&gt;order.paid&lt;/code&gt; event to all registered webhooks: email confirmations, stock updates, CRM syncs, everything listens there. The PayPal flow plugs into an event system that runs parallel to Stripe's native one, thanks to Webround's architecture and its webhooks.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The frontend component&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;On the Webround side, this is a custom React component dropped into the built-in IDE, added to the cart page below the native checkout button.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;PayPalButton&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;wr&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;wr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Wr&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setMessage&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currencyCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;wr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;currencyCode&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EUR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cartItems&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;wr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;variantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;variantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;priceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}));&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cartItems&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;currencyCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;customerEmail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;wr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customerEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;wr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isLoggedIn&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;wr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;customerJwt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;wr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isLoggedIn&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;wr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;billingAddress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;wr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selectedBillingAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;shippingAddress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;wr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selectedShippingAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PayPalScriptProvider&lt;/span&gt; &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PAYPAL_CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;currencyCode&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PayPalButtons&lt;/span&gt;
                &lt;span class="na"&gt;createOrder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;WORKER_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/orders`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
                        &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                    &lt;span class="p"&gt;});&lt;/span&gt;
                    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
                &lt;span class="na"&gt;onApprove&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;WORKER_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/orders/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderID&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/capture`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="p"&gt;});&lt;/span&gt;
                    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orderData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;errorDetail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;orderData&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;details&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

                    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errorDetail&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;issue&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INSTRUMENT_DECLINED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errorDetail&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errorDetail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

                    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/checkout/success?clean_cart=true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;PayPalScriptProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  &lt;strong&gt;What this unlocked&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The Worker became the foundation for the client's entire payment layer outside Stripe. Clean separation: Stripe has its own isolated microservice, PayPal goes through the Worker. Both converge on Webround's order system and fire the same &lt;code&gt;order.paid&lt;/code&gt; event downstream, with the difference that PayPal does everything from the outside via REST API.&lt;/p&gt;

&lt;p&gt;The client now runs both payment methods in parallel, with full webhook coverage and correct event tracking on both sides.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The takeaway&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If you're building on a platform that uses Stripe Connect in standard mode, don't assume PayPal is just a plugin away. It isn't. But it's also not impossible: it just requires owning the orchestration yourself.&lt;/p&gt;

&lt;p&gt;Cloudflare Workers are a good fit for this use case because the logic is stateless, latency matters at checkout, and you get secrets management and D1 out of the box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test the rounding before you go live.&lt;/strong&gt; It won't show up in happy-path testing. Explicitly compare totals between your platform's calculation and PayPal's, with real product data and mixed quantities, before you ship.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The full Worker source, React component, D1 schema, and wrangler config are on&lt;/em&gt; GitHub: &lt;a href="https://github.com/WebroundAdmin/wr-paypal-integration" rel="noopener noreferrer"&gt;github.com/WebroundAdmin/wr-paypal-integration&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built on&lt;/em&gt; &lt;a href="https://webround.com" rel="noopener noreferrer"&gt;&lt;strong&gt;Webround&lt;/strong&gt;&lt;/a&gt;&lt;em&gt;, an API-first e-commerce platform. All order, catalog, and checkout endpoints are publicly documented at&lt;/em&gt; &lt;a href="https://docs.webround.com" rel="noopener noreferrer"&gt;&lt;strong&gt;docs.webround.com&lt;/strong&gt;&lt;/a&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ecommerce</category>
      <category>stripe</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>Why I built an API-first e-commerce platform</title>
      <dc:creator>Luca Siviero</dc:creator>
      <pubDate>Tue, 07 Jul 2026 09:33:37 +0000</pubDate>
      <link>https://dev.to/luca_at_webround/why-i-built-an-api-first-e-commerce-platform-2f3m</link>
      <guid>https://dev.to/luca_at_webround/why-i-built-an-api-first-e-commerce-platform-2f3m</guid>
      <description>&lt;p&gt;Hi! I'm Luca, from Italy, and I've spent the last two years building &lt;strong&gt;Webround&lt;/strong&gt;: an API-first e-commerce platform.&lt;/p&gt;

&lt;p&gt;This is something that started as a project of mine with a totally different target: I wanted a quicker way to update my portfolio because I was completing different projects every week. After a lot of headaches, early mornings, late nights and a ton of coffee, I had what I wanted: a way to no-code my portfolio and update it as I wanted. &lt;/p&gt;

&lt;p&gt;But while I was completing the project, an idea of making it a headless platform started growing: why would you keep things static and limited to portfolios? AI's already great at doing that! &lt;/p&gt;

&lt;p&gt;This idea started getting more serious, to the point that I fired myself from the previous job and after waaay more coffee, late nights, early mornings and a lot of building, breaking, changing, crying, it started to take shape as the product that is today.&lt;/p&gt;




&lt;p&gt;While building the platform, I worked as a freelance dev for smaller businesses in town and I had the (very bad) privilege of seeing what they were doing with e-commerce platforms, website management, hosting and more. Nothing ever made any sense because every set-up started from a mix of old technology, forgotten stuff, weird hosting providers, on-premise setups, big ahh monoliths, hacked servers, random throwing errors systems and much more ugly stuff.&lt;/p&gt;

&lt;p&gt;I never tried to force Webround into making sense. I've always wanted to be free (in general in my life) and as a developer, the fact that other platforms started feeling as a constraint rather than a tool when things started evolving, was throwing me off.&lt;/p&gt;

&lt;p&gt;After working with other popular platforms, I realized that today's landscape is almost never about making things easier, rather about making users dependent on the platform so it's harder to go away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Webround?
&lt;/h2&gt;

&lt;p&gt;Before answering this question, we have to take a look at two examples.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Shopify: this is arguably the best go-to platform for immediate satisfaction. But you're not buying a website, you're buying an ecosystem and all the deriving things from it. Shopify has a great source of strength that is, at the same time, a great source of pain: the app store.
Don't get me wrong, extensibility is huge, but usual customers on Shopify are smaller business owners that are chaotic, lack expertise and threat their website like it's a phone with apps. But a website is not a phone and needs real expertise to make it work correctly, so why would anyone keep finding workarounds to make something work with half-solutions to their unique problems?
As a developer, working with Shopify never felt great with all the apps and the ecosystem they force. While merchants keep scaling requests to devs or agencies because they keep finding out that they bought a complex (and closed) ecosystem instead of a website.&lt;/li&gt;
&lt;li&gt;WooCommerce, Medusa, Vendure, Prestashop: these tools are open-source and carry all the benefits of open-source solutions, but when you start working with these softwares, you enter a maze of weird stuff that you have to actually understand before you get productive at even writing a single line of code. Then you have to manage the infrastructure, without talking of the common issues that these platform have: poorly designed plugins, the "Plugin Hell" situation, non-existent backwards compatibility, monolithical architecture... well, this is not something that looks promising to build onto.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;That's where Webround starts making sense.&lt;/strong&gt;&lt;br&gt;
Let me make you one question: what if you had a managed environment that lets you build natively, in a modern and standardized way, leveraging SDKs and APIs to build virtually anything, while keeping plugins and extensions as separated microservices that work &lt;strong&gt;with&lt;/strong&gt; the core of the platform and not &lt;strong&gt;within&lt;/strong&gt; the core of the platform?&lt;/p&gt;

&lt;p&gt;Well, that's exactly what Webround is. A flexible solution to build virtually anything while keeping the developer at the center.&lt;/p&gt;
&lt;h2&gt;
  
  
  Who's the target user?
&lt;/h2&gt;

&lt;p&gt;In Webround, the more you know, the more you can do. I have always developed the platform with three users in mind.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The no-code user. This person doesn't want or know how to write code and likes to keep things simple, without the risk of running custom implementations. There are limitations, because this user can only work within the boundaries of no-code components, but there is no room to break stuff, while keeping the flexibility to build any layout through a drag &amp;amp; drop editor.&lt;/li&gt;
&lt;li&gt;The React developer. This user can speed up website creation by a lot, for a good reason: not only you can write React code inside an integrated IDE, but you can also install any NPM package and use Tailwind. Although, the real advantage lies in the built-in SDK. With a simple &lt;code&gt;wr&lt;/code&gt; prop, you can access the underlying app's state and functions to leverage e-commerce functionalities straight from your custom React components. You can access these tools from the browser, without installing anything. Even builds are server-side.&lt;/li&gt;
&lt;li&gt;The builder. Their attitude is simple: "Give me a bunch of APIs and I'll figure it out". Webround is an API-first platform that publicly exposes all the endpoints you need, both for the storefront and for managing the e-commerce, the authentication flow of its customers, the catalog system, the cart and the checkout flow. You can use Webround to build headless stores or even as a data layer for your own custom app! It's totally doable; all you need to do is read the docs at &lt;a href="https://docs.webround.com" rel="noopener noreferrer"&gt;docs.webround.com&lt;/a&gt; and you're ready to go.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  There's more
&lt;/h2&gt;

&lt;p&gt;E-commerce is not just a storefront. Behind every store there are events to handle, data to sync, notifications to send, external systems to connect. The usual answer on other platforms is: open another app, install another plugin and pay another subscription.&lt;/p&gt;

&lt;p&gt;With Webround, you have native tools for this.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Webhooks to react to platform events in real time.&lt;/li&gt;
&lt;li&gt;Edge Hooks to inject secure runtime data before your pages render.&lt;/li&gt;
&lt;li&gt;Checkout Hooks to manipulate the payment flow before it reaches Stripe (Webround partners with Stripe Connect).&lt;/li&gt;
&lt;li&gt;App Extensions to build isolated micro-services that plug directly into the platform without touching the core.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a way bigger topic that needs to be analyzed in a separated session, so I'll just anticipate that these tools exist for now :)&lt;/p&gt;
&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;I'm not here to sell you anything. I'm looking for users. No-coders who want to explore a modern solution, React developers who want to speed up their work, and hardcore builders who just want a solid set of APIs and the freedom to do whatever they want with them.&lt;/p&gt;

&lt;p&gt;If you want to reach out or give me some thoughts about Webround, what you like, what you don't like, what you think is a good or a bad idea, you're absolutely free to reach out!&lt;/p&gt;

&lt;p&gt;Just a little anticipation: the website requires you to sign-up with email or google and to verify your account. But, apart from this, every function is basically free to use. Another thing, is that you'd need a subscription to call APIs from the outside (for publicly accessible data). You can either activate a trial, either contact me to request an activation: I'll be more than happy to welcome you here.&lt;/p&gt;

&lt;p&gt;Finally, if you want to see my big face, I've created an  introduction video to Webround, uploaded on the official channel, where you'll also find a set of tutorials to do the basic stuff with the no-code editor.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/AJnfTxUMKvU"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;I'll leave the links to the official website and documentation, so you can know more about it!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://webround.com" rel="noopener noreferrer"&gt;webround.com&lt;/a&gt;&lt;br&gt;
&lt;a href="https://docs.webround.com" rel="noopener noreferrer"&gt;docs.webround.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ecommerce</category>
      <category>programming</category>
      <category>react</category>
      <category>api</category>
    </item>
  </channel>
</rss>
