Das offizielle Image von Odoo 19 Community bringt 685 Module mit, und keines davon kann wiederkehrende Abrechnung. Es gibt kein sale_subscription und kein Feld recurring_invoice, von dem man erben könnte. Sobald ein Projekt denselben Kunden also jeden Monat belasten muss, passiert das Geld woanders, und es bleibt eine Frage: was kommt zurück, und wie weit darf man dem trauen.
Auf agrobessarabia.com verkaufen wir zwei Dinge — ein Abo für den Eintrag im Verzeichnis und einen Platz auf der Startseite —, abgerechnet wird bei Paddle. Es folgt der Konnektor dazwischen, mit dem Code, der die Arbeit macht. Es ist ein eigenes Odoo-Modul, und die Website ist sein erster Kunde.
Ein Merchant of Record ist kein Payment-Gateway
Bei einem Gateway bleiben Sie der Verkäufer: die Umsatzsteuer im Land des Käufers ist Ihre, die Rechnung stellen Sie, das Chargeback klären Sie. Paddle ist Merchant of Record — verkauft im eigenen Namen an Ihren Kunden, berechnet die Steuer je Land, führt sie ab und zahlt Ihnen den Rest aus. Für ein kleines Team, das über Grenzen hinweg verkauft, fällt damit eine ganze Compliance-Ebene weg, und keine Kartennummer erreicht jemals die Odoo-Datenbank.
Der Preis dafür ist Kontrolle und ein Anteil am Umsatz. Und eine Folge, aus der jede Zeile weiter unten wächst: Odoo sieht die Zahlung nie. Es sieht einen Strom von Aussagen über ein Abo, geliefert über HTTP von einem System, das allen Grund hat, sich zu wiederholen.
Die ganze Integration ist ein Endpunkt und ein Flag
Der Konnektor nimmt signierte Webhooks an, protokolliert jeden davon, spiegelt das Abo in einen Datensatz und gibt genau einen booleschen Wert heraus: ob der Zugang dieses Kunden gerade bezahlt ist. Alles, was die Website damit tut — ein Lagerhaus im Verzeichnis halten, einen Platz auf der Startseite halten —, liest dieses Flag und sonst nichts.
Die Oberfläche ist bewusst klein. Nicht klein ist etwas anderes: die Zahl der Arten, auf die die Zustellung dieser Aussagen schiefgeht.
Eine 200 heißt zugestellt — also nie versehentlich antworten
Die Routentypen in Odoo sind hier nicht austauschbar. Eine jsonrpc-Route antwortet immer mit HTTP 200 und legt den Fehler in den Body. Für Paddle heißt 200 zugestellt, und ein zugestelltes Event kommt nie wieder: eine Exception im Handler wird zu einer Zahlung, von der niemand erfährt. Nur type='http' lässt den Handler den Statuscode wählen.
@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")Deshalb sind die Codes bewusst gewählt. Eine 503, solange das Secret fehlt, denn das wird wiederholt und das Event kommt zurück, sobald jemand es einträgt. Eine 401 bei falscher Signatur, denn eine Fälschung und ein frisch rotiertes Secret sind von hier aus nicht zu unterscheiden, und den zweiten Fall heilt der Retry selbst. Eine 500 erst nach dem Rollback der Transaktion, denn ein halb angewandter Zustand ist schlimmer als ein nicht angenommenes Event.
Die Signatur deckt Bytes ab, die Sie noch nicht geparst haben
Der Header ist ts=<unix>;h1=<hex>, signiert werden der Zeitstempel, ein Doppelpunkt und der rohe Request-Body. Roh ist der ganze Punkt: JSON parsen und zurück serialisieren ändert Schlüsselreihenfolge und Leerzeichen, und die Bytes passen nicht mehr zu den signierten. In Odoo heißt das, request.httprequest.get_data() zu lesen, bevor json.loads den Body anfasst.
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())Zwei Details, bei denen man leicht falsch liegt. In konstanter Zeit vergleichen — ein zeichenweiser Vergleich von Hex-Strings verrät die Antwort über die Antwortzeit. Und einen Zeitstempel nicht nur aus der Vergangenheit, sondern auch aus der Zukunft ablehnen: sonst öffnet eine vorgelaufene Uhr beim Absender ein Replay-Fenster von einem Tag.
Die Toleranz liegt hier bei fünf Minuten, während Paddles eigenes SDK standardmäßig fünf Sekunden nimmt. Das ist eine bewusste Lockerung. Ein selbst gehostetes Odoo ist keine Maschine mit disziplinierter Uhr, und ein Fünf-Sekunden-Fenster macht aus jedem NTP-Aussetzer ein abgelehntes Zahlungsereignis — fünf Minuten sind dabei immer noch viel zu kurz, um einen Request aus einem Log erneut abzuspielen.
Jedes Event kommt mindestens zweimal an
Paddle wiederholt bis zur ersten 2xx: in Live bis zu 60 Versuche über etwa drei Tage, jedes Mal mit derselben event_id. Ein Event zweimal zu verarbeiten ist also kein Randfall, den man später absichert, sondern der normale Verkehr. Die Absicherung ist ein Journal mit eindeutigem Schlüssel, und das Insert passiert zuerst, bevor irgendein Zustand angefasst wird.
@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, FalseDrei Details zählen, und keines davon ist von außen sichtbar. Die Eindeutigkeit muss der Index erzwingen, nicht eine Suche vor dem Insert: zwei gleichzeitige Zustellungen bestehen diese Suche beide. Das Flush muss innerhalb des Savepoints passieren, sonst wird der Constraint später außerhalb geprüft und reißt die ganze Transaktion mit statt nur einer Anweisung. Und der Savepoint gehört in einen Context-Manager: Savepoint.close() rollt in Odoo standardmäßig zurück und entfernt still die gerade geschriebene Zeile.
Zustellungen sind nicht sortiert
Der Retry eines älteren Events kann nach einem neueren eintreffen. Blind angewandt, erweckt er ein gekündigtes Abo wieder oder schaltet ein aktives mit einem Update ab, das vor Stunden überholt wurde. Deshalb merkt sich jedes Abo, wann sein zuletzt angewandtes Event stattfand, und alles Ältere wird protokolliert und ignoriert.
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 ist ein zahlender Kunde
Scheitert eine Belastung, kündigt Paddle nichts: das Abo geht auf past_due, und die Wiederholungen laufen tagelang. Das als unbezahlt zu behandeln sperrt jemanden aus, dessen Karte schlicht abgelaufen ist — genau an dem Tag, an dem er es am ehesten korrigiert. Der Zugang fällt bei paused und canceled, nicht früher.
# 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_STATUSESDerselbe Fehler in zweiter Gestalt. Eine Kündigung zum Periodenende kommt als geplante Änderung, während der Status active bleibt. Halten Sie sie in einem eigenen Feld, sonst sperren Sie einen Kunden aus, der noch drei Wochen bezahlt hat.
Ein Preis, den Sie noch nie gesehen haben, ist kein Müll
Ein Event mit einem Preis, der nicht im Plankatalog steht, ist echt, bezahlt und nicht anwendbar. Wegwerfen verliert die Zahlung; mit einem Fehler zu antworten lässt Paddle drei Tage lang etwas wiederholen, das nur ein Mensch beheben kann. Also wird das Abo ohne Plan gespeichert, das Event bleibt als unmapped liegen, und der Endpunkt antwortet 2xx. Es wartet unter dem Filter „Needs attention", bis der Katalog stimmt, und wird von dort erneut eingespielt.

