Skip to content

Go SDK — relay handler

github.com/paylera/paylera-go/relay is the Go implementation of the ten-route SDK relay protocol. The merchant’s backend mounts it under any prefix (convention: /api/paylera) and the React SDK calls into it from the browser — the relay forwards to Paylera with the secret token injected upstream.

import (
"net/http"
"os"
"github.com/paylera/paylera-go/relay"
)
mux := http.NewServeMux()
mux.Handle("/api/paylera/", http.StripPrefix("/api/paylera",
relay.Handler(relay.Options{
APIToken: os.Getenv("PAYLERA_API_TOKEN"),
AutoCreateCustomer: true,
CSRF: relay.CSRFEnabled,
Identify: func(r *http.Request) (relay.CustomerIdentity, error) {
sess, err := mySessionLookup(r)
if err != nil {
return relay.CustomerIdentity{}, err
}
return relay.CustomerIdentity{
CustomerID: sess.PayleraCustomerID,
Email: sess.Email,
Name: sess.DisplayName,
TenantID: sess.TenantID,
}, nil
},
})))

The handler is http.Handler and therefore framework-agnostic — any router that consumes Go HTTP handlers can mount it.

Routes

RouteForwards toCSRFIdempotency
GET /csrf-token(relay-only)n/an/a
POST /attachPOST /v1/attachyesrequired
POST /checkPOST /v1/checkyesoptional
GET /check?feature_code=…POST /v1/checknon/a
POST /trackPOST /v1/trackyesrequired
GET /entitlements?subscription_id=…GET /v1/subscriptions/{id}/entitlementsnon/a
GET /meGET /v1/customers/{id} (auto-create if enabled)non/a
GET /plansGET /v1/plansnon/a
GET /invoicesGET /v1/customers/{id}/invoicesnon/a
POST /billing-portalPOST /v1/customers/{id}/billing-portal-sessionsyesrequired
POST /subscriptions/{id}/upgradePOST /v1/subscriptions/{id}/change-planyesrequired
POST /subscriptions/{id}/cancelPOST /v1/subscriptions/{id}/cancel?version=Nyesrequired

Identify

The relay’s IdentifyFunc runs once per request inside the request hot path. Keep it cheap and non-blocking — it’s where you bridge your session store to a Paylera CustomerIdentity:

type CustomerIdentity struct {
CustomerID string // optional when AutoCreateCustomer is on + Email is set
Email string // required for auto-create
Name string // forwarded on auto-create
Currency string // ISO-4217; default "USD"
Metadata map[string]any // merged on auto-create
TenantID string // mixes into the auto-create idempotency key
}

Returning an error → 401 with an RFC 9457 problem-details body. Returning an empty identity (no CustomerID, no Email) → 401.

When AutoCreateCustomer: true, calling /me with CustomerID == "" and a usable Email mints the Paylera customer with the protocol’s idempotency key:

paylera-relay-autocreate:{tenant_id}:{email}

Concurrent first-time renders share the key — no double-create.

Mounting under common routers

The relay implements http.Handler so any router that consumes Go HTTP handlers can mount it.

net/http

mux := http.NewServeMux()
mux.Handle("/api/paylera/", http.StripPrefix("/api/paylera",
relay.Handler(opts)))

chi

import "github.com/go-chi/chi/v5"
r := chi.NewRouter()
r.Mount("/api/paylera", relay.Handler(opts))

chi.Mount already strips the prefix.

gorilla/mux

import "github.com/gorilla/mux"
m := mux.NewRouter()
m.PathPrefix("/api/paylera/").Handler(http.StripPrefix("/api/paylera",
relay.Handler(opts)))

Gin

import "github.com/gin-gonic/gin"
g := gin.New()
relayMounted := http.StripPrefix("/api/paylera", relay.Handler(opts))
g.Any("/api/paylera/*path", gin.WrapH(relayMounted))

The wildcard *path catches every sub-route. Install your auth middleware on the relay group before the wrap — the relay reads the session off the *http.Request context, not off gin.Context.

Working example: examples/go/gin-server/.

Echo

import "github.com/labstack/echo/v4"
e := echo.New()
e.Any("/api/paylera/*", echo.WrapHandler(http.StripPrefix("/api/paylera",
relay.Handler(opts))))

Fiber

import (
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/adaptor"
)
app := fiber.New()
app.Use("/api/paylera", adaptor.HTTPHandler(http.StripPrefix("/api/paylera",
relay.Handler(opts))))

Options.MountPrefix is an alternative to http.StripPrefix when the host router passes the full path through — set it to the same prefix you’d otherwise strip:

relay.Handler(relay.Options{
APIToken: token,
Identify: identify,
MountPrefix: "/api/paylera",
})

CSRF

CSRFEnabled (the default) enforces the double-submit-cookie pattern locked in the protocol:

  • GET /csrf-token mints a 32-byte base64url token, sets the paylera_csrf cookie (SameSite=Lax, HttpOnly=false), and returns { "token": "…", "expires_at": "…" }.
  • Every state-changing route (POST, PUT, DELETE, PATCH) compares the Paylera-CSRF-Token header to the cookie value with subtle.ConstantTimeCompare. Missing / mismatched → 403 with paylera.csrf_mismatch.
  • GET, HEAD, OPTIONS skip the check by HTTP semantics.

CSRFDisabled turns the middleware off entirely — only use this when you chain your host framework’s own anti-CSRF middleware upstream of the relay.

relay.Handler(relay.Options{
APIToken: token,
Identify: identify,
CSRF: relay.CSRFEnabled,
CookieDomain: ".example.com",
CookiePath: "/api/paylera",
CookieSecure: true,
})

Forwarding contract

  • The Paylera response body is passed back verbatim — RFC 9457 problem-details from Paylera reach the browser untouched.
  • Content-Type, Content-Encoding, and X-Request-Id from upstream are preserved; hop-by-hop headers are stripped.
  • Idempotency-Key: caller-supplied wins; otherwise the relay stamps a UUID v7 on every state-changing route that requires it (Attach, Track, BillingPortal, Upgrade, Cancel).
  • Authorization and Paylera-Api-Version are injected upstream — the browser never sees the secret token.

OpenTelemetry

The relay emits server-kind spans named Paylera.Relay.<Op> against the tracer Paylera.Relay. Attributes:

  • paylera.endpoint
  • paylera.customer_id
  • paylera.tenant_id
  • paylera.subscription_id
  • paylera.feature_code
  • paylera.idempotency_key (truncated to 8 chars)
  • paylera.api_mode
  • paylera.problem.type on error

No-op when no OTel SDK is registered.

See also