Go
A net/http middleware for Penni using only the standard library. Wrap any handler and price its paths in the dashboard.
No module to install: the middleware below uses only the standard library. Set PENNI_SITE_ID and PENNI_API_KEY in the environment and wrap your mux with Penni.
main.go
package main import ( "bytes" "encoding/base64" "encoding/json" "net/http" "net/url" "os" "time" ) const api = "https://api.pennipay.eu/v1" var ( siteID = os.Getenv("PENNI_SITE_ID") apiKey = os.Getenv("PENNI_API_KEY") client = &http.Client{Timeout: 90 * time.Second} fields = []string{"scheme", "network", "asset", "payTo", "amount"} ) type siteConfig struct { Blocked bool `json:"blocked"` Match struct{ Free bool } `json:"match"` Accepts []map[string]any `json:"accepts"` } func call(method, path string, body any, out any) error { var buf bytes.Buffer if body != nil { json.NewEncoder(&buf).Encode(body) } req, _ := http.NewRequest(method, api+path, &buf) req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Content-Type", "application/json") res, err := client.Do(req) if err != nil { return err } defer res.Body.Close() return json.NewDecoder(res.Body).Decode(out) } func b64(v any) string { data, _ := json.Marshal(v) return base64.StdEncoding.EncodeToString(data) } // Penni wraps a handler: free paths pass, priced paths need a settled x402 payment. func Penni(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 1. Price of this path (cache it for up to 60 seconds in production) var config siteConfig if err := call("GET", "/sites/"+siteID+"/config?path="+url.QueryEscape(r.URL.Path), nil, &config); err != nil { http.Error(w, "payments unavailable", http.StatusServiceUnavailable) return } if config.Blocked { w.Header().Set("Retry-After", "300") http.Error(w, "payments temporarily unavailable", http.StatusServiceUnavailable) return } if config.Match.Free || len(config.Accepts) == 0 { next.ServeHTTP(w, r) return } required := map[string]any{"x402Version": 2, "resource": map[string]string{"url": "https://" + r.Host + r.URL.RequestURI()}, "accepts": config.Accepts} paymentRequired := func(reason string) { required["error"] = reason w.Header().Set("PAYMENT-REQUIRED", b64(required)) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusPaymentRequired) json.NewEncoder(w).Encode(required) } // 2. No payment yet: answer 402 with the price header := r.Header.Get("PAYMENT-SIGNATURE") if header == "" { paymentRequired("PAYMENT-SIGNATURE header is required") return } // 3. The payment must match one of your current prices exactly var payment map[string]any raw, err := base64.StdEncoding.DecodeString(header) if err != nil || json.Unmarshal(raw, &payment) != nil { paymentRequired("invalid_payment") return } accepted, _ := payment["accepted"].(map[string]any) var chosen map[string]any for _, a := range config.Accepts { match := accepted != nil for _, k := range fields { match = match && a[k] == accepted[k] } if match { chosen = a break } } if chosen == nil { paymentRequired("Payment does not match the current price") return } // 4. Verify, then settle on-chain body := map[string]any{"x402Version": 2, "paymentPayload": payment, "paymentRequirements": chosen} var verified struct { IsValid bool `json:"isValid"` InvalidReason string `json:"invalidReason"` } if call("POST", "/x402/verify", body, &verified) != nil || !verified.IsValid { paymentRequired("verify_failed: " + verified.InvalidReason) return } var settled map[string]any for attempt := 0; attempt < 2; attempt++ { // Cardano may answer settlement_pending once settled = map[string]any{} if call("POST", "/x402/settle", body, &settled) != nil || settled["errorReason"] != "settlement_pending" { break } } if settled["success"] != true { paymentRequired("settle_failed") return } w.Header().Set("PAYMENT-RESPONSE", b64(settled)) next.ServeHTTP(w, r) }) } func main() { mux := http.NewServeMux() mux.HandleFunc("/premium/report", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"report": "..."}) }) http.ListenAndServe(":8080", Penni(mux)) }
For production
- Cache the config per path for up to 60 seconds; the config API supports ETags.
- Use your public URL for
resource.urlif TLS ends at a proxy in front of the server. - Timeouts: verify answers within seconds; give settle up to 90 seconds, Cardano waits for confirmations.
The four calls are explained step by step in the HTTP walkthrough.