Governing HTTP-native agent payments on x402
x402 revives the HTTP 402 Payment Required status code for machine-to-machine commerce: an agent hits a resource, receives a 402 with payment terms, and settles in-band. This guide covers every step to put Axiru in front of that settlement — so no x402 payment goes through without a valid, tenant-bound authorization.
What x402 is
x402 is a protocol that restores HTTP 402 Payment Required as a first-class status code for agent-to-service commerce. A merchant resource returns a 402 with machine-readable payment terms; a client agent parses those terms, requests authorization from its governance layer, and submits a signed settlement intent back to the merchant's facilitator. The facilitator verifies the settlement before releasing the resource.
Axiru's role is to be the governance layer for the agent's side. Before the agent's wallet (Privy) signs anything, it calls the Axiru policy hook. Axiru evaluates your policies, and if the transfer is allowed, returns a signed AuthorizationTokenClaims JWS. Privy verifies the JWS and proceeds to sign the payment. If no valid JWS is returned, Privy must not sign — fail-closed by construction.
Architecture
Privy is the wallet that holds the agent's signing key. Before it signs anything, the policy hook calls Axiru. There is no fallback — if Axiru is unreachable or the JWS doesn't verify, Privy must not sign. This is the fail-closed contract from spec §11.4.
Prerequisites
- A Privy embedded wallet provisioned for your tenant, with Policy Hooks enabled in the Privy dashboard. Alternatively, any compatible signer that implements the
PrivyPolicyHookClientinterface (signWithPolicyKeyRPC). - An x402 facilitator endpoint that handles the payment-required handshake on the merchant side.
- An Axiru workspace with at least one active connection. The rail is
x402; no Stripe account is required for x402-only traffic. - An Axiru API key with scope
x402:authorize. Generate from Dashboard → Settings → API keys. - The
@axiru/auth-token-verifierpackage (T-038) in your TypeScript runtime, or the equivalent Go verifier (T-039 / T-040 / T-041) for Go facilitators.
Provision the Privy wallet and set environment variables
The Privy policy-hook adapter reads four environment variables. These must be set on your Axiru host before the production signer is active.
| Variable | Purpose |
|---|---|
AXIRU_PRIVY_WALLET_ID | Privy wallet ID for the issuing tenant. |
AXIRU_PRIVY_KEY_ID | Privy key ID — used as the JWS kid header. Must match the key configured in the Privy token-acceptance policy. |
AXIRU_PRIVY_API_KEY | Privy server-API key. Used to authenticate the signing RPC from Axiru to Privy. |
AXIRU_PRIVY_API_SECRET | Privy server-API secret. |
When any of the four AXIRU_PRIVY_* variables are missing, the route falls back to the fail-closed default-deny signer — which itself is never invoked because the Decision Engine returns deny when the production signer is not wired. This is the correct behaviour for local development before Privy onboarding is complete.
In the Privy dashboard, for the issuing wallet:
- Enable Policy Hooks for the wallet.
- Set the policy-hook URL to
https://www.axiru.com/api/v1/authorizations. - Provision an Axiru API key with scope
x402:authorizeand set it as theAuthorization: Bearer ak_…header on the policy-hook request. - Set the token-acceptance policy to "require Axiru JWS with kid matching
AXIRU_PRIVY_KEY_ID". This is what makes the integration fail-closed at Privy's end: even a successful response without a valid JWS is rejected.
Configure the policy hook to call POST /api/v1/authorizations
The Privy policy hook fires before the wallet signs. Your hook handler calls the Axiru pre-authorization endpoint and returns the result to Privy. The handler must be synchronous from Privy's perspective — no fire-and-forget.
curl — direct policy-hook request
curl -i -X POST https://www.axiru.com/api/v1/authorizations \
-H "Authorization: Bearer ak_live_…" \
-H "Content-Type: application/json" \
-d '{
"challenge": {
"facilitator_url": "https://facilitator.x402.example.com",
"resource_url": "https://merchant.example.com/api/articles/42",
"asset": "USDC",
"chain": "base-sepolia",
"amount_minor_units": "500",
"currency": "USD",
"pay_to_address": "0xMerchantPayoutAddress…"
},
"context": {
"initiator": {
"kind": "agent",
"id": "agent_content_fetcher_v1",
"agent_metadata": {
"model": "gpt-5",
"version": "2026-04-01",
"scope": "payments:write"
}
},
"agent_run_id": "run_abc123",
"idempotency_key": "example-x402-request-id"
}
}'
# → on allow (200):
# HTTP/1.1 200 OK
# content-type: application/json
# axiru-authorization: eyJhbGciOiJFUzI1NiIsImtpZCI6ImF4aXJ1LTIwMjYtMDEifQ…
# axiru-event-id: ev_…
# axiru-ovt-fingerprint: sha256:…
#
# { "decision": "allow", "reason_code": "…", "reason_text": "…", "event_id": "ev_…", "ovt_fingerprint": "sha256:…" }
#
# → on deny (403):
# HTTP/1.1 403 Forbidden
# content-type: application/json
#
# { "decision": "deny", "reason_code": "axiru.deny.agent_scope_missing", "reason_text": "…", "event_id": "ev_…" }Node / TypeScript — hook handler
// In your x402 facilitator or Privy policy-hook handler:
const res = await fetch("https://www.axiru.com/api/v1/authorizations", {
method: "POST",
headers: {
authorization: `Bearer ${process.env.AXIRU_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
challenge: {
facilitator_url: req.facilitatorUrl,
resource_url: req.resourceUrl,
asset: req.asset, // e.g. "USDC"
chain: req.chain, // e.g. "base-sepolia"
// amount_minor_units is a decimal string so values above 2^53 are
// not silently truncated by JSON. Server coerces with BigInt().
amount_minor_units: String(req.amountMinorUnits),
currency: req.currency, // e.g. "USD"
pay_to_address: req.payToAddress,
},
context: {
initiator: {
kind: "agent",
id: req.agentId,
agent_metadata: {
model: req.agentModel,
version: req.agentVersion,
scope: req.agentScope, // single space-delimited scope string
},
},
agent_run_id: req.agentRunId,
idempotency_key: req.idempotencyKey,
},
}),
});
if (res.status !== 200) {
// Fail closed: deny/quarantine/require_approval all return non-200.
// Privy must not sign if no valid JWS was returned.
const body = await res.json().catch(() => ({}));
return { allowed: false, reason: body.reason_code ?? "http_" + res.status };
}
// The signed JWS is delivered in the response HEADER, not the body.
// The JSON body carries decision metadata only.
const authorizationToken = res.headers.get("axiru-authorization");
if (!authorizationToken) {
// 200 without the header is a contract violation; fail closed.
return { allowed: false, reason: "missing_axiru_authorization_header" };
}
// Pass authorizationToken to Privy for signing.
// Privy verifies the JWS against AXIRU_PRIVY_KEY_ID before signing.
return { allowed: true, authorizationToken };Use the rail = "x402" rule kind to scope policies to x402 traffic exclusively. The counterparty rule kind matches on the resource URL or counterparty country. Therolling_window rule kind enforces per-agent spend ceilings.
Verify the AuthorizationTokenClaims JWS
The authorization_token is a compact JWS signed by the Axiru JWKS. Both sides of the trust boundary — Axiru to Privy, and the x402 facilitator receiving the settlement — must verify the JWS independently. The @axiru/auth-token-verifier package (landed in T-038) provides the TypeScript verifier with automatic JWKS rotation. The x402 Go verifier (T-039 / T-040 / T-041) provides an idiomatic Go alternative for facilitators written in Go.
Node / TypeScript — verify with jose
import { createRemoteJWKSet, compactVerify } from "jose";
const JWKS_URL = "https://www.axiru.com/axiru-jwks.json";
const ISSUER = "https://api.axiru.com";
const jwks = createRemoteJWKSet(new URL(JWKS_URL));
/**
* Verify an AuthorizationTokenClaims JWS returned by Axiru.
* Mirrors the @axiru/auth-token-verifier package (T-038).
*/
async function verifyX402AuthorizationToken(jws: string, expectedFingerprint: string) {
const { payload } = await compactVerify(jws, jwks);
const claims = JSON.parse(new TextDecoder().decode(payload));
if (claims.iss !== ISSUER) throw new Error("Unexpected issuer");
if (Date.now() / 1000 > claims.exp) throw new Error("Token expired");
if (claims.rail !== "x402") throw new Error("Wrong rail in claims");
if (claims.ovt_fingerprint !== expectedFingerprint) throw new Error("OVT fingerprint mismatch");
// audit_hash links this token to the exact ledger entry.
return claims; // { jti, iss, iat, exp, rail, ovt_fingerprint, audit_hash, … }
}Go — verify with the Axiru Go verifier
// Using the Axiru x402 Go verifier (T-039 / T-040 / T-041).
// go get github.com/axiru/auth-token-verifier/go
import (
axiru "github.com/axiru/auth-token-verifier/go"
)
verifier := axiru.NewJWSVerifier(axiru.Config{
JWKSURL: "https://www.axiru.com/axiru-jwks.json",
Issuer: "https://api.axiru.com",
CacheTTL: 30 * time.Minute,
})
claims, err := verifier.Verify(ctx, jwsToken)
if err != nil {
// Fail closed — do not proceed with settlement.
return fmt.Errorf("authorization token invalid: %w", err)
}
if claims.OVTFingerprint != expectedFingerprint {
return fmt.Errorf("OVT fingerprint mismatch")
}
// claims.JTI links to the audit ledger entry.Keys are published at https://www.axiru.com/axiru-jwks.json. The kid in the JWS header identifies which key signed the token. Set your JWKS cache TTL to 30 minutes or less. The @axiru/auth-token-verifier package manages rotation transparently. Both the TS and Go verifiers surface a kid_not_found error on stale caches, so your error handler should refresh the JWKS and retry once before failing.
Decision outcomes and the fail-closed contract
The Decision Engine returns one of three outcomes. The contract from spec §11.4 is explicit: Privy must not sign unless the outcome is allow and the JWS verifies.
| Outcome | JWS issued? | Required action |
|---|---|---|
allow | Yes — authorization_token in the response. | Verify the JWS (signature, issuer, expiry, OVT fingerprint). Pass to Privy for signing only if verification passes. |
deny | No JWS. | Privy must not sign. Return an error to the agent. The decision is recorded in the ledger for audit. |
quarantine | No JWS. | Privy must not sign. The agent's request is flagged for manual review. The approval queue entry links to the ledger record. |
The integration is fail-closed at three layers:
- Route layer: if the Decision Engine throws or returns a non-allow outcome, the signer is never called. Proven by the T-044 route test suite.
- Adapter layer: if the Privy client throws or returns a malformed JWS, a
PrivySignerErroris thrown and the route renders a 5xx — no silent fallback to an allow. - Privy layer: if Axiru returns no JWS or an unverifiable JWS, Privy refuses to sign (enforced by the token-acceptance policy set in Step 1).
This three-layer fail-closed design is what the SOC 2 control references: no agent x402 payment proceeds without a fresh, valid, tenant-bound authorization. No PII or secret material appears in logs or response bodies — only decision IDs, reason codes, and the public JWS.
Shadow → enforcing rollout
Start in shadow mode. Every decision is recorded and auditable, but no x402 transaction is blocked. This lets you observe policy behavior against real agent traffic before flipping enforcement.
- Wire the Privy policy hook (Steps 1–2) with the Axiru endpoint. Verify the hook fires for every x402 authorization attempt by checking the decision ledger for
rail = x402entries. - Compare v1 and v2 policy agreement in the ledger. Fix rules that fire too broadly (e.g., rolling-window thresholds that would block legitimate agent traffic) or too narrowly (missing counterparty guards).
- When the agreement rate is satisfactory (>98% over 7 days of representative traffic), flip the x402 surface to enforcing from Dashboard → Connectors.
- Enforcing mode activates the Privy token-acceptance policy: only x402 settlements with a valid Axiru JWS will be signed. Privy rejects settlements for any request that returns
denyorquarantine.
Audit + replay
Every x402 authorization attempt lands in the append-only decision ledger. Each entry includes:
decision_idandledger_idfor correlation with facilitator logs and x402 settlement records.audit_hash— the SHA-256 hash of the canonical OVT, linking the authorization token to the exact ledger entry deterministically. Theaudit_hashclaim appears in the JWS payload; facilitators can verify the hash matches their own canonical serialization.- The policy version matched, the rule kind that fired, and the full policy snapshot at evaluation time.
- The JTI of the issued AuthorizationToken (when outcome is
allow), enabling correlation from the signed token back to the exact ledger entry. - Shadow vs enforcing mode flag and v1/v2 agreement result.
Browse and filter decisions from the decision ledger. Replay re-evaluates the original OVT against the policy version active at the time, so re-derived outcomes are deterministic and regulator-ready.
Common errors and how to fix them.
These are the most frequent issues when integrating x402 with the Axiru policy hook.
| Error / symptom | Cause | Fix |
|---|---|---|
outcome: "deny" on every request in local dev | The four AXIRU_PRIVY_* env vars are not set. The route has fallen back to the fail-closed default-deny signer. | Set all four env vars (or mock them in your test harness). The deny with reason pre_authorization.engine_not_wired signals the signer stub is active. |
Privy refuses to sign despite outcome: allow | The kid in the JWS doesn't match theAXIRU_PRIVY_KEY_ID configured in the Privy token-acceptance policy. | Verify AXIRU_PRIVY_KEY_ID matches the key ID in the published JWKS and in the Privy policy. Privy enforces an exact kid match. |
Verifier throws kid_not_found | The JWKS cache is stale. Axiru has rotated the signing key since the last JWKS fetch. | Force a JWKS cache refresh and retry verification once. Set cache TTL to 30 minutes or less. @axiru/auth-token-verifier does this automatically. |
| OVT fingerprint mismatch on verification | The canonical OVT serialization differs between the policy-hook caller and the verifying code. | Use canonicalSha256FingerprintSync from@axiru/auth-token-verifier on both sides. Ensure field ordering is consistent — the canonical form sorts keys alphabetically with no whitespace. |
| Token expired on receipt at facilitator | The AuthorizationToken has a 5-minute expiry. Network latency between Privy, the policy hook, and the facilitator consumed the window. | Request a fresh authorization immediately before each settlement attempt. Do not pre-fetch or cache tokens across requests. |
PrivySignerError: malformed_jws in logs | The Privy client returned a JWS that is not a valid compact three-part token (or has an empty segment). | Check your Privy SDK version and the wallet configuration. The malformed_jws error code in the log indicates the response body shape doesn't match the expected { jws: string } contract. |
Next steps
Govern Stripe Direct-Account-Authorized agent payments — same decision endpoint, different rail.
Legacy decisions APIThe v1 decisions endpoint for Stripe refunds and payouts — analogous request/response shape.
Agentic payments overviewCross-rail governance overview, the four primitives, and the full Phase 1 + Phase 2 rail roadmap.