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

Go code for payment sources and their provider endpoint, which explain the protocol and Studio setup. Use a client whose secret key has project:purchases:write.

Report payments

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

A one-time purchase

Amounts are int64 minor units, and optional fields such as Tax, Fee and CustomerID are pointers.

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,
})

A subscription payment

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,
})

A refund

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

What comes back

Charge and Refund return a *voidhash.PaymentSourceTransaction.

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)
}

Retry safely

Every API failure is a *voidhash.APIError; branch on its Code.

_, 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
}

Report subscription state

Status takes the voidhash.PaymentSourceSubscription… constants; set CanceledAt for a canceled subscription, or it defaults to UpdatedAt.

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,
})

Translate your provider's webhooks

verifyPaddleEvent and the event fields stand in for your provider's SDK.

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)
}

Cancel and refund at the provider

When the endpoint can't act, these return a *voidhash.APIError with its StatusCode.

// 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,
})

Build a provider endpoint

provider.NewHandler finds your type's methods through these interfaces:

InterfaceMethodOperation
provider.CatalogListerListCatalogcatalog.list
provider.HistoryListerListHistoryhistory.list
provider.SubscriptionGetterGetSubscriptionsubscription.get
provider.SubscriptionCancelerCancelSubscriptionsubscription.cancel
provider.TransactionRefunderRefundTransactiontransaction.refund
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 }

Products

req.Cursor is nil on the first page; return a nil NextCursor on the last.

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
}

History

Fill only the page field that matches req.Kind.

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 says WillRenew where reports say IsAutoRenewing.

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,
	}
}

Subscriptions

Return nil with no error when your provider doesn't 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
}
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
}

Refunds

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
}

Answer errors deliberately

Return a *provider.Error, wrapped or not, with provider.CodeNotFound, CodeInvalidRequest or CodeUnavailable.

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
}

Mount the endpoint

NewHandler takes the source's slug, not its ID, and returns a concurrency-safe http.Handler; mount it without body-parsing middleware.

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

Test your endpoint

providertest.Call sends a signed production request through a handler; pass providertest.WithEnvironment(provider.EnvironmentTest) for a test one.

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)
	}
}

Connect it in Studio

Set the signing secret as VOIDHASH_PROVIDER_SECRET and connect the endpoint.

Next steps

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

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

PreviousPersons and eventsNextRust library

On this page

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