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
Go 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 Go, 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 in Go:

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

Report payments

client.PaymentSources.Source returns a handle for one source. Create it once and share it, like the client.

billing/voidhash.go
import voidhash "github.com/voidhashcom/voidhash-go"

client, err := voidhash.New(os.Getenv("VOIDHASH_SECRET_KEY"))
if err != nil {
    log.Fatalf("create voidhash client: %v", err)
}
paddle := client.PaymentSources.Source(os.Getenv("VOIDHASH_PAYMENT_SOURCE_ID"))

Report a one-time charge

Charge records a payment under your provider's id for it. Amounts are integers in minor units, so 4900 is €49.00. Name the buyer with DistinctID, the identifier your app passes to identify().

userID := "user_123"
tax := int64(818)

transaction, err := paddle.Charge(ctx, "txn_123", voidhash.ChargeParams{
    DistinctID: &userID,
    ProductKey: "LIFETIME",
    Amount:     4900,
    Currency:   "EUR",
    Tax:        &tax,
    OccurredAt: paidAt,
})

Fee is optional too: what your provider kept as its commission. When the buyer has no distinct id yet, set CustomerID to your provider's id for them, such as ctm_42. See Customers without a distinct id.

Report a subscription charge

A subscription's charge also names the subscription and the period it pays for. The first one starts the subscription and each later one renews it.

subscriptionID := "sub_9"
periodStart := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
periodEnd := time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC)

transaction, err := paddle.Charge(ctx, "txn_124", voidhash.ChargeParams{
    DistinctID:     &userID,
    ProductKey:     "pri_monthly",
    Amount:         999,
    Currency:       "EUR",
    OccurredAt:     paidAt,
    SubscriptionID: &subscriptionID,
    PeriodStart:    &periodStart,
    PeriodEnd:      &periodEnd,
})

Report the subscription's state as well: the charge books revenue, the state decides access.

Report a refund

Refund records a refund your provider already made, under its own id, and names the charge it returns money for. It takes the buyer and product from the charge, and its currency must be the charge's. A refund of everything left of the charge ends access; less is a partial refund. See Refunds.

refund, err := paddle.Refund(ctx, "re_1", voidhash.RefundParams{
    RefundsTransactionID: "txn_123",
    Amount:               4900,
    Currency:             "EUR",
    OccurredAt:           refundedAt,
})

Read the result

Charge and Refund return a *voidhash.PaymentSourceTransaction:

FieldNotes
Statusapplied once recorded, or pending while it waits.
PendingReasonWhy it waits: product_not_mapped or charge_not_recorded. nil when applied.
BookedAmountThe amount Voidhash recorded. A refund can record less than you sent; see below.
PurchaseIdThe Voidhash purchase the transaction belongs to, once applied.

A transaction waits with product_not_mapped until you map its product key, and a refund with charge_not_recorded until you report its charge. It is recorded on its own once that happens, so you don't need to send it again.

switch transaction.Status {
case "applied":
    log.Printf("recorded %s as purchase %s", transaction.Id, *transaction.PurchaseId)
case "pending":
    log.Printf("%s waits: %s", transaction.Id, *transaction.PendingReason)
}

When refunds of the same charge arrive at the same moment, a later one records only what the others left, and its BookedAmount says how much that was.

Retry safely

Every report is addressed by your provider's id, so you can send it again after a timeout or a redelivered webhook. The same id with the same fields returns the same result and records nothing new. The same id with different fields fails with a 409 whose Code is transaction_conflict: a recorded payment is never edited. To correct one, refund it and report the right payment under a new id. See Retries and corrections.

Every API failure is a *voidhash.APIError. Branch on its Code, never on its Message:

_, err := paddle.Charge(ctx, "txn_123", params)

var apiErr *voidhash.APIError
switch {
case errors.As(err, &apiErr) && apiErr.Code == "transaction_conflict":
    // txn_123 is already recorded with other fields. Don't retry: refund and re-report it.
    log.Printf("conflict reporting txn_123 (request %s)", apiErr.RequestID)
case err != nil:
    // Nothing was recorded; send the same report again later.
    return err
}

Test with sandbox payments

Set Environment: voidhash.PaymentSourceSandbox on ChargeParams and SubscriptionParams for records from your provider's test mode. They stay out of production revenue. The default is voidhash.PaymentSourceProduction. A refund takes its charge's environment, so you can leave it unset on RefundParams.

Report subscription state

