VoidhashDocs
DevelopersHelp centerGuidesAPI reference
Dashboard
Get started
Introduction
Quickstart
How Voidhash works
Glossary
Versions and releases
Concepts
Teams, projects and environments
API keys
Products and perks
Offers
Subscription lifecycle
People and identity
Events
Features
Paywalls in your app
Feature flags and A/B tests
Paywall components
Push notifications
Ad attribution in your app
Client SDKs
React Native
Swift
Kotlin
Web
Your backend
Node.js
PHP
Go
Rust
Other languages
Payment sources
Forward store notifications
Developer tools
CLI
MCP server
Query analytics with VoidQL
Rust library
Check access
Receive webhooks
Persons and events
Custom payment providers

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>.

src/billing.rs
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.

src/paddle_webhook.rs
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.

TraitMethodBuilder methodOperation
CatalogSourcelist_catalog.catalogcatalog.list
HistorySourcelist_history.historyhistory.list
SubscriptionSourceget_subscription.subscriptionsubscription.get
CancellationSourcecancel_subscription.cancellationssubscription.cancel
RefundSourcerefund_transaction.refundstransaction.refund

Builder methods take your type by value, so make it cheap to clone.

src/paddle_provider.rs
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.

ReturnAnswers
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.

src/main.rs
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.

src/paddle_provider.rs
#[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

  • Rust backend example
  • Payment sources
  • Receive webhooks
  • Check access

Something wrong or missing on this page? Tell us by email or on Discord.

PreviousPersons and eventsNextOther languages

On this page

Report paymentsA one-time purchaseA subscription paymentA refundWhat comes backRetry safelyTest paymentsReport subscription stateTranslate your provider's webhooksCancel and refund at the providerBuild a provider endpointProductsHistorySubscriptionsRefundsAnswer errors deliberatelyMount the endpointTest your endpointConnect it in StudioNext steps