Custom payment providers
Report payments from Paddle, Lemon Squeezy or any other provider from Rust, and serve the endpoint Voidhash calls to import, cancel, refund and start checkouts.
When you take payments outside the App Store, Google Play and Stripe, a
payment source records them in Voidhash so they grant access and
count as revenue. This page shows both halves with the voidhash crate:
- Report payments: your backend tells Voidhash about each charge, refund and subscription change, usually from your provider's webhooks.
- Serve a provider endpoint: Voidhash calls your server to import products and history, check on a subscription, cancel, refund and start checkouts at your provider.
Read Record payments from other sources first. It explains what each field means and how to create the source and map its product keys in Studio. You also need:
- The Rust library set up with a secret key that has the
project:purchases:writescope, or full access. - The source's payment provider id. Its Payments API tab in Studio shows it in the
Transactions endpoint address, after
/payment-providers/. The examples read it fromVOIDHASH_PAYMENT_SOURCE_ID. - For the provider endpoint on axum, the crate's
axumfeature:
cargo add voidhash --features axumReporting payments, and serving the endpoint from another framework, need no feature.
Report payments
client.payment_source returns a handle for one source. It borrows the client, so create it where
you report, next to the client you share across handlers.
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")?);Every amount is an integer in minor units, such as cents. Dates are chrono::DateTime<Utc>.
A one-time purchase
charge takes your provider's transaction id and a Charge. Charge::new names the buyer by
their distinct id, the product key, the amount, the currency and when they paid. The other fields
are optional setters.
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?;.customer links your provider's customer id to the person. When the buyer has no distinct id
yet, start with Charge::for_customer("ctm_42", ...) instead; see
Customers without a distinct id.
A subscription payment
A charge with .subscription_id pays for one period of that subscription. Give the period with
.period:
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?;The first charge starts the subscription and later ones renew it. Report its state as well, as described below.
A refund
refund takes the refund's own id and a Refund naming the charge it returns money for. It
takes the charge's person, product and environment; its currency must be the charge's.
use voidhash::payment_sources::Refund;
paddle
.refund("re_1", Refund::new("txn_123", 999, "EUR", Utc::now()))
.await?;A refund of everything left of the charge ends the access it granted; a smaller one is partial. See Refunds.
What comes back
charge and refund return a voidhash::generated::types::PaymentSourceTransaction:
| Field | Type | Notes |
|---|---|---|
status | PaymentSourceTransactionStatus | Applied once recorded, Pending while it waits. |
pending_reason | Option<PaymentSourceTransactionPendingReason> | ProductNotMapped or ChargeNotRecorded. |
purchase_id | Option<String> | The purchase it recorded or refunded. |
booked_amount | Option<i64> | What it recorded, which a refund can lower. |
id, type_ | String, PaymentSourceTransactionType | Your id, and whether it is a charge or a refund. |
use voidhash::generated::types::{
PaymentSourceTransactionPendingReason as PendingReason, PaymentSourceTransactionStatus,
};
match (recorded.status, recorded.pending_reason) {
(PaymentSourceTransactionStatus::Applied, _) => {
println!("purchase {:?}, booked {:?}", recorded.purchase_id, recorded.booked_amount);
}
(PaymentSourceTransactionStatus::Pending, Some(PendingReason::ProductNotMapped)) => {
println!("Map the product key in Studio; the payment is recorded then");
}
(PaymentSourceTransactionStatus::Pending, _) => {
println!("Waits for its charge to be reported");
}
}A pending payment is recorded on its own once what it waits for arrives. You don't send it again.
Retry safely
Each record is keyed by your provider's id, so sending the same id with the same values again
changes nothing and returns the same result. Retry transport errors and 5xx answers as they are.
Sending an id again with different values fails with 409 and the code transaction_conflict:
a recorded payment is never edited. Branch on the error's code, never its message:
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()),
}Sandbox payments
Mark test payments with .environment(Environment::Sandbox) on a Charge, Refund or
SubscriptionState. They stay out of production revenue. A refund takes its charge's environment
unless you set one.
use voidhash::payment_sources::Environment;
let charge = Charge::new("user_123", "LIFETIME", 4900, "EUR", Utc::now())
.environment(Environment::Sandbox);Report subscription state
A subscription's charges book revenue; its state decides access. Report the state whenever your
provider says it changed, with subscription:
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.
}The arguments are the distinct id, the product key, the status, the current period, whether it
renews, and updated_at, when your provider last changed the subscription. SubscriptionStatus is
Trialing, Active, PastDue, Canceled or Expired. For Canceled, set when access ended
with .canceled_at. SubscriptionState::for_customer names the subscriber by your provider's
customer id instead.
Voidhash orders states by updated_at, so events may arrive in any order: one older than the
recorded state is answered Superseded. The result also has Applied and Pending, with a
pending_reason, and the recorded subscription_id. See
Subscriptions for what each status does to access.
Translate your provider's webhooks
Most providers tell you about payments through webhooks. Verify each event as your provider
documents, then report it. Here paddle stands for your provider's client library and its event
types:
use std::sync::Arc;
use axum::body::Bytes;
use axum::extract::State;
use axum::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(())
}Use your provider's ids for transactions, refunds and subscriptions. Then a redelivered event reports the same records again, and nothing is counted twice. To know which person paid, pass the distinct id to your provider when the checkout starts, for example in its custom data, and read it back from the event.
Cancel and refund at the provider
When your provider endpoint implements them, you can ask Voidhash to cancel a subscription or refund a payment at your provider. Voidhash calls your endpoint and records what it answers, just as if you had reported it.
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?;cancel_subscription returns the recorded state, a PaymentSourceSubscription, and
refund_transaction the recorded refund, a PaymentSourceTransaction. refund reports a refund
your provider already made; refund_transaction asks your provider to make one. When your
provider also sends a webhook for a refund or cancellation Voidhash started, report it as usual
under your provider's id: nothing is recorded twice.
When the endpoint can't do it, Voidhash records nothing and the call returns voidhash::Error::Api.
Its message says why:
type_ | code | Meaning |
|---|---|---|
conflict | payment_source_endpoint_not_configured, payment_source_endpoint_unsupported, payment_source_endpoint_rejected | No endpoint, the operation isn't implemented, or it refused. |
unavailable | payment_source_endpoint_unavailable, payment_source_endpoint_invalid_response | It did not answer properly. You can retry. |
not_found | transaction_not_found | The charge to refund isn't recorded. |
match paddle.cancel_subscription("sub_9", CancelAt::Now).await {
Ok(state) => println!("{:?}", state.status),
Err(voidhash::Error::Api { type_, message, .. }) if type_ == "conflict" => {
eprintln!("Paddle did not cancel: {message}");
}
Err(voidhash::Error::Api { type_, .. }) if type_ == "unavailable" => {
// Try again later.
}
Err(error) => return Err(error.into()),
}Build a provider endpoint
The endpoint is an HTTPS route on your server that Voidhash calls with signed requests. The
voidhash::provider module has one trait per operation. Implement those your provider supports and
register them 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 |
CheckoutSource | create_checkout | .checkouts | checkout.create |
The trait methods are async fns. Each builder method takes your type by value, so make it cheap
to clone. This Paddle holds a client for live payments and one for test payments, and picks
one by the request's environment:
use voidhash::provider::*;
#[derive(Clone)]
pub struct Paddle {
live: paddle::Client,
sandbox: paddle::Client,
}
impl Paddle {
fn client(&self, environment: Environment) -> &paddle::Client {
match environment {
Environment::Production => &self.live,
Environment::Sandbox => &self.sandbox,
}
}
}
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.clone())
.checkouts(paddle)
}ProviderHandler::new takes the source's slug, the one you gave it in Studio, and its signing
secret (vhps_…).
Products
catalog.list feeds Studio's product import. Return one page of at most 1,000 products, starting
at request.cursor, and the cursor of the next page, or None on the last one.
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 = match price.recurring {
true => ProductKind::Subscription,
false => ProductKind::OneTime,
};
CatalogProduct::new(price.id, price.name, kind)
})
.collect();
Ok(CatalogPage::new(items, page.next_cursor))
}
}ProductKind is Subscription, OneTime or Consumable. Each product's key is the product key
your payments name.
History
history.list imports what happened before you connected the source. Voidhash asks for
HistoryKind::Subscriptions first, then HistoryKind::Transactions, and pages through each with
cursor. List only records created before request.before, when the import started; later ones
reach Voidhash through your reports. Answer with the HistoryPage variant of the kind asked for.
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,
}
}Subscription and Transaction carry the same fields you report, plus the record's id. A
subscription's renewal flag is will_renew here, where SubscriptionState calls it
is_auto_renewing. Records also reported through your webhooks are recorded once.
Subscriptions
Voidhash calls subscription.get when a subscription's period ended without a renewal charge or a
final state. Return its current state, or None when your provider doesn't know it.
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)),
}
}
}Cancellations
subscription.cancel cancels at the period's end when request.at_period_end is true, and at
once otherwise. Return the subscription's state afterwards. Cancelling one that is already
canceled should return its state, not fail.
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
transaction.refund refunds request.amount of the charge request.transaction_id, or
everything left of it when the amount is None. request.reason is what the person who started
the refund gave, if anything. Return the refund, built with Transaction::refund.
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))
}
}Checkouts
checkout.create runs when an app asks Voidhash for a checkout, for example with the web SDK's
createCheckout. Create one at your provider for request.product_key and return its URL. Pass
request.distinct_id (and request.customer, when Voidhash knows one) on to your provider, so the
payment it reports later names the buyer. Checkouts always arrive as production requests; see
Test checkouts.
impl CheckoutSource for Paddle {
async fn create_checkout(&self, request: CheckoutRequest) -> Result<Checkout, ProviderError> {
let checkout = self
.client(request.environment)
.create_checkout(
&request.product_key,
request.distinct_id.as_deref(),
request.customer.as_ref().map(|customer| customer.external_id.as_str()),
&request.success_url,
&request.cancel_url,
)
.await
.map_err(ProviderError::other)?;
Ok(Checkout::new(checkout.url).expires_at(checkout.expires_at))
}
}The payment arrives through your usual reports.
Answer errors deliberately
Return a ProviderError variant that says what went wrong. Voidhash acts on the code it receives:
| Return | Answers | Use it when |
|---|---|---|
ProviderError::InvalidRequest(message) | invalid_request | The input can't be answered, such as a refund of more than is left. |
ProviderError::NotFound(message) | not_found | Your provider has no such subscription, charge or product. |
ProviderError::Unavailable(message) | unavailable | Your provider is down. Voidhash retries reads later. |
ProviderError::other(error) | provider_error | Anything else. ? on a Box<dyn Error + Send + Sync> converts too. |
A provider_error carries your error's message only for sandbox requests. Production requests get
voidhash::provider::HIDDEN_ERROR_MESSAGE instead, so your provider's internals stay private.
Voidhash retries reads (catalog.list, history.list, subscription.get) that failed with
unavailable or a server error. It sends a cancellation, refund or checkout only once, because one
that timed out may still have happened at your provider.
What the handler does for you
- Checks signatures. It refuses a request without exactly one
x-voidhash-timestampand onex-voidhash-signatureheader, with a wrong signature, or signed more than 5 minutes from your server's clock. Keep the clock in sync. - Checks the request. It refuses a request for another source or protocol version, and decodes
each operation's input into the request type your method receives. Input it can't decode is
answered
invalid_requestwithout calling you. - Answers
describe. Studio's Test connection calls it, and the handler lists the operations you registered. - Validates your results. Before answering, it checks what your method returned, such as ids
and lengths, three-letter currency codes, at most 1,000 items per page, a history page of the
kind asked for, and a refund of type
refund. A result that fails is answeredinvalid_resultwith the field it concerns, and Voidhash records nothing from it. A cancellation or refund may still have happened at your provider; your usual reports record it. - Answers
unsupported_operationfor operations you did not register.
Mount the endpoint
With the axum feature, into_axum turns the handler into a POST route for a router with any
state:
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 any other framework, call handle with the request's headers and its raw body, exactly as
received: the signature covers the bytes. It never fails; every problem becomes an error answer.
ProviderResponse has status, headers and a JSON body, 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();
let response = handler
.handle(ProviderRequest { headers: parts.headers, body })
.await;
response.into()
}Mount the route without middleware that rewrites the body, such as decompression or JSON parsing.
ProviderHandler is Clone, Send and Sync, so you can share it across requests.
Test your endpoint
handler.test sends a correctly signed sandbox request for an operation through the handler, the
way Voidhash does, and returns the ProviderResponse. Its json() is { "id", "result" } on
success and { "id", "error": { "code", "message" } } otherwise.
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[tokio::test]
async fn lists_prices_as_products() {
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");
}
#[tokio::test]
async fn cannot_cancel_an_unknown_subscription() {
let handler = provider_handler(test_paddle(), "vhps_test".to_string());
let response = handler
.test("subscription.cancel", json!({ "id": "sub_404", "atPeriodEnd": true }))
.await;
assert_eq!(response.status, 404);
assert_eq!(response.json()["error"]["code"], "not_found");
}
}test_paddle stands for your Paddle backed by a test double of the provider's client. Inputs use
the field names Voidhash sends, in camel case, and the optional ones must be present, with null
for none. Because the request is a sandbox one, provider_error answers show your own messages.
To test a trait method alone, call it with a request built by its constructor, such as
CatalogRequest::new(None) or RefundTransactionRequest::new("txn_123", Some(500)). These build
production requests.
Connect it in Studio
On the source's Provider endpoint tab, enter the endpoint's public HTTPS URL and save. Studio
shows the signing secret once: set it as VOIDHASH_PROVIDER_SECRET, then select Test
connection to see the operations your endpoint implements. See
Connect an endpoint.
Complete example
The Rust backend example implements every operation against an in-memory provider with unit tests, and reports its webhook events as charges and subscription states.