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: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.
Report payments
client.PaymentSources.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"))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:
| Field | Notes |
|---|---|
Status | applied once recorded, or pending while it waits. |
PendingReason | Why it waits: product_not_mapped or charge_not_recorded. nil when applied. |
BookedAmount | The amount Voidhash recorded. A refund can record less than you sent; see below. |
PurchaseId | The 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.
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:
StatusCode409,Typeconflict: 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).StatusCode503,Typeunavailable: 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:
| 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 |
provider.CheckoutCreator | CreateCheckout | checkout.create |
Implement only what your provider can do. In the snippets below, p.api stands for your provider's
own client.
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.
Code | Use it when |
|---|---|
provider.CodeNotFound | Nothing exists for the input, such as an unknown transaction. |
provider.CodeInvalidRequest | Your provider cannot serve the input, such as a refund too big. |
provider.CodeUnavailable | Your 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.
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.
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.