Skip to content

Payment Integrations

This guide covers building payment gateway integrations using the online_payment backbone module.

The payment system is provider-agnostic. The online_payment module handles:

  • Transaction lifecycle management
  • API endpoints for initiating/tracking payments
  • Webhook infrastructure
  • Payment record creation and reconciliation

Payment provider modules (Stripe, PayPal, Razorpay) implement the integration interface.

┌─────────────────────────────────────────────────────────────────┐
│ CONSUMING MODULES │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ portal │ │ sales │ │ invoicing │ │
│ │ (Pay Invoice)│ │(Pay Quote) │ │(Collect) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └────────────────┼────────────────┘ │
│ ▼ │
│ ┌────────────────────────────────┐ │
│ │ online_payment (backbone) │ │
│ │ - PaymentTransaction model │ │
│ │ - PaymentLink model │ │
│ │ - API routes & webhooks │ │
│ └────────────────┬───────────────┘ │
│ │ │
│ ┌────────────────┼────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │payment_stripe│ │payment_razorpay│ │payment_paypal│ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘

The PaymentIntegration model in online_payment defines the interface:

class PaymentIntegration(Model):
__inherit__ = "Integration"
# Payment-specific fields. Test vs Live is NOT one of them — `Integration` declares
# `integration_mode`, `sandbox_url` and the `api_base_url()` seam for every category;
# `online_payment` only opts in, via `calc_supports_test_mode`.
payment_integration_type = Selection(
choices=["Redirect", "Embedded JS", "QR Code"],
default="Redirect",
)
payment_display_name = Char(max_length=100)
payment_sequence = Integer(default=10)
# --- Methods to override ---
async def create_payment_session(self, transaction, integration_type: str) -> dict:
"""Create payment session with gateway."""
raise NotImplementedError()
async def process_webhook(self, event_type: str, payload: dict) -> dict:
"""Process webhook event from gateway."""
raise NotImplementedError()
async def process_return(self, request_data: dict, transaction=None) -> dict:
"""Handle return from redirect flow. Verify against the gateway."""
return {"status": "pending"}
async def fetch_transaction_status(self, transaction) -> dict:
"""Authoritative current state, asked of the gateway. REQUIRED."""
return {"status": "unknown"}
async def webhook_endpoint_configured(self) -> bool:
"""Whether gateway events can actually reach this instance."""
return True
async def verify_webhook_signature(self, payload: bytes, signature: str) -> bool:
"""Verify webhook signature."""
return True
async def refund_transaction(self, transaction, amount: float = None) -> dict:
"""Process refund."""
raise NotImplementedError()
async def get_client_config(self) -> dict:
"""Get client-side configuration for embedded JS."""
return {}
TypeDescriptionExample Providers
RedirectCustomer redirected to gateway, then returnsPayPal, Stripe Checkout
Embedded JSPayment form embedded in pageStripe Elements, Razorpay
QR CodeCustomer scans QR to payUPI, WeChat Pay
┌─────────┐ ┌─────────┐ ┌────────────┐ ┌──────────┐
│ Draft │ ──► │ Pending │ ──► │ Authorized │ ──► │ Captured │
└─────────┘ └─────────┘ └────────────┘ └──────────┘
│ │
▼ ▼
┌─────────┐ ┌──────────┐
│ Failed │ │ Refunded │
└─────────┘ └──────────┘
┌───────────┐
│ Cancelled │
└───────────┘
  • Draft: Transaction created, not yet initiated
  • Pending: Sent to gateway, awaiting response
  • Authorized: Payment authorized, not yet captured
  • Settling: A delayed-settlement bank debit (ACH) has been submitted but funds have not yet settled — see Bank Debit (ACH) below
  • Captured: Payment successful, funds received (for ACH, funds have settled)
  • Failed: Payment failed
  • Cancelled: Customer cancelled
  • Refunded: Payment refunded (merchant-initiated)
  • Returned: A settled bank debit was reversed by the bank (ACH return) — reverses the recorded payment

Instant rails (cards, wallets) never touch Settling/Returned; they go straight to Captured. Those two states exist for bank debits, whose settlement is delayed and reversible.

US bank debit (ACH) is a rail, not a provider — several gateways carry it (Stripe, Authorize.Net eCheck, Braintree, Dwolla). It differs structurally from a card:

  • Settlement is delayed and reversible. A debit is submitted immediately but settles a few business days later, and can be returned by the bank days after that (NSF, closed account, unauthorized — the NACHA R01R85 codes), even after it settled. The backbone models this with the Settling → Captured states and the Returned reversal.
  • It needs a stored authorization. The customer links and authorizes a bank account once (a mandate); recurring debits reuse it off-session. The mandate is stored as a PaymentToken with payment_method_type = "ach".

