Backend Integration - Go
Use this library when your backend is written in Go and runs behind the Productify proxy. It reads the user and tenant the proxy puts in request headers, registers your backend with the Manager so it can receive scheduled triggers, verifies those trigger callbacks, and fetches per-tenant configuration.
It ships as a dependency-free net/http core (module github.com/ProductifyFW/be-integrations/go) plus a Gin adapter submodule (github.com/ProductifyFW/be-integrations/go/gin). It speaks the same wire protocol as @productifyfw/node-express; a shared parity test suite (go/parity_test.go) keeps the request and response shapes identical.
Versioning
The current release tags are go/v0.2.0 (core) and go/gin/v0.2.0 (Gin adapter). go get without a version resolves to the latest tag.
Overview
The library covers the four backend integration surfaces:
- Identity middleware: extracts the proxy-injected
X-User-*/X-Tenant-*/X-Auth-Typeheaders into a typed request context, with optional proxy shared-secret verification - Registration client: registers the backend with the Manager, heartbeats, re-registers on 404, and unregisters on shutdown
- Trigger handler: receives Manager cron callbacks with timing-safe token checks and optional replay dedupe
- Config client: fetches per-tenant frontend configuration and language packs
How It Works
Proxy ──(X-User-* / X-Tenant-* / X-Proxy-Auth)──▶ your handler
│
productify.FromContext(r.Context())
▼
Context{User, Tenant, AuthType}
Backend ──register / heartbeat / config──▶ Manager
Manager ──trigger callbacks (Bearer token)──▶ BackendInstallation
go get github.com/ProductifyFW/be-integrations/go@v0.2.0 # core (net/http only)
go get github.com/ProductifyFW/be-integrations/go/gin@v0.2.0 # optional Gin adapterThe core module has no dependencies beyond the standard library; the Gin adapter is a separate module so gin never enters your go.sum unless you use it.
Quick Start
package main
import (
"context"
"encoding/json"
"net/http"
"os"
productify "github.com/ProductifyFW/be-integrations/go"
)
func main() {
mw := productify.Middleware(productify.MiddlewareConfig{
// strict auto-enables when the secret is set;
// override with Strict: &trueVal / &falseVal if needed
ProxySharedSecret: os.Getenv("PROXY_SHARED_SECRET"),
})
mux := http.NewServeMux()
mux.HandleFunc("/api/profile", func(w http.ResponseWriter, r *http.Request) {
ctx, ok := productify.FromContext(r.Context())
if !ok {
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
json.NewEncoder(w).Encode(map[string]any{
"user": ctx.User, // ID, Email, Name, Username
"tenant": ctx.Tenant, // ID, Name
})
})
http.ListenAndServe(":8080", mw(mux))
}Identity Middleware
func Middleware(cfg MiddlewareConfig) func(http.Handler) http.Handler
type MiddlewareConfig struct {
ProxySharedSecret string // verify X-Proxy-Auth (constant-time); "" disables
Strict *bool // default: false, auto-true when secret is set
OnMissingHeaders func(*http.Request)
}
func FromContext(ctx context.Context) (Context, bool)
type Context struct {
User User // from X-User-ID / -Email / -Name / -Username
Tenant Tenant // from X-Tenant-ID / -Name
AuthType string // from X-Auth-Type: "oauth" | "api-key" | ...
// TenantRole / TenantPermissions / Picture are set only when the
// application opts into role forwarding in the Manager (nil otherwise)
TenantRole *string
TenantPermissions []string
Picture *string // from X-User-Picture (Pocket ID picture claim)
}Behavior:
- When
ProxySharedSecretis set,X-Proxy-Authis verified with a constant-time compare before any identity header is read; 401 on mismatch - Strict mode (production): missing or partial identity headers → 401 with a JSON error body (
{"error":"Unauthorized","message":"Productify identity headers required"}). The fourX-User-*headers are required; the tenant headers may be empty (a user can be signed in before picking a tenant) - Non-strict mode (bare local development): requests without headers pass through with no context;
FromContextreturnsok == false - Header values are never logged by the library
- Difference from the Node library: in strict mode Node's
productifyMiddlewarelets the request through withreq.productifyunset and relies onrequireProductifyfor the 401; the Go middleware answers 401 itself
Backend Registration
backend, err := productify.Register(ctx, productify.RegisterConfig{
ManagerURL: os.Getenv("MANAGER_URL"),
MachineUserToken: os.Getenv("MACHINE_USER_TOKEN"),
ApplicationID: os.Getenv("APPLICATION_ID"),
CallbackURL: os.Getenv("CALLBACK_URL"), // internal URL the Manager POSTs triggers to
Version: buildVersion, // stored by the Manager
HeartbeatEvery: 30 * time.Second, // default
RequestTimeout: 10 * time.Second, // default
OnHeartbeatLost: func(consecutive int) {
slog.Warn("manager unreachable", "failures", consecutive)
},
})backend.ID()/backend.Token()return the live registration id and callback token (they can change after an automatic re-registration, so always read them through these getters)- Heartbeats run in the background with jittered backoff; a
404(Manager restarted or registration reaped) triggers transparent re-registration backend.Close(ctx)stops the heartbeat and unregisters. Call it from your own shutdown path; the library installs no signal handlers and never callsos.Exit- Registration failure is yours to decide: the recommended pattern is non-fatal: serve traffic without triggers and retry in the background
Trigger Handling
mux.Handle("/productify/triggers", productify.TriggerHandler(productify.TriggerConfig{
Token: func() string { return backend.Token() }, // live getter — survives re-registration
Handlers: map[string]productify.TriggerFunc{
"report-schedules": func(ctx context.Context, p productify.TriggerPayload) error {
return enqueueReportRuns(ctx, p) // enqueue only — never do the work inline
},
},
Dedupe: true, // skip redelivered (trigger_id, executed_at) pairs
}))- Bearer token verified with a constant-time compare
- The Manager's dispatch timeout is 5 seconds, so handlers must acknowledge fast and do real work asynchronously (a jobs table/queue)
- With
Dedupe, a redelivered run returns 200 without re-invoking the handler; failed handlers are not marked seen, so redelivery retries them - Multi-tenant fan-out: a trigger fires once per deployment; list the tenants that have your app enabled and enqueue per-tenant work:
tenants, err := backend.ListEnabledTenants(ctx, appID) // GET /api/machine/application/:id/tenants
for _, t := range tenants {
enqueue(jobFor(t.ID))
}Config Client
client := productify.ManagerClient{
BaseURL: os.Getenv("MANAGER_URL"),
Token: os.Getenv("MACHINE_USER_TOKEN"),
}
cfg, err := client.FrontendConfigForTenant(ctx, appID, tenantID)
// cfg.EnabledModules []ModuleRef{Name, Version}
// cfg.AvailableLocales, cfg.DefaultTheme, cfg.KeyValuePairs
pack, err := client.LanguagePack(ctx, appID, "hu") // for backend-rendered content (emails)The library does not cache; cache per (application, tenant) with a short TTL in your own store (Redis, in-memory) as fits your app.
The Manager currently rejects these calls
ManagerClient sends only Authorization: Bearer <machine-user-token>. On the Manager, GET /api/frontend-config/... and GET /language-packs/... are registered in the proxy-only route group, which checks the X-Proxy-Auth shared secret and never looks at a Bearer token. Against a real Manager the config client therefore gets 401 from the proxy trust boundary; it works only against managertest. Until the Manager exposes a machine-user route for configuration, read per-tenant settings another way (for example from your own database) or raise the gap with the platform team.
Gin Adapter
import ginproductify "github.com/ProductifyFW/be-integrations/go/gin"
r := gin.New()
r.Use(ginproductify.Middleware(productify.MiddlewareConfig{
ProxySharedSecret: os.Getenv("PROXY_SHARED_SECRET"),
}))
r.GET("/api/profile", func(c *gin.Context) {
ctx, _ := ginproductify.FromContext(c)
c.JSON(200, gin.H{"user": ctx.User, "tenant": ctx.Tenant})
})
r.POST("/productify/triggers", ginproductify.TriggerHandler(triggerCfg))Testing with managertest
A fake Manager ships with the library, so your suites never need a live platform:
import "github.com/ProductifyFW/be-integrations/go/managertest"
func TestRegistrationLifecycle(t *testing.T) {
m := managertest.New(t) // httptest server: register/heartbeat/unregister/config
backend, err := productify.Register(ctx, productify.RegisterConfig{
ManagerURL: m.URL(), MachineUserToken: "dev", ApplicationID: "app-1",
CallbackURL: "http://127.0.0.1:0/triggers",
})
// scriptable failures:
m.SetHeartbeat404(true) // exercises re-registration
m.SetFailure(managertest.FailHTML) // exercises the "behind the proxy?" guard
require.Eventually(t, func() bool { return m.HeartbeatCount() > 0 }, ...)
}The same scenarios run against @productifyfw/node-express (via @productifyfw/testing) from shared fixtures, so the two SDKs are kept behavior-identical.
Best Practices
- Run strict mode with
PROXY_SHARED_SECRETin production; the backend must be reachable only through the proxy's network - Treat header identity as authentication; authorization (what the user may do inside your app) is your job; query
Context.TenantPermissionsonly if your app opted into role forwarding - Enqueue trigger work, never execute inline; make jobs idempotent (redelivery is expected)
- Read
backend.Token()lazily (function form) anywhere the callback token is needed
Troubleshooting
401 on every request
Strict mode is on (secret configured) but requests reach the backend without proxy headers: the proxy is being bypassed. For bare local development, unset PROXY_SHARED_SECRET and put pfy dev proxy in front of your backend, which injects the production header contract from a local identity file (see the dev command docs).
"expected JSON but got text/html"
The MANAGER_URL points at the proxy's public hostname instead of the Manager's internal address; registration calls must go directly to the Manager (machine-user authenticated).
Triggers never arrive
Check backend.ID() registered successfully, the CallbackURL is reachable from the Manager (callbacks bypass the proxy), and the trigger's run_key matches a key in Handlers.
See Also
- Integration Overview
- Node.js/Express Integration
- Manager Backend Registration - what the Manager does with a registration
- Add Notifications -
CommsClient.Emit - Live Data Updates -
CommsClient.Publish