Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

ResponsibilityOwner
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 WebhookEventverify_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 statesYour BillingProvider implementation
Owner binding, plan allowlists, persistence, reconciliation and entitlementsYour 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_mode returns a configuration error, so an adapter that forgets it can never be mounted behind the middleware. An empty secret is a configuration error; a mock_* secret selects WebhookVerificationMode::Mock.
  • Authenticate before parsing. handle_webhook verifies the exact body bytes with the gateway’s documented algorithm and a constant-time comparison (ring::hmac::verify here), and checks the timestamp window when the protocol signs one. Mock fixtures still require the configured mock_* secret.
  • Map only documented states. An unknown status or a missing identity is a PayloadParseError, never Active.
  • Offline fallback. Empty or mock_* API keys return deterministic URLs on reserved .invalid hosts, 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::charge only after forwarding the request’s idempotency key and binding amount and currency in the response. Metered billing uses the separate MeteredBillingProvider trait; return UsageDeduplication::ApplicationOutboxRequired from UsageReceipt::from_verified_provider_response when 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.