Skip to main content

04 - Stripe in Italy: Setting Up SaaS Payments

Stripe payments in Italy: who the seller is, price-list fees compared, a checkout built locally and a webhook with a verified signature.

What do you think about this article?

04 - Stripe in Italy: Setting Up SaaS Payments
Indie Maker ItaliaPhoto:SiddePexels

At a Glance

Stripe payments in Italy: who the seller is, price-list fees compared, a checkout built locally and a webhook with a verified signature.

Who is the seller: the question that comes before checkout

Before writing a line of Stripe integration you have to decide something that is not technical: when a customer pays, who sells the product? If it is you, VAT, the invoice and the relationship with the consumer are yours. If it is an intermediary acting as "merchant of record", that is, as the official seller, those duties pass to it, in exchange for a higher fee. Stripe's documentation writes this in a table: with "Managed Payments" the merchant of record is Stripe, with the other Stripe products it is your business. Almost everything else in this article follows from this choice.

For a business under the regime forfettario (Italy's flat-rate scheme for small sole traders, which does not charge VAT on domestic sales), the choice weighs even more, because VAT on sales to consumers in other European countries depends on the customer's location (Article 58 of Directive 2006/112/EC). The article on the regime forfettario explains what I verified and what I did not; here we look at what each route costs and how to build a checkout that trusts no one.

What the three routes cost

The price lists are public and they change, so every figure below carries its consultation date (2026-11-13). On Stripe's Italian price list, standard cards from the European Economic Area cost 1.5% plus 0.25 euros; premium cards from the same area 2.8% plus 0.25 euros; United Kingdom cards 2.5% plus 0.25 euros, and international cards 3.15% plus 0.25 euros, plus 2% if currency conversion is needed. Stripe Tax, which calculates taxes, costs 0.5% per transaction if your business is registered to collect taxes. Paddle and Lemon Squeezy both state, on their pricing pages, 5% plus 50 cents per transaction, acting as merchant of record.

The listing below turns these figures into a comparison by price. Mind an approximation declared in the comment: Paddle's and Lemon Squeezy's rates are in dollars and here the 0.50 is treated as euros to see the order of magnitude, without converting the currency. The three functions are deliberately separate, so you can change a single figure when the price list changes.

# Percentage cost of a collection, for three ways of selling.
# The price lists are those read on 2026-11-13. Careful: Paddle and Lemon Squeezy
# quote 50 cents of a dollar; here we treat 0.50 as euros ONLY to
# see the order of magnitude, without converting the currency.
def stripe(prezzo):
    return prezzo * 0.015 + 0.25                 # standard EEA cards

def stripe_con_tax(prezzo):
    return stripe(prezzo) + prezzo * 0.005       # + Stripe Tax, 0.5% per transaction

def merchant_of_record(prezzo):
    return prezzo * 0.05 + 0.50                  # 5% + 50 cents

for prezzo in (5, 9, 29, 99):
    riga = [f"{prezzo:>3} EUR"]
    for nome, f in (("Stripe", stripe), ("Stripe+Tax", stripe_con_tax), ("MoR", merchant_of_record)):
        costo = f(prezzo)
        riga.append(f"{nome} {costo:5.2f} ({costo / prezzo * 100:4.1f}%)")
    print(" | ".join(riga))

# Output:
#   5 EUR | Stripe  0.33 ( 6.5%) | Stripe+Tax  0.35 ( 7.0%) | MoR  0.75 (15.0%)
#   9 EUR | Stripe  0.39 ( 4.3%) | Stripe+Tax  0.43 ( 4.8%) | MoR  0.95 (10.6%)
#  29 EUR | Stripe  0.69 ( 2.4%) | Stripe+Tax  0.83 ( 2.9%) | MoR  1.95 ( 6.7%)
#  99 EUR | Stripe  1.73 ( 1.8%) | Stripe+Tax  2.23 ( 2.3%) | MoR  5.45 ( 5.5%)

The result shows the shape of the problem. On a 9-euro product Stripe costs about 4.3% and a merchant of record about 10.6%; on a 99-euro product the percentages fall to 1.8% and 5.5%. The fixed part of 25 or 50 cents therefore weighs heavily on low prices, and it is a good reason to prefer subscriptions or licenses over micropayments. The difference between the two routes, however, is not only a cost: the merchant of record takes on duties that have a value for you, and for a digital product sold in many countries that value can exceed the difference. These are comparisons with price-list numbers, not a recommendation: the choice depends on where you sell and on your accountant.

The hosted checkout: what it means and what you send

To begin with, the least risky way is Stripe's hosted Checkout: the customer is taken to a Stripe page, where they enter the card details, and you neither see nor store them. You create a "session" from the server and send the customer to the address it returns. The documentation of the creation interface lists the essential parameters: mode, which can be a one-off payment or a subscription, the items of the order and the return addresses.

The listing below shows the request the official library would send, without contacting Stripe: a fake HTTP client records method, address and fields, and the key is plainly fake. The amount is expressed in cents (900 for 9.00 euros), because decimal amounts are a classic source of errors. With mode=payment you collect once; for a subscription you use subscription and a recurring price. The library is version 17.0.0 from PyPI in an isolated environment, and the request was only built, not sent.

# Builds a Checkout Session WITHOUT contacting Stripe: a fake HTTP client
# records the request the official library would send. The key is fake.
import json
import stripe

class FakeClient(stripe.HTTPClient):
    def __init__(self):
        super().__init__()
        self.richieste = []
        self.name = "fake-client"
    def request(self, method, url, headers, post_data=None, **kwargs):
        self.richieste.append((method, url, post_data))
        corpo = json.dumps({"id": "cs_test_fake", "object": "checkout.session",
                            "url": "https://checkout.example/fake"})
        return corpo, 200, {}

cliente_http = FakeClient()
client = stripe.StripeClient("sk_test_fake_key", http_client=cliente_http)
sessione = client.v1.checkout.sessions.create({
    "mode": "payment",                       # one-off payment; "subscription" for subscriptions
    "line_items": [{"price_data": {"currency": "eur",
                                   "unit_amount": 900,      # cents: 9.00 EUR
                                   "product_data": {"name": "Tool licence"}},
                    "quantity": 1}],
    "success_url": "https://example.test/thanks",
    "cancel_url": "https://example.test/cancelled",
})
metodo, url, dati = cliente_http.richieste[0]
print(metodo, url)
for campo in sorted(dati.split("&")):
    print("  ", campo)
print("fake response:", sessione.id)

# Output:
# post https://api.stripe.com/v1/checkout/sessions
#    cancel_url=https%3A%2F%2Fexample.test%2Fcancelled
#    line_items[0][price_data][currency]=eur
#    line_items[0][price_data][product_data][name]=Tool+licence
#    line_items[0][price_data][unit_amount]=900
#    line_items[0][quantity]=1
#    mode=payment
#    success_url=https%3A%2F%2Fexample.test%2Fthanks
# fake response: cs_test_fake

The fields are sorted in the output to make the result deterministic: the library does not guarantee the order. In a real application you do not write the fake client: you use it only to prove that the integration sends what you think. The scheme to adapt is the client.v1.checkout.sessions.create block with your secret key, taken from an environment variable, never from the code. I did not run it against Stripe, because it needs an account and a key: what follows after payment, that is the webhook, is the part where mistakes really happen.

The webhook: do not trust the customer, trust the signature

The customer comes back to your confirmation page, but that page does not prove they paid: anyone can type the address. The proof comes from Stripe's server, which calls an address of yours with a signed event. The documentation says to verify the Stripe-Signature header with the webhook secret, to use the raw body of the request and to answer with a 2xx before any long processing. It also recommends recording event identifiers, because the same event may arrive more than once.

The program below applies these rules with the official function. The function gestisci verifies the signature, discards duplicates (the set gia_visti stands in for a table with a uniqueness constraint) and tells the events apart. The secret is a test string, and the header is built by the library's test function. The five tests cover the happy path, the duplicate, an altered body, a signature an hour old and a missing signature. The in-memory set does not survive a restart or several processes: in production you need a shared store.

# Verification of a Stripe webhook, locally and without a network.
# The secret is an invented test string: the real one (whsec_...) lives
# in your dashboard and must never be written in code.
import json
import time
import stripe

SEGRETO = "whsec_local_test_not_real"
gia_visti = set()  # in production: a table with a uniqueness constraint on the event id

def gestisci(corpo_grezzo: str, intestazione: str):
    """Returns (HTTP code, message). The body must be the RAW one."""
    try:
        evento = stripe.Webhook.construct_event(corpo_grezzo, intestazione, SEGRETO)
    except stripe.SignatureVerificationError as errore:
        return 400, "signature rejected: " + str(errore).split(" (")[0].split(".")[0]
    if evento["id"] in gia_visti:
        return 200, "duplicate ignored"      # answer 2xx, otherwise Stripe retries
    gia_visti.add(evento["id"])
    if evento["type"] == "checkout.session.completed":
        return 200, "payment recorded: " + evento["data"]["object"]["id"]
    return 200, "unhandled event: " + evento["type"]

corpo = json.dumps({"id": "evt_test_1", "object": "event",
                    "type": "checkout.session.completed",
                    "data": {"object": {"id": "cs_test_1", "object": "checkout.session"}}})
firma = stripe.WebhookSignature.generate_signature_header(corpo, SEGRETO)
vecchia = stripe.WebhookSignature.generate_signature_header(corpo, SEGRETO, int(time.time()) - 3600)

print("1 valid            ->", gestisci(corpo, firma))
print("2 same event       ->", gestisci(corpo, firma))
print("3 body tampered    ->", gestisci(corpo.replace("cs_test_1", "cs_test_2"), firma))
print("4 signature 1h old ->", gestisci(corpo, vecchia))
print("5 no signature     ->", gestisci(corpo, ""))

# Output:
# 1 valid            -> (200, 'payment recorded: cs_test_1')
# 2 same event       -> (200, 'duplicate ignored')
# 3 body tampered    -> (400, 'signature rejected: No signatures found matching the expected signature for payload')
# 4 signature 1h old -> (400, 'signature rejected: Timestamp outside the tolerance zone')
# 5 no signature     -> (400, 'signature rejected: No Stripe-Signature header value was provided')

In the result, the second event answers 200 even though it is a duplicate: Stripe retries failed deliveries, so answering a duplicate with an error would generate more of them. The third case confirms that changing even one character of the body invalidates the signature, and the fourth that an old signature is rejected, with a default tolerance of five minutes in the library, as the documentation says. Verification also fails if a framework rewrites the body before you read it: it is the most frequent mistake, and Stripe explicitly points it out. A rejection with code 400 is the right answer to a wrong signature.

Obligations towards the consumer at checkout

If you sell to consumers with a distance contract, the Italian Consumer Code (Codice del consumo) provides a right of withdrawal of fourteen days (art. 52, legislative decree 206/2005). For digital content supplied without a tangible medium, art. 59, paragraph 1, letter o), provides an exception: performance must have begun and three conditions must be met, namely the consumer's express consent to the start during the withdrawal period, their acknowledgement that they thereby lose the right, and the trader's confirmation provided for by articles 50, paragraph 2, or 51, paragraph 7. That is why many checkouts add two checkboxes, one for each declaration.

