I recently shipped CutoutKit, a small web app that removes image backgrounds and returns a transparent PNG.

The interface is intentionally simple: upload an image, wait a few seconds, and download the result. The implementation behind that flow was less simple. Once I moved past the prototype, I needed authentication, usage limits, payments, webhook reliability, privacy boundaries, and an architecture that could run comfortably on Cloudflare.

This post covers the decisions and mistakes that were most useful to me. It is not a tutorial for a specific starter repository; it is a set of patterns you can reuse in other API-backed SaaS products.

The constraints shaped the architecture

I started with a few deliberate constraints:

  • The frontend would use Next.js, React, and Tailwind CSS.
  • The application would run on Cloudflare Pages and its server-side runtime.
  • Uploaded images would not be persisted by the application.
  • Background removal would be delegated to the remove.bg API.
  • Google OAuth would identify users.
  • Cloudflare D1 would store users, sessions, usage, and purchases.
  • Paid access would use one-time credit packs instead of recurring subscriptions.

The resulting request flow looks like this:

Browser
  |
  +--> Next.js UI on Cloudflare
  |
  +--> Cloudflare server endpoint
          |-- verify session and request
          |-- reserve one usage credit in D1
          |-- send the image to remove.bg
          |-- stream the processed PNG back
          `-- release the reservation if processing fails

Payment provider webhook
  `--> verify signature --> record event --> grant credits in D1

Enter fullscreen mode Exit fullscreen mode

One important privacy detail: “not stored” does not mean “never leaves the browser.” The uploaded file is sent through my server endpoint to the image-processing provider. My application does not write the original or processed image to a database, object store, or disk, but the processor still receives it to perform the requested operation. That distinction belongs in both the product copy and the privacy policy.

Keep third-party API keys behind a server boundary

The first production rule is obvious but easy to violate during a quick prototype: never call a paid processing API directly from browser code if it requires a secret key.

The browser uploads a multipart/form-data request to a same-origin server endpoint. That endpoint reads the remove.bg key from a Cloudflare secret, adds it to the upstream request, and returns the image response. The key is never included in the JavaScript bundle or exposed through a public configuration endpoint.

The proxy also became the right place to enforce the application’s rules:

  1. Require an authenticated user.
  2. Reject cross-origin requests.
  3. Accept only multipart/form-data.
  4. Limit the request to 22 MB.
  5. Allow only JPEG, PNG, and WebP MIME types.
  6. Optionally verify a Turnstile token.
  7. Apply a short-term IP rate limit.
  8. Check and reserve the user’s available credit.
  9. Add a timeout to the upstream API request.

Client-side validation still improves the experience, but it is not a security boundary. Every meaningful rule is repeated on the server.

Reserve usage before doing expensive work

Usage accounting looks trivial until two requests arrive at nearly the same time.

A fragile implementation follows this sequence:

read remaining credits
call expensive API
increment usage

Enter fullscreen mode Exit fullscreen mode

Two concurrent requests can both see the same remaining credit and both proceed. The better pattern is to reserve usage before calling the paid API:

reserve credit atomically
call upstream processor
confirm reservation on success
release reservation on failure

Enter fullscreen mode Exit fullscreen mode

This gives the request a small transaction-like lifecycle even though the database operation and external API call cannot share a real database transaction.

The failure path matters as much as the success path. A timeout, rejected file, upstream outage, or exhausted provider balance should not charge the user. In my handler, upstream failures trigger a best-effort release of the reservation before returning a normalized error to the client.

This pattern is useful for any metered API product: image generation, transcription, document conversion, email verification, or LLM calls.

Google OAuth on an edge deployment

Google OAuth is conventional, but custom domains and preview domains create a sharp edge: the redirect URI must match exactly.

My application builds the callback from a single canonical application origin:

https://imagebackground.site/api/auth/google/callback

Enter fullscreen mode Exit fullscreen mode

That exact URI must appear in the Google OAuth client configuration. Differences in protocol, hostname, port, path, or trailing characters cause redirect_uri_mismatch.

