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
Import every payments type from suprnova::payments by its name, as the examples in this guide do. The modules of the request and result types are under suprnova::payments::dto, so suprnova::payments::session::StartSessionRequest is not a path.
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;
UpdateSubscriptionRequest::new_price_refs has one meaning on every adapter. The list is the set of prices the subscription has after the call. An item whose price is in the list keeps its id and quantity, and an item whose price is not in the list is removed. A price with no item is added with a quantity of 1. The exception is a swap: when exactly one item is removed and exactly one price is added, the new price takes the quantity of the removed item. Refuse an empty list and a list that names a price twice with PaymentError::Validation, before any request. Compute the change from a read of the subscription. The change is not atomic: a change made at the provider between your read and your write can be undone or left in place, depending on whether the provider takes the whole list of items or one item at a time. Return the provider's view of the outcome in the SubscriptionResult. MockPaymentProvider follows the same contract, so a test that runs against it sees what an adapter does.
Build the text of a PaymentError::Provider from the SDK error yourself, without the message of the provider and without any id or URL, because the text ends up in logs and in responses. Name the operation and give the kind of the error, its code and the HTTP status, and use a fixed text for the other variants. The sdk_error.rs files of suprnova-payments-stripe and suprnova-payments-paddle show the shape: provider_error(operation, error) returns PaymentError::Provider("stripe customers.retrieve: api error invalid_request_error resource_missing, status 404") for a Stripe API error.
UpdateSubscriptionRequest::proration is an Option<Proration> that you read only when new_price_refs is Some. Map None and Proration::ProrateAtRenewal to your provider's mode that adds the difference to the next invoice, ProrateNow to the mode that bills it at once, and DoNotProrate to the mode that bills nothing for the change. Proration is #[non_exhaustive], so end the match with an arm that returns PaymentError::NotSupported. If the provider cannot take a part of a request, such as a price change together with cancel_at_period_end, return NotSupported and send nothing.
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 |
Database { context, source } |
A statement against the payments tables failed. source is the suprnova::DbErr; ? converts a DbErr into this variant |
Give every call of your adapter a deadline. The Stripe and Paddle adapters use 30 seconds and report a call that runs out of time as Provider. For a call that only reads, the text is <provider> <operation> timed out. For a call that changes something, the text adds ; outcome is unknown, reconcile before retrying, because the provider may have carried out the change. The error of a change never says that the call failed.
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
