← all posts

Shipping a paywalled ML model with Flask

How a scikit-learn car-price model became a small paid web product: loading the artifacts without taking the site down, carrying form state across a payment redirect, and being honest about what a return URL proves.

A trained model sitting in a folder is not a product. Someone still has to load it on a server, turn a web form into the exact features it was trained on, take money before showing the answer, and keep the whole thing up when one of the files goes missing.

carprices.me is a used-car price estimator for the Jordanian market: an Arabic form, a price in dinars. My collaborator Saleh (salehabbass on GitHub) built the model. My part was everything around it: the Flask app, the paywall, and the deployment. This post walks through that part, including the bits I would not ship the same way again.

In short:

What I was handed

The model is a scikit-learn RandomForestRegressor, saved with joblib. On its own it is useless, because it expects numbers and the form produces strings like “Toyota” and “50,000 - 59,999”. So it comes with preprocessing artifacts; Saleh committed the encoders and the scaler to the repository himself:

Artifact What it does
RandomForestRegressor.pkl the model, about 104 MB
encoder_1.pkl to encoder_6.pkl label encoders for make, model, transmission, mileage band, fuel, engine size
MinMaxScaler.pkl scales the manufacturing year

Seven inputs, eight files. The size matters later: a 104 MB file is fine on disk and awkward everywhere else.

Loading the artifacts without taking the site down

Everything loads once, at import time, so gunicorn workers start ready to predict. The encoders and scaler live in static/. The model is looked up in static/, then the project root, and if it is in neither, downloaded from a file ID set in an environment variable.

The important decision was what happens when any of that fails. My first commit after the initial one was literally “Allow app to run in dummy mode if models are missing”: instead of crashing the process, the app records the error and switches to a placeholder price.

# Simplified from app.py
MODELS_LOADED = False
MODEL_LOAD_ERROR = None

def load_models():
    global MODELS_LOADED, MODEL_LOAD_ERROR, encoders, scaler, model
    try:
        encoders = [joblib.load(f"static/encoder_{i}.pkl") for i in range(1, 7)]
        scaler = joblib.load("static/MinMaxScaler.pkl")
        model = joblib.load(find_or_download_model())
        MODELS_LOADED = True
    except Exception as e:
        MODEL_LOAD_ERROR = f"{type(e).__name__}: {e}"
        MODELS_LOADED = False   # serve a placeholder price instead of crashing

load_models()
any errorLoad encoders and scalersix label encoders, one MinMaxScalerFind the model filestatic/, then project rootDownload if missingfile id from an environment variablejoblib.load the modelMODELS_LOADED = TrueDummy modeerror kept for /_health
fig. 1 / model loading at startup. any failure lands in dummy mode, and the reason is kept for the health endpoint.

The dummy price is deliberately crude: 10,000 minus 500 per year of age, never below 1,500. It exists so the pages render while I fix the deploy, not to fool anyone.

A /_health endpoint reports whether the models loaded, the error if they did not, and which artifact files exist. That one endpoint saved me more time than anything else in the project, because “the site is up” and “the model is loaded” turned out to be two very different statements.

The other loading lesson came from the pickles themselves. Pickled scikit-learn objects are tied to the library versions that wrote them, so in the commit that got the encoders working I pinned scikit-learn==1.2.2, numpy==1.24.4, and joblib==1.2.0, and moved the runtime from Python 3.14 back to 3.11.9 so those versions would install. Unpinned “latest” is fine for a web framework. It is not fine for a file someone else serialized months ago.

From form to features

The prediction path is short, and real enough to show almost as it is:

# From app.py (lightly trimmed)
make  = enc_1.transform(np.array([data["make"]]))
model = enc_2.transform(np.array([data["model"]]))
year  = loaded_scaler.transform(np.array([[int(data["year"])]]))
trans = enc_3.transform(np.array([data["transmission"]]))
km    = enc_4.transform(np.array([data["kilometers"]]))
fuel  = enc_5.transform(np.array([data["fuel"]]))
eng   = enc_6.transform(np.array([data["engine_size"]]))

X = np.array([make, model, year, trans, km, fuel, eng], dtype=object).reshape(1, -1)
return int(loaded_model.predict(X)[0])
Form values7 stringsEncode + scale6 encoders, scalerFeature rowshape (1, 7)RandomForestpredict, round
fig. 2 / request to prediction. the encoders only know the exact strings they were trained on.

The catch with label encoders is that they only accept values they saw during training, spelled exactly the same way. So the form never sends what the user reads. Every option has an English value matching the training data and an Arabic label for display: the value is “Automatic”, the user sees “أوتوماتيك”. The model dropdown is driven by a small JavaScript map of 62 makes, each model stored as an English and Arabic pair. Mileage and engine size are not free-text numbers but the same bands the data used, from “1 - 999” up to “200,000+” kilometers.

