Skip to content

Repository files navigation

X402Bundle

Symfony bundle for protecting routes with the x402 HTTP 402 Payment Required protocol (v2).

This bundle acts as a Resource Server: it does not implement wallets or direct blockchain logic. Payment verification and settlement are delegated to a facilitator behind the FacilitatorClientInterface — either an external HTTP service (default) or your own PHP implementation (see Facilitator).

Requirements

  • PHP 8.4+
  • Symfony 7.4 or 8.x

Installation

composer require berenger/x402-bundle

Register the bundle in config/bundles.php:

return [
    // ...
    X402\Bundle\X402Bundle::class => ['all' => true],
];

Configuration

# config/packages/x402.yaml
x402:
    facilitator_url: 'https://facilitator.example.com'  # required with HttpFacilitatorClient
    pay_to: '0xYourRecipientAddress'
    network: 'eip155:84532'       # CAIP-2 format (Base Sepolia)
    asset: '0xUSDC'                # required for protected routes
    default_amount: '1000'         # required for protected routes
    settle: after                  # after (recommended) | before | manual, default: after
    timeout: 10.0                  # seconds, default
    replay_cache_ttl: 300          # seconds, default
    max_timeout_seconds: 60        # payment validity window, default
    asset_name: 'USDC'             # EIP-712 token name (exact scheme extra)
    asset_version: '2'             # EIP-712 token version (exact scheme extra)
    replay:
        cache_pool: cache.app      # Symfony cache pool for replay protection
        lock_store: null           # optional lock store service id (defaults to FlockStore)

facilitator_url is optional in configuration but required at compile time when the default HttpFacilitatorClient is used. If you provide a custom FacilitatorClientInterface, you can omit it. The bundle requires Symfony's FrameworkBundle because it uses cache.app, controller argument resolver tags and framework services.

Usage

Protect a controller action with the #[RequiresPayment] attribute:

use X402\Bundle\Attribute\CurrentPayment;
use X402\Bundle\Attribute\RequiresPayment;
use X402\Bundle\Payment\PaymentContext;
use X402\Bundle\Payment\PaymentVerificationResult;
use X402\Bundle\Payment\PaymentSettlementService;

final class ApiController
{
    #[RequiresPayment(amount: '1000', description: 'Premium API access')]
    public function premium(#[CurrentPayment] PaymentVerificationResult $payment): JsonResponse
    {
        return new JsonResponse(['data' => 'premium content', 'payer' => $payment->payer]);
    }
}

For settle: manual, inject PaymentSettlementService and use the _x402_context request attribute. Call settle() exactly once after the application work succeeds, or release() when the payment will not be settled:

$context = $request->attributes->get('_x402_context');
if (!$context instanceof PaymentContext) {
    throw new \LogicException('No verified payment context.');
}

$settlement = $paymentSettlementService->settle($context);

Attribute fields are nullable and fall back to bundle configuration when omitted. Method-level attributes override class-level attributes field by field. The settle field accepts a SettleMode enum, e.g. #[RequiresPayment(settle: SettleMode::Manual)]. The mimeType field sets the resource MIME type advertised to the client in the payment requirements (e.g. #[RequiresPayment(mimeType: 'application/json')]).

Multiple payment options

You can expose several accepted payment options on the same route:

#[RequiresPayment(amount: '1000', network: 'eip155:84532')]
#[RequiresPayment(amount: '2000', network: 'eip155:1')]
public function multiNetwork(): JsonResponse
{
    // ...
}

The client chooses one option in PAYMENT-SIGNATURE; the bundle matches it against the server's accepts list before calling the facilitator.

Events

The bundle dispatches Symfony events you can listen to:

Event When
PaymentVerifiedEvent After successful facilitator verification
PaymentSettledEvent After successful settlement
PaymentFailedEvent When a payment is rejected (invalid payload, replay, verify/settle failure, facilitator error)

Payment flow (x402 v2)

