Santiant
← Technical cases
Odoo Backend Subscriptions Integrations SaaS Data Migration

Billing and subscription portal on Odoo for a SaaS platform

Case study on building a billing back office in Odoo 19 integrated with an external SaaS platform via REST API, JWT SSO and async webhooks, keeping the external system as the single source of truth.

S
Santiago Moreno Arce ·

Summary

A subscription SaaS platform needed a billing, accounting and subscription lifecycle management back office that would coexist with an external system where all customer and product data already lived. The work involved building, on top of Odoo 19, a modular portal integrated via REST API, SSO and webhooks — keeping the external platform as the single source of truth — and writing the migration scripts needed to import the history of customers, subscriptions and orders from the previous system without duplicating data or losing state.

Context

The company operated a mobile and web application with thousands of active users and a subscription-based revenue model, with several product modalities (main service, associated modules, add-ons) and different payment methods (card, bank transfer, annual and quarterly cycles).

The product’s technical foundation already existed: a proprietary platform handling authentication, content and end-user communication. In parallel, several operational functions (recurring subscription management, billing and accounting, referral and promotion management, part of customer analytics) lived in separate SaaS tools. Each solved its own part well, but together they generated accumulated recurring costs, data spread across systems and significant manual work to reconcile them.

The decision to build on Odoo 19 was not a framework preference but a consolidation logic: a single platform could progressively absorb those pieces, providing accounting, invoicing, product management and customer portals out of the box, while leaving the external system as the master of user identity. The challenge was not Odoo itself, but integrating it correctly with the external platform without duplicating logic or breaking the end-user experience.

Problem

  • The external system was the master of customer data, but Odoo by default assumes it is the source of truth.
  • Users needed to access the billing portal from the app without re-authenticating, maintaining session across multiple subdomains.
  • Subscriptions had non-trivial business rules: different trial periods per product and payment method, mutually incompatible plans, billing cycles that cannot be mixed and a maximum of active products per type.
  • The company operated with multiple legal entities and needed certain invoices to be automatically reflected between companies.
  • Every relevant subscription change (signup, payment, cancellation) had to reach the external platform in real time, without blocking accounting operations if the endpoint was down.
  • There was a significant history of customers, addresses, subscriptions (in various states) and orders in the previous system that needed to be imported into the new environment, preserving relationships, states and traceability.

A naive integration would have meant error-prone manual configurations, duplicate customer records, duplicated emails from two systems and poor traceability when something failed.

Goals

  • Turn Odoo into a billing back office without operators or customers perceiving it as a separate system.
  • Progressively consolidate in one platform functions that previously lived in separate SaaS tools, reducing recurring operational cost.
  • Keep the external platform as the single source of truth for customers.
  • Model the complete subscription lifecycle inside Odoo, with explicit business rules.
  • Guarantee reliable two-way communication between Odoo and the external platform.
  • Migrate the history from the previous system in a controlled, repeatable and reversible way in staging.
  • Leave operational configuration in the hands of the admin team, without needing to touch code.

Technical approach

The work was structured into eleven Odoo modules with well-defined responsibilities. The modular separation was a conscious decision: each functional block (synchronization, authentication, subscriptions, payments, webhooks, referrals, portal) could evolve and be deployed independently.

Customer synchronization via API. A REST endpoint was exposed as the sole channel for customer creation and updates. Each record carries an external identifier that acts as a link key; email is kept only as a fallback. Self-registration was disabled and the default Odoo emails (welcome, password reset) were silenced so all user communication remained centralized in the external platform.

JWT SSO with cross-subdomain session. An authentication endpoint was built that validates a short-lived signed JWT (HS256, 60 seconds), with reuse detection via JTI and full logging of each attempt. Only portal-type users are accepted; internal users are excluded by design. The session cookie is anchored to the parent domain, keeping the session active when navigating between subdomains.

Subscription modeling with explicit rules. Subscriptions were taxonomized by product type and payment method. The trial period is configured at the product and method level, not as a global constant. If the payment method cannot be determined with certainty when confirming the order, no trial is granted: the conservative option always prevails. Cart and back-office orders have a four-layer protection (maximum quantities per line, automatic replacement between equivalent plans, prevention of active duplicates and billing cycle consistency) so no flow — public or internal — can leave the system in an invalid state.