Subscription records a subscription's latest state under its id. It carries no money: each period's price arrives with its charge.

recorded, err := paddle.Subscription(ctx, "sub_9", voidhash.SubscriptionParams{
    DistinctID:         &userID,
    ProductKey:         "pri_monthly",
    Status:             voidhash.PaymentSourceSubscriptionActive,
    CurrentPeriodStart: periodStart,
    CurrentPeriodEnd:   periodEnd,
    IsAutoRenewing:     true,
    UpdatedAt:          event.OccurredAt,
})

Status is one of PaymentSourceSubscriptionTrialing, PaymentSourceSubscriptionActive, PaymentSourceSubscriptionPastDue, PaymentSourceSubscriptionCanceled and PaymentSourceSubscriptionExpired. Set CanceledAt when a canceled subscription ended; without it, UpdatedAt is used. What each status does to access is in Subscriptions.

UpdatedAt orders a subscription's states. Set it to when your provider changed the subscription, not to when your server received the event. A state older than the one Voidhash has changes nothing and comes back with Status superseded, so you can report webhooks in whatever order they arrive. The result's Status is applied, superseded, or pending with a PendingReason: product_not_mapped, or charge_not_recorded while the first paid period's charge is missing.

Translate your provider's webhooks

Most integrations report from the provider's webhooks. Verify the provider's signature, report the charge before the subscription's state, and answer with an error status when a report fails so the provider redelivers. Reporting the same ids again is safe.

billing/paddle_webhook.go
func (s *Server) handlePaddleWebhook(w http.ResponseWriter, r *http.Request) {
    // verifyPaddleEvent stands for your provider's own signature check and parsing.
    event, err := verifyPaddleEvent(r)
    if err != nil {
        http.Error(w, "invalid webhook", http.StatusUnauthorized)
        return
    }

    ctx := r.Context()
    switch event.Type {
    case "transaction.completed":
        _, err = s.paddle.Charge(ctx, event.Transaction.ID, voidhash.ChargeParams{
            DistinctID:     event.Transaction.UserID,
            CustomerID:     &event.Transaction.CustomerID,
            ProductKey:     event.Transaction.PriceID,
            Amount:         event.Transaction.Total,
            Currency:       event.Transaction.Currency,
            Tax:            &event.Transaction.Tax,
            Fee:            &event.Transaction.Fee,
            OccurredAt:     event.Transaction.BilledAt,
            SubscriptionID: event.Transaction.SubscriptionID,
            PeriodStart:    event.Transaction.PeriodStart,
            PeriodEnd:      event.Transaction.PeriodEnd,
        })
    case "subscription.created", "subscription.updated", "subscription.canceled":
        _, err = s.paddle.Subscription(ctx, event.Subscription.ID, voidhash.SubscriptionParams{
            DistinctID:         event.Subscription.UserID,
            CustomerID:         &event.Subscription.CustomerID,
            ProductKey:         event.Subscription.PriceID,
            Status:             voidhash.PaymentSourceSubscriptionStatus(event.Subscription.Status),
            CurrentPeriodStart: event.Subscription.PeriodStart,
            CurrentPeriodEnd:   event.Subscription.PeriodEnd,
            IsAutoRenewing:     !event.Subscription.ScheduledCancel,
            CanceledAt:         event.Subscription.CanceledAt,
            UpdatedAt:          event.OccurredAt,
        })
    case "adjustment.refunded":
        _, err = s.paddle.Refund(ctx, event.Refund.ID, voidhash.RefundParams{
            RefundsTransactionID: event.Refund.TransactionID,
            Amount:               event.Refund.Amount,
            Currency:             event.Refund.Currency,
            OccurredAt:           event.OccurredAt,
        })
    }

    var apiErr *voidhash.APIError
    switch {
    case errors.As(err, &apiErr) && apiErr.Code == "transaction_conflict":
        // A redelivery cannot fix a conflict; acknowledge it and look into it.
        log.Printf("paddle event %s conflicts with a recorded payment: %v", event.ID, err)
    case err != nil:
        // The provider redelivers, and the same report is recorded once.
        http.Error(w, "report failed", http.StatusBadGateway)
        return
    }
    w.WriteHeader(http.StatusOK)
}

Map your provider's statuses onto Voidhash's five before converting them, if they differ. When a refund also ends a subscription, report the canceled state too: access follows the state.

Cancel and refund at the provider