A submitted ACH debit is in-transit money — recorded on submission (state Settling) against the company’s in-transit Outstanding Receipts account, so the invoice reads as paid. Bank reconciliation clears that to the Bank account when it settles; a return reverses the whole payment and re-opens the invoice. The backbone posts and reverses these entries for you — a provider never touches accounting.

Implement these on your Integration subclass (all cooperative — defer to super() when the integration isn’t yours, exactly like create_payment_session):

async def supports_ach(self) -> bool:
"""Return True if this provider can collect US ACH bank debits."""
return await self._is_mygateway() or await super().supports_ach()
async def create_ach_session(self, transaction, verification_mode: str = "instant") -> dict:
"""Start collecting a bank-debit mandate — hand off to the gateway's hosted
bank-link + authorization UI. Return {"redirect_url": ...} or {"client_config": {...}}.
verification_mode is "instant" (bank login) or "micro_deposits"."""
async def confirm_ach_mandate(self, transaction, request_data: dict) -> dict:
"""Finalize the mandate after the hosted flow returns. Return the details to
persist on the PaymentToken:
{"token_reference": str, "mandate_reference": str|None,
"verification_status": "Verified"|"Pending Verification",
"payment_method_type": "ach",
"payment_method_details": {"bank_name", "last4", "account_type"}}"""
async def verify_ach_micro_deposits(self, token, amounts: list) -> dict:
"""Confirm a micro-deposit mandate by the two deposit amounts (in cents).
Return {"verified": bool, "error_message": str|None}."""
async def charge_ach(self, transaction, token, amount: float) -> dict:
"""Submit an off-session debit against a verified mandate. Unlike a card charge
this does NOT settle synchronously — return {"status": "settling", ...}. Settlement
and returns arrive later by webhook."""

Map the gateway’s ACH events into the shared statuses returned by process_webhook:

Return statusMeaning
"settling"Debit submitted / processing (not yet settled)
"captured"Settlement confirmed
"returned"Bank returned the debit — reverses the payment

For a return, also set return_code (a NACHA R… code) and return_reason in the process_webhook result. The backbone records them, reverses the recorded payment, and — on a hard return (closed account, unauthorized, etc.) — revokes the mandate so it is never auto-charged again. Soft returns (R01 insufficient funds) leave the mandate active for a retry.

The built-in sandbox gateway (is_sandbox = True) implements the full ACH lifecycle end-to-end (submit → settle → return, plus micro-deposit verification with the magic amounts 0.32 / 0.45) against internal pages — no real bank is contacted. Use it to develop and test ACH flows without gateway credentials.

A gateway that can store a payment method and charge it later — without the customer present — enables recurring billing (e.g. subscriptions). A saved method is a PaymentToken (holding only the gateway’s opaque token_reference and display-safe details like brand/last4, never raw card data). Recurring billing calls the one primitive await token.charge(amount, financial_document=..., currency=...), which creates the off-session transaction, dispatches to your provider, records the accounting payment, and reconciles the invoice.

Implement these cooperative hooks on your Integration subclass (defer to super() when the integration isn’t yours):

async def supports_tokenization(self) -> bool:
"""Return True if this gateway can save a method for later off-session charges."""
return await self._is_mygateway() or await super().supports_tokenization()
async def save_token_from_transaction(self, transaction) -> dict:
"""After a customer-present payment that opted to save the method, return
{"token_reference", "payment_method_type", "payment_method_details"} (or {})."""
async def charge_saved_token(self, transaction, token, amount: float) -> dict:
"""Charge a saved method off-session. Cards confirm synchronously — return
{"status": "captured"/"failed", "gateway_reference": str, "error_message": str|None}."""
# Save a method WITHOUT a payment (an "add / update card" flow, no charge):
async def create_setup_session(self, contact, company, return_url, cancel_url=None) -> dict:
"""Start a hosted flow (e.g. a setup-mode checkout) that saves a method with no
charge. Return {"redirect_url": str, "reference": str} or {}."""
async def finalize_setup_session(self, session_reference, contact, company) -> dict:
"""After the hosted setup flow returns, resolve the saved method and return
{"token_reference", "payment_method_type", "payment_method_details"} (or {}) so the
caller can create the PaymentToken."""