  1. Client requests a protected route without PAYMENT-SIGNATURE header → HTTP 402 with PAYMENT-REQUIRED header (base64-encoded payment requirements) and JSON body.
  2. Client retries with PAYMENT-SIGNATURE header (base64-encoded PaymentPayload JSON).
  3. Bundle validates the accepted option, claims the replay id, then calls the facilitator verify endpoint.
  4. On success, payment info is stored in Request attribute _x402_payment and the controller runs. If the payment payload contains resource.url, it must exactly match the requested URL.
  5. Settlement happens according to the settle mode (see below), and when it succeeds a PAYMENT-RESPONSE header is added to the response.

Settlement timing (settle)

The settle option controls when the facilitator settle endpoint is called:

Mode Behaviour When to use
after (default) Settle in kernel.response, only if the controller returned a successful (2xx) response. The resource is produced first, then the payment is settled. If settlement fails, the response is replaced with a 402 and the replay nonce is released. Recommended. Avoids charging a payer for a resource that was never delivered.
before Settle in kernel.controller, before the controller runs. The payer is charged regardless of whether the controller then succeeds. When you want to gate purely on payment and the controller cannot meaningfully fail.
manual Only verify is performed. The verified PaymentContext is stored in _x402_context; the application must call PaymentSettlementService::settle() or release(). Advanced flows (deferred/queued settlement).

In after mode, _x402_payment carries settlement === null inside the controller, because settlement has not happened yet — the settlement result is delivered to the client via the PAYMENT-RESPONSE header. In before mode the controller already sees the populated settlement.

Note: even in after mode a tiny irreducible window remains — if settlement succeeds on-chain but delivery to the client then fails, the payer is charged without receiving the body. This is inherent to combining HTTP with on-chain settlement; after minimises it, before widens it.

HTTP headers (v2)

Header Direction Description
PAYMENT-REQUIRED Server → Client Base64-encoded PaymentRequired object
PAYMENT-SIGNATURE Client → Server Base64-encoded PaymentPayload object
PAYMENT-RESPONSE Server → Client Base64-encoded SettlementResponse object

HTTP status codes

Situation Status
Payment required 402
Invalid payload / replay / accepted mismatch 400
Verification or settlement rejected by facilitator 402
Facilitator unreachable or HTTP error 502
Missing bundle configuration (asset/amount) 500

Facilitator

The facilitator is the component that actually verifies signatures and settles payments (the cryptographic/on-chain part). This bundle never talks to a blockchain directly: it only depends on the FacilitatorClientInterface, which has two methods, verify() and settle().

There are two ways to provide a facilitator. Both are valid.

Option A — Delegate to an external HTTP facilitator (default)

This is what the bundle ships with: HttpFacilitatorClient sends the payment to a remote facilitator service over HTTP. This is where facilitator_url is used. It's the base URL of that service:

  • verify() → POST {facilitator_url}/verify
  • settle() → POST {facilitator_url}/settle

Each call POSTs a JSON body of the form { x402Version, paymentPayload, paymentRequirements } and expects the facilitator's standard verify/settle response. Use this option when you rely on a hosted or self-hosted facilitator (e.g. a TypeScript/Go implementation). In this case facilitator_url is required and is the only facilitator-related setting you need.

Option B — Implement the facilitator in PHP

facilitator_url is not used when you replace the default client. If you implement FacilitatorClientInterface yourself, you can put any logic you want inside verify()/settle() — including connecting directly to a blockchain RPC node, recovering the EIP-712 signer (EIP-3009 transferWithAuthorization), checking balances, or broadcasting the settlement transaction from PHP.

namespace App\Facilitator;

use X402\Bundle\Facilitator\FacilitatorClientInterface;
use X402\Bundle\Payment\PaymentPayload;
use X402\Bundle\Payment\PaymentRequirements;
use X402\Bundle\Payment\SettleResult;
use X402\Bundle\Payment\VerifyResult;

final class OnChainFacilitatorClient implements FacilitatorClientInterface
{
    public function verify(PaymentPayload $payload, PaymentRequirements $requirements): VerifyResult
    {
        return new VerifyResult(isValid: true, payer: '0x...');
    }

    public function settle(PaymentPayload $payload, PaymentRequirements $requirements): SettleResult
    {
        return new SettleResult(success: true, transaction: '0x...', network: $requirements->network);
    }
}

Wire it in your application so it replaces the default client:

# config/services.yaml
services:
    App\Facilitator\OnChainFacilitatorClient: ~

    X402\Bundle\Facilitator\FacilitatorClientInterface: '@App\Facilitator\OnChainFacilitatorClient'

Note: doing real EIP-712 / EIP-3009 verification and on-chain settlement in PHP is possible but non-trivial — the PHP crypto ecosystem is less mature than viem/ethers.js. Many integrations therefore prefer Option A and delegate to a dedicated facilitator service.

Replay protection

Replay protection is always enforced. The bundle derives a replay identifier from the payment payload, preferring authorization.nonce (EIP-3009), then nonce, then signature. If none of these is present, it falls back to a SHA-256 hash of the raw PAYMENT-SIGNATURE header so an exact duplicate submission is still rejected.

The identifier is reserved atomically before the facilitator is called (claim/release), which closes the double-spend window during verification/settlement. By default a FlockStore (filesystem lock) is used, which is suitable for a single server. For a multi-node deployment, configure a distributed lock store:

x402:
    replay:
        lock_store: app.redis_lock_store

The replay cache pool must also be shared by all application nodes. Lock contention is treated as a failed claim rather than blocking the request indefinitely.

Development

composer install
composer test
composer phpstan
composer cs

Limitations

  • No wallet implementation, and no built-in crypto/blockchain logic: verification and settlement are delegated to a FacilitatorClientInterface. The bundle ships only the HTTP implementation (facilitator_url); a direct on-chain implementation in PHP is possible but is up to you (see Facilitator).
  • The default replay lock store (FlockStore) is single-server; use a distributed store for multi-node setups.
  • Experimental bundle, API may change.

License

MIT

About

Symfony bundle for protecting routes with the x402 HTTP 402 Payment Required protocol

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages