Payment Integrations
This guide covers building payment gateway integrations using the online_payment backbone module.
Architecture
Section titled “Architecture”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│ ││ └─────────────┘ └─────────────┘ └─────────────┘ │└─────────────────────────────────────────────────────────────────┘Integration Interface
Section titled “Integration Interface”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 {}Integration Types
Section titled “Integration Types”| Type | Description | Example Providers |
|---|---|---|
Redirect | Customer redirected to gateway, then returns | PayPal, Stripe Checkout |
Embedded JS | Payment form embedded in page | Stripe Elements, Razorpay |
QR Code | Customer scans QR to pay | UPI, WeChat Pay |
Transaction Lifecycle
Section titled “Transaction Lifecycle”┌─────────┐ ┌─────────┐ ┌────────────┐ ┌──────────┐│ 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.
Bank Debit (ACH)
Section titled “Bank Debit (ACH)”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
R01–R85codes), even after it settled. The backbone models this with theSettling → Capturedstates and theReturnedreversal. - 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
PaymentTokenwithpayment_method_type = "ach".
Accounting
Section titled “Accounting”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.
Provider hooks
Section titled “Provider hooks”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."""Webhooks
Section titled “Webhooks”Map the gateway’s ACH events into the shared statuses returned by process_webhook:
Return status | Meaning |
|---|---|
"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.
Built-in sandbox
Section titled “Built-in sandbox”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.
Saved Methods & Off-Session Charging
Section titled “Saved Methods & Off-Session Charging”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).
Building a Payment Provider Module
Section titled “Building a Payment Provider Module”Step 1: Create Module Structure
Section titled “Step 1: Create Module Structure”fullfinity/modules/payment_mygateway/├── __init__.py├── manifest.yaml├── models/│ ├── __init__.py│ └── mygateway_provider.py├── views/│ └── mygateway_views.yaml└── static/ └── img/ └── mygateway_logo.pngStep 2: Module Manifest
Section titled “Step 2: Module Manifest”name: MyGateway Paymentsidentifier: payment_mygatewayversion: '1.0'category: module_category_integrationsintegration_category: Online Paymentdescription: Accept payments via MyGatewaydependencies:- online_paymenticon: CreditCardimage: /static/img/mygateway_logo.pngstatic_paths:- imgStep 3: Provider Model
Section titled “Step 3: Provider Model”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()Step 4: View Extension
Section titled “Step 4: View Extension”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.
- 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')Webhook Handling
Section titled “Webhook Handling”The online_payment module provides a webhook route:
POST /payment/webhook/{integration_id}The webhook controller:
- Looks up the integration by ID
- Calls
verify_webhook_signature()to validate - Calls
process_webhook()to get status - Finds the transaction by gateway reference
- Calls
transaction.process_gateway_response()
Your integration just needs to implement the interface methods.
Return URL Handling
Section titled “Return URL Handling”For redirect-based flows, the backbone handles return:
GET /api/online_payment/return?txn={reference}&...The controller:
- Finds transaction by reference
- Calls
integration.process_return(params, transaction) - Calls
transaction.process_gateway_response() - 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.
Payment Success Actions
Section titled “Payment Success Actions”When a transaction is captured, PaymentTransaction automatically:
- Creates a
Paymentrecord - Creates a
Reconciliationlinking payment to invoice - Updates invoice payment status
- 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()Reacting to an invoice being paid
Section titled “Reacting to an invoice being paid”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 resultpayment_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.
Deferred Actions Pattern
Section titled “Deferred Actions Pattern”For flows where actions should only happen after payment:
# Initiating module stores pending action datatransaction = 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 successasync 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()API Endpoints
Section titled “API Endpoints”The online_payment module provides these endpoints:
| Endpoint | Auth | Description |
|---|---|---|
GET /api/online_payment/integrations | User | List available payment integrations |
POST /api/online_payment/initiate | User | Create transaction, get integration instructions |
GET /api/online_payment/transaction/{id} | User | Get transaction status |
GET /api/online_payment/return | Public | Handle redirect return |
GET /api/online_payment/cancel | Public | Handle cancelled payment |
POST /api/online_payment/ach/setup | Public | Start the ACH mandate flow (bank-link handoff) |
GET /api/online_payment/ach/return | Public | Handle hosted bank-link return; submit the debit if verified |
POST /api/online_payment/ach/verify | Public | Confirm micro-deposit amounts and submit the debit |
POST /payment/webhook/{id} | Public | Receive gateway webhooks |
Initiate Response Format
Section titled “Initiate Response Format”# 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/" }}Testing
Section titled “Testing”- Create an Integration record for your provider
- Configure test credentials
- Enable the integration
- Create a test invoice
- Initiate payment via API or UI
- Complete payment in gateway sandbox
- Verify webhook updates transaction
- Check Payment record was created
Reference Implementation
Section titled “Reference Implementation”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
Testing without a gateway account
Section titled “Testing without a gateway account”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:
python3 tests/payment_mock.py # serves the bundled gateways' shapes on :8788Then 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:
| Knob | What it exercises |
|---|---|
?mock_decline=1 | a 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=402 | an HTTP error carrying that gateway’s own error envelope, so your error reader has its real shape to parse |
?mock_delay=5 | timeout 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.pycame from the gateways’ own API references, which is how it caught a Stripe field that no longer exists (charges, replaced bylatest_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.
Best Practices
Section titled “Best Practices”- Always verify webhooks - Use HMAC signature verification
- Store gateway reference - Save the external ID for lookups
- Handle idempotency - Webhooks may be sent multiple times
- Resolve every endpoint through
api_base_url(test_url, live_url)- it appliesintegration_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 - Log webhook payloads - Store in
transaction.webhook_logfor debugging - Validate before enable - Check credentials in
enable_integration() - Support partial refunds - Accept optional amount parameter
- 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
- 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
Next Steps
Section titled “Next Steps”- Integrations - General integration framework
- Creating Modules - Module development guide