The create_setup_session / finalize_setup_session pair is what a customer-facing “update payment method” page uses: it redirects to redirect_url, and on return calls finalize_setup_session with the gateway’s session reference to mint the token. ACH mandates follow the same shape via the ACH provider hooks above (a verified mandate is itself a reusable PaymentToken).

fullfinity/modules/payment_mygateway/
├── __init__.py
├── manifest.yaml
├── models/
│ ├── __init__.py
│ └── mygateway_provider.py
├── views/
│ └── mygateway_views.yaml
└── static/
└── img/
└── mygateway_logo.png
manifest.yaml
name: MyGateway Payments
identifier: payment_mygateway
version: '1.0'
category: module_category_integrations
integration_category: Online Payment
description: Accept payments via MyGateway
dependencies:
- online_payment
icon: CreditCard
image: /static/img/mygateway_logo.png
static_paths:
- img
models/mygateway_provider.py
from fullfinity.engine.base import *
import logging
logger = logging.getLogger(__name__)
class MyGatewayIntegration(Model):
"""Extends Integration with MyGateway-specific fields and methods."""
__inherit__ = "Integration"
# Gateway credentials (company_scoped for multi-company)
mygateway_api_key = Char(
max_length=255,
description="API Key",
hint="Your MyGateway API key",
company_scoped=True,
)
mygateway_secret_key = Char(
max_length=255,
description="Secret Key",
company_scoped=True,
)
mygateway_webhook_secret = Char(
max_length=255,
description="Webhook Secret",
company_scoped=True,
)
async def _is_mygateway(self) -> bool:
"""Check if this integration is MyGateway."""
await self.fetch_related("module")
return self.module and self.module.identifier == "payment_mygateway"
async def enable_integration(self):
"""Validate credentials before enabling."""
if await self._is_mygateway():
if not self.mygateway_api_key:
raise UserError("Please enter your MyGateway API Key.")
if not self.mygateway_secret_key:
raise UserError("Please enter your MyGateway Secret Key.")
await super().enable_integration()
async def create_payment_session(self, transaction, integration_type: str = "Redirect") -> dict:
"""Create payment session with MyGateway."""
if not await self._is_mygateway():
return await super().create_payment_session(transaction, integration_type)
await transaction.fetch_related("currency", "contact")
# Call MyGateway API to create session
session = await self._api_create_session(
amount=transaction.amount,
currency=transaction.currency.name if transaction.currency else "USD",
reference=transaction.reference,
return_url=self._build_return_url(transaction, "success"),
cancel_url=self._build_return_url(transaction, "cancel"),
)
# Store gateway reference
transaction.gateway_reference = session["id"]
await transaction.save()
# Return based on integration type
if integration_type == "Embedded JS":
return {
"integration_type": "embedded_js",
"client_config": {
"api_key": self.mygateway_api_key,
"session_id": session["id"],
"js_url": "https://js.mygateway.com/v1/",
},
}
else:
return {
"integration_type": "redirect",
"redirect_url": session["checkout_url"],
}
def _build_return_url(self, transaction, status: str) -> str:
"""Build return URL with transaction reference."""
base_url = transaction.return_url if status == "success" else transaction.cancel_url
if not base_url:
return ""
separator = "&" if "?" in base_url else "?"
return f"{base_url}{separator}txn={transaction.reference}"
async def verify_webhook_signature(self, payload: bytes, signature: str) -> bool:
"""Verify MyGateway webhook signature."""
if not await self._is_mygateway():
return await super().verify_webhook_signature(payload, signature)
if not self.mygateway_webhook_secret:
logger.warning(f"MyGateway integration {self.id} has no webhook secret")
return False
# Implement signature verification (HMAC, etc.)
import hmac
import hashlib
expected = hmac.new(
self.mygateway_webhook_secret.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
async def process_webhook(self, event_type: str, payload: dict) -> dict:
"""Process MyGateway webhook events."""
if not await self._is_mygateway():
return await super().process_webhook(event_type, payload)
# Map gateway events to transaction status
status_map = {
"payment.completed": "captured",
"payment.failed": "failed",
"payment.cancelled": "cancelled",
"payment.refunded": "refunded",
}
status = status_map.get(event_type, "pending")
data = payload.get("data", {})
return {
"status": status,
"transaction_reference": data.get("payment_id"),
"gateway_status": event_type,
"error_message": data.get("error_message"),
}
async def process_return(self, request_data: dict, transaction=None) -> dict:
"""Handle return from redirect flow.
NEVER resolve the outcome from `request_data` alone — those parameters are
supplied by the customer's browser. Verify with the gateway. The transaction is
passed so you can look up your own `gateway_reference` to do it.
"""
if not await self._is_mygateway():
return await super().process_return(request_data, transaction)
return await self.fetch_transaction_status(transaction)
async def fetch_transaction_status(self, transaction) -> dict:
"""The authoritative current state of `transaction`, asked of the gateway.
Implement this. It is what the reconciliation sweep calls, and it is the only
confirmation channel that always exists — see "Why both channels can fail" below.
Return "unknown" when you genuinely cannot look the payment up, so the sweep
reports the gap instead of assuming everything is fine.
"""
if not await self._is_mygateway():
return await super().fetch_transaction_status(transaction)
reference = transaction.gateway_reference if transaction else None
if not reference:
return {"status": "unknown"}
payment = await self._api_get_payment(reference)
status_map = {"completed": "captured", "processing": "settling",
"declined": "failed", "cancelled": "cancelled"}
return {
"status": status_map.get(payment["status"], "pending"),
"gateway_reference": reference,
}
async def refund_transaction(self, transaction, amount: float = None) -> dict:
"""Process MyGateway refund."""
if not await self._is_mygateway():
return await super().refund_transaction(transaction, amount)
try:
refund = await self._api_create_refund(
payment_id=transaction.gateway_reference,
amount=amount or transaction.amount,
)
return {
"success": True,
"refund_reference": refund["id"],
}
except Exception as e:
return {
"success": False,
"error_message": str(e),
}
# --- Private API methods ---
async def _api_create_session(self, amount, currency, reference, return_url, cancel_url):
"""Create payment session via MyGateway API."""
# Implement API call
import httpx
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.mygateway.com/v1/sessions",
headers={"Authorization": f"Bearer {self.mygateway_secret_key}"},
json={
"amount": int(amount * 100), # cents
"currency": currency,
"reference": reference,
"success_url": return_url,
"cancel_url": cancel_url,
},
)
response.raise_for_status()
return response.json()
async def _api_get_session_status(self, session_id):
"""Get session status from MyGateway API."""
import httpx
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.mygateway.com/v1/sessions/{session_id}",
headers={"Authorization": f"Bearer {self.mygateway_secret_key}"},
)
response.raise_for_status()
return response.json().get("status")
async def _api_create_refund(self, payment_id, amount):
"""Create refund via MyGateway API."""
import httpx
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.mygateway.com/v1/refunds",
headers={"Authorization": f"Bearer {self.mygateway_secret_key}"},
json={
"payment_id": payment_id,
"amount": int(amount * 100),
},
)
response.raise_for_status()
return response.json()

