Goal: add support for a new or regional payment provider — a PSP the platform doesn't ship with — as a plugin, without forking or touching core. When you're done, that payment rail is available to any deployment and swappable per instance.
Developers who need a payment method the built-in providers don't cover: a regional PSP (think a local card acquirer, a bank-transfer rail, a country-specific wallet), a crypto processor, or a provider with better rates in your market. If one of the existing payment plugins already covers your PSP, use it — this is for when it doesn't. You'll need to be comfortable in Python/Flask and have sandbox credentials from the PSP.
dev-install-ce.sh (backend running, tests passing).Create a new plugin under plugins/<your-psp>/ following the standard layout — an __init__.py exposing the plugin, a source package, tests/, and the plugin's config. This is the same drop-in structure every VBWD plugin uses; nothing about payments makes it special at the packaging level.
The key idea: your PSP integration lives entirely in this plugin. Core already knows how to route a payment through a provider — what it doesn't know is how your specific PSP's API works. That knowledge is exactly what the plugin supplies, and nothing in core changes.
Success looks like: the empty plugin loads and appears in the plugin manager without errors.
This is the substance. The platform uses an SDK adapter pattern for payments: core defines a provider port (an interface), and each PSP plugin implements that port. Your job is to implement the four operations against your PSP's SDK or REST API:
Each method takes the platform's neutral input and returns its neutral result — you translate to and from your PSP's shape inside the adapter. The rest of the platform (checkout, invoicing, entitlement) only ever speaks the neutral interface, which is why swapping providers later is a config change rather than a rewrite.
Gotcha: the webhook handler is where integrations rot. Verify the PSP's signature on every incoming webhook (each provider has its own scheme), and make the handler idempotent — PSPs retry notifications, and processing "payment succeeded" twice must not double-fulfil an order. Key on the PSP's event/transaction id.
Wire it in: add the plugin to plugins.json and its config, and register the provider so the payments layer can discover it. If your adapter needs dependencies (repositories, an HTTP client), register them with the DI container in the plugin's on_enable — that's the supported seam for a plugin to add what it needs without core knowing about it.
Success looks like: with the plugin enabled, your PSP shows up as an available payment method in the admin, alongside the built-in ones.
Prove each operation against the PSP's sandbox before any real money is involved. Run the full lifecycle: authorize a test charge, capture it, refund it, and fire a sandbox webhook to confirm your handler updates the payment state correctly. Use the PSP's test cards for the failure paths too — a declined card, an insufficient-funds result, a disputed charge — because those are the paths that break in production if you only tested the happy one.
Write these as plugin tests under tests/ so the integration stays green under the gate. Mock at the PSP's transport boundary, not at your own adapter's methods, so the tests actually exercise the translation logic.
Success looks like: authorize → capture → refund all succeed against sandbox; a sandbox webhook moves the payment to the right state; declined-card and duplicate-webhook cases behave correctly; tests pass.
Because it's a plugin, each deployment decides whether to use it. One instance can run your regional PSP, another the default provider, a third both with a choice at checkout — all from the same codebase, toggled by which plugins are enabled. Enable it on the instances that need it and leave it off elsewhere.
Success looks like: the PSP is live on the intended instance and absent on others, with no code differences between them — only configuration.
The adapter is your responsibility to keep current. PSPs change APIs, deprecate endpoints, and update webhook schemes. Your plugin is a living integration, not a one-time build — budget maintenance.
Payments are unforgiving. A bug here charges the wrong amount, double-fulfils, or fails to refund — all of which cost real money and trust. Test the failure paths as seriously as the success path, and never ship a payment plugin whose webhook handler you haven't hammered with duplicates and bad signatures.
Compliance is outside the code. PCI scope, strong customer authentication (SCA/3-D Secure), and your PSP's own requirements are yours to satisfy. The adapter moves the transaction; it doesn't make you compliant.
Next: if your PSP supports it, add saved payment methods and recurring charges so the provider works for subscriptions, not just one-off payments — the subscription engine will drive it through the same port.
Supporting a new payment provider shouldn't mean forking the platform, and with the SDK adapter pattern it doesn't. Core defines the provider port; your plugin implements authorize, capture, refund and webhook against your PSP inside a contained adapter, registers through the plugin system and DI container, and becomes a payment rail any instance can switch on. The rest of the platform only ever speaks the neutral interface, so providers are swappable per deployment by configuration. You write the integration that's genuinely yours — one PSP's API — and inherit checkout, invoicing and entitlement unchanged. Because it's self-hosted and source-available under BSL 1.1 (free for commercial use while annual VBWD-attributable sales stay under the value of 6.7 BTC per year), you're extending a platform you own, not negotiating with one you rent.
Adding a payment rail? See the payment adapter pattern in the developer docs, the existing providers in the plugin catalogue, and the architecture for how the port fits.