Skip to content

Go SDK — webhooks

github.com/paylera/paylera-go/webhook verifies the inbound Paylera-Signature header and dispatches the decoded event to a per-type handler registry. It implements http.Handler, so it mounts under any router that accepts a Go HTTP handler.

import (
"context"
"net/http"
"os"
"github.com/paylera/paylera-go/webhook"
)
func main() {
mux := http.NewServeMux()
mux.Handle("/webhooks/paylera", webhook.Handler(webhook.Options{
SigningSecret: []byte(os.Getenv("PAYLERA_WEBHOOK_SECRET")),
ToleranceSeconds: 300,
OnEvent: webhook.EventTypeRegistry{
webhook.EventTypeInvoicePaid: handleInvoicePaid,
webhook.EventTypeSubscriptionCanceled: handleSubscriptionCanceled,
},
}))
http.ListenAndServe(":8080", mux)
}
func handleInvoicePaid(ctx context.Context, ev webhook.Event) error {
var data webhook.DataInvoicePaid
if err := ev.DecodeData(&data); err != nil {
return err
}
// …idempotent business logic keyed on ev.ID…
return nil
}

Behaviour

OutcomeResponse
Signature valid, handler returns nil200
Signature valid, no handler registered200 (silent — see “no handler” below)
Signature valid, handler returns error500 (Paylera retries)
Signature missing / mismatched401
Timestamp outside tolerance401
Body > 1 MiB413
Body malformed JSON after passing signature400
Method != POST405

Critical gotchas

Raw bytes. The handler reads r.Body once with io.ReadAll. The HMAC is computed over the bytes Paylera signed, so do not install a JSON-parsing middleware that consumes the body before this handler. Mount it under a prefix the parsing middleware skips.

No handler. Paylera retries only on non-2xx responses. An unregistered event type therefore MUST 200 silently — otherwise the delivery retries forever. Use the webhook.CatchAllHandler ("*") key to register a fallback for the long tail:

OnEvent: webhook.EventTypeRegistry{
webhook.EventTypeInvoicePaid: handleInvoicePaid,
webhook.CatchAllHandler: handleEverythingElse,
},

Idempotency. Paylera retries at-least-once on any non-2xx. Your handler MUST dedupe on event.ID before applying side effects — a handler that successfully charged a downstream effect and then crashed will be called again.

Multi-secret rotation

When you rotate the webhook signing secret (via POST /v1/admin/webhook-endpoints/{id}/rotate-secret), Paylera signs deliveries under both the previous and new secrets for 24 hours so receivers can roll forward without dropping events. Wire both into Options:

webhook.Handler(webhook.Options{
SigningSecret: []byte(currentSecret),
AcceptedSecrets: [][]byte{[]byte(previousSecret)},
OnEvent: registry,
})

The verifier iterates the secret set and accepts on any constant-time match. Drop AcceptedSecrets once the overlap window closes.

Verifying signatures directly

If you’ve already got your own HTTP plumbing and just want the verifier, call webhook.Verify or webhook.VerifyMulti directly:

ok, err := webhook.Verify(rawBody, r.Header.Get(webhook.HeaderName),
secret, webhook.DefaultTolerance)
if !ok {
log.Printf("paylera webhook rejected: %v", err)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}

The wire format is locked: Paylera-Signature: t=<unix>,v1=<hex>[,v1=<hex>], HMAC-SHA-256 over "<t>.<raw body>". See the cross-language spec.

Typed event data

The package ships typed Data* structs for the high-traffic event families. Decode via Event.DecodeData(&dst):

ConstantData struct
EventTypeInvoicePaidDataInvoicePaid
EventTypeInvoiceFinalizedDataInvoiceFinalized
EventTypeInvoicePartiallyPaidDataInvoicePartiallyPaid
EventTypeInvoiceVoidedDataInvoiceVoided
EventTypePaymentSucceededDataPaymentSucceeded
EventTypePaymentFailedDataPaymentFailed
EventTypePaymentRefundedDataPaymentRefunded
EventTypeSubscriptionCreatedDataSubscriptionCreated
EventTypeSubscriptionUpdatedDataSubscriptionUpdated
EventTypeSubscriptionCanceledDataSubscriptionCanceled
EventTypeSubscriptionPausedDataSubscriptionPaused
EventTypeSubscriptionResumedDataSubscriptionResumed
EventTypeCustomerCreatedDataCustomerCreated
EventTypeCustomerExternalRefDiscoveredDataCustomerExternalRefDiscovered
EventTypeCheckoutSessionCompletedDataCheckoutSessionCompleted
EventTypeRevenueCapturedDataRevenueCaptured
EventTypeRevenueRefundedDataRevenueRefunded
EventTypeRevenueSubscriptionStartedDataRevenueSubscriptionStarted
EventTypeRevenueSubscriptionRenewedDataRevenueSubscriptionRenewed
EventTypeRevenueSubscriptionCanceledDataRevenueSubscriptionCanceled
EventTypeRevenueSubscriptionGracePeriodEnteredDataRevenueSubscriptionGracePeriodEntered
EventTypeRevenueSubscriptionRecoveredDataRevenueSubscriptionRecovered
EventTypeRevenueSubscriptionExpiredDataRevenueSubscriptionExpired
EventTypeRevenueOneTimePurchaseDataRevenueOneTimePurchase
EventTypeRevenueUnknownCurrencySeenDataRevenueUnknownCurrencySeen

The ten revenue.* events are observability projections of upstream provider activity (Stripe, Toss, Apple App Store Server Notifications V2, Google Play RTDN) — see Multi-source revenue capture in the catalog for the family overview. EventTypeCustomerExternalRefDiscovered is fired by the RevenueAttribution worker when a previously-orphaned revenue.* event gets back-linked to an existing customer via email-fingerprint match.

Decimal precision

DataRevenue*.FxRateUsed is *string (not *float64) so high-precision FX rates round-trip losslessly over the wire. Merchants who compute source_amount_minor * fx_rate_used should parse with github.com/shopspring/decimal or math/big.Float for exact arithmetic.

This is a breaking change from earlier Go SDK versions; see sdks/paylera-go/CHANGELOG.md [Unreleased] for the full migration guide.

The same wire convention applies to DataSubscriptionCredit{Consumed,Expired,Granted,RolledOver}.Amount and .BalanceAfter (required decimals → string, no pointer) and to DataUsageLateEventReceived.Value.

For events without a typed struct (or to read forward-compatible new fields), Event.Data is a json.RawMessage you can unmarshal into any caller-defined shape:

func handleAnything(ctx context.Context, ev webhook.Event) error {
var custom struct {
MyField string `json:"my_field"`
}
if err := ev.DecodeData(&custom); err != nil {
return err
}
return process(custom)
}

The full event-type catalogue lives in contracts/vocabulary.yaml; match against the EventType* constants where typed Data* structs exist, and against raw string literals for the long tail.

OpenTelemetry

Each delivery opens a server-kind span named Paylera.Webhook.<EventType> on the tracer Paylera.Webhook, with attributes:

  • paylera.endpoint
  • paylera.event_type
  • paylera.event_id
  • paylera.verification (ok / failed)
  • paylera.handler_outcome (ok / error / no_handler)

No-op when the host process has no OTel SDK registered.

Working examples

See also