GuideMarch 13, 2026

Payment Providers in Odoo 19:
Stripe, PayPal & Custom Gateway Integration

INTRODUCTION

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.

01

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:

ModelPurposeKey Fields
payment.providerConfiguration record for each gateway (credentials, features, state)code, state, company_id, allow_tokenization
payment.transactionIndividual payment attempt -- tracks state from draft to done/errorprovider_id, state, amount, currency_id, provider_reference
payment.tokenSaved 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: draftpendingauthorizeddone (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.

Python -- Payment transaction state machine (payment.transaction)
# 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
Odoo 19 Change: payment.acquirer is Gone

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.

02

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:

KeyFormatUsed For
Publishable Keypk_live_... or pk_test_...Client-side (Stripe.js) -- tokenizes card details in the browser
Secret Keysk_live_... or sk_test_...Server-side -- creates payment intents, processes captures/refunds

Step 3: Configure the Provider in Odoo

Odoo UI -- Invoicing / Accounting → Configuration → Payment Providers
# 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.

Stripe Dashboard -- Developers → Webhooks → Add Endpoint
# 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.
Test Mode vs Live Mode Webhooks

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.

03

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

Odoo UI -- Invoicing / Accounting → Configuration → Payment Providers
# 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 Gotcha

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.

04

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

Python -- Enabling tokenization programmatically
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.

PCI Compliance Note

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.

05

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.

ScenarioOdoo RecordsStripe ChargesBank ReceivesReconciliation
Same currency (USD → USD)$100.00$100.00$100.00Exact 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.50Exact match, but customer confused
Rounding Trap

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.

06

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

CheckWhy It MattersHow to Verify
Signature verification enabledWithout it, anyone can POST fake payment confirmations to your endpointCheck stripe_webhook_secret is set in provider config
Endpoint returns 2xx within 10sStripe retries failed webhooks up to 3 days, but delays cause stale dataMonitor Stripe Dashboard → Webhooks → Recent Deliveries
Idempotency handlingStripe may send the same event twice; processing it twice creates duplicate paymentsOdoo checks provider_reference uniqueness before creating entries
HTTPS endpoint accessibleStripe only sends webhooks to HTTPS URLs (no self-signed certs)Run stripe listen --forward-to localhost:8069 for local testing
Shell -- Testing webhooks locally with Stripe CLI
# 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.succeeded
07

Building 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

Python -- models/payment_provider.py
# 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

Python -- models/payment_transaction.py
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

Python -- controllers/main.py
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'}
08

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

Python -- Refund via payment.transaction
# 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 TypeProvider BehaviorOdoo AccountingCustomer Timeline
Full refund (Stripe)Immediate reversal, no fee returnedFull reversed entry, auto-reconciled3-5 business days to card
Partial refund (Stripe)Partial reversal, original charge staysPartial reversed entry3-5 business days to card
Full refund (PayPal)Reversal + original fee returned to merchantFull reversed entry5-10 business days
Partial refund (PayPal)Partial reversal, no fee returnedPartial reversed entry5-10 business days
Stripe Fee Recovery

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.

09

5 Payment Integration Gotchas That Cost Real Money

1

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.

Our Fix

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.

2

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.

Our Fix

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.

3

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.

Our Fix

Always create separate Stripe/PayPal accounts for each legal entity. After duplicating the provider, immediately update credentials and run a test transaction per company.

4

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.

Our Fix

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.

5

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.

Our Fix

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.

BUSINESS ROI

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.

+23%Checkout 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.

0%Lost Payments

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.

4hrs/moSaved Reconciliation Time

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.

SEO NOTES

Optimization Metadata

Meta Desc

Complete guide to configuring Stripe, PayPal, and custom payment gateways in Odoo 19. Covers tokenization, webhooks, multi-currency payments, refunds, and PCI compliance.

H2 Keywords

1. "Configuring Stripe in Odoo 19" 2. "Payment Tokenization in Odoo 19" 3. "Building a Custom Payment Gateway" 4. "5 Payment Integration Gotchas"

Payments Are Where Revenue Meets Engineering

A misconfigured payment stack doesn't just cause technical errors -- it silently leaks revenue. Missed webhook events mean unpaid orders. Missing tokenization means higher cart abandonment. Broken multi-currency handling means hours of manual reconciliation every month. Every issue in this guide is something we have seen (and fixed) in production Odoo deployments.

If you're launching Odoo e-commerce or need to integrate a payment provider that isn't supported out of the box, we can help. We configure and test the full payment stack -- Stripe, PayPal, tokenization, webhooks, multi-currency, refunds -- and hand you a checkout flow that converts. We also build custom gateway integrations for regional providers and specialized payment methods.

Book a Free Payment Audit