Odoo 19 comes equipped with a robust payment provider framework, which allows easy integration with third-party providers or developing your own payment providers from scratch. In this blog, we shall be creating our very own CybroPay module, where we register two unique providers: CybroPay Online, an external/redirect flow-based provider, and CybroPay Direct, an inline/direct flow-based provider. At the end of the tutorial, we will get to know all aspects of this module development process.
Module Structure Overview
Before writing any code it helps to see the full directory layout of the module:
payment_cybropay/
+-- __init__.py
+-- __manifest__.py
+-- controllers/
¦ +-- __init__.py
¦ +-- main.py
+-- data/
¦ +-- payment_method_data.xml
¦ +-- payment_provider_data.xml
+-- models/
¦ +-- __init__.py
¦ +-- payment_provider.py
¦ +-- payment_transaction.py
+-- security/
¦ +-- ir.model.access.csv
+-- static/src/
¦ +-- js/payment_form.js
¦ +-- xml/payment_cybropay_templates.xml
+-- views/
+-- payment_cybropay_templates.xml
+-- payment_provider_views.xml
Step 1 — Module Manifest (__manifest__.py)
The manifest declares dependencies, data files, frontend assets, and the init/uninstall hooks that wire the module into Odoo's payment registry.
{
'name': 'CybroPay Payment Provider',
'version': '1.0.0',
'category': 'Accounting/Payment Providers',
'depends': ['payment', 'account_payment'],
'data': [
'security/ir.model.access.csv',
'views/payment_provider_views.xml',
'views/payment_cybropay_templates.xml',
'data/payment_method_data.xml',
'data/payment_provider_data.xml',
],
'assets': {
'web.assets_frontend': [
'payment_cybropay/static/src/js/payment_form.js',
'payment_cybropay/static/src/xml/payment_cybropay_templates.xml',
],
},
'post_init_hook': 'post_init_hook',
'uninstall_hook': 'uninstall_hook',
'license': 'LGPL-3',
}Key Points
- Requires both 'payment' and 'account_payment', wherein the payment library offers the base models, whereas account_payment connects the providers with the bank journal entries.
- The file data is sequentially imported. Security must be first, followed by views, and finally the records.
- The Frontend JS and QWeb templates are added to web.assets_frontend to be included in the customer check-out page.
- post_init_hook and uninstall_hook invoke Odoo's internal utilities setup_provider and reset_payment_provider respectively.
Step 2 — Initialization Hooks (__init__.py)
The root __init__.py imports models and controllers and defines the hooks that Odoo calls automatically after installation and before uninstallation.
from . import models
from . import controllers
from odoo.addons.payment import setup_provider, reset_payment_provider
def post_init_hook(env):
setup_provider(env, 'cybropay')
setup_provider(env, 'cybropay_direct')
def uninstall_hook(env):
reset_payment_provider(env, 'cybropay')
reset_payment_provider(env, 'cybropay_direct')
Why hooks?
The setup_provider method is responsible for creating the necessary account.payment.method records in Odoo. These records allow Odoo to link each payment provider with a corresponding bank journal.
If this step is skipped, the provider may still be installed successfully, but it won’t appear in the checkout widget—because Odoo has no payment method configured to connect it with the accounting side.
Step 3 — Defining Payment Methods and Providers (XML)
1. Payment Methods (data/payment_method_data.xml)
Each provider needs a corresponding payment.method record. CybroPay Online supports tokenization; CybroPay Direct does not.
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="payment_method_cybropay" model="payment.method">
<field name="name">CybroPay Online</field>
<field name="code">cybropay</field>
<field name="sequence">10</field>
<field name="support_tokenization">True</field>
</record>
<record id="payment_method_cybropay_direct" model="payment.method">
<field name="name">CybroPay Direct</field>
<field name="code">cybropay_direct</field>
<field name="sequence">11</field>
<field name="support_tokenization">False</field>
</record>
</data>
</odoo>
2. Payment Providers (data/payment_provider_data.xml)
Provider records tie each code to its QWeb form template. CybroPay Online uses a redirect form; CybroPay Direct uses an inline form rendered inside the checkout page.
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="payment_provider_cybropay" model="payment.provider">
<field name="name">CybroPay Online</field>
<field name="code">cybropay</field>
<field name="redirect_form_view_id"
ref="payment_cybropay_redirect_form"/>
<field name="payment_method_ids"
eval="[(6, 0, [ref('payment_method_cybropay')])]"/>
</record>
<record id="payment_provider_cybropay_direct" model="payment.provider">
<field name="name">CybroPay Direct</field>
<field name="code">cybropay_direct</field>
<field name="inline_form_view_id"
ref="payment_cybropay_inline_form"/>
<field name="payment_method_ids"
eval="[(6, 0, [ref('payment_method_cybropay_direct')])]"/>
</record>
</data>
</odoo>
Step 4 — Backend Models
1. Provider Model (models/payment_provider.py)
Inherits from payment.provider and extends the code field Selection to incorporate the new provider codes. The ondelete action will ensure that should the provider be deleted, then the value of the field will default to 'set default.'
from odoo import fields, models
class PaymentProvider(models.Model):
_inherit = 'payment.provider'
code = fields.Selection(selection_add=[
('cybropay', 'CybroPay Online'),
('cybropay_direct', 'CybroPay Direct'),
], ondelete={
'cybropay': 'set default',
'cybropay_direct': 'set default',
})
def _get_default_payment_method_codes(self):
res = super()._get_default_payment_method_codes()
if self.code == 'cybropay': return {'cybropay'}
if self.code == 'cybropay_direct': return {'cybropay_direct'}
return res
2. Transaction Model (models/ payment_transaction.py)
In the transaction model, the actual payment flow is controlled. Here, three methods are overridden:
from odoo import models
class PaymentTransaction(models.Model):
_inherit = 'payment.transaction'
def _get_specific_rendering_values(self, processing_values):
"""Provides the values needed to render the QWeb redirect form."""
res = super()._get_specific_rendering_values(processing_values)
if self.provider_code != 'cybropay':
return res
return {
'api_url': '/payment/cybropay/simulate_payment',
'reference': self.reference,
}
def _extract_amount_data(self, payment_data):
"""Skip amount validation for simulated providers."""
if self.provider_code in ('cybropay', 'cybropay_direct'):
return None
return super()._extract_amount_data(payment_data)
def _apply_updates(self, payment_data):
"""Set the transaction state based on the simulated status."""
super()._apply_updates(payment_data)
if self.provider_code not in ('cybropay', 'cybropay_direct'):
return
status = payment_data.get('status')
if status == 'done': self._set_done()
elif status == 'cancel': self._set_canceled()
Method Breakdown
- _get_specific_rendering_values — retrieves the object that will be used as QWeb context in the rendering of the redirection template. This only works for 'cybropay'.
- _extract_amount_data — returning None disables the Odoo system-wide amount validation functionality. It makes sense for simulation backends that don't provide much information.
- _apply_updates — this method works when the backend has processed a payment notification sent from Odoo. Changes the 'status' value into the appropriate state transition method (_set_done/_set_canceled).
Step 5 — UI Templates (views/payment_cybropay_templates.xml)
Two QWeb templates handle the two payment flows. These live in the views/ directory and are loaded as standard Odoo view records.
1. Redirect Form (CybroPay Online)
When the customer clicks Pay, Odoo renders this form and auto-submits it via POST to the provider URL. The reference is passed as a hidden field.
<template id="payment_cybropay_redirect_form">
<form t-att-action="api_url" method="post">
<input type="hidden" name="reference"
t-att-value="reference"/>
<button type="submit"
class="btn btn-primary d-none">Pay</button>
</form>
</template>
2. Inline Form (CybroPay Direct)
The inline form renders directly inside the checkout page without redirecting. It exposes a dropdown letting users simulate a successful or cancelled payment.
<template id="payment_cybropay_inline_form">
<div t-attf-id="cybropay-direct-container-{{provider_id}}">
<select id="cybropay_simulated_state" class="form-select">
<option value="done">Successful</option>
<option value="cancel">Cancelled</option>
</select>
</div>
</template>
t-attf-id vs t-att-id: t-attf-id allows f-string-style interpolation ({{provider_id}}), making the container ID unique when multiple providers share the same page.
Step 6 — JavaScript Integration (static/src/js/payment_form.js)
The JS file patches Odoo'sPaymentForm widget to inject CybroPay-specific behaviour for the direct flow.
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { rpc } from "@web/core/network/rpc";
import { PaymentForm } from "@payment/interactions/payment_form";
patch(PaymentForm.prototype, {
async _prepareInlineForm(providerId, providerCode,
paymentOptionId, paymentMethodCode, flow) {
if (providerCode !== 'cybropay_direct') {
await super._prepareInlineForm(...arguments);
return;
}
if (flow === 'token') return;
this._setPaymentFlow('direct');
},
async _processDirectFlow(providerCode, paymentOptionId,
paymentMethodCode, processingValues) {
if (providerCode !== 'cybropay_direct') {
await super._processDirectFlow(...arguments);
return;
}
const simulatedState =
document.getElementById('cybropay_simulated_state')
?.value || 'done';
rpc('/payment/cybropay/direct/simulate_payment', {
reference: processingValues.reference,
simulated_state: simulatedState,
}).then(() => {
window.location = '/payment/status';
}).catch((error) => {
this._displayErrorDialog(
"Payment processing failed", error.message);
this._enableButton();
});
},
});
What Each Patch Does
- _prepareInlineForm — captures the initialization process of the inline form specifically for cybropay_direct. Uses _setPaymentFlow('direct') to instruct Odoo that it should follow the direct path.
- _processDirectFlow — takes the state from the dropdown, makes an RPC request to the controller, and redirects to /payment/status if successful, or shows an error dialog if unsuccessful.
Error handling: The .catch block re-enables the Pay button (_enableButton) so the user can retry without refreshing the page.
Step 7 — Controller (controllers/main.py)
The controller exposes two HTTP routes — one for each provider flow. Both receive payment data, construct a standardised payment_data dictionary, and hand it to payment.transaction._process().
from odoo import http
from odoo.http import request
class CybroPayController(http.Controller):
# -- CybroPay Direct (JSON-RPC, inline flow) ------------------
@http.route('/payment/cybropay/direct/simulate_payment',
type='jsonrpc', auth='public')
def cybropay_direct_simulate_payment(self, **data):
payment_data = {
'reference': data.get('reference'),
'status': data.get('simulated_state', 'done'),
}
request.env['payment.transaction'].sudo() \
._process('cybropay_direct', payment_data)
# -- CybroPay Online (HTTP POST, redirect flow) ----------------
@http.route('/payment/cybropay/simulate_payment',
type='http', auth='public',
methods=['POST'], csrf=False)
def cybropay_simulate_payment(self, **data):
payment_data = {
'reference': data.get('reference'),
'status': data.get('status', 'done'),
}
request.env['payment.transaction'].sudo() \
._process('cybropay', payment_data)
return request.redirect('/payment/status')
Route Design Differences
- The CybroPay Direct service has type='jsonrpc', as it is accessed through rpc() from JavaScript. The response will be JSON-encoded automatically by Odoo.
- The CybroPay Online service has type='http' with methods=['POST'], csrf=False, because the data is submitted through a standard HTML form POST request. A manual response is needed.
- Both services need sudo(), as there is no write access for public users to payment.transaction entries.
Step 8 — Backend View Extension (views/payment_provider_views.xml)
The provider views file inherits the standard payment.provider form and adds a placeholder inside the credentials group. For a real gateway this is where API key fields would be added.
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="payment_provider_form_cybropay" model="ir.ui.view">
<field name="name">payment.provider.form.cybropay</field>
<field name="model">payment.provider</field>
<field name="inherit_id"
ref="payment.payment_provider_form"/>
<field name="arch" type="xml">
<group name="provider_credentials" position="inside">
<!-- Add API key / secret fields here for a real gateway -->
</group>
</field>
</record>
</odoo>
Understanding the Two Payment Flows
CybroPay Online — Redirect Flow
This flow closely reflects how real payment gateways like Stripe and PayPal operate in redirect mode:
- The customer clicks Pay on the Odoo checkout page.
- Odoo generates a QWeb form and automatically submits it via a POST request to /payment/cybropay/simulate_payment.
- The controller then handles the payment and redirects the user to /payment/status.
- Finally, Odoo shows the payment result to the customer.
CybroPay Direct — Inline Flow
In the direct flow, the entire payment process happens without leaving the current page:
- The customer selects CybroPay Direct, and the inline form (such as a dropdown) appears right on the checkout page.
- When the customer clicks Pay, a JavaScript patch sends an RPC request to /payment/cybropay/direct/simulate_payment.
- The controller processes the payment on the server side using the submitted data.
- Once the payment is completed successfully, JavaScript redirects the user to /payment/status.
This approach creates a smoother experience, as the customer never has to navigate away from the checkout page.
Creating a tailor-made payment provider in Odoo 19 is a stepwise process. The example of CybroPay shows that, by having the appropriate knowledge about all layers—manifest, hooks, models, XML data, QWeb template, JS patching, and HTTP controller—it is possible to implement any gateway flawlessly.
For a production gateway, the key differences from this simulation module would be:
- Storing API credentials (keys, secrets) as fields on payment.provider, rendered in the backend view.
- Replacing the simulated status logic in _apply_updates with real signature verification of the webhook payload.
- Adding HTTPS signature checks in the controller before calling _process.
- Potentially adding tokenization support by implementing _send_payment_request for the direct flow.
To read more about How to Configure Payment Acquirers for Online Payments in Odoo 17 Accounting, refer to our blog How to Configure Payment Acquirers for Online Payments in Odoo 17 Accounting.