Go SDK for the Machine Payment Protocol (MPP): the server returns 402 Payment Required with a challenge, and the client can automatically pay and retry the request.
go get github.com/cp0x-org/mppxRequirement: Go 1.25+ (see go.mod).
github.com/cp0x-org/mppx- core protocol types:Challenge,Credential,Receipt- header serialization/deserialization
PaymentError+ RFC 9457ProblemDetailsExpires.*,ComputeDigest,VerifyDigest
github.com/cp0x-org/mppx/server- server-side 402/200 flow (Handle,Compose,Middleware,HandlerFunc)github.com/cp0x-org/mppx/client- automatic402handling and retry withAuthorization: Payment ...github.com/cp0x-org/mppx/middleware/gin- Gin middlewaregithub.com/cp0x-org/mppx/middleware/fiber- Fiber middlewaregithub.com/cp0x-org/mppx/tempo- Tempo payment method (chargeandsession)github.com/cp0x-org/mppx/stripe- Stripe payment method (charge)
# PowerShell
$env:MPP_SECRET_KEY="demo-secret-key-change-in-production"
go run ./examples/basic/serverIn another terminal:
go run ./examples/basic/client- Client sends a regular HTTP request.
- Server responds with
402, addsWWW-Authenticate: Payment ..., and returnsapplication/problem+json. - Client parses the challenge, creates a credential, and retries with
Authorization: Payment .... - Server verifies the credential and returns
200+Payment-Receipt.
If a method returns *mppx.PaymentError from Verify(), the server serializes it as RFC 9457 application/problem+json (usually with a new challenge).
Main SDK errors:
| Constructor | HTTP | Problem Type |
|---|---|---|
ErrMalformedCredential |
402 | .../malformed-credential |
ErrInvalidChallenge |
402 | .../invalid-challenge |
ErrVerificationFailed |
402 | .../verification-failed |
ErrPaymentRequired |
402 | .../payment-required |
ErrPaymentExpired |
402 | .../payment-expired |
ErrInvalidPayload |
402 | .../invalid-payload |
ErrPaymentInsufficient |
402 | .../payment-insufficient |
ErrInsufficientBalance |
402 | .../session/insufficient-balance |
ErrInvalidSignature |
402 | .../session/invalid-signature |
ErrBadRequest |
400 | .../bad-request |
ErrMethodUnsupported |
400 | .../method-unsupported |
ErrChannelNotFound |
410 | .../session/channel-not-found |
ErrChannelClosed |
410 | .../session/channel-finalized |
Useful helpers:
mppx.IsPaymentError(err)/mppx.AsPaymentError(err)for checking/unwrapping payment errors.- Any non-
PaymentErrorreturned fromVerify()is automatically wrapped asErrVerificationFailed(...).
- Server challenge:
WWW-Authenticate: Payment id="...", realm="...", method="...", intent="...", request="..." - Client credential:
Authorization: Payment <base64url-json> - Server receipt:
Payment-Receipt: <base64url-json> - Error body:
Content-Type: application/problem+json(RFC 9457)
Challenge fields:
realm,method,intent,requestare required for payment routing.digestbinds the challenge to the request body (see below).expiresis supported and validated viamppx.IsExpired.descriptionis set viaserver.WithDescription.opaqueis set viaserver.WithMeta.
Payment-Receipt is only attached to a response that succeeded. If the paid
handler answers with 4xx/5xx, the receipt is dropped, so an error response
can never be recorded as "paid and delivered". Handlers read the receipt from
server.ReceiptFromContext(r.Context()) (or GetReceipt(c) in the Gin/Fiber
middleware) rather than from the response header.
server.New(server.Config{SecretKey, Realm, DefaultExpiry, BodyDigestLimit})srv.Handle(r, method, req, opts...)srv.Compose(entries...)- multiple payment methods for one endpointsrv.Middleware(...)/srv.HandlerFunc(...)result.ReceiptWriter(w)- emitsPayment-Receiptonly on a successful responseserver.ReceiptFromContext(ctx)- the verified receipt inside a paid handlerserver.WithDescription(...),server.WithExpires(...),server.WithMeta(...)
Validations inside Handle:
- challenge signature (
SecretKey+ HMAC), method/intent/realmmatch,- key request field match:
amount,currency,recipient, - request body digest match,
expirescheck.
Handle hashes the request body, puts the digest into the challenge (which is
HMAC-bound), and requires the paid retry to carry the same body. Without it a
credential bought for one POST body could be presented with another one, so a
route whose body decides the work being purchased would run something other than
what was priced. The client must therefore send the same body on both legs of the
flow; client.Do already does.
BodyDigestLimitcaps how much of the body is hashed (default 1 MiB). Larger bodies are rejected with413instead of silently skipping the binding.BodyDigestLimit: -1disables body binding.Handlereplacesr.Bodywith a re-readable copy, so handlers still read it normally.
mppx.Store implementations used for replay protection (for example
tempo.ChargeConfig.Store) should also implement mppx.AtomicStore:
PutIfAbsent(ctx context.Context, key, value string) (bool, error)mppx.ReserveKey/mppx.MarkUsed claim a key with a single PutIfAbsent call,
so concurrent requests carrying the same transaction hash cannot all pass the
check. MemoryStore implements it; back a production store with SET NX, a
unique index, or a compare-and-swap. A plain Store falls back to a
mutex-guarded Get/Put, which is atomic only inside one process.
client.New(client.Config{Methods, HTTPClient, OnChallenge})c.Do(req),c.Get(ctx, url),c.Post(...)client.GetReceipt(resp)
Do behavior:
- validates URL (
http/https), - on
402, parses challenge, - finds registered method by
method/intent, - creates credential and retries the request.
Detailed documentation:
In short:
Payment(...)- one payment method per route.Compose(...)- multiple payment methods.GetReceipt(c)- extractsmppx.Receiptin the handler.
go run ./examples/basic/server- basicnet/http+ Gingo run ./examples/fiber/server- Fiber middlewarego run ./examples/http-middleware/server- HTTP middleware patternsgo run ./examples/multi-method/server- multi-method (server.Compose)go run ./examples/session/server- session flow (tempo/session-like)go run ./examples/stripe/server- Stripe flow (STRIPE_SECRET_KEY=sk_test_...)go run ./examples/tempo/charge/server- Tempo one-time charge (TIP-20 transfer)go run ./examples/tempo/session/server- Tempo payment channel (open/voucher/close)
Clients are in matching examples/*/client folders.
How to run the Tempo charge example (server + client), configure .env files, and see real output: docs/tempo-charge-example.md.
package main
import (
"context"
"encoding/json"
"net/http"
"os"
mppx "github.com/cp0x-org/mppx"
"github.com/cp0x-org/mppx/server"
)
type DemoMethod struct{}
func (m *DemoMethod) Name() string { return "demo" }
func (m *DemoMethod) Intent() string { return "charge" }
func (m *DemoMethod) DefaultRequest() map[string]any {
return map[string]any{"currency": "USDC", "recipient": "0xabc"}
}
func (m *DemoMethod) Verify(_ context.Context, cred mppx.Credential, _ map[string]any) (mppx.Receipt, error) {
payload, ok := cred.Payload.(map[string]any)
if !ok {
return mppx.Receipt{}, mppx.ErrInvalidPayload("expected object payload")
}
txHash, _ := payload["txHash"].(string)
if txHash == "" {
return mppx.Receipt{}, mppx.ErrInvalidPayload("missing txHash")
}
return mppx.NewReceipt(mppx.ReceiptParams{Method: "demo", Reference: txHash}), nil
}
func main() {
srv := server.New(server.Config{SecretKey: os.Getenv("MPP_SECRET_KEY"), Realm: "localhost"})
method := &DemoMethod{}
http.HandleFunc("/premium", func(w http.ResponseWriter, r *http.Request) {
result := srv.Handle(r, method, map[string]any{"amount": "1000000"}, server.WithDescription("Premium API"))
if result.Status != http.StatusOK {
result.Write(w) // 402 + WWW-Authenticate + Problem Details
return
}
// Payment-Receipt is attached only if this handler answers successfully.
rw := result.ReceiptWriter(w)
defer rw.Finish()
_ = json.NewEncoder(rw).Encode(map[string]any{"ok": true})
})
_ = http.ListenAndServe(":8080", nil)
}package main
import (
"context"
"net/http"
mppx "github.com/cp0x-org/mppx"
"github.com/cp0x-org/mppx/client"
)
type DemoClientMethod struct{}
func (m *DemoClientMethod) Name() string { return "demo" }
func (m *DemoClientMethod) Intent() string { return "charge" }
func (m *DemoClientMethod) CreateCredential(_ context.Context, ch mppx.Challenge) (string, error) {
cred := mppx.Credential{
Challenge: ch,
Payload: map[string]any{"txHash": "0xdeadbeef"},
Source: "did:pkh:eip155:1:0xDemo",
}
return mppx.SerializeCredential(cred), nil
}
func main() {
c := client.New(client.Config{
Methods: []mppx.ClientMethod{&DemoClientMethod{}},
HTTPClient: http.DefaultClient,
})
resp, err := c.Get(context.Background(), "http://localhost:8080/premium")
if err != nil {
panic(err)
}
defer resp.Body.Close()
receipt, err := client.GetReceipt(resp) // Payment-Receipt -> mppx.Receipt
if err != nil {
panic(err)
}
_ = receipt // use receipt.Method / receipt.Reference / receipt.Timestamp
}go build ./...
go test ./...