Odoo 19 Community ships 685 modules in the official image, and not one of them does recurring billing. There is no sale_subscription, and no recurring_invoice field to inherit from. So the moment a project has to charge the same customer every month, the money happens somewhere else, and the only question left is what comes back and how much of it can be trusted.
On agrobessarabia.com we sell two things — a listing subscription and a slot on the front page — and the charging belongs to Paddle. What follows is the connector between the two, with the code that does the work. It is an Odoo module of its own, and the site is its first customer.
A merchant of record is not a payment gateway
With a gateway you remain the seller: you owe the VAT in the buyer’s country, you issue the invoice, you answer the chargeback. Paddle is a merchant of record — it sells to your customer under its own name, charges the tax due in each jurisdiction, remits it, and pays you out. For a small team selling across borders that removes an entire compliance surface, and no card number ever reaches the Odoo database.
What you give up is control and a share of the revenue. And there is one consequence that shapes every line below: Odoo never sees the payment. It sees a stream of statements about a subscription, delivered over HTTP by a system that has every reason to repeat itself.
The whole integration is one endpoint and one flag
The connector accepts signed webhooks, journals every one of them, mirrors the subscription into a record, and exposes a single boolean: whether this customer’s access is paid for right now. Everything the site does with it — keeping an elevator in the directory, holding a slot on the front page — reads that flag and nothing else.
The surface is deliberately small. What is not small is the number of ways the delivery of those statements goes wrong.
A 200 means delivered, so never answer it by accident
Route types in Odoo are not interchangeable here. A jsonrpc route always answers HTTP 200 and puts the error inside the body. To Paddle, 200 means delivered, and a delivered event is never sent again: an exception in the handler becomes a payment nobody ever hears about. Only type='http' lets the handler choose the status code.
@http.route("/paddle/webhook", type="http", auth="public",
methods=["POST"], csrf=False, save_session=False)
def receive(self, **kwargs):
secret = request.env["ir.config_parameter"].sudo().get_param(
"paddle_connector.webhook_secret")
raw_body = request.httprequest.get_data()
header = request.httprequest.headers.get("Paddle-Signature")
# An empty secret means "not configured", not "accept anything".
# A 503 is retried, so the event comes back once it is set up.
if not secret:
return _reply("error", 503, message="webhook secret not configured")
# 401 rather than 200: a forgery and a rotated secret look the same
# from here, and a retry fixes the second case.
if not signature.verify(raw_body, header, secret):
return _reply("error", 401, message="invalid signature")So the codes are picked on purpose. A 503 while the secret is not configured, because that is retryable and the event should come back once somebody sets it up. A 401 for a bad signature, because a forgery and a freshly rotated secret are indistinguishable from here, and a retry fixes the second case by itself. A 500 only after the transaction has been rolled back, because partially applied state is worse than an event not accepted at all.
The signature covers bytes you have not parsed yet
The header is ts=<unix>;h1=<hex>, and what is signed is the timestamp, a colon, and the raw request body. Raw is the entire point: parse the JSON and serialise it back, and key order and whitespace change, and the bytes no longer match the ones that were signed. In Odoo that means reading request.httprequest.get_data() before anything touches json.loads.
def compute(body: bytes, secret: str, ts: int) -> str:
"""HMAC-SHA256 of "<ts>:" + body, lowercase hex."""
mac = hmac.new(secret.encode("utf-8"),
f"{ts}:".encode() + body,
hashlib.sha256)
return mac.hexdigest()
def verify(body, header, secret, tolerance=300, now=None):
parsed = parse_header(header) # ts=...;h1=...
if parsed is None:
return False
ts, digest = parsed
# Anti-replay. The future is rejected too: a sender whose clock ran
# ahead would otherwise open a window a day wide.
current = int(time.time()) if now is None else now
if abs(current - ts) > tolerance:
return False
# Constant time only: comparing hex strings character by character
# lets an attacker guess the signature from the response time.
return hmac.compare_digest(compute(body, secret, ts), digest.lower())Two details are easy to get wrong. Compare in constant time — comparing hex strings character by character leaks the answer through response timing. And reject a timestamp too far in the future as well as too far in the past: a sender whose clock has run ahead would otherwise open a replay window a day wide.
The tolerance here is five minutes, where Paddle’s own SDK defaults to five seconds. That is a deliberate loosening. A self-hosted Odoo is not a machine with a disciplined clock, and a five-second window turns every NTP hiccup into a rejected payment event — while five minutes is still far too short to replay a request somebody found in a log.
Every event arrives at least twice
Paddle retries until it gets a 2xx: in live, up to 60 attempts spread over roughly three days, carrying the same event_id every time. Handling one event twice is therefore not an edge case to be defended against later — it is the ordinary traffic. The defence is a journal with a unique key, and the insert is the first thing that happens, before any state is touched.
@api.model
def register(self, event_id, event_type, occurred_at, payload):
"""Register an event. Returns (record, is_duplicate)."""
try:
with self.env.cr.savepoint():
record = self.create({
"event_id": event_id, "event_type": event_type,
"occurred_at": occurred_at, "payload": payload,
})
# The flush is mandatory: without it UNIQUE is checked later,
# outside the savepoint, breaking the whole transaction.
record.flush_recordset()
except IntegrityError:
existing = self.search([("event_id", "=", event_id)], limit=1)
if existing.state in self.TERMINAL_STATES:
return existing, True
# pending/failed is a delivery interrupted midway. Hand it back
# for reprocessing instead of dismissing it as a duplicate.
existing.attempts += 1
return existing, False
return record, FalseThree details matter here and none of them is visible from outside. Uniqueness has to be enforced by the index rather than by a search before the insert, because two concurrent deliveries both pass that search. The flush has to happen inside the savepoint, or the constraint is checked later, outside it, and takes down the whole transaction instead of one statement. And the savepoint has to be used as a context manager: Savepoint.close() in Odoo rolls back by default, which quietly removes the row just written.
Deliveries are not ordered
A retry of an older event can land after a newer one. Applied blindly, it brings a cancelled subscription back to life, or switches off an active one with an update that was superseded hours ago. So every subscription remembers when its last applied event occurred, and anything older is journalled and ignored.
def _is_stale_event(self, occurred_at):
"""Paddle events are not ordered."""
self.ensure_one()
return bool(
self.last_occurred_at and occurred_at
and occurred_at < self.last_occurred_at
)past_due is a paying customer
When a charge fails, Paddle cancels nothing: the subscription moves to past_due and the retries run for days. Treating that as unpaid locks out somebody whose card merely expired, on exactly the day they are most likely to fix it. Access is revoked on paused and canceled, not before.
# past_due is here on purpose: it means "the last charge failed,
# Paddle is retrying". Access is revoked on paused/canceled.
ENTITLED_STATUSES = ("active", "trialing", "past_due")
@api.depends("status")
def _compute_is_entitled(self):
for record in self:
record.is_entitled = record.status in ENTITLED_STATUSESThe same mistake wears a second shape. A cancellation at period end arrives as a scheduled change while the status stays active. Keep it in its own field, or you will cut off a customer who has paid for the three weeks that are still left.
A price you have never seen is not garbage
An event naming a price that is not in the plan catalogue is authentic, paid for, and unusable. Dropping it loses the payment; raising an error makes Paddle retry for three days something only a human can fix. So the subscription is stored without a plan, the event is parked as unmapped, and the endpoint answers 2xx. It waits under a "Needs attention" filter until the catalogue is corrected, and is replayed from there.