The year list is fixed to 1971 through 2024 instead of running up to the current year. A random forest cannot predict outside the range of prices it was trained on, and the scaler has never seen a newer year, so offering one only invites a confident wrong answer.

Carrying form state across the payment redirect

The product rule is simple: fill in the form, pay, see the price. The awkward part is that paying means leaving the site. The user goes to a hosted checkout page and comes back on a redirect, and the form they filled in has to survive the trip.

I kept that state in Flask’s session, which by default is a cookie signed with the app’s secret key:

  1. The form posts JSON to /start-estimate. The server checks all seven fields are present and returns a 400 listing the missing ones if not.
  2. It stores the form in session["pending_estimate"] with a timestamp, builds the return URL, and sends the browser to /payment.
  3. /payment shows a single “pay now” button pointing at the checkout.
  4. After payment, Dodo Payments redirects to /payment/return, the cookie comes back with the request, and the server runs the prediction on the stored form.
BrowserArabic formFlask appgunicorn on RenderDodo checkouthosted payment pagePOST /start-estimatesession cookie + /paymentpay now: checkout linkredirect to return URLGET /payment/return + cookieestimate, session clearedcookie holds the 7 form fieldssigned with the app secret:readable by the user, not editable
fig. 3 / the form survives the round trip to the payment page inside a signed session cookie. no server-side store.

No database, no server-side session store, nothing to clean up. The cookie is readable by the user but cannot be edited without the secret, which suits data the user typed in themselves.

There are two ways to reach the checkout. If an API token is configured, the server creates a checkout session through Dodo’s API and passes the return URL in the request. If not, the button falls back to a static payment link with the return URL baked into its query string. The return URL itself is built from a configured public base URL rather than from the incoming request, so it does not depend on what Flask thinks its own host name is behind a hosting proxy.

What the return URL actually verifies

This is the part worth being precise about. Here is the whole check:

# From app.py
status = (request.args.get("status") or "").strip().lower()
if status and status not in {"succeeded", "success", "paid", "active"}:
    return render_template("payment_failed.html", status=status, ...)

predicted_price = _predict_price(pending["form_data"])
session.pop("pending_estimate", None)

What it verifies: the browser holds a valid signed session with a pending estimate, and the query string does not report a failed payment. That is all. The status value comes from the URL, so it is whatever the browser sends, and if it is missing entirely the request goes through. Anyone who submits the form and then types the return URL by hand gets an estimate without paying.

There are a few more gaps of the same kind:

For a prototype selling a cheap one-off estimate, I accepted the first gap knowingly. For anything I would put real volume through, the browser should never be the one that says “paid”. The production version looks like this:

Create the checkout server-sidestore a pending order id with the formProvider calls a webhooksignature verified with the webhook secretMark the order paidamount and product checkedReturn URL looks up the orderby id from the session, not the query stringShow the estimate onceorder marked consumed
fig. 4 / what a production version verifies. the payment provider tells the server, not the browser.

If waiting for the webhook is too slow for the redirect, the return handler can instead fetch the payment by its ID from the provider’s API and check the status, amount, and product itself. Either way the source of truth is a server-to-server call. On top of that: remove or authenticate /predict, expire pending estimates, and have dummy mode hide the pay button instead of selling a placeholder.

Deploying on Render

Deployment is the boring part, which is the point. Render runs a Python web service with pip install -r requirements.txt as the build command and gunicorn app:app as the start command, with configuration in environment variables: the Flask secret, the public base URL, the Dodo API token, base URL and product ID, and the model’s file ID.

Two details from the commit history:

As I write this, the carprices.me landing page still responds, but the Render service behind it is suspended, so the estimator itself is not currently serving. I am not going to claim traffic or revenue numbers for it.

What I would do differently

  1. Make the server the only judge of payment. Webhook or API lookup, never a query string. This is the one change that turns the paywall from a speed bump into a lock.
  2. Fail closed when the model is missing. Dummy mode was right for keeping pages up during deploys. It is wrong behind a pay button.
  3. Refuse to start without a real secret. The app falls back to a hard-coded development key if the environment variable is missing. With signed-cookie sessions, that key is the only thing stopping a forged session.
  4. Ship model artifacts as a versioned bundle. One archive with the model, encoders, scaler, the pinned library versions, and a checksum, instead of files spread across the repo root, static/, and a download link.
  5. Trim the health endpoint. File paths are useful while debugging a deploy and unnecessary once it works.

If you are turning someone’s model into something people pay for, I’m happy to compare notes. You can reach me through my site.