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.
I assumed it would be straightforward, but it wasn't.
The Stripe Connect dead end
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.
Except with PayPal.
Stripe Connect in standard mode doesn't support PayPal unless you're a merchant of record, 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 phone call (yes, Stripe calls you, wherever you are), I got confirmation that what my client needed required a change in Webround's internal responsibility model.
It wasn't a viable path, but fortunately, with Webround on my side, reaching a workaround wasn't difficult.
The PayPal equivalent
The second idea I had was to integrate PayPal's equivalent of Stripe Connect (PayPal Marketplace) 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.
The standalone approach
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.
I've been relying on Cloudflare Workers for this kind of thing for a few years now: lightweight, distributed at the edge, perfect for stateless serverless integrations.
I published the full source on GitHub: github.com/WebroundAdmin/wr-paypal-integration. Feel free to use it as a starting point.
How the flow works
- The customer clicks PayPal: the Worker validates the cart via the Webround API. It never trusts what the frontend says.
- The Worker creates the Webround order (status:
created) and the PayPal order. - The PayPal order ID goes back to the frontend, which opens the payment page.
- The customer approves and the frontend captures the payment. We don't trust the frontend for the order status change. We use webhooks!
- PayPal sends the signed webhook and the Worker verifies the HMAC signature.
- The Worker updates the Webround order to
paid. - Webround fires the
order.paidevent to your existing integrations.
Step 1. Validate the cart server-side
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.
const verifiedItems = await Promise.all(
items.map(async item => {
const { unitNet, taxRate, packageWeight } = await fetchVariantData( // Method that calls the Webround API
env,
item.variantId,
item.productId,
item.priceId,
currencyCode,
shippingAddress.countryCode
);
return { ...item, netAmount: unitNet, taxRate, packageWeight };
})
);
Step 2. Calculate the total (and the rounding trap)
This is where things get interesting.
Stripe and PayPal calculate order totals differently:
Stripe:
unitGross = round(netAmount × (1 + taxRate/100) × 100) / 100
lineTotal = unitGross × quantity
PayPal:
lineTotal = round(netAmount × quantity × (1 + taxRate/100) × 100) / 100
This is an implementation detail: 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.
On a single-unit purchase, the difference is zero. On a cart with mixed quantities and decimal prices, the totals diverge.
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.
Here's an example:
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.
The fix: align PayPal's calculation to Stripe's method, which is Webround's native integration, so PayPal adapts.
const grandTotal = verifiedItems.reduce((sum, item) => {
// Stripe-compatible rounding: round unit gross first, then multiply by quantity
const unitGrossCents = Math.round(item.netAmount * (1 + item.taxRate / 100) * 100);
return sum + (unitGrossCents / 100) * item.quantity;
}, 0);
Apply the same method when creating order items:
const unitGrossCents = Math.round(item.netAmount * (1 + item.taxRate / 100) * 100);
const unitGross = unitGrossCents / 100;
const unitNet = item.taxRate > 0 ? unitGross / (1 + item.taxRate / 100) : unitGross;
const unitTax = unitGross - unitNet;
Pick one method and apply it consistently everywhere. The mismatch is exactly what causes the bug.
Step 3. Create the Webround order and the PayPal order in parallel
const [wrCustomerId, paypalToken] = await Promise.all([
ensureCustomer(env, customerEmail, billingAddress, customerId),
getPayPalToken(env),
]);
const [wrOrder, paypalOrder] = await Promise.all([
createOrder(env, wrCustomerId, customerEmail, currencyCode, grandTotalStr, shippingCostStr, billingAddress, shippingAddress),
createPayPalOrder(env, paypalToken, grandTotalStr, currencyCode),
]);
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.
await Promise.all([
createOrderItems(env, wrOrder.id, verifiedItems),
saveOrderMapping(env, paypalOrder.id, wrOrder.id),
]);
The Worker returns the PayPal order ID to the frontend, which passes it to the PayPal JS SDK to open the payment page.
Step 4. Handle the webhook
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!
const body = await request.text();
const token = await getPayPalToken(env);
const valid = await verifyWebhookSignature(env, request, body, token);
if (!valid) return new Response("Unauthorized", { status: 401 });
const event = JSON.parse(body) as { event_type: string; resource: any };
const wrStatus = WEBHOOK_STATUS_MAP[event.event_type];
// PAYMENT.CAPTURE.COMPLETED → "paid"
// PAYMENT.CAPTURE.DENIED → "failed"
// CHECKOUT.ORDER.CANCELLED → "canceled"
const mapping = await getOrderMapping(env, paypalOrderId);
await Promise.all([
updateMappingStatus(env, paypalOrderId, wrStatus),
updateOrderStatus(env, mapping.wr_order_id, wrStatus),
]);
When the Webround order status becomes paid, Webround fires the order.paid 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.
The frontend component
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.
export default function PayPalButton({ wr }: { wr: Wr }) {
const [message, setMessage] = useState("");
const currencyCode = wr.customer.cart.items[0]?.currencyCode ?? "EUR";
const cartItems = wr.customer.cart.items.map((item) => ({
variantId: item.variantId,
productId: item.productId,
priceId: item.priceId,
quantity: item.quantity,
}));
const payload = {
items: cartItems,
currencyCode,
customerEmail: wr.cart.customerEmail,
customerId: wr.customer.isLoggedIn ? wr.customer.profile?.id : undefined,
customerJwt: wr.customer.isLoggedIn ? wr.customer.accessToken : undefined,
billingAddress: wr.cart.selectedBillingAddress,
shippingAddress: wr.cart.selectedShippingAddress,
};
return (
<PayPalScriptProvider options={{ clientId: PAYPAL_CLIENT_ID, currency: currencyCode }}>
<PayPalButtons
createOrder={async () => {
const res = await fetch(`${WORKER_URL}/api/orders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (data.id) return data.id;
throw new Error(JSON.stringify(data));
}}
onApprove={async (data, actions) => {
const res = await fetch(`${WORKER_URL}/api/orders/${data.orderID}/capture`, {
method: "POST",
});
const orderData = await res.json();
const errorDetail = orderData?.details?.[0];
if (errorDetail?.issue === "INSTRUMENT_DECLINED") return actions.restart();
if (errorDetail) throw new Error(errorDetail.description);
window.location.href = "/checkout/success?clean_cart=true";
}}
/>
</PayPalScriptProvider>
);
}
What this unlocked
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 order.paid event downstream, with the difference that PayPal does everything from the outside via REST API.
The client now runs both payment methods in parallel, with full webhook coverage and correct event tracking on both sides.
The takeaway
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.
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.
Test the rounding before you go live. 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.
The full Worker source, React component, D1 schema, and wrangler config are on GitHub: github.com/WebroundAdmin/wr-paypal-integration
Built on Webround, an API-first e-commerce platform. All order, catalog, and checkout endpoints are publicly documented at docs.webround.com.
Top comments (0)