Give the inheriting view its own identifier and point inherited_view: at the base form. Patch it with arch.operations. Gate each field on the record’s own identifier Char plus its state (the module relation’s fields never reach the client, so Q(module__identifier=…) cannot be used in visible:). Credential fields use the PasswordInput widget.

views/mygateway_views.yaml
- data_type: UiView
name: MyGateway Integration Form Extension
identifier: mygateway_integration_form_extension
type: Form
model: Integration
inherited_view: integration_form_view
arch:
operations:
- action: add
target:
field: description
position: after
value:
type: field
name: mygateway_api_key
properties:
widget: TextInput
visible: Q(identifier='payment_mygateway') & Q(state__neq='Not Installed')
- action: add
target:
field: mygateway_api_key
position: after
value:
type: field
name: mygateway_secret_key
properties:
widget: PasswordInput
visible: Q(identifier='payment_mygateway') & Q(state__neq='Not Installed')
- action: add
target:
field: mygateway_secret_key
position: after
value:
type: field
name: mygateway_webhook_secret
properties:
widget: PasswordInput
visible: Q(identifier='payment_mygateway') & Q(state__neq='Not Installed')

The online_payment module provides a webhook route:

POST /payment/webhook/{integration_id}

The webhook controller:

  1. Looks up the integration by ID
  2. Calls verify_webhook_signature() to validate
  3. Calls process_webhook() to get status
  4. Finds the transaction by gateway reference
  5. Calls transaction.process_gateway_response()

Your integration just needs to implement the interface methods.

For redirect-based flows, the backbone handles return:

GET /api/online_payment/return?txn={reference}&...