Async outgoing webhooks to the external platform. Each relevant change (subscription confirmed, payment confirmed, cancellation) emits an outgoing webhook. Delivery is delegated to queue_job when available, with exponential backoff and automatic retries; if the library is not installed, the system falls back to synchronous delivery without breaking the operation. Each attempt is logged with URL, payload, HTTP code, response and next retry, accessible from the back office. A webhook failure never interrupts the accounting operation.

Multi-company with reflected invoicing. When an invoice is posted in the main company, the system automatically generates mirror documents (sale order, customer invoice, purchase order and supplier invoice) in linked companies, according to rules configured at the product level. Failures are isolated: if a secondary company fails, the others and the original invoice remain intact.

Portal lockdown and webview mode. The portal was modified so customers cannot modify data that resides in the external system (addresses, security, profile). A session variable is injected from the JWT to hide the header, navigation and footer when rendered inside the app, giving a native experience without duplicating templates.

History migration scripts. Before the production cutover, the customers, addresses, subscriptions (in their various states) and past orders from the previous system needed to be imported into the new environment. A battery of independent migration scripts was written, one per entity, executable in order and idempotent:

  • Each script reads from its source (structured export or legacy API) and normalizes data to an intermediate structure before touching Odoo.
  • Data model mappings were resolved: subscription states (active, in trial, expired, paused, cancelled), payment methods, product types, billing cycles, countries and provinces by ISO code or name, currencies and languages.
  • Relationships between entities were preserved (customer → addresses, customer → subscriptions, subscription → product and cycle, subscription → historical payments) and external identifiers were kept so live synchronization afterward would link without duplicating.
  • Each script is idempotent: when re-executed it detects already-created records by their external identifier and updates them instead of duplicating.
  • A full dry-run mode was included that reports what would be created, what would be updated and which references would remain unresolved, without touching the database.
  • Processing is done in batches and error logging is emitted per record, not per batch, so an isolated failure never stops the full migration.

This allowed rehearsing the migration several times in staging before the final cutover, adjusting mappings as edge cases appeared (customers with multiple addresses of the same type, subscriptions in grace period with no associated payment, products discontinued in the previous system but still referenced by active subscriptions).

Decisions and trade-offs

Building a custom billing system was ruled out because Odoo already provided accounting, tax handling, document management and customer portals. The cost of staying within the framework was lower than reinventing it.

Modularization was prioritized even though it meant more files and more deployment surface. The alternative — a single monolithic module — would have made any future change much riskier and would have required deploying everything at once each time.

In critical flows, the conservative option was chosen over the permissive one: no trial if payment method cannot be identified, no webhooks during migrations, satellite system failures never block the main one. The cost is occasional friction; the benefit is that the system does not enter inconsistent states.

Migration scripts were designed as maintainable, repeatable code, not throwaway scripts. The one-shot dump alternative would have been faster to write but would have required starting from scratch at every incident or staging round. The extra cost of making them idempotent with dry-run paid for itself on the first repetition.

All relevant configuration (JWT secret, cookie domain, webhook URL, trial days per product, multi-company mappings) lives in the settings panel. This transfers control to the operations team without needing to touch code or redeploy.

Result

  • A fully operational billing and subscription back office, integrated with the main platform without duplicating data sources.
  • End users move between app and portal without perceiving them as separate systems.
  • The operations team manages signups, renewals, payments, promotions and referrals without technical intervention.
  • Outgoing webhooks survive temporary receiver outages without losing events.
  • The cutover from the previous system ran against a history already rehearsed multiple times in staging, with no loss of relationships or subscription states.
  • Modular separation allowed adding subsequent features (referrals, promotions, payment flow adjustments) without touching the synchronization or SSO core.

Tech stack

  • Odoo 19 (Community/Enterprise) on Odoo.sh
  • Python
  • PostgreSQL
  • REST API + JWT (HS256)
  • OCA queue_job (with synchronous fallback)
  • Outgoing webhooks with exponential backoff
  • Idempotent migration scripts with dry-run and batch processing
  • Stripe as payment provider
  • XML / QWeb for views and portal

What this case demonstrates

This case demonstrates the ability to design a modular backend on Odoo in a context where the ERP is not the source of truth but a specialized satellite for billing, and to accompany that design with a careful migration of the previous history. It combines architectural judgment (clear separation of responsibilities, resilient integrations, configuration outside code) with operational sensitivity (defensive business rules, repeatable migration scripts, traceability of every event, coherent end-user experience).


If your product has its own system as the source of truth and needs a billing back office, ERP or customer portal without duplicating data or losing control over business rules, I can help you design and build it.