Refund reports a refund that already happened. To make one happen, CancelSubscription and RefundTransaction ask your provider endpoint to act at your provider, then record what it answers. They work only once the endpoint is connected and implements the operation.

// Cancels at the end of the paid period. IsImmediate: true ends access now.
state, err := paddle.CancelSubscription(ctx, "sub_9", voidhash.CancelSubscriptionParams{})

// A nil Amount refunds everything left of the charge.
amount, reason := int64(500), "Billed twice"
refund, err := paddle.RefundTransaction(ctx, "txn_123", voidhash.RefundTransactionParams{
    Amount: &amount,
    Reason: &reason,
})

CancelSubscription returns the subscription's recorded state and RefundTransaction the recorded refund, in the same shapes as Subscription and Refund. When your provider later sends you a webhook for the same change and you report it, nothing is recorded twice.

When the endpoint cannot act, Voidhash records nothing and returns a *voidhash.APIError whose Message says why:

  • StatusCode 409, Type conflict: the endpoint is not connected (payment_source_endpoint_not_configured), does not implement the operation (payment_source_endpoint_unsupported) or refused it (payment_source_endpoint_rejected).
  • StatusCode 503, Type unavailable: the endpoint did not answer, or answered in a way Voidhash could not use (payment_source_endpoint_unavailable, payment_source_endpoint_invalid_response).

A 409 needs a change first: connect the endpoint, implement the operation, or resolve what your endpoint refused. A 503 means your endpoint did not answer properly, and you can try again.

Build a provider endpoint

The provider package implements the endpoint. Write one type, give it a method for each operation your provider supports, and pass it to provider.NewHandler. The handler finds the methods through these interfaces:

InterfaceMethodOperation
provider.CatalogListerListCatalogcatalog.list
provider.HistoryListerListHistoryhistory.list
provider.SubscriptionGetterGetSubscriptionsubscription.get
provider.SubscriptionCancelerCancelSubscriptionsubscription.cancel
provider.TransactionRefunderRefundTransactiontransaction.refund
provider.CheckoutCreatorCreateCheckoutcheckout.create

Implement only what your provider can do. In the snippets below, p.api stands for your provider's own client.

billing/provider.go
import "github.com/voidhashcom/voidhash-go/provider"

// Paddle answers Voidhash's calls for the paddle payment source.
type Paddle struct{ api *paddle.Client }

List the catalog

ListCatalog returns one page of the products you sell. Each Key is the product key your payments name, and Kind is ProductKindSubscription, ProductKindOneTime or ProductKindConsumable. Studio's product import lists them, ready to map.

func (p *Paddle) ListCatalog(ctx context.Context, req provider.CatalogRequest) (provider.CatalogPage, error) {
    prices, next, err := p.api.ListPrices(ctx, req.Cursor)
    if err != nil {
        return provider.CatalogPage{}, paddleError(err)
    }
    page := provider.CatalogPage{NextCursor: next}
    for _, price := range prices {
        kind := provider.ProductKindOneTime
        if price.BillingCycle != nil {
            kind = provider.ProductKindSubscription
        }
        page.Items = append(page.Items, provider.CatalogProduct{Key: price.ID, Name: price.Description, Kind: kind})
    }
    return page, nil
}

req.Cursor is nil for the first page and the previous page's NextCursor after that. Return a nil NextCursor on the last page. A page holds at most 1,000 items.

List history

ListHistory lets Voidhash import what happened before you connected the source. It asks for subscriptions first, then transactions: req.Kind is provider.HistorySubscriptions or provider.HistoryTransactions, and you fill only the matching field of the page. Return records created before req.Before, when the import started; later ones reach Voidhash through your reports. Anything already recorded under its id is not recorded twice.

func (p *Paddle) ListHistory(ctx context.Context, req provider.HistoryRequest) (provider.HistoryPage, error) {
    if req.Kind == provider.HistorySubscriptions {
        subscriptions, next, err := p.api.ListSubscriptions(ctx, req.Before, req.Cursor)
        if err != nil {
            return provider.HistoryPage{}, paddleError(err)
        }
        page := provider.HistoryPage{NextCursor: next}
        for _, subscription := range subscriptions {
            page.Subscriptions = append(page.Subscriptions, toSubscription(subscription))
        }
        return page, nil
    }

    transactions, next, err := p.api.ListTransactions(ctx, req.Before, req.Cursor)
    if err != nil {
        return provider.HistoryPage{}, paddleError(err)
    }
    page := provider.HistoryPage{NextCursor: next}
    for _, transaction := range transactions {
        page.Transactions = append(page.Transactions, toTransaction(transaction))
    }
    return page, nil
}

