Install

PHP

One file with curl and json, no Composer package. Require it at the top of any page you want to charge for.

Running WordPress? Use the plugin instead. For any other PHP site, save this as penni.php, set PENNI_SITE_ID and PENNI_API_KEY in the environment, and add require __DIR__ . '/penni.php'; at the top of the pages you want to charge for.

penni.php
<?php
// penni.php: require it at the top of every page you want to charge for.
const PENNI_API = 'https://api.pennipay.eu/v1';

function penni_call(string $method, string $path, ?array $body = null): array
{
    $ch = curl_init(PENNI_API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 90,
        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('PENNI_API_KEY'), 'Content-Type: application/json'],
        CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
    ]);
    $response = curl_exec($ch);
    curl_close($ch);
    return is_string($response) ? (json_decode($response, true) ?? []) : [];
}

function penni_payment_required(array $required, string $error): void
{
    $required['error'] = $error;
    http_response_code(402);
    header('PAYMENT-REQUIRED: ' . base64_encode(json_encode($required)));
    header('Content-Type: application/json');
    echo json_encode($required);
    exit;
}

function penni_gate(): void
{
    // 1. Price of this path (cache it for up to 60 seconds in production)
    $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
    $config = penni_call('GET', '/sites/' . getenv('PENNI_SITE_ID') . '/config?path=' . rawurlencode($path));
    if (!empty($config['blocked'])) {
        http_response_code(503);
        header('Retry-After: 300');
        exit('Payments are temporarily unavailable');
    }
    if (!isset($config['accepts']) || !empty($config['match']['free']) || $config['accepts'] === []) {
        return; // free path, or nothing to pay with: serve as usual
    }

    // 2. No payment yet: answer 402 with the price
    $url = 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
    $required = ['x402Version' => 2, 'resource' => ['url' => $url], 'accepts' => $config['accepts']];
    $header = $_SERVER['HTTP_PAYMENT_SIGNATURE'] ?? '';
    if ($header === '') {
        penni_payment_required($required, 'PAYMENT-SIGNATURE header is required');
    }

    // 3. The payment must match one of your current prices exactly
    $payment = json_decode((string) base64_decode($header, true), true);
    $accepted = is_array($payment) ? ($payment['accepted'] ?? []) : [];
    $chosen = null;
    foreach ($config['accepts'] as $option) {
        $same = true;
        foreach (['scheme', 'network', 'asset', 'payTo', 'amount'] as $key) {
            $same = $same && isset($accepted[$key]) && $option[$key] === $accepted[$key];
        }
        if ($same) {
            $chosen = $option;
            break;
        }
    }
    if ($chosen === null) {
        penni_payment_required($required, 'Payment does not match the current price');
    }

    // 4. Verify, then settle on-chain
    $body = ['x402Version' => 2, 'paymentPayload' => $payment, 'paymentRequirements' => $chosen];
    $verified = penni_call('POST', '/x402/verify', $body);
    if (empty($verified['isValid'])) {
        penni_payment_required($required, $verified['invalidReason'] ?? 'invalid_payment');
    }
    for ($attempt = 0; $attempt < 2; $attempt++) { // Cardano may answer settlement_pending once
        $settled = penni_call('POST', '/x402/settle', $body);
        if (($settled['errorReason'] ?? '') !== 'settlement_pending') {
            break;
        }
    }
    if (empty($settled['success'])) {
        penni_payment_required($required, $settled['errorReason'] ?? 'settle_failed');
    }
    header('PAYMENT-RESPONSE: ' . base64_encode(json_encode($settled)));
}

penni_gate();

For production

  • Cache the config per path for up to 60 seconds, for example in APCu or a file; the config API supports ETags.
  • Frameworks: in Laravel or Symfony, move penni_gate() into a middleware and return responses instead of calling exit.

The four calls are explained step by step in the HTTP walkthrough.