Every Abandoned Cart Is a Payment Friction Problem
According to Baymard Institute, 22% of online shoppers abandon their cart because the checkout process is too complicated. Another 9% leave because there aren't enough payment methods. That means nearly one-third of your potential revenue evaporates at the moment the customer is ready to pay.
Odoo 19 ships with built-in support for Stripe, PayPal, Authorize.net, Adyen, Mollie, Razorpay, and several regional providers. But installing the module is the easy part. The hard part is configuring tokenization so returning customers pay in one click, handling webhooks so payment confirmations don't silently fail, supporting multi-currency checkout without rounding errors, and processing refunds that actually reconcile in your accounting.
This guide covers the complete payment provider setup for Odoo 19 -- from Stripe and PayPal configuration through tokenization, webhook handling, multi-currency payments, refunds, and building a custom gateway integration from scratch.
How Odoo 19 Payment Architecture Works Under the Hood
Before configuring any provider, you need to understand the data model. Odoo 19 uses a three-layer architecture for payments:
| Model | Purpose | Key Fields |
|---|---|---|
payment.provider | Configuration record for each gateway (credentials, features, state) | code, state, company_id, allow_tokenization |
payment.transaction | Individual payment attempt -- tracks state from draft to done/error | provider_id, state, amount, currency_id, provider_reference |
payment.token | Saved payment method for returning customers (card fingerprint, not raw PAN) | provider_id, partner_id, provider_ref, active |
The payment flow follows a strict state machine: draft → pending → authorized → done (or error / cancel at any step). Every state transition triggers specific accounting entries. Understanding this prevents the most common integration bug: confirming sales orders before the payment actually settles.
# Odoo 19 payment transaction states
TRANSACTION_STATES = [
('draft', 'Draft'), # Created, not yet sent to provider
('pending', 'Pending'), # Sent to provider, awaiting confirmation
('authorized', 'Authorized'), # Provider approved, capture pending
('done', 'Done'), # Payment captured successfully
('cancel', 'Canceled'), # Canceled by user or timeout
('error', 'Error'), # Provider returned an error
]
# Key transitions: authorized→done creates account.payment + journal entry
# done→refund creates reversed journal entry In Odoo 16 and earlier, the model was called payment.acquirer. Odoo 17 renamed it to payment.provider. If you're migrating custom payment modules from Odoo 16 or earlier, every reference to payment.acquirer must be updated. The old model name will raise MissingError at runtime.
Configuring Stripe in Odoo 19: API Keys, Payment Intents, and Checkout Flow
Stripe is the most popular payment provider for Odoo e-commerce. Odoo 19 uses the Payment Intents API (not the legacy Charges API), which supports SCA (Strong Customer Authentication) required by PSD2 in Europe.
Step 1: Install the Stripe Module
Navigate to Apps, search for "Stripe", and install payment_stripe. This also installs the base payment module if not already present.
Step 2: Create a Stripe Account and Retrieve API Keys
From your Stripe Dashboard, navigate to Developers → API Keys. You need two keys:
| Key | Format | Used For |
|---|---|---|
| Publishable Key | pk_live_... or pk_test_... | Client-side (Stripe.js) -- tokenizes card details in the browser |
| Secret Key | sk_live_... or sk_test_... | Server-side -- creates payment intents, processes captures/refunds |
Step 3: Configure the Provider in Odoo
# Payment Provider Configuration for Stripe
# ─────────────────────────────────────────
# Provider: Stripe
# State: Test Mode (switch to "Enabled" for production)
# Company: Your Company
# Credentials Tab:
# ─────────────────
# Secret Key: sk_test_4eC39HqLyjWDarjtT1zdp7dc
# Publishable Key: pk_test_TYooMQauvdEDq54NiTphI7jx
# Webhook Secret: whsec_... (generated in Step 4)
# Configuration Tab:
# ──────────────────
# Payment Journal: Bank (or a dedicated "Online Payments" journal)
# Allow Tokenization: Yes (save cards for returning customers)
# Capture Manually: No (auto-capture on confirmation)
# Payment Icons: Visa, Mastercard, Amex (shown at checkout)Step 4: Set Up the Stripe Webhook Endpoint
This is the step most implementations skip -- and the one that causes the most production issues. Without webhooks, Odoo relies solely on the redirect callback after checkout. If the customer closes the browser before the redirect, the payment succeeds on Stripe but Odoo never knows about it.
# Endpoint URL: https://your-odoo-domain.com/payment/stripe/webhook
# Events: checkout.session.completed, payment_intent.succeeded,
# payment_intent.payment_failed, payment_intent.canceled,
# charge.refunded, charge.refund.updated
# Copy the Signing Secret (whsec_...) into Odoo provider config.Stripe maintains separate webhook endpoints for test and live mode. If you configure the webhook in test mode but then switch the Odoo provider to live mode, the webhook won't fire. Create webhooks in both modes and update the signing secret in Odoo when you switch.
Setting Up PayPal in Odoo 19: REST API, Smart Buttons, and IPN
Odoo 19 uses the PayPal REST API v2 (Orders API), replacing the legacy NVP/SOAP integration from older Odoo versions. This means you need a PayPal Business account with REST API credentials, not the old "Classic API" username/password/signature.
Step 1: Create REST API Credentials
Go to developer.paypal.com → Apps & Credentials. Create a new app (or use the default sandbox app for testing). Copy the Client ID and Secret.
Step 2: Configure PayPal in Odoo
# Payment Provider Configuration for PayPal
# ───────────────────────────────────────────
# Provider: PayPal
# State: Test Mode
# Credentials Tab:
# ─────────────────
# Email Address: your-business@company.com (PayPal Business account)
# Client ID: AaBbCcDd... (from REST API app)
# Client Secret: EeFfGgHh... (from REST API app)
# Configuration Tab:
# ──────────────────
# Payment Journal: Bank
# Allow Tokenization: No (PayPal tokenization requires vault approval)
# Fees:
# Extra Fees: Domestically: 0%, Internationally: 0%
# (Or pass provider fees to customer -- check local regulations)Step 3: Enable IPN (Instant Payment Notification)
PayPal's IPN serves as a fallback for edge cases (network timeouts, browser closures). Set the Notification URL to https://your-odoo-domain.com/payment/paypal/ipn under Account Settings → Notifications → IPN. Odoo verifies each IPN message by sending it back to PayPal for validation, preventing spoofed notifications.
PayPal sandbox accounts expire after 12 months of inactivity. If your test payments suddenly start failing with "INVALID_CLIENT", generate new sandbox credentials. Also, sandbox IPN delivery is notoriously unreliable -- test IPN in production with small amounts instead.
Payment Tokenization in Odoo 19: Save Cards Without Storing Card Numbers
Tokenization lets returning customers pay with a saved card in one click. Odoo never stores the actual card number -- it stores a provider-issued token that represents the card. This keeps you out of PCI-DSS scope (Stripe and PayPal handle the sensitive data).
Enabling Tokenization
stripe_provider = self.env['payment.provider'].search([
('code', '=', 'stripe'),
('company_id', '=', self.env.company.id),
], limit=1)
stripe_provider.write({
'allow_tokenization': True,
'allow_express_checkout': True, # Apple Pay / Google Pay
})
# Tokens are created automatically when a customer checks "Save my
# payment details" at checkout. The payment.token stores provider_ref
# (Stripe pm_XXXX), partner_id, and masked card info.How the Token Flow Works
On the first purchase, Stripe.js tokenizes the card in the browser, Odoo creates a PaymentIntent with setup_future_usage='off_session', and on success creates a payment.token linked to the customer's res.partner. On returning purchases, the customer selects the saved card ("Visa **** 4242"), Odoo creates a PaymentIntent with the stored payment_method ID, and Stripe charges the card off-session -- no 3D Secure prompt unless the bank requires it.
Tokenization via Stripe.js or PayPal SDK means card numbers never touch your Odoo server. The browser sends card data directly to Stripe/PayPal, which returns a token. Your PCI scope is limited to SAQ-A (the simplest self-assessment questionnaire). If you build a custom gateway that processes raw card numbers server-side, you jump to SAQ-D -- which requires annual penetration testing and costs $20,000+ in compliance audits.
Multi-Currency Payments in Odoo 19: Exchange Rates, Rounding, and Settlement
When a customer pays in EUR but your company books in USD, three exchange rates are in play: Odoo's rate (from the ECB feed or manual entry), the payment provider's rate (Stripe/PayPal conversion), and the bank's settlement rate. Mismatches between these rates create journal entry imbalances that block period closing.
Configuration: Let the Provider Handle Conversion
Recommended approach: Charge in the customer's currency. Stripe/PayPal charges in EUR, settles to your USD account at their exchange rate. Odoo records the invoice and payment in EUR (matched exactly), the bank statement shows the converted USD amount, and the exchange difference is auto-posted to the gain/loss account. Verify supported currencies with provider._get_supported_currencies().
Not recommended: Converting before charging (Odoo converts EUR to USD using its rate, then charges USD). The customer sees a different amount than the product price, creating confusion and potential chargebacks.
| Scenario | Odoo Records | Stripe Charges | Bank Receives | Reconciliation |
|---|---|---|---|---|
| Same currency (USD → USD) | $100.00 | $100.00 | $100.00 | Exact match |
| Customer currency (EUR → USD) | €100.00 | €100.00 | $108.50 (Stripe rate) | Exchange diff auto-posted |
| Force company currency | $108.50 | $108.50 | $108.50 | Exact match, but customer confused |
Stripe amounts are in cents (integer). Odoo amounts are in units (float). A EUR 99.99 payment becomes 9999 in the Stripe API. If your currency has zero decimal places (JPY, KRW), Odoo must send the amount without multiplying by 100. Odoo 19 handles this via payment_utils.to_minor_currency_units(), but custom gateways must implement this conversion manually.
Webhook Handling in Odoo 19: Why Your Payments Silently Fail Without It
Here is the scenario: a customer completes a Stripe Checkout session, Stripe charges the card successfully, but the customer's browser crashes before the redirect to Odoo. Without a webhook, Odoo never learns the payment succeeded. The sale order stays unpaid, the customer gets no confirmation email, and your finance team sees a payment in Stripe that doesn't match any Odoo record.
Odoo's Stripe controller at /payment/stripe/webhook follows a three-step pattern: verify the signature (using stripe.Webhook.construct_event() with the webhook secret), route the event to the appropriate handler based on event type (checkout.session.completed, payment_intent.succeeded, charge.refunded), and return 200 to tell Stripe to stop retrying. If the signature check fails, it returns 403 Forbidden.
Webhook Reliability Checklist
| Check | Why It Matters | How to Verify |
|---|---|---|
| Signature verification enabled | Without it, anyone can POST fake payment confirmations to your endpoint | Check stripe_webhook_secret is set in provider config |
| Endpoint returns 2xx within 10s | Stripe retries failed webhooks up to 3 days, but delays cause stale data | Monitor Stripe Dashboard → Webhooks → Recent Deliveries |
| Idempotency handling | Stripe may send the same event twice; processing it twice creates duplicate payments | Odoo checks provider_reference uniqueness before creating entries |
| HTTPS endpoint accessible | Stripe only sends webhooks to HTTPS URLs (no self-signed certs) | Run stripe listen --forward-to localhost:8069 for local testing |
# Forward webhooks to local Odoo (install: https://stripe.com/docs/stripe-cli)
stripe listen --forward-to http://localhost:8069/payment/stripe/webhook
# In another terminal, trigger a test event
stripe trigger payment_intent.succeededBuilding a Custom Payment Gateway Integration for Odoo 19
When your payment provider isn't supported out of the box (regional banks, crypto gateways, BNPL services), you need a custom integration. Odoo 19 provides a clean extension pattern: inherit payment.provider and payment.transaction, override the key methods, and register your provider code.
Module Structure and Provider Model
# Module structure:
# payment_custom_gateway/
# __manifest__.py
# models/payment_provider.py, payment_transaction.py
# controllers/main.py
# views/payment_provider_views.xml
# data/payment_provider_data.xml
from odoo import fields, models
class PaymentProvider(models.Model):
_inherit = 'payment.provider'
code = fields.Selection(
selection_add=[('custom_gw', "Custom Gateway")],
ondelete={'custom_gw': 'set default'},
)
custom_gw_api_key = fields.Char(
string="API Key",
required_if_provider='custom_gw',
groups='base.group_system',
)
custom_gw_merchant_id = fields.Char(
string="Merchant ID",
required_if_provider='custom_gw',
)
custom_gw_webhook_secret = fields.Char(
string="Webhook Secret",
groups='base.group_system',
)
def _get_supported_currencies(self):
supported = super()._get_supported_currencies()
if self.code != 'custom_gw':
return supported
return supported.filtered(
lambda c: c.name in ('USD', 'EUR', 'GBP', 'CAD')
)The Transaction Model
import requests
from odoo import models, _
from odoo.exceptions import ValidationError
from odoo.addons.payment import utils as payment_utils
CUSTOM_GW_API_URL = 'https://api.customgateway.com/v1'
class PaymentTransaction(models.Model):
_inherit = 'payment.transaction'
def _get_specific_rendering_values(self, processing_values):
res = super()._get_specific_rendering_values(processing_values)
if self.provider_code != 'custom_gw':
return res
amount = payment_utils.to_minor_currency_units(
self.amount, self.currency_id
)
provider = self.provider_id
response = requests.post(
f'{CUSTOM_GW_API_URL}/sessions',
json={
'merchant_id': provider.custom_gw_merchant_id,
'amount': amount,
'currency': self.currency_id.name,
'reference': self.reference,
'return_url': self._get_return_url(),
'webhook_url': self._get_webhook_url(),
},
headers={
'Authorization': f'Bearer {{provider.custom_gw_api_key}}',
},
timeout=30,
)
response.raise_for_status()
session = response.json()
return {**res, 'checkout_url': session['checkout_url']}
def _get_tx_from_notification_data(self, provider_code, data):
if provider_code != 'custom_gw':
return super()._get_tx_from_notification_data(
provider_code, data
)
tx = self.search([
('reference', '=', data.get('reference')),
('provider_code', '=', 'custom_gw'),
])
if not tx:
raise ValidationError(
_("No transaction for ref: %s", data.get('reference'))
)
return tx
def _process_notification_data(self, data):
super()._process_notification_data(data)
if self.provider_code != 'custom_gw':
return
self.provider_reference = data.get('gateway_transaction_id')
status = data.get('status')
if status == 'completed':
self._set_done()
elif status == 'pending':
self._set_pending()
elif status in ('failed', 'declined'):
self._set_error(_(
"Payment failed: %s", data.get('error_message', '')
))
elif status == 'canceled':
self._set_canceled()The Webhook Controller
import hmac, hashlib, json
from odoo import http
from odoo.http import request
from werkzeug.exceptions import Forbidden
class CustomGatewayController(http.Controller):
@http.route('/payment/custom_gw/webhook', type='json',
auth='public', csrf=False)
def webhook(self):
data = request.get_json_data()
sig = request.httprequest.headers.get('X-Signature')
provider = request.env['payment.provider'].sudo().search(
[('code', '=', 'custom_gw')], limit=1
)
expected = hmac.new(
provider.custom_gw_webhook_secret.encode(),
json.dumps(data, sort_keys=True).encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(sig or '', expected):
raise Forbidden()
request.env['payment.transaction'].sudo()\
._handle_notification_data('custom_gw', data)
return {'status': 'ok'}Processing Refunds in Odoo 19: Full, Partial, and Automated Reconciliation
Odoo 19 supports full and partial refunds directly from the payment transaction or credit note. The refund is sent to the payment provider via API, and when the provider confirms (via webhook), Odoo creates a reversed journal entry that reconciles with the original payment.
Triggering a Refund from Code
# Full refund
transaction = self.env['payment.transaction'].browse(tx_id)
transaction._send_refund_request()
# Partial refund (refund 25.00 of a 100.00 payment)
transaction._send_refund_request(amount_to_refund=25.00)
# Behind the scenes: Odoo calls the provider refund API,
# the webhook fires (charge.refunded), Odoo creates a new
# payment.transaction with operation='refund', posts a reversed
# journal entry, and links it via source_transaction_id.| Refund Type | Provider Behavior | Odoo Accounting | Customer Timeline |
|---|---|---|---|
| Full refund (Stripe) | Immediate reversal, no fee returned | Full reversed entry, auto-reconciled | 3-5 business days to card |
| Partial refund (Stripe) | Partial reversal, original charge stays | Partial reversed entry | 3-5 business days to card |
| Full refund (PayPal) | Reversal + original fee returned to merchant | Full reversed entry | 5-10 business days |
| Partial refund (PayPal) | Partial reversal, no fee returned | Partial reversed entry | 5-10 business days |
Stripe does not return the processing fee on refunds. A $100 payment with a $3.20 fee nets you $96.80. A full refund returns $100 to the customer, but you lose the $3.20 fee. For high-refund businesses, this adds up fast. Consider offering store credit (Odoo wallet) instead of refunds for low-value returns.
5 Payment Integration Gotchas That Cost Real Money
Sale Order Confirmed Before Payment Settles
Odoo's default e-commerce flow confirms the sale order when the payment transaction reaches the authorized state. But authorized is not done -- the money hasn't actually moved yet. If the capture fails (insufficient funds after auth, bank decline on capture), you've already started fulfillment on an unpaid order.
Set Capture Manually = No on your payment provider (auto-capture on authorization). If you need manual capture (hotel pre-auths, variable-amount services), build a cron job that cancels sale orders where the auth expires without capture.
Webhook Endpoint Blocked by Nginx or Cloudflare
Your Nginx rate limiting config (from our Nginx guide) limits API endpoints to 30 requests/minute. During a flash sale, Stripe can send hundreds of webhooks per minute. If your rate limiter blocks them, payments succeed on Stripe but Odoo never processes the confirmation. Orders hang in "Payment Processing" indefinitely.
Whitelist Stripe's webhook IPs in your Nginx config and exempt /payment/*/webhook from rate limiting. Stripe publishes their IP ranges at https://stripe.com/files/ips/ips_webhooks.json.
Multi-Company Provider Shares Credentials Across Companies
Each payment.provider record is company-specific (company_id field). But if you duplicate a provider for a second company and forget to update the API keys, both companies share the same Stripe account. Payments from Company B are deposited into Company A's bank account, creating an intercompany accounting nightmare.
Always create separate Stripe/PayPal accounts for each legal entity. After duplicating the provider, immediately update credentials and run a test transaction per company.
Zero-Decimal Currencies Break Payment Amounts
Japanese Yen (JPY) and Korean Won (KRW) have zero decimal places. When Stripe expects 1000 for ¥1000, a naive implementation sends 100000 (multiplying by 100 like it does for USD). The customer is charged 100x the intended amount. We have seen this in production.
Always use payment_utils.to_minor_currency_units(amount, currency) instead of hardcoding int(amount * 100). This function checks the currency's decimal_places field and converts correctly for all currencies.
Token Expiration Is Not Handled Gracefully
Saved card tokens can fail silently: the card expires, the customer's bank revokes authorization, or the customer deletes the payment method on Stripe's side. When the next subscription charge attempts to use the token, the payment fails, but Odoo shows a generic "Payment Error" with no actionable message.
Subscribe to the payment_method.detached and customer.source.expiring Stripe events. On receipt, deactivate the corresponding payment.token and notify the customer to update their payment method before the next billing cycle.
The Revenue Impact of a Properly Configured Payment Stack
Payment configuration is not infrastructure work -- it is revenue work. Every friction point in the checkout flow directly reduces conversion.
Adding tokenization (one-click payments) and express checkout (Apple Pay / Google Pay) increases conversion by 10-23% for returning customers according to Stripe's benchmark data.
Proper webhook configuration eliminates "phantom payments" -- transactions that succeed on the provider but never register in Odoo. We've recovered $15,000+ in missed payments for a single client after webhook setup.
Multi-currency payments with auto-posted exchange differences and webhook-driven refund reconciliation eliminate manual journal entries that consume 4-6 hours per month for finance teams.
Beyond conversion: PCI-DSS compliance through tokenization avoids $20,000+/year in security audit costs. Merchants who process raw card data on their servers face SAQ-D requirements, quarterly vulnerability scans, and annual penetration tests. Stripe.js/PayPal SDK tokenization keeps you at SAQ-A -- a two-page self-assessment.
Optimization Metadata
Complete guide to configuring Stripe, PayPal, and custom payment gateways in Odoo 19. Covers tokenization, webhooks, multi-currency payments, refunds, and PCI compliance.
1. "Configuring Stripe in Odoo 19" 2. "Payment Tokenization in Odoo 19" 3. "Building a Custom Payment Gateway" 4. "5 Payment Integration Gotchas"