provider.Subscription and provider.Transaction have the fields you report, plus their ID. One name differs: a reported subscription's IsAutoRenewing is WillRenew here. A conversion such as toSubscription is yours to write; toTransaction fills Type (provider.TransactionCharge or provider.TransactionRefund) and the charge or refund fields the same way.

func toSubscription(s paddle.Subscription) provider.Subscription {
    return provider.Subscription{
        ID:                 s.ID,
        DistinctID:         &s.UserID,
        Customer:           &provider.Customer{ExternalID: s.CustomerID},
        ProductKey:         s.PriceID,
        Status:             provider.SubscriptionStatus(s.Status),
        CurrentPeriodStart: s.PeriodStart,
        CurrentPeriodEnd:   s.PeriodEnd,
        WillRenew:          !s.ScheduledCancel,
        CanceledAt:         s.CanceledAt,
        UpdatedAt:          s.UpdatedAt,
    }
}

Get a subscription

When a subscription's period ends without a renewal charge or a final state, Voidhash asks GetSubscription for its state and records what you return. Return nil with no error when your provider does not know the id.

func (p *Paddle) GetSubscription(ctx context.Context, req provider.SubscriptionRequest) (*provider.Subscription, error) {
    subscription, err := p.api.GetSubscription(ctx, req.ID)
    if errors.Is(err, paddle.ErrNotFound) {
        return nil, nil
    }
    if err != nil {
        return nil, paddleError(err)
    }
    state := toSubscription(subscription)
    return &state, nil
}

Cancel a subscription

CancelSubscription runs when someone cancels from Studio, through the API or with PaymentSource.CancelSubscription above. req.AtPeriodEnd is true to keep access until the paid period ends and false to end it now. Return the subscription's state afterwards.

func (p *Paddle) CancelSubscription(ctx context.Context, req provider.CancelRequest) (provider.Subscription, error) {
    subscription, err := p.api.CancelSubscription(ctx, req.ID, req.AtPeriodEnd)
    if err != nil {
        return provider.Subscription{}, paddleError(err)
    }
    return toSubscription(subscription), nil
}

Refund a transaction

RefundTransaction refunds the charge req.TransactionID. req.Amount is nil to refund everything left of it, and req.Reason is set when one was given. Return the refund your provider made, as a provider.Transaction of type provider.TransactionRefund.

func (p *Paddle) RefundTransaction(ctx context.Context, req provider.RefundRequest) (provider.Transaction, error) {
    refund, err := p.api.RefundTransaction(ctx, req.TransactionID, req.Amount, req.Reason)
    if err != nil {
        return provider.Transaction{}, paddleError(err)
    }
    return provider.Transaction{
        ID:                   refund.ID,
        Type:                 provider.TransactionRefund,
        Amount:               refund.Amount,
        Currency:             refund.Currency,
        OccurredAt:           refund.CreatedAt,
        RefundsTransactionID: &refund.TransactionID,
    }, nil
}

Create a checkout

CreateCheckout starts a purchase when your web app asks for one, for example with the web SDK's createCheckout. Pass req.DistinctID or req.Customer on to your provider, so the payment it reports later names the same buyer. Either can be nil. Return the URL to send the buyer to. Checkouts always arrive as production requests; see Test checkouts.

func (p *Paddle) CreateCheckout(ctx context.Context, req provider.CheckoutRequest) (provider.Checkout, error) {
    if req.DistinctID == nil {
        return provider.Checkout{}, &provider.Error{Code: provider.CodeInvalidRequest, Message: "Sign in before checking out"}
    }
    checkout, err := p.api.CreateCheckout(ctx, req.ProductKey,
        map[string]string{"distinct_id": *req.DistinctID}, req.SuccessURL, req.CancelURL)
    if err != nil {
        return provider.Checkout{}, paddleError(err)
    }
    return provider.Checkout{URL: checkout.URL, ExpiresAt: &checkout.ExpiresAt}, nil
}

The buyer pays at your provider, and the payment reaches Voidhash through your usual reports. See Start a checkout.

Answer errors deliberately

Return a *provider.Error to tell Voidhash what went wrong. The handler finds it with errors.As, so you can wrap it.

