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.
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.
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:
| Interface | Method | Operation |
|---|---|---|
provider.CatalogLister | ListCatalog | catalog.list |
provider.HistoryLister | ListHistory | history.list |
provider.SubscriptionGetter | GetSubscription | subscription.get |
provider.SubscriptionCanceler | CancelSubscription | subscription.cancel |
provider.TransactionRefunder | RefundTransaction | transaction.refund |
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.
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.
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
Something wrong or missing on this page? Tell us by email or on Discord.