The login flow uses an OAuth state value stored in a short-lived, HTTP-only cookie. The callback compares that cookie with the returned state before exchanging the authorization code. After retrieving the Google profile, the app upserts the user in D1 and creates a server-side session.

The browser receives a signed session identifier in a secure, HTTP-only cookie. It does not receive the Google access token, and the application does not need to keep that token after fetching the profile.

D1 stores only the durable product data the app needs: the Google subject identifier, email, display information, sessions, usage events, and purchases.

One-time credit packs simplified the billing model

I chose 30-day credit packs rather than automatic subscriptions. The current model has a small free allowance and two paid packs. A purchase adds a fixed number of credits with an expiry date; it does not create recurring billing.

Supporting both PayPal and Creem helped me separate the payment provider from the entitlement model. The provider collects money, but my own database decides whether a user may process an image.

The common flow is:

  1. The signed-in user selects a plan.
  2. The server maps the public plan ID to a server-owned price and credit count.
  3. The server creates a checkout or order with an internal purchase ID.
  4. D1 records the purchase as pending.
  5. The provider completes the payment.
  6. A verified server callback marks the purchase complete and grants the entitlement.

The browser never submits an authoritative amount or number of credits. It submits only a plan identifier, and the server looks up the plan details.

Treat payment webhooks as hostile and repetitive

A payment success page is useful for the user experience, but it is not a reliable source of truth. The customer can close the tab, lose connectivity, or manipulate a browser request. Webhooks are the durable confirmation channel.

My webhook handlers follow four rules:

  • Verify the provider signature before doing any work.
  • Store every provider event ID.
  • Make an already completed event return success without granting credits again.
  • Link the event back to a purchase created by the server.

This is idempotency in practical form. Payment providers retry webhooks, and your endpoint must assume the same event can arrive more than once.

I also store processing status for each webhook event. If an event fails halfway through, it is visible and can be retried without pretending it never arrived.

Cloudflare was more than static hosting

The public pages are statically rendered, while the authenticated and image-processing routes run at the edge. Cloudflare D1 provides the relational state, and environment secrets keep provider credentials out of source control.

The deployment also has one canonical host. Requests for the www hostname and the Pages preview hostname redirect to the apex domain. This fixed several subtle issues at once:

  • OAuth callbacks consistently use one origin.
  • Session cookies behave predictably.
  • Search engines see one canonical version of each page.
  • Analytics does not fragment the same product across hostnames.

I added a generated sitemap, robots.txt, canonical metadata, structured data, a favicon, and focused landing pages for real use cases such as product photos and transparent PNG creation. SEO did not change the application architecture, but the canonical-host decision definitely did.

What I would do earlier next time

If I built another API-backed SaaS, I would make these decisions before polishing the interface:

  1. Define the privacy boundary precisely. List every system that receives user content and distinguish processing from persistence.
  2. Design usage as a state machine. Think in terms of reserved, completed, and released usage—not a single counter.
  3. Choose the canonical origin immediately. Use it for OAuth, cookies, payment return URLs, metadata, and redirects.
  4. Make webhooks idempotent from day one. Retrofitting event deduplication after real payments is unnecessarily stressful.
  5. Keep plans on the server. Prices, currencies, credits, and expiration rules should never be trusted from the browser.
  6. Normalize third-party failures. Users need actionable product errors, not raw provider responses.
  7. Test the failure path. Timeouts, duplicate callbacks, invalid signatures, and exhausted quotas are normal production states.

Closing thoughts

The background-removal model was not the hardest part of this product because I deliberately used a specialized API. The interesting engineering work was everything around it: protecting the API boundary, accounting for usage correctly, handling identity at the edge, and turning payment notifications into reliable entitlements.

That is also encouraging. A small product does not need a huge infrastructure stack, but it does need clear trust boundaries and careful state transitions.

You can try the finished implementation at CutoutKit. If you are building a similar API-backed tool, I would be interested to hear which part caused the most trouble for you.