The journal of incoming events: every delivery, its state, and the payload it arrived with.
Five seconds is the entire budget
Paddle marks a delivery failed if nothing answered within five seconds, and that sets the shape of the handler: verify, journal, write the mirror, answer. Everything heavier — provisioning, emails, reconciliation against the API — belongs to a cron or a queue that runs after the answer has gone out.
How it looks on agrobessarabia.com
The settings screen is the whole configuration: the endpoint URL to paste into Paddle, the signing secret of that notification destination, the environment, and the buyer’s own API key, restricted to system administrators. Sandbox and production are separate destinations with separate secrets, and an event signed with the wrong one is refused.

Connector settings in Odoo 19: the webhook URL to paste into Paddle, the signing secret and the environment.
The connector itself knows nothing about elevators, carriers or front pages, and it must not: it is a separate product with its own licence and its own tests. The link lives in our module, as an extension of the connector’s model — two dozen lines that turn "the subscription changed" into "this placement is visible, or it is not".
class PaddleSubscription(models.Model):
_inherit = "paddle.subscription"
def write(self, vals):
result = super().write(vals)
# Exactly the fields that visibility depends on.
if {"status", "current_period_end", "price_id"} & set(vals):
self._sync_agro_placement()
return resultOne rule underneath is worth stating plainly: our catalogue writes into the connector’s catalogue, never the other way round. Two sources of truth for "which price means which plan" would disagree within a month, and every disagreement would surface as an unmapped event that nobody looks at any more.

Mirrored subscriptions: status, billing period, plan, and the access flag the site actually reads.
Three questions worth asking before building one
What does the endpoint answer when your own database is down, and will that answer bring the event back?
Which field links a payment to a customer, and who controls its value — you, or the person typing into a checkout?
How do you find out tomorrow morning that last night’s events were all applied?
If you sell a subscription out of Odoo and the billing half is still a spreadsheet, write to me. The connector is a module of its own, it runs on Odoo 19, and it is not tied to our domain in any way.