Das Journal eingehender Events: jede Zustellung, ihr Status und der Payload, mit dem sie kam.
Fünf Sekunden sind das ganze Budget
Paddle wertet eine Zustellung als fehlgeschlagen, wenn binnen fünf Sekunden niemand geantwortet hat, und das gibt dem Handler seine Form: prüfen, protokollieren, Spiegel schreiben, antworten. Alles Schwerere — Freischaltung, E-Mails, Abgleich über die API — gehört in einen Cron oder eine Queue, die nach der Antwort läuft.
Wie das auf agrobessarabia.com aussieht
Der Einstellungsbildschirm ist die gesamte Konfiguration: die URL des Endpunkts zum Einfügen in Paddle, das Signing Secret dieses Notification Destination, die Umgebung und der eigene API-Schlüssel des Käufers, beschränkt auf Systemadministratoren. Sandbox und Produktion sind getrennte Destinations mit getrennten Secrets, und ein falsch signiertes Event wird abgelehnt.

Konnektor-Einstellungen in Odoo 19: Webhook-URL zum Einfügen in Paddle, Signing Secret und Umgebung.
Der Konnektor selbst weiß nichts über Lagerhäuser, Spediteure oder Startseiten, und das soll er auch nicht: er ist ein eigenes Produkt mit eigener Lizenz und eigenen Tests. Die Verbindung liegt in unserem Modul, als Erweiterung seines Modells — zwei Dutzend Zeilen, die aus „das Abo hat sich geändert" ein „diese Platzierung ist sichtbar oder nicht" machen.
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 resultDarunter liegt eine Regel, die man aussprechen sollte: unser Katalog schreibt in den Katalog des Konnektors, nie umgekehrt. Zwei Quellen der Wahrheit dafür, welcher Preis welchen Plan bedeutet, driften binnen eines Monats auseinander, und jede Abweichung taucht als unmapped-Event auf, das längst niemand mehr ansieht.

Gespiegelte Abos: Status, Abrechnungszeitraum, Plan und genau das Zugangs-Flag, das die Website liest.
Drei Fragen, bevor man so etwas selbst baut
Was antwortet der Endpunkt, wenn Ihre eigene Datenbank weg ist, und holt diese Antwort das Event zurück?
Welches Feld verbindet eine Zahlung mit einem Kunden, und wer bestimmt seinen Wert — Sie oder die Person, die im Checkout tippt?
Woran sehen Sie morgen früh, dass die Events der Nacht alle angewandt wurden?
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.