It is not a magic formula: the exception concerns digital content whose performance has already begun, and the text of the consent must be written well. The text to show, the moment of confirmation and the handling of subscription services are matters for a lawyer or for the accountant. What I can tell you is where to look.

Frequently asked questions

Does Stripe issue invoices on my behalf?

It depends on who the seller is. According to the comparison table in the documentation, with Stripe products other than Managed Payments the merchant of record is your business, so the invoicing obligations remain yours. What to do in practice depends on your tax regime and should be asked of the accountant.

Can I test the integration without real money?

Yes: Stripe provides test keys and test cards in test mode, but I did not use them here because they require an account. The program above tests the most delicate part, the signature, without a network.

Better a subscription or a one-off payment?

It depends on the product. A subscription makes revenue more predictable but requires handling renewals, expired cards and refunds; a one-off payment is simpler. With the fees seen above, very low prices are the most penalized.

What you can do now

You can tell who the seller is with Stripe and with a merchant of record, compare fees at equal price with a program you can update, build a checkout session without sending data, and verify a webhook with signature, tolerance and protection from duplicates. The next step is the positioning of the product, which you find in the article on organic marketing; for the overall cost of the infrastructure go back to stack and costs, and for the launch risks to the pre-mortem.

When payments work, customers' questions arrive: how to organize them is explained in the article on solo support, while the numbers of a launch plan are read more cautiously after the article on success stories.

Sources

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects