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
| Outcome | Response |
|---|---|
Signature valid, handler returns nil | 200 |
| Signature valid, no handler registered | 200 (silent — see “no handler” below) |
| Signature valid, handler returns error | 500 (Paylera retries) |
| Signature missing / mismatched | 401 |
| Timestamp outside tolerance | 401 |
| Body > 1 MiB | 413 |
| Body malformed JSON after passing signature | 400 |
Method != POST | 405 |
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):
| Constant | Data struct |
|---|---|
EventTypeInvoicePaid | DataInvoicePaid |
EventTypeInvoiceFinalized | DataInvoiceFinalized |
EventTypeInvoicePartiallyPaid | DataInvoicePartiallyPaid |
EventTypeInvoiceVoided | DataInvoiceVoided |
EventTypePaymentSucceeded | DataPaymentSucceeded |
EventTypePaymentFailed | DataPaymentFailed |
EventTypePaymentRefunded | DataPaymentRefunded |
EventTypeSubscriptionCreated | DataSubscriptionCreated |
EventTypeSubscriptionUpdated | DataSubscriptionUpdated |
EventTypeSubscriptionCanceled | DataSubscriptionCanceled |
EventTypeSubscriptionPaused | DataSubscriptionPaused |
EventTypeSubscriptionResumed | DataSubscriptionResumed |
EventTypeCustomerCreated | DataCustomerCreated |
EventTypeCustomerExternalRefDiscovered | DataCustomerExternalRefDiscovered |
EventTypeCheckoutSessionCompleted | DataCheckoutSessionCompleted |
EventTypeRevenueCaptured | DataRevenueCaptured |
EventTypeRevenueRefunded | DataRevenueRefunded |
EventTypeRevenueSubscriptionStarted | DataRevenueSubscriptionStarted |
EventTypeRevenueSubscriptionRenewed | DataRevenueSubscriptionRenewed |
EventTypeRevenueSubscriptionCanceled | DataRevenueSubscriptionCanceled |
EventTypeRevenueSubscriptionGracePeriodEntered | DataRevenueSubscriptionGracePeriodEntered |
EventTypeRevenueSubscriptionRecovered | DataRevenueSubscriptionRecovered |
EventTypeRevenueSubscriptionExpired | DataRevenueSubscriptionExpired |
EventTypeRevenueOneTimePurchase | DataRevenueOneTimePurchase |
EventTypeRevenueUnknownCurrencySeen | DataRevenueUnknownCurrencySeen |
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.endpointpaylera.event_typepaylera.event_idpaylera.verification(ok/failed)paylera.handler_outcome(ok/error/no_handler)
No-op when the host process has no OTel SDK registered.
Working examples
examples/go/stdlib-server/—net/httpmountexamples/go/gin-server/— Gin viagin.WrapH