CodeUse it when
provider.CodeNotFoundNothing exists for the input, such as an unknown transaction.
provider.CodeInvalidRequestYour provider cannot serve the input, such as a refund too big.
provider.CodeUnavailableYour provider is down. Voidhash retries reads later.

Voidhash receives its Message as you wrote it, so write it for a person. Any other error is answered as a generic provider failure: its message is passed on only for sandbox requests, so internal details don't leak from production. The snippets above translate the provider's errors in one place:

func paddleError(err error) error {
    var apiErr *paddle.APIError
    switch {
    case errors.Is(err, paddle.ErrNotFound):
        return &provider.Error{Code: provider.CodeNotFound, Message: "Paddle has no such record"}
    case errors.As(err, &apiErr) && (apiErr.Status == 429 || apiErr.Status >= 500):
        return &provider.Error{Code: provider.CodeUnavailable, Message: "Paddle is unavailable"}
    }
    return err
}

Voidhash retries reads (catalog.list, history.list, subscription.get) that fail with CodeUnavailable 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. Each request is signed with the source's signing secret. The handler refuses a missing or wrong signature, and a timestamp more than 5 minutes from your server's clock, so 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.
  • Answers describe. Studio's Test connection calls it, and the handler lists the operations your type implements. Any other operation is answered as unsupported.
  • Validates your results. Before answering, it checks what your method returned, such as required ids and dates, known statuses and kinds, three-letter currency codes, non-negative amounts, at most 1,000 items per page, a history page holding only the kind asked for, and a refund of type refund. Voidhash records nothing from a result that fails, so fix the conversion. A cancellation or refund may still have happened at your provider; your usual reports record it.

Mount it

*provider.Handler is an http.Handler. NewHandler takes the source's slug, its signing secret (vhps_…) and your type. It returns an error when the slug or the secret is empty, or when your type implements none of the operations.

main.go
handler, err := provider.NewHandler("paddle", os.Getenv("VOIDHASH_PROVIDER_SECRET"), &Paddle{api: api})
if err != nil {
    log.Fatalf("create provider endpoint: %v", err)
}

mux := http.NewServeMux()
mux.Handle("POST /voidhash/provider", handler)
log.Fatal(http.ListenAndServe(":8080", mux))

The slug is the one you gave the source in Studio, not its payment provider id. The handler is safe for concurrent use and works with any router that takes an http.Handler, such as chi or gorilla. Mount it without body-parsing middleware: the signature covers the raw request body.

Test it

The providertest package sends correctly signed requests through a handler, the way Voidhash does, so you can test each operation without a network. providertest.Call returns the *http.Response. Its body is {"id", "result"} on success and {"id", "error": {"code", "message"}} otherwise.

billing/provider_test.go
import (
    "encoding/json"
    "net/http"
    "testing"

    "github.com/voidhashcom/voidhash-go/provider"
    "github.com/voidhashcom/voidhash-go/provider/providertest"
)

func TestCatalogListsPrices(t *testing.T) {
    h, err := provider.NewHandler("paddle", "vhps_test", newTestPaddle(t))
    if err != nil {
        t.Fatal(err)
    }

    resp := providertest.Call(h, "catalog.list", map[string]any{"cursor": nil})
    if resp.StatusCode != http.StatusOK {
        t.Fatalf("status = %d", resp.StatusCode)
    }
    var body struct {
        Result provider.CatalogPage `json:"result"`
    }
    if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
        t.Fatal(err)
    }
    if len(body.Result.Items) == 0 || body.Result.Items[0].Key != "pri_monthly" {
        t.Fatalf("items = %+v", body.Result.Items)
    }
}

newTestPaddle stands for your Paddle backed by a test double of the provider's client. Requests are for production by default. Pass providertest.WithEnvironment(provider.EnvironmentSandbox) to send a sandbox request, which also shows your own error messages in provider_error answers. To test the handler mounted on your router, build the request with providertest.NewRequest and serve it through your mux.

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 Go backend example implements every operation against an in-memory billing provider, and reports its webhook events as charges and subscription states.

PreviousReceive webhooksNextMCP server

On this page

Report paymentsReport a one-time chargeReport a subscription chargeReport a refundRead the resultRetry safelyTest with sandbox paymentsReport subscription stateTranslate your provider's webhooksCancel and refund at the providerBuild a provider endpointList the catalogList historyGet a subscriptionCancel a subscriptionRefund a transactionCreate a checkoutAnswer errors deliberatelyWhat the handler does for youMount itTest itConnect it in StudioComplete example