Writing your own payment provider
Rullst Capital ships two built-in adapters: Stripe (supported) and InfinitePay (experimental until it is validated against a live account). Every other gateway is integrated by the application through the same provider-neutral contracts the built-in adapters use. Version 13 removed the Paddle, Lemon Squeezy, Polar, Razorpay, Mercado Pago, Alipay, Coinbase Commerce, PicPay and Wise adapters; see the migration guide row “Capital providers and NFS-e removed”.
This guide walks through a complete adapter for a fictitious gateway,
AcmePay. The code below is included from
rullst-capital/examples/custom_provider.rs,
which CI compiles and which runs offline from a clone of the Rullst repository:
cargo run -p rullst-capital --example custom_provider
In an application, the same types are available through the facade as
rullst::capital::* with the capital feature. BillingProvider is an
#[async_trait] trait: use the async-trait crate, as the example does, or
the facade’s rullst::async_trait. The example’s HMAC check uses ring, hex
and subtle; add equivalent crates to your own Cargo.toml.
What the framework does and what the adapter does
| Responsibility | Owner |
|---|---|
Bounding the webhook body (2 MiB), rejecting duplicate Standard Webhooks headers, refusing mock_* secrets in production state, replay protection, restoring the exact body and inserting the WebhookEvent | verify_webhook_with_state (Axum) or verify_webhook_actix_with_state (Actix) |
| Declaring real or mock verification, authenticating the exact signed bytes, checking freshness, mapping only documented states | Your BillingProvider implementation |
| Owner binding, plan allowlists, persistence, reconciliation and entitlements | Your application, with the Capital checkout, entitlement and quota contracts |
The adapter is trusted server code. A common trait does not mean every gateway
supports every operation: an operation the gateway does not offer, or that you
have not reviewed, must return CapitalError::UnsupportedOperation instead of
fabricating success.
1. Implement BillingProvider
/// Largest clock difference accepted between AcmePay and this server.
const WEBHOOK_TOLERANCE_SECONDS: i64 = 5 * 60;
/// Adapter for the fictitious AcmePay gateway.
pub struct AcmePayProvider {
api_key: String,
webhook_secret: String,
}
impl AcmePayProvider {
pub fn new(api_key: impl Into<String>, webhook_secret: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
webhook_secret: webhook_secret.into(),
}
}
/// Empty or `mock_*` credentials select the deterministic offline fixture.
fn is_offline(&self) -> bool {
self.api_key.is_empty() || self.api_key.starts_with("mock_")
}
/// Checks `acme-signature: t=<unix>,v1=<hex HMAC-SHA256("<t>.<body>")>`.
fn verify_signature(
&self,
payload: &[u8],
headers: &HashMap<String, String>,
now: i64,
) -> Result<(), CapitalError> {
let header = headers
.get("acme-signature")
.ok_or_else(|| CapitalError::InvalidSignature("missing acme-signature".into()))?;
if self.webhook_verification_mode()? == WebhookVerificationMode::Mock {
// Local fixtures still require the configured `mock_*` secret.
return match header
.as_bytes()
.ct_eq(self.webhook_secret.as_bytes())
.into()
{
true => Ok(()),
false => Err(CapitalError::InvalidSignature("mock signature".into())),
};
}
let mut timestamp = None;
let mut signature = None;
for part in header.split(',') {
match part.split_once('=') {
Some(("t", value)) => timestamp = value.parse::<i64>().ok(),
Some(("v1", value)) => signature = hex::decode(value).ok(),
_ => {}
}
}
let (Some(timestamp), Some(signature)) = (timestamp, signature) else {
return Err(CapitalError::InvalidSignature(
"malformed acme-signature".into(),
));
};
if (now - timestamp).abs() > WEBHOOK_TOLERANCE_SECONDS {
return Err(CapitalError::StaleWebhook("acmepay timestamp".into()));
}
let key = hmac::Key::new(hmac::HMAC_SHA256, self.webhook_secret.as_bytes());
let mut signed = format!("{timestamp}.").into_bytes();
signed.extend_from_slice(payload);
// `ring::hmac::verify` compares in constant time.
hmac::verify(&key, &signed, &signature)
.map_err(|_| CapitalError::InvalidSignature("acmepay signature".into()))
}
}
#[async_trait]
impl BillingProvider for AcmePayProvider {
fn name(&self) -> &'static str {
"acmepay"
}
// Without this override the canonical middleware refuses the provider.
fn webhook_verification_mode(&self) -> Result<WebhookVerificationMode, CapitalError> {
match self.webhook_secret.trim() {
"" => Err(CapitalError::ConfigurationError(
"acmepay webhook secret cannot be empty".into(),
)),
secret if secret.starts_with("mock_") => Ok(WebhookVerificationMode::Mock),
_ => Ok(WebhookVerificationMode::Real),
}
}
async fn create_checkout_session(
&self,
_customer_email: &str,
plan_id: &str,
_redirect_url: &str,
) -> Result<String, CapitalError> {
if plan_id.is_empty()
|| !plan_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_')
{
return Err(CapitalError::ConfigurationError("invalid plan ID".into()));
}
if self.is_offline() {
// A reserved `.invalid` host never reaches a real gateway.
return Ok(format!("https://mock.acmepay.invalid/checkout/{plan_id}"));
}
// Call the gateway here, bind the response to the request and return
// only a validated HTTPS URL. Never report success you did not verify.
Err(CapitalError::UnsupportedOperation(
"acmepay live checkout is not implemented in this example".into(),
))
}
fn handle_webhook(
&self,
payload: &[u8],
headers: &HashMap<String, String>,
) -> Result<WebhookEvent, CapitalError> {
// Authenticate the exact bytes before parsing anything.
self.verify_signature(payload, headers, chrono::Utc::now().timestamp())?;
let json: serde_json::Value = serde_json::from_slice(payload)
.map_err(|_| CapitalError::PayloadParseError("acmepay JSON".into()))?;
let text = |field: &str| {
json["data"][field]
.as_str()
.filter(|value| !value.is_empty())
.map(str::to_owned)
.ok_or_else(|| CapitalError::PayloadParseError(format!("acmepay {field}")))
};
// Map only documented states; an unknown state is an error, not access.
let status = match text("status")?.as_str() {
"active" => SubscriptionStatus::Active,
"trialing" => SubscriptionStatus::Trialing,
"past_due" => SubscriptionStatus::PastDue,
"canceled" => SubscriptionStatus::Canceled,
_ => return Err(CapitalError::PayloadParseError("acmepay status".into())),
};
Ok(WebhookEvent {
subscription_id: text("subscription")?,
customer_id: text("customer")?,
customer_email: text("email")?,
plan_id: text("plan")?,
status,
ends_at: json["data"]["current_period_end"].as_i64(),
})
}
async fn create_customer_portal(
&self,
_customer_email: &str,
_return_url: &str,
) -> Result<String, CapitalError> {
if self.is_offline() {
return Ok("https://mock.acmepay.invalid/portal".into());
}
Err(CapitalError::UnsupportedOperation("acmepay portal".into()))
}
async fn cancel_subscription(&self, subscription_id: &str) -> Result<(), CapitalError> {
if subscription_id.is_empty() {
return Err(CapitalError::SubscriptionError(
"empty subscription ID".into(),
));
}
if self.is_offline() {
return Ok(());
}
Err(CapitalError::UnsupportedOperation("acmepay cancel".into()))
}
// Operations the gateway does not offer fail explicitly.
async fn pause_subscription(&self, _subscription_id: &str) -> Result<(), CapitalError> {
Err(CapitalError::UnsupportedOperation("acmepay pause".into()))
}
async fn report_usage(&self, _: &str, _: &str, _: u64) -> Result<(), CapitalError> {
Err(CapitalError::UnsupportedOperation("acmepay usage".into()))
}
async fn apply_coupon(&self, _: &str, _: &str) -> Result<(), CapitalError> {
Err(CapitalError::UnsupportedOperation("acmepay coupons".into()))
}
async fn extend_trial(&self, _: &str, _: i64) -> Result<(), CapitalError> {
Err(CapitalError::UnsupportedOperation("acmepay trials".into()))
}
}
Points that matter for security and testing:
- Declare the verification mode. The default
webhook_verification_modereturns a configuration error, so an adapter that forgets it can never be mounted behind the middleware. An empty secret is a configuration error; amock_*secret selectsWebhookVerificationMode::Mock. - Authenticate before parsing.
handle_webhookverifies the exact body bytes with the gateway’s documented algorithm and a constant-time comparison (ring::hmac::verifyhere), and checks the timestamp window when the protocol signs one. Mock fixtures still require the configuredmock_*secret. - Map only documented states. An unknown status or a missing identity is a
PayloadParseError, neverActive. - Offline fallback. Empty or
mock_*API keys return deterministic URLs on reserved.invalidhosts, so tests and local sandboxes never contact the gateway. Live paths must bind the provider response to the request before returning it. - Optional contracts. Override
BillingProvider::chargeonly after forwarding the request’s idempotency key and binding amount and currency in the response. Metered billing uses the separateMeteredBillingProvidertrait; returnUsageDeduplication::ApplicationOutboxRequiredfromUsageReceipt::from_verified_provider_responsewhen the gateway offers no deduplication key.
2. Plug it into checkout and subscriptions
/// The subscription owner, loaded from authenticated application state.
struct Workspace {
billing_email: String,
subscription_id: Option<String>,
}
#[async_trait]
impl Billable for Workspace {
fn email(&self) -> String {
self.billing_email.clone()
}
fn subscription_id(&self) -> Option<String> {
self.subscription_id.clone()
}
}
let provider = Arc::new(AcmePayProvider::new("mock_api_key", "mock_webhook_secret"));
let workspace = Workspace {
billing_email: "[email protected]".into(),
subscription_id: Some("sub_42".into()),
};
// Checkout through the trait object, exactly as for a built-in adapter.
let checkout = provider
.create_checkout_session(
&workspace.email(),
"plan_pro",
"https://app.example/billing",
)
.await?;
assert_eq!(checkout, "https://mock.acmepay.invalid/checkout/plan_pro");
// Subscription operations with static dispatch on the explicit provider.
workspace
.subscription_with(provider.as_ref())?
.cancel()
.await?;
let paused = workspace
.subscription_with(provider.as_ref())?
.pause()
.await;
assert!(matches!(paused, Err(CapitalError::UnsupportedOperation(_))));
// `charge_with` keeps the trait's fail-closed default for direct charges.
let charge = workspace
.charge_with(provider.as_ref(), 4_990, "BRL", "cus_1", "pm_1", "order_1")
.await;
assert!(matches!(charge, Err(CapitalError::UnsupportedOperation(_))));
subscription_with and charge_with dispatch statically to the provider you
pass. The compatibility helpers (Billable::subscribe,
Billable::billing_portal_url, Billable::cancel_subscription) use the global
provider; select it once at start-up with
rullst::capital::try_init_provider(Box::new(provider)), which reports a
second initialization instead of replacing the first provider.
Derive the subscription ID and the plan from authenticated application state,
and keep a server-owned plan allowlist. A checkout redirect is not proof of
payment; grant access from verified, reconciled state, for example with the
entitlements::EntitlementGate.
3. Verify webhooks with the canonical middleware
async fn billing_webhook(Extension(event): Extension<WebhookEvent>) -> StatusCode {
// Only events that passed the provider check and replay claim arrive here.
// Persist them against the owner you already know; never trust the email alone.
println!(
"verified {} event for {}",
event.status.as_str(),
event.subscription_id
);
StatusCode::OK
}
fn billing_router(provider: Arc<AcmePayProvider>) -> Router {
let replay_store = Arc::new(InMemoryWebhookReplayStore::default());
// Production uses `WebhookMiddlewareState::production_with_provider`,
// which rejects `mock_*` secrets; this offline example opts in to mocks.
let state = WebhookMiddlewareState::local_mock_with_provider(provider, replay_store);
Router::new()
.route("/billing/webhook", post(billing_webhook))
.layer(middleware::from_fn_with_state(
state,
verify_webhook_with_state,
))
}
Mount WebhookMiddlewareState::production_with_provider in production. It
rejects mock_* secrets with MockWebhookNotAllowed; local_mock_with_provider
is for local fixtures only. The middleware maps failures to HTTP status codes:
401 for a bad signature or stale timestamp, 409 for a replay, 413 for an
oversized body and 503 when the replay store is full or unavailable.
The in-memory replay store is process-local. With several processes, use the
webhook-sql feature: SqlWebhookReplayStore shares replay claims, and its
check_and_record_event_key_with_transaction claims the gateway’s stable event
ID inside your own database transaction. Body-hash replay protection
does not stop a gateway from resending the same event with a different body;
prefer the stable event ID when the gateway provides one.
4. Test the adapter
The example’s main exercises the contract offline:
- a signed mock delivery returns 200 and its identical replay returns 409;
- a wrong signature returns 401 and never reaches the handler;
- the live HMAC path accepts a fresh signature and rejects a stale timestamp;
- unsupported operations and direct charges fail with
UnsupportedOperation.
Add the gateway’s published webhook examples as fixtures, and validate the live adapter against a sandbox account before enabling it in production. Tests with mocks establish local contracts, not provider interoperability.