Install

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.url if 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.