The controller:

  1. Finds transaction by reference
  2. Calls integration.process_return(params, transaction)
  3. Calls transaction.process_gateway_response()
  4. Redirects the customer back with the actual outcome (success / submitted / failed / pending) — never report success for anything you have not confirmed

Why both channels can fail — and what backs them up

Section titled “Why both channels can fail — and what backs them up”

A payment’s outcome reaches you two ways, and neither is guaranteed:

  • The customer’s return happens exactly once, at submission. It cannot carry a bank settlement that happens days later, and an off-session charge against a saved mandate has no return at all.
  • A webhook needs an endpoint and a signing secret, which a fresh install does not have.

If a provider resolves the return as “pending” and leaves the rest to a webhook, then on an instance with no webhook configured its payments are never confirmed: money taken, no Payment posted, the document never updated. For a bank debit it is worse — a submitted debit sits in Settling with its Payment already posted against the in-transit account and the document reading as paid, so a bank return that never arrives leaves money booked as received that came back.

The reconciliation sweep (PaymentTransaction.cron_reconcile_pending, a CronJob) is the net under both: every few minutes it re-asks the gateway about transactions still in Pending or Settling, via fetch_transaction_status, and applies whatever it learns — including terminal-bad outcomes. process_gateway_response is idempotent, so a webhook arriving afterwards is a no-op rather than a double post.

This is why fetch_transaction_status is not optional: a gateway that cannot answer it has no safety net. online_payment/tests/test_provider_seams.py fails for any installed gateway that does not implement it.

If your gateway offers bank debit, also override webhook_endpoint_configured to report whether events can actually reach the instance — enabling bank debit without a channel for settlements and returns warns the merchant at the point of enabling.

When a transaction is captured, PaymentTransaction automatically:

  1. Creates a Payment record
  2. Creates a Reconciliation linking payment to invoice
  3. Updates invoice payment status
  4. Calls document-specific success hooks (e.g., SaleOrder confirmation)
# PaymentTransaction._on_payment_success()
async def _on_payment_success(self):
"""Called after successful capture."""
# Handle SaleOrder confirmation with signature
if self.document_model == "SaleOrder" and self.document_id:
await self._confirm_sale_order()

To run your own logic when a customer invoice becomes fully paid — accrue a commission, release a licence, notify a partner — override update() and watch payment_status:

class MyInvoiceHook(Model):
__inherit__ = "FinancialDocument"
async def update(cls, records, **vals):
result = await super().update(records, **vals)
if vals.get("payment_status") == "Paid":
for record in records:
await record.my_effect()
return result

payment_status is a stored calculated field, and it reaches update() the same as any other write — whether a caller set it or the engine derived it from a reconciliation. So one override covers every path that settles a document: the Record Payment wizard, credit-note application, register_payment, and gateway callbacks.

Do not hook register_payment(). It creates a Payment but reconciles nothing, and total_due derives strictly from amount_residual on the receivable lines — so the status does not move there, and most payment paths never call it at all.

A derived value can be reported more than once for one settlement, so make your effect idempotent — key it on something stable such as the document number plus the line id.

For flows where actions should only happen after payment:

# Initiating module stores pending action data
transaction = await PaymentTransaction.create(
integration=integration_id,
document_model="SaleOrder",
document_id=order.id,
amount=amount,
pending_action_data={
"signature_data": {
"signature": signature_image,
"signed_by": signer_name,
}
},
)
# PaymentTransaction applies on success
async def _confirm_sale_order(self):
pending_data = self.pending_action_data or {}
signature_data = pending_data.get("signature_data", {})
if signature_data:
order.signature = signature_data.get("signature")
order.signed_by = signature_data.get("signed_by")
await order.save()
await order.action_confirm()

The online_payment module provides these endpoints:

EndpointAuthDescription
GET /api/online_payment/integrationsUserList available payment integrations
POST /api/online_payment/initiateUserCreate transaction, get integration instructions
GET /api/online_payment/transaction/{id}UserGet transaction status
GET /api/online_payment/returnPublicHandle redirect return
GET /api/online_payment/cancelPublicHandle cancelled payment
POST /api/online_payment/ach/setupPublicStart the ACH mandate flow (bank-link handoff)
GET /api/online_payment/ach/returnPublicHandle hosted bank-link return; submit the debit if verified
POST /api/online_payment/ach/verifyPublicConfirm micro-deposit amounts and submit the debit
POST /payment/webhook/{id}PublicReceive gateway webhooks
# For redirect flow
{
"transaction_id": 123,
"reference": "TXN-ABC123",
"integration_type": "redirect",
"redirect_url": "https://checkout.mygateway.com/session/xyz"
}
# For embedded JS flow
{
"transaction_id": 123,
"reference": "TXN-ABC123",
"integration_type": "embedded_js",
"client_config": {
"api_key": "pk_test_...",
"session_id": "sess_xyz",
"js_url": "https://js.mygateway.com/v1/"
}
}
  1. Create an Integration record for your provider
  2. Configure test credentials
  3. Enable the integration
  4. Create a test invoice
  5. Initiate payment via API or UI
  6. Complete payment in gateway sandbox
  7. Verify webhook updates transaction
  8. Check Payment record was created

