VoidhashDocs
DocumentationAPI reference
Dashboard
Get started
Introduction
Quickstart
Setup
Setup overview
Create a project
Products and perks
Paywall locations
CLI
Paywall components
Components
Develop a component
Editor panels
Component definition
Publishing
Installing and capabilities
SDKs
Swift
Kotlin
React Native
Web
Store setup
Connect the App Store
Connect Google Play
Connect Stripe
Record payments from other sources
API libraries
Node.js
PHP
Rust
Go
Resources
MCP server
Ad measurement
Meta ads
Migrate in observer mode
Delete a person's data
Rust library
Check access from your backend
Receive webhooks
Custom payment providers

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:write scope, 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 from VOIDHASH_PAYMENT_SOURCE_ID.
  • For the provider endpoint on axum, the crate's axum feature:
cargo add voidhash --features axum

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

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")?);

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:

FieldTypeNotes
statusPaymentSourceTransactionStatusApplied once recorded, Pending while it waits.
pending_reasonOption<PaymentSourceTransactionPendingReason>ProductNotMapped or ChargeNotRecorded.
purchase_idOption<String>The purchase it recorded or refunded.
booked_amountOption<i64>What it recorded, which a refund can lower.
id, type_String, PaymentSourceTransactionTypeYour 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()),
}

See Retries and corrections.

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:

src/paddle_webhook.rs
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_codeMeaning
conflictpayment_source_endpoint_not_configured, payment_source_endpoint_unsupported, payment_source_endpoint_rejectedNo endpoint, the operation isn't implemented, or it refused.
unavailablepayment_source_endpoint_unavailable, payment_source_endpoint_invalid_responseIt did not answer properly. You can retry.
not_foundtransaction_not_foundThe 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.

TraitMethodBuilder methodOperation
CatalogSourcelist_catalog.catalogcatalog.list
HistorySourcelist_history.historyhistory.list
SubscriptionSourceget_subscription.subscriptionsubscription.get
CancellationSourcecancel_subscription.cancellationssubscription.cancel
RefundSourcerefund_transaction.refundstransaction.refund
CheckoutSourcecreate_checkout.checkoutscheckout.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:

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

ReturnAnswersUse it when
ProviderError::InvalidRequest(message)invalid_requestThe input can't be answered, such as a refund of more than is left.
ProviderError::NotFound(message)not_foundYour provider has no such subscription, charge or product.
ProviderError::Unavailable(message)unavailableYour provider is down. Voidhash retries reads later.
ProviderError::other(error)provider_errorAnything 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-timestamp and one x-voidhash-signature header, 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_request without 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 answered invalid_result with 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_operation for 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:

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

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

PreviousReceive webhooksNextGo library

On this page

Report paymentsA one-time purchaseA subscription paymentA refundWhat comes backRetry safelySandbox paymentsReport subscription stateTranslate your provider's webhooksCancel and refund at the providerBuild a provider endpointProductsHistorySubscriptionsCancellationsRefundsCheckoutsAnswer errors deliberatelyWhat the handler does for youMount the endpointTest your endpointConnect it in StudioComplete example