Custom payment providers
Report payments from Paddle, Lemon Squeezy or any other provider, and build the endpoint Voidhash calls.
Rust code for payment sources and their
provider endpoint, whose pages explain the protocol and
Studio setup. Use a client with project:purchases:write.
Report payments
payment_source returns a handle that borrows the client; dates are chrono::DateTime<Utc>.
use voidhash::VoidhashClient;
let voidhash = VoidhashClient::new(std::env::var("VOIDHASH_SECRET_KEY")?)?;
let paddle = voidhash.payment_source(std::env::var("VOIDHASH_PAYMENT_SOURCE_ID")?);A one-time purchase
Optional fields are setters; Charge::for_customer replaces the distinct ID with a customer ID.
use chrono::{TimeZone, Utc};
use voidhash::payment_sources::Charge;
let paid_at = Utc.with_ymd_and_hms(2026, 10, 20, 9, 30, 0).unwrap();
let recorded = paddle
.charge(
"txn_120",
Charge::new("user_123", "LIFETIME", 4900, "EUR", paid_at)
.customer("ctm_42")
.fee(245)
.tax(818),
)
.await?;A subscription payment
let period_start = Utc.with_ymd_and_hms(2026, 10, 1, 0, 0, 0).unwrap();
let period_end = Utc.with_ymd_and_hms(2026, 11, 1, 0, 0, 0).unwrap();
paddle
.charge(
"txn_123",
Charge::new("user_123", "pri_monthly", 999, "EUR", period_start)
.customer("ctm_42")
.subscription_id("sub_9")
.period(period_start, period_end),
)
.await?;A refund
use voidhash::payment_sources::Refund;
paddle
.refund("re_1", Refund::new("txn_123", 999, "EUR", Utc::now()))
.await?;What comes back
An Applied payment carries purchase_id and booked_amount. A Pending one waits for its
charge, or for its product key to be mapped in Studio:
use voidhash::generated::types::PaymentSourceTransactionPendingReason as PendingReason;
if recorded.pending_reason == Some(PendingReason::ProductNotMapped) {
println!("Map the product key in Studio; the payment is recorded then");
}Retry safely
Retry transport errors and 5xx answers as they are; branch on the code.
match paddle.charge("txn_123", charge).await {
Ok(recorded) => println!("{:?}", recorded.status),
Err(voidhash::Error::Api { code, .. }) if code == "transaction_conflict" => {
// Retrying won't help. To correct the payment, refund it and report
// the right one under a new id.
}
Err(error) => return Err(error.into()),
}Test payments
Without .environment(…), the key decides.
use voidhash::payment_sources::Environment;
let charge = Charge::new("user_123", "LIFETIME", 4900, "EUR", Utc::now())
.environment(Environment::Test);Report subscription state
Set .canceled_at for Canceled; SubscriptionState::for_customer names the subscriber by
customer ID.
use voidhash::generated::types::PaymentSourceSubscriptionStatus;
use voidhash::payment_sources::{SubscriptionState, SubscriptionStatus};
let state = paddle
.subscription(
"sub_9",
SubscriptionState::new(
"user_123",
"pri_monthly",
SubscriptionStatus::Active,
period_start,
period_end,
true, // renews at the period's end
Utc.with_ymd_and_hms(2026, 10, 1, 0, 0, 5).unwrap(),
)
.customer("ctm_42"),
)
.await?;
if state.status == PaymentSourceSubscriptionStatus::Superseded {
// Voidhash already has a newer state; this one changed nothing.
}Translate your provider's webhooks
paddle stands for your provider's client library and event types.
use std::sync::Arc;
use axum::{body::Bytes, extract::State, http::{HeaderMap, StatusCode}};
use voidhash::payment_sources::{
Charge, PaymentSourceApi, Refund, SubscriptionState, SubscriptionStatus,
};
use voidhash::VoidhashClient;
#[derive(Clone)]
pub struct App {
pub voidhash: Arc<VoidhashClient>,
pub payment_source_id: String,
pub paddle_webhook_secret: String,
}
pub async fn paddle_webhook(
State(app): State<App>,
headers: HeaderMap,
body: Bytes,
) -> StatusCode {
let Ok(event) = paddle::webhook::verify(&headers, &body, &app.paddle_webhook_secret) else {
return StatusCode::UNAUTHORIZED;
};
let source = app.voidhash.payment_source(&app.payment_source_id);
match report(&source, event).await {
Ok(()) => StatusCode::OK,
// Redelivering won't change the answer; look at it instead.
Err(voidhash::Error::Api { code, .. }) if code == "transaction_conflict" => StatusCode::OK,
// Anything else makes the provider redeliver, which is safe.
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
async fn report(
source: &PaymentSourceApi<'_>,
event: paddle::webhook::Event,
) -> Result<(), voidhash::Error> {
match event {
paddle::webhook::Event::TransactionCompleted(txn) => {
let mut charge =
Charge::new(&txn.user_id, &txn.price_id, txn.amount, &txn.currency, txn.billed_at)
.customer(&txn.customer_id)
.fee(txn.fee);
if let Some(period) = txn.billing_period {
charge = charge
.subscription_id(&period.subscription_id)
.period(period.starts_at, period.ends_at);
}
source.charge(&txn.id, charge).await?;
}
paddle::webhook::Event::AdjustmentCreated(refund) => {
let report = Refund::new(
&refund.transaction_id,
refund.amount,
&refund.currency,
refund.created_at,
);
source.refund(&refund.id, report).await?;
}
paddle::webhook::Event::SubscriptionUpdated(sub) => {
let status = match sub.status {
paddle::Status::Trialing => SubscriptionStatus::Trialing,
paddle::Status::Active => SubscriptionStatus::Active,
paddle::Status::PastDue => SubscriptionStatus::PastDue,
paddle::Status::Canceled => SubscriptionStatus::Canceled,
paddle::Status::Expired => SubscriptionStatus::Expired,
};
let mut state = SubscriptionState::new(
&sub.user_id,
&sub.price_id,
status,
sub.period_start,
sub.period_end,
sub.renews,
sub.updated_at,
)
.customer(&sub.customer_id);
if let Some(canceled_at) = sub.canceled_at {
state = state.canceled_at(canceled_at);
}
source.subscription(&sub.id, state).await?;
}
}
Ok(())
}Cancel and refund at the provider
When the endpoint can't act, these return voidhash::Error::Api. Its type_ is conflict when
the provider refused, with the reason in message, or unavailable when you can try again later.
use voidhash::payment_sources::{CancelAt, RefundTransaction};
// Stop renewal; access lasts to the period's end. CancelAt::Now ends it at once.
let state = paddle.cancel_subscription("sub_9", CancelAt::PeriodEnd).await?;
// RefundTransaction::full() refunds everything left of the charge.
let refund = paddle
.refund_transaction("txn_123", RefundTransaction::partial(500).reason("Duplicate order"))
.await?;Build a provider endpoint
Implement a trait for each operation your provider supports and register it on a ProviderHandler.
| Trait | Method | Builder method | Operation |
|---|---|---|---|
CatalogSource | list_catalog | .catalog | catalog.list |
HistorySource | list_history | .history | history.list |
SubscriptionSource | get_subscription | .subscription | subscription.get |
CancellationSource | cancel_subscription | .cancellations | subscription.cancel |
RefundSource | refund_transaction | .refunds | transaction.refund |
Builder methods take your type by value, so make it cheap to clone.
use voidhash::provider::*;
#[derive(Clone)]
pub struct Paddle {
live: paddle::Client,
test: paddle::Client,
}
impl Paddle {
fn client(&self, environment: Environment) -> &paddle::Client {
match environment {
Environment::Production => &self.live,
Environment::Test => &self.test,
}
}
}
pub fn provider_handler(paddle: Paddle, signing_secret: String) -> ProviderHandler {
ProviderHandler::new("paddle", signing_secret)
.catalog(paddle.clone())
.history(paddle.clone())
.subscription(paddle.clone())
.cancellations(paddle.clone())
.refunds(paddle)
}Products
impl CatalogSource for Paddle {
async fn list_catalog(&self, request: CatalogRequest) -> Result<CatalogPage, ProviderError> {
let api = self.client(request.environment);
let page = api
.list_prices(request.cursor.as_deref())
.await
.map_err(ProviderError::other)?;
let items = page
.items
.into_iter()
.map(|price| {
let kind = if price.recurring { ProductKind::Subscription } else { ProductKind::OneTime };
CatalogProduct::new(price.id, price.name, kind)
})
.collect();
Ok(CatalogPage::new(items, page.next_cursor))
}
}History
Subscription says will_renew where SubscriptionState says is_auto_renewing.
impl HistorySource for Paddle {
async fn list_history(&self, request: HistoryRequest) -> Result<HistoryPage, ProviderError> {
let api = self.client(request.environment);
let cursor = request.cursor.as_deref();
Ok(match request.kind {
HistoryKind::Subscriptions => {
let page = api
.list_subscriptions(cursor, request.before)
.await
.map_err(ProviderError::other)?;
HistoryPage::Subscriptions {
items: page.items.into_iter().map(to_subscription).collect(),
next_cursor: page.next_cursor,
}
}
HistoryKind::Transactions => {
let page = api
.list_transactions(cursor, request.before)
.await
.map_err(ProviderError::other)?;
HistoryPage::Transactions {
items: page.items.into_iter().map(to_transaction).collect(),
next_cursor: page.next_cursor,
}
}
})
}
}
fn to_subscription(sub: paddle::Subscription) -> Subscription {
let status = match sub.status {
paddle::Status::Trialing => SubscriptionStatus::Trialing,
paddle::Status::Active => SubscriptionStatus::Active,
paddle::Status::PastDue => SubscriptionStatus::PastDue,
paddle::Status::Canceled => SubscriptionStatus::Canceled,
paddle::Status::Expired => SubscriptionStatus::Expired,
};
let mut state = Subscription::new(
sub.id,
sub.price_id,
status,
sub.period_start,
sub.period_end,
sub.renews,
sub.updated_at,
)
.customer(sub.customer_id);
if let Some(user_id) = sub.user_id {
state = state.distinct_id(user_id);
}
if let Some(canceled_at) = sub.canceled_at {
state = state.canceled_at(canceled_at);
}
state
}
fn to_transaction(txn: paddle::Transaction) -> Transaction {
let record = match (txn.refunds, txn.billed) {
(Some(charge_id), _) => {
Transaction::refund(txn.id, charge_id, txn.amount, txn.currency, txn.occurred_at)
}
(None, billed) => {
let charge =
Transaction::charge(txn.id, txn.price_id, txn.amount, txn.currency, txn.occurred_at)
.fee(txn.fee);
match billed {
Some(period) => charge.subscription(
period.subscription_id,
period.period_start,
period.period_end,
),
None => charge,
}
}
};
let record = record.customer(txn.customer_id);
match txn.user_id {
Some(user_id) => record.distinct_id(user_id),
None => record,
}
}Subscriptions
Return None when your provider doesn't know the subscription.
impl SubscriptionSource for Paddle {
async fn get_subscription(
&self,
request: SubscriptionRequest,
) -> Result<Option<Subscription>, ProviderError> {
match self.client(request.environment).get_subscription(&request.id).await {
Ok(sub) => Ok(sub.map(to_subscription)),
Err(error) if error.is_outage() => Err(ProviderError::Unavailable(error.to_string())),
Err(error) => Err(ProviderError::other(error)),
}
}
}impl CancellationSource for Paddle {
async fn cancel_subscription(
&self,
request: CancelSubscriptionRequest,
) -> Result<Subscription, ProviderError> {
let api = self.client(request.environment);
let canceled = api
.cancel_subscription(&request.id, request.at_period_end)
.await
.map_err(ProviderError::other)?;
match canceled {
Some(sub) => Ok(to_subscription(sub)),
None => Err(ProviderError::NotFound(format!("No subscription {}", request.id))),
}
}
}Refunds
impl RefundSource for Paddle {
async fn refund_transaction(
&self,
request: RefundTransactionRequest,
) -> Result<Transaction, ProviderError> {
let refund = self
.client(request.environment)
.refund(&request.transaction_id, request.amount, request.reason.as_deref())
.await
.map_err(ProviderError::other)?;
Ok(to_transaction(refund))
}
}Answer errors deliberately
Return a ProviderError variant. In production, a provider_error carries
voidhash::provider::HIDDEN_ERROR_MESSAGE instead of your message.
| Return | Answers |
|---|---|
ProviderError::InvalidRequest(message) | invalid_request |
ProviderError::NotFound(message) | not_found |
ProviderError::Unavailable(message) | unavailable |
ProviderError::other(error) | provider_error |
Mount the endpoint
With the axum feature (cargo add voidhash --features axum), into_axum makes a POST route.
let handler = provider_handler(paddle, std::env::var("VOIDHASH_PROVIDER_SECRET")?);
let app = axum::Router::new()
.route("/voidhash/provider", handler.into_axum())
.route("/webhooks/paddle", axum::routing::post(paddle_webhook))
.with_state(app_state);For other frameworks, handle takes the headers and raw body, never fails, and converts into an
http::Response<Vec<u8>>.
use voidhash::provider::{ProviderHandler, ProviderRequest};
async fn serve(
handler: &ProviderHandler,
request: http::Request<Vec<u8>>,
) -> http::Response<Vec<u8>> {
let (parts, body) = request.into_parts();
handler.handle(ProviderRequest { headers: parts.headers, body }).await.into()
}Test your endpoint
handler.test sends a signed test request with camel-case input fields.
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[tokio::test]
async fn answers_operations() {
let handler = provider_handler(test_paddle(), "vhps_test".to_string());
let response = handler.test("catalog.list", json!({ "cursor": null })).await;
assert_eq!(response.status, 200);
assert_eq!(response.json()["result"]["items"][0]["key"], "pri_monthly");
let response = handler
.test("subscription.cancel", json!({ "id": "sub_404", "atPeriodEnd": true }))
.await;
assert_eq!(response.json()["error"]["code"], "not_found");
}
}Connect it in Studio
Set the signing secret as VOIDHASH_PROVIDER_SECRET and
connect the endpoint.
Next steps
Something wrong or missing on this page? Tell us by email or on Discord.