This guide walks through building a third-party adapter crate - suprnova-payments-mollie - that plugs into Suprnova's provider-neutral payments surface. By the end you will have a crate that registers itself, passes the discriminator flow, and can be dropped into any Suprnova app with a single cargo add.
The same structure applies to any provider: Square, Braintree, Adyen, or anything else with an HTTP API.
Why Suprnova diverges
Laravel ships Cashier as a first-party Stripe integration. It is excellent for the Stripe path, but it codifies one provider's vocabulary into the framework - adding a second provider means either forking Cashier or building a parallel surface beside it.
Suprnova keeps every provider on the same five-trait contract: Checkout, Subscription, CustomerStore, WebhookHandler, and the optional Payment for server-capture providers. Domain code only ever holds Arc<dyn PaymentProvider> from the registry. Swapping Stripe for Paddle (or for the Mollie adapter you're about to write) is a bootstrap change, not a code change. The reference adapters at crates/suprnova-payments-stripe/ and crates/suprnova-payments-paddle/ prove the trait contract holds for two very different commercial models - direct-capture gateway and Merchant of Record - and your adapter slots into the same shape.
1. Create the Workspace Member Crate
From the repo root:
Add it to your root Cargo.toml:
[workspace]
members = [
"framework",
"app",
"suprnova-cli",
"suprnova-macros",
"crates/suprnova-payments-mollie", # add this line
]
(The reference adapters - crates/suprnova-payments-stripe and crates/suprnova-payments-paddle - live in this same crates/ directory and are good templates to read alongside this guide.)
crates/suprnova-payments-mollie/Cargo.toml:
[package]
name = "suprnova-payments-mollie"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Mollie payment adapter for Suprnova"
[dependencies]
suprnova = { path = "../../framework" }
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
inventory = "0.3"
tracing = "0.1"
tokio = { version = "1", features = ["macros", "rt"] }
# Your Mollie SDK:
mollie-rs = "0.1"
hmac = "0.12" # for webhook HMAC verification
sha2 = "0.10"
hex = "0.4"
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
2. Lay Out the Source Files
Mirror the structure used by the shipped adapters:
crates/suprnova-payments-mollie/src/
├── lib.rs # MollieProvider struct, PaymentProvider impl, from_env
├── checkout.rs # Checkout impl
├── customer.rs # CustomerStore impl
├── subscription.rs # Subscription impl
├── webhook.rs # WebhookHandler impl
├── event_map.rs # provider event string → NeutralEventKind
└── payment.rs # Payment impl (if Mollie supports server-capture)
3. lib.rs - the Provider Struct
use async_trait;
use ;
pub use mollie_event_to_neutral;
/// Mollie adapter for Suprnova's provider-neutral payments surface.
PaymentProvider is the umbrella trait - the supertrait clause is Checkout + Subscription + CustomerStore + WebhookHandler, so the compiler will refuse to bind your provider until all four are implemented. The fifth trait, Payment, is optional - only providers that expose server-side capture implement it, and as_payment() reports the result to the framework. The default as_payment() returns None, so omit the override entirely if your provider doesn't do server-capture.
4. Implement the Four Required Traits
checkout.rs
use async_trait;
use ;
use crateMollieProvider;
customer.rs
use async_trait;
use ;
use crateMollieProvider;
subscription.rs
use async_trait;
use ;
use crateMollieProvider;
If your provider doesn't support a method, return PaymentError::NotSupported:
Err
payment.rs - server-side capture (optional)
Only implement this if your provider supports direct server-side charges against a stored payment method. Remove the as_payment() override in lib.rs if you skip this.
use async_trait;
use ;
use crateMollieProvider;
5. Map Provider Events to NeutralEventKind
event_map.rs:
use NeutralEventKind;
/// Map a Mollie webhook event type string to the framework's neutral taxonomy.
/// Returns `None` for provider-specific events that have no neutral equivalent.
Cover at minimum the events listed above. For any event not in the neutral taxonomy, return None - it still gets persisted in payments_webhook_events under provider_event_type + raw_payload so domain code can read it.
6. Implement Webhook Signature Verification
webhook.rs:
Mollie signs webhook payloads using HMAC-SHA256. Always compare signatures in constant time to prevent timing attacks.
use async_trait;
use ;
use Sha256;
use ;
use crate::;
type HmacSha256 = ;
Key points:
PaymentError::WebhookSignature(String)is the single variant for any signature failure - missing header, malformed encoding, mismatch. The framework's webhook route treats everyWebhookSignature(_)as a 401.- Use
PaymentError::Validation(String)for unparseable bodies. The webhook route returns 400 on any parse failure. - The framework's
webhook_routeshandler callsverifybeforeparse_event, then hydrates inside a DB transaction. Hydration failures return 503 so the provider retries. - Never log the raw secret or the received signature.
Mirror-table hydration: extract_payload_ids + extract_payment_snapshot + extract_customer_snapshot
After parse_event returns a WebhookEvent, the framework's webhook route hydrates the mirror tables. Three optional trait methods drive that - all have safe default no-op implementations, so an adapter can ship without them and still pass through the audit layer:
;
;
;
PayloadIds is the bridge between the parsed event and the framework's mirror logic. Implement it so the framework can find the right entity:
For each neutral value, populate the IDs that the provider's payload exposes. Subscription events should set subscription_id so the framework can call Subscription::get(id) and refresh the mirror from the canonical state. Customer events set customer_id. Payment / invoice events set transaction_id, plus subscription_id when it's a recurring charge.
PaymentSnapshot is built directly from the webhook payload - there's no Payment::get callback. Implement it for payment / invoice neutrals:
Stripe's reference implementation reads data.object.{id,amount,currency,customer} for PaymentIntent/Charge events and data.object.{id,amount_paid,tax,currency,customer,subscription,status_transitions.paid_at} for Invoice events. Paddle's reads data.{id,customer_id,currency_code,details.totals.{total,tax},billed_at,subscription_id}. Mirror the conventions that match your provider's payload shape - the framework doesn't care how you extract, only that the snapshot is correct.
If you return None from extract_payment_snapshot, the audit row is still written but payments_transactions is not touched. That is the correct return for subscription / customer events, or for any payment event where the payload doesn't carry enough information to populate a row.
CustomerSnapshot keeps customer-mirror sync provider-driven (no hardcoded JSON paths in the framework):
The framework will email = Set(snapshot.email) only when the snapshot supplies one; provider_metadata is always replaced with the provider's view of the customer (updated_at is also bumped regardless). Customer-mirror rows are only ever updated - never inserted - because user_id is NOT NULL and the app owns the user ↔ customer link via CustomerStore::create_customer.
Failure semantics
If extract_payload_ids returns None for subscription_id on a subscription event (or for customer_id on a customer event), the framework treats that as a Validation error: the hydration transaction rolls back, the audit row's process_error is set, and the HTTP response is 503 hydration-failed so the provider retries. Silent success on a malformed payload would leave the mirror stale without operator visibility - provider retries are the recovery mechanism.
This contract means an adapter's extractor must populate the relevant IDs honestly. Returning None is reserved for events your provider can't translate at all (e.g. a payment event with no charge ID in the payload), not for "I didn't bother to parse this one."
7. Register at App Boot
Two mechanisms are available - pick one:
Runtime registration (recommended for apps with env-var config)
use Arc;
use PaymentProviderRegistry;
use MollieProvider;
let mollie = from_env.expect;
bind;
Compile-time registration via inventory
For adapter crates that want zero-config registration - useful when shipping a library that consumers just cargo add without any boot-time wiring:
use ;
use inventory;
// In lib.rs, in a static initializer:
submit!;
inventory::submit! runs before main. The factory closure is called once when the registry is first accessed.
8. Pass the Discriminator Test
Every adapter crate should include an integration test that proves the trait contract is correct end to end. This is the soundness proof - if this test passes, the provider plugs into any Suprnova app without surprises.
// tests/discriminator.rs (inside crates/suprnova-payments-mollie/)
use *;
use MollieProvider;
/// Requires MOLLIE_API_KEY and MOLLIE_WEBHOOK_SECRET to be set.
/// Run with: cargo test --test discriminator -- --ignored
async
Gate live integration tests with #[ignore] so cargo test passes in CI without credentials. Run them explicitly with -- --ignored against a sandbox account.
9. PaymentError Variants Reference
The full enum lives in framework/src/payments/error.rs. Pick the variant that matches what actually went wrong:
| Variant | When to use |
|---|---|
Provider(String) |
The provider's API returned an error you don't need to translate further |
Validation(String) |
Request fields are invalid, or a webhook body won't parse |
NotSupported(String) |
The method isn't applicable for this provider (e.g. Paddle's subscribe) |
Declined { reason, decline_code } |
Card declined - pass decline_code through when the provider supplies one |
Authentication(String) |
Provider rejected your API key or credentials |
NotFound(String) |
Customer, subscription, or transaction ID doesn't exist |
WebhookSignature(String) |
Any signature failure - missing header, malformed encoding, or mismatch |
InvalidPhoneNumber(String) |
E.164 validation failed in mobile-money flows |
InvalidCountryCode(String) |
ISO-3166-1 alpha-2 validation failed |
Internal(String) |
Unexpected SDK error, network failure, HMAC init failure, or any other framework-side problem |
The webhook route maps these to status codes: WebhookSignature(_) → 401, Validation(_) from parse_event → 400, anything else from hydration → 503 (so the provider retries).
Once your adapter compiles and the discriminator test passes:
- Add your crate to your app's
Cargo.tomlwithcargo add suprnova-payments-mollie --path ./crates/suprnova-payments-mollie. - Register at bootstrap as shown in step 7.
- Mount
webhook_routes(db.clone())once at app boot - the same handler dispatches to every registered provider by name, so a single mount serves Stripe, Paddle, and your new adapter.
Next
- Payments - the provider-neutral surface and Quick Start
- Payments - Stripe Adapter - full template for a gateway adapter
- Payments - Paddle Adapter - full template for a Merchant-of-Record adapter
- Payments Frontend - how to render the
SessionPayloadyour adapter returns - Error Model - how
PaymentErrorlands as anHttpResponse
