Payment Providers
The Payment capability wraps any hosted-checkout provider (Stripe, Lemon Squeezy, Paddle, ...) behind a deliberately small trait so a storefront can create checkouts, look up subscriptions, and decode webhooks without hard-coding a vendor SDK.
Webhook signature verification is handled by the Webhook capability, not this trait. Always run a WebhookPlugin verifier before calling decode_webhook_event.
Philosophy: A Small Trait On Purpose
Stripe, Lemon Squeezy, and Paddle disagree on the shape of their line items, product objects, and webhook envelopes. They agree on three things every storefront needs:
1. Send the customer to a hosted checkout for a bag of line items. 2. Look up whether a known subscription is still active. 3. Decide what a webhook event means in the abstract.
The trait exposes exactly those three operations. Everything
vendor-specific (Stripe's automatic_tax, Lemon Squeezy's
variant_id, Paddle's custom_data) lives in an
attributes: HashMap<String, String> escape hatch on each data type.
The trait itself never grows a field that mentions a specific vendor.
The Trait
pub trait PaymentPlugin: Send + Sync {
fn name(&self) -> &str;
fn provider(&self) -> &str; // "stripe", "lemonsqueezy", "paddle"
fn create_checkout(
&self,
items: Vec<LineItem>,
opts: CheckoutOpts,
) -> Result<CheckoutSession, PaymentError>;
fn get_subscription(&self, id: &str) -> Result<Subscription, PaymentError>;
fn decode_webhook_event(
&self,
raw_body: &[u8],
) -> Result<PaymentEvent, PaymentError>;
}
Signature verification for webhooks is handled separately by a
WebhookPlugin; decode_webhook_event assumes the body has already
been authenticated.
Key Types
| Type | Purpose |
|---|---|
Money |
amount_cents: i64 + Currency. Integer cents across every currency, including zero-decimal ones like JPY. |
Currency |
ISO-4217 three-letter code, validated at construction. |
LineItem |
name, description, quantity, unit_price, sku. |
CheckoutOpts |
customer_email, success_url, cancel_url, metadata, mode. |
CheckoutMode |
OneTime or Subscription { interval } with Day/Week/Month/Year. |
CheckoutSession |
id, url, expires_at, attributes. |
Subscription |
id, customer_id, status, billing period, cancel_at_period_end, attributes. |
SubscriptionStatus |
Active, Trialing, PastDue, Canceled, Unpaid, Incomplete. |
PaymentEvent |
7 variants: CheckoutCompleted, SubscriptionCreated, SubscriptionUpdated, SubscriptionCanceled, PaymentSucceeded, PaymentFailed, Unknown { event_type }. |
PaymentError |
InvalidCurrency (400), InvalidAmount (400), NotFound (404), ProviderError (502), Backend (500). |
Reference Implementations
| Plugin | Provider | Notes |
|---|---|---|
@bext/pay-stripe (live) |
Stripe | StripeLivePaymentPlugin — real api.stripe.com REST calls (checkout sessions, subscription lookup). |
@bext/pay-stripe (double) |
Stripe | StripePaymentPlugin — network-free reference double; deterministic stub sessions + the shared webhook decoder. |
@bext/pay-lemonsqueezy |
Lemon Squeezy | Network-free reference double (Lemon Squeezy live client is future work). |
The server picks the Stripe plugin by configuration: set
BEXT_PAYMENT_STRIPE_KEY (or BEXT_PAYMENT_API_KEY) to a secret key and
the live client is used (real HTTP to Stripe); with no key set, the
network-free double is used so tests and offline environments exercise
the trait contract without hitting a live API. Webhook decoding is
shared by both and is always available; webhook signature verification is
a separate concern handled by @bext/webhook-verifiers (real HMAC-SHA256)
in front of the payment handler.
Example: Create A Checkout
use bext_plugin_api::payment::*;
use std::collections::HashMap;
let plugin: &dyn PaymentPlugin = /* @bext/pay-stripe */;
let session = plugin.create_checkout(
vec![LineItem {
name: "Pro Plan".into(),
description: Some("Monthly access".into()),
quantity: 1,
unit_price: Money::new(1999, Currency::from_code("USD")?),
sku: Some("PRO-M-1".into()),
}],
CheckoutOpts {
customer_email: Some("alice@example.com".into()),
success_url: "https://app.example.com/thanks".into(),
cancel_url: "https://app.example.com/pricing".into(),
metadata: HashMap::new(),
mode: CheckoutMode::Subscription {
interval: SubscriptionInterval::Month,
},
},
)?;
// Redirect the customer
redirect(&session.url);
Example: Handle A Webhook
fn on_webhook(plugin: &dyn PaymentPlugin, raw: &[u8]) -> Result<(), PaymentError> {
// Signature is verified by a WebhookPlugin upstream of this call.
match plugin.decode_webhook_event(raw)? {
PaymentEvent::CheckoutCompleted { session_id, customer_id } => {
mark_paid(&session_id, customer_id.as_deref());
}
PaymentEvent::SubscriptionCreated(sub)
| PaymentEvent::SubscriptionUpdated(sub) => {
upsert_subscription(&sub);
}
PaymentEvent::SubscriptionCanceled { subscription_id } => {
revoke_access(&subscription_id);
}
PaymentEvent::PaymentSucceeded { amount, customer_id } => {
record_payment(amount, customer_id.as_deref());
}
PaymentEvent::PaymentFailed { reason, customer_id } => {
alert_dunning(&reason, customer_id.as_deref());
}
PaymentEvent::Unknown { event_type } => {
tracing::info!(event_type, "unknown payment event (ignored)");
}
}
Ok(())
}
Example: Look Up A Subscription
let sub = plugin.get_subscription("sub_abc123")?;
if sub.status == SubscriptionStatus::Active && !sub.cancel_at_period_end {
allow_pro_features(&sub.customer_id);
}
Currency Rules
Currency::from_code rejects anything that is not exactly three
uppercase ASCII letters. The ISO list itself is not enforced at
construction — that is the payment processor's job at the API
boundary — but typos like "usd" or "US" fail fast.
Money is signed (i64) so refunds and adjustments round-trip
through the same type. Zero-decimal currencies (JPY, KRW) use the
same field; the plugin divides by 1 instead of 100.
Feature Flag
None. The Payment trait and types live in bext-plugin-api and are
always available; no cargo feature gates them.
See Also
- @bext/pay-stripe — Stripe reference implementation.
- @bext/pay-lemonsqueezy — Lemon Squeezy reference implementation.
- Webhook — handles signature verification before decode_webhook_event runs.
- Capabilities overview — the full list of pluggable capabilities.
- Session — where customer identity flows from into checkout metadata.