See payment_stripe module for a complete example:

  • stripe_provider.py - Full Stripe integration
  • Supports both Checkout (redirect) and Elements (embedded JS)
  • Webhook signature verification
  • Refund support

Every bundled gateway’s sandbox needs a merchant account and API keys, which leaves you unable to call anything at all while building a provider. Set Sandbox URL on the integration (the field appears on the form in Test mode) and every request goes there instead:

Terminal window
python3 tests/payment_mock.py # serves the bundled gateways' shapes on :8788

Then set http://localhost:8788 as the Sandbox URL and drive the app normally — open a checkout session, return, refund. Add your own gateway’s routes to _ROUTES in that file.

The knobs matter more here than anywhere else:

KnobWhat it exercises
?mock_decline=1a 200 response carrying a refusal — a declined card, a failed refund. This is the expensive one: the HTTP call succeeded, so a provider reading only the status posts a Payment for money it never took
?mock_status=402an HTTP error carrying that gateway’s own error envelope, so your error reader has its real shape to parse
?mock_delay=5timeout handling

A live integration can never be redirected: saving a Sandbox URL while Mode is Live is refused, and api_base_url returns the live host regardless of what is stored.

The mock also serves Stripe Connect (/v1/accounts, /v1/account_links, /v1/transfers) for payout flows, which have no Sandbox URL field because they are not integration records — point the Stripe SDK’s api_base at the mock instead. An Idempotency-Key header replays the first response verbatim, exactly as Stripe does, so a retried payout run that pays twice fails the test rather than the customer.

Build your mock’s responses from the gateway’s published schema, not from what your provider happens to read. A mock that mirrors the provider’s assumptions confirms the provider agrees with itself — it cannot catch a field you invented. Every response shape in payment_mock.py came from the gateways’ own API references, which is how it caught a Stripe field that no longer exists (charges, replaced by latest_charge) and an endpoint constant that swallowed its own path when overridden.

Point your api_base_url at a HOST, not a full endpoint. api_base_url resolves the base; your provider appends its own path. Folding the path into the constant means a Sandbox URL replaces it wholesale and every request lands on the bare host:

# WRONG — the override eats the path, and every call 404s
_API_URL = {"Test": "https://apitest.gateway.com/v1/request", ...}
return self.api_base_url(_API_URL["Test"], _API_URL["Live"])
# RIGHT — override the host, append the path
_API_HOST = {"Test": "https://apitest.gateway.com", "Live": "https://api.gateway.com"}
return self.api_base_url(_API_HOST["Test"], _API_HOST["Live"]) + "/v1/request"

Each provider also ships an *_against_mock.py test that drives its real HTTP path against this mock in CI — see payment_stripe/tests/test_stripe_against_mock.py. Those are the tests that can catch a wrong path or a wrong amount unit; the transport-patched tests beside them cannot, by construction.

  1. Always verify webhooks - Use HMAC signature verification
  2. Store gateway reference - Save the external ID for lookups
  3. Handle idempotency - Webhooks may be sent multiple times
  4. Resolve every endpoint through api_base_url(test_url, live_url) - it applies integration_mode (Test/Live) and the Sandbox URL override, so a gateway can be driven against a mock with no account. A gateway whose API key (not host) picks the network passes the same URL twice — that is what makes the override reach it
  5. Log webhook payloads - Store in transaction.webhook_log for debugging
  6. Validate before enable - Check credentials in enable_integration()
  7. Support partial refunds - Accept optional amount parameter
  8. Never trust return parameters - The query string on a return URL is supplied by the customer’s browser. Confirm every outcome with the gateway before reporting it
  9. Always implement fetch_transaction_status - It is the only confirmation channel that is always available, and the sweep that recovers payments no return or webhook ever reported depends on it