VoidhashDocs
DevelopersHelp centerGuidesAPI reference
Dashboard
Get started
Introduction
Quickstart
How Voidhash works
Glossary
Versions and releases
Concepts
Teams, projects and environments
API keys
Products and perks
Offers
Subscription lifecycle
People and identity
Events
Features
Paywalls in your app
Feature flags and A/B tests
Paywall components
Push notifications
Ad attribution in your app
Client SDKs
React Native
Swift
Kotlin
Web
Your backend
Node.js
PHP
Go
Rust
Other languages
Payment sources
Forward store notifications
Developer tools
CLI
MCP server
Query analytics with VoidQL
PHP library
Check access
Receive webhooks
Persons and events
Custom payment providers

Custom payment providers

Report payments from Paddle, Lemon Squeezy or any other provider, and build the endpoint Voidhash calls.

PHP code for payment sources and their provider endpoint, which explain the protocol and Studio setup. Use a client whose secret key has project:purchases:write.

Report payments

source() takes the payment provider ID, not the slug, and makes no request; charge() and refund() take DateTimeImmutable dates and return a PaymentSourceTransaction.

use Voidhash\VoidhashClient;

$voidhash = VoidhashClient::create(getenv("VOIDHASH_SECRET_KEY"));
$paddle = $voidhash->paymentSources->source(getenv("VOIDHASH_PAYMENT_SOURCE_ID"));

A one-time purchase

customerId is sent as customer.externalId.

use Voidhash\PaymentSources\Charge;

$result = $paddle->charge("txn_123", new Charge(
    productKey: "LIFETIME",
    amount: 4900,
    currency: "EUR",
    occurredAt: new DateTimeImmutable("2026-10-20T09:30:00Z"),
    distinctId: "user_123",
    fee: 245,
    tax: 818,
));

A subscription payment

$paddle->charge("txn_124", new Charge(
    productKey: "pri_monthly",
    amount: 999,
    currency: "EUR",
    occurredAt: new DateTimeImmutable("2026-10-01T00:00:03Z"),
    distinctId: "user_123",
    subscriptionId: "sub_9",
    periodStart: new DateTimeImmutable("2026-10-01T00:00:00Z"),
    periodEnd: new DateTimeImmutable("2026-11-01T00:00:00Z"),
));

A refund

use Voidhash\PaymentSources\Refund;

$paddle->refund("re_1", new Refund(
    refundsTransactionId: "txn_123",
    amount: 4900,
    currency: "EUR",
    occurredAt: new DateTimeImmutable("2026-10-22T14:00:00Z"),
));

Retry safely

API errors throw Voidhash\Exception\ApiException; a request that never arrived throws the PSR-18 ClientExceptionInterface.

use Voidhash\Exception\ApiException;

try {
    $paddle->charge("txn_123", $charge);
} catch (ApiException $error) {
    if ($error->getErrorCode() !== "transaction_conflict") {
        throw $error; // Retry rate_limited, unavailable and internal later, under the same id.
    }
    // 409 conflict: txn_123 was already recorded with other values. Retrying cannot help.
    error_log("txn_123 conflicts: " . $error->getMessage());
}

Report subscription state

subscription() returns a PaymentSourceSubscription; set canceledAt with SubscriptionStatus::Canceled.

use Voidhash\PaymentSources\SubscriptionState;
use Voidhash\PaymentSources\SubscriptionStatus;

$state = $paddle->subscription("sub_9", new SubscriptionState(
    productKey: "pri_monthly",
    status: SubscriptionStatus::Active,
    currentPeriodStart: new DateTimeImmutable("2026-10-01T00:00:00Z"),
    currentPeriodEnd: new DateTimeImmutable("2026-11-01T00:00:00Z"),
    isAutoRenewing: true,
    updatedAt: new DateTimeImmutable("2026-10-01T00:00:05Z"),
    distinctId: "user_123",
));

Translate your provider's webhooks

PaddleWebhooks and the field names stand in for your provider's SDK.

public/billing-webhook.php
use Voidhash\PaymentSources\Charge;
use Voidhash\PaymentSources\Refund;
use Voidhash\PaymentSources\SubscriptionState;
use Voidhash\PaymentSources\SubscriptionStatus;

$event = PaddleWebhooks::verify(file_get_contents("php://input"), getallheaders());
$data = $event["data"];

match ($event["type"]) {
    "transaction.completed" => $paddle->charge($data["id"], new Charge(
        productKey: $data["price_id"],
        amount: $data["total"],
        currency: $data["currency"],
        occurredAt: new DateTimeImmutable($data["billed_at"]),
        distinctId: $data["custom_data"]["distinct_id"] ?? null,
        customerId: $data["customer_id"],
        fee: $data["fee"],
        tax: $data["tax"],
        subscriptionId: $data["subscription_id"] ?? null,
        periodStart: isset($data["period"]) ? new DateTimeImmutable($data["period"]["starts_at"]) : null,
        periodEnd: isset($data["period"]) ? new DateTimeImmutable($data["period"]["ends_at"]) : null,
    )),
    "refund.completed" => $paddle->refund($data["id"], new Refund(
        refundsTransactionId: $data["transaction_id"],
        amount: $data["amount"],
        currency: $data["currency"],
        occurredAt: new DateTimeImmutable($data["completed_at"]),
    )),
    "subscription.updated" => $paddle->subscription($data["id"], new SubscriptionState(
        productKey: $data["price_id"],
        status: match ($data["status"]) {
            "trialing" => SubscriptionStatus::Trialing,
            "active" => SubscriptionStatus::Active,
            "past_due" => SubscriptionStatus::PastDue,
            "paused", "canceled" => SubscriptionStatus::Canceled,
            // Fail loudly so the provider retries once you map a new status.
            default => throw new UnexpectedValueException("Unmapped Paddle status: {$data["status"]}"),
        },
        currentPeriodStart: new DateTimeImmutable($data["current_period"]["starts_at"]),
        currentPeriodEnd: new DateTimeImmutable($data["current_period"]["ends_at"]),
        isAutoRenewing: $data["scheduled_change"] === null,
        updatedAt: new DateTimeImmutable($data["updated_at"]),
        customerId: $data["customer_id"],
        canceledAt: isset($data["canceled_at"]) ? new DateTimeImmutable($data["canceled_at"]) : null,
    )),
    default => null,
};

http_response_code(200);

Cancel and refund at the provider

When the endpoint can't act, these throw an ApiException.

// At the end of the paid period, or now with isImmediate: true.
$state = $paddle->cancelSubscription("sub_9");
$state = $paddle->cancelSubscription("sub_9", isImmediate: true);

// Everything left of the charge, or an amount in minor units, with an optional reason.
$refund = $paddle->refundTransaction("txn_123");
$refund = $paddle->refundTransaction("txn_123", amount: 1000, reason: "requested_by_customer");

Build a provider endpoint

Implement an interface from Voidhash\Provider for each operation your provider supports.

InterfaceMethodOperation
CatalogSourcelistCatalog(CatalogRequest): CatalogPagecatalog.list
HistorySourcelistHistory(HistoryRequest): HistoryPagehistory.list
SubscriptionSourcegetSubscription(SubscriptionRequest): ?Subscriptionsubscription.get
SubscriptionCancellationcancelSubscription(CancelSubscriptionRequest): Subscriptionsubscription.cancel
TransactionRefundsrefundTransaction(RefundTransactionRequest): Transactiontransaction.refund

Products

src/PaddleProvider.php
use Voidhash\Provider\CatalogPage;
use Voidhash\Provider\CatalogProduct;
use Voidhash\Provider\CatalogRequest;
use Voidhash\Provider\CatalogSource;
use Voidhash\Provider\ProductKind;

final class PaddleProvider implements CatalogSource
{
    public function __construct(private readonly PaddleApi $paddle)
    {
    }

    public function listCatalog(CatalogRequest $request): CatalogPage
    {
        $page = $this->paddle->listPrices(after: $request->cursor);

        return new CatalogPage(
            items: array_map(
                fn (array $price) => new CatalogProduct(
                    key: $price["id"],
                    name: $price["description"],
                    kind: $price["billing_cycle"] === null ? ProductKind::OneTime : ProductKind::Subscription,
                ),
                $page["data"],
            ),
            nextCursor: $page["next"],
        );
    }
}

History

use Voidhash\Provider\HistoryKind;
use Voidhash\Provider\HistoryPage;
use Voidhash\Provider\HistoryRequest;
use Voidhash\Provider\Transaction;
use Voidhash\Provider\TransactionType;

public function listHistory(HistoryRequest $request): HistoryPage
{
    if ($request->kind === HistoryKind::Subscriptions) {
        $page = $this->paddle->listSubscriptions(createdBefore: $request->before, after: $request->cursor);

        return new HistoryPage(array_map($this->toSubscription(...), $page["data"]), $page["next"]);
    }

    $page = $this->paddle->listTransactions(createdBefore: $request->before, after: $request->cursor);

    return new HistoryPage(
        items: array_map(fn (array $txn) => new Transaction(
            id: $txn["id"],
            type: $txn["refunds"] === null ? TransactionType::Charge : TransactionType::Refund,
            amount: $txn["total"],
            currency: $txn["currency"],
            occurredAt: new DateTimeImmutable($txn["billed_at"]),
            customerId: $txn["customer_id"],
            productKey: $txn["price_id"],
            subscriptionId: $txn["subscription_id"],
            refundsTransactionId: $txn["refunds"],
        ), $page["data"]),
        nextCursor: $page["next"],
    );
}

Subscriptions

Subscription says willRenew where SubscriptionState says isAutoRenewing.

use Voidhash\PaymentSources\SubscriptionStatus;
use Voidhash\Provider\Subscription;
use Voidhash\Provider\SubscriptionRequest;

public function getSubscription(SubscriptionRequest $request): ?Subscription
{
    $subscription = $this->paddle->findSubscription($request->id);

    return $subscription === null ? null : $this->toSubscription($subscription);
}

private function toSubscription(array $sub): Subscription
{
    return new Subscription(
        id: $sub["id"],
        productKey: $sub["price_id"],
        status: match ($sub["status"]) {
            "trialing" => SubscriptionStatus::Trialing,
            "active" => SubscriptionStatus::Active,
            "past_due" => SubscriptionStatus::PastDue,
            "paused", "canceled" => SubscriptionStatus::Canceled,
            default => throw new UnexpectedValueException("Unmapped Paddle status: {$sub["status"]}"),
        },
        currentPeriodStart: new DateTimeImmutable($sub["current_period"]["starts_at"]),
        currentPeriodEnd: new DateTimeImmutable($sub["current_period"]["ends_at"]),
        willRenew: $sub["scheduled_change"] === null,
        updatedAt: new DateTimeImmutable($sub["updated_at"]),
        customerId: $sub["customer_id"],
        canceledAt: isset($sub["canceled_at"]) ? new DateTimeImmutable($sub["canceled_at"]) : null,
    );
}
use Voidhash\Provider\CancelSubscriptionRequest;
use Voidhash\Provider\ProviderErrorCode;
use Voidhash\Provider\ProviderException;
use Voidhash\Provider\Subscription;

public function cancelSubscription(CancelSubscriptionRequest $request): Subscription
{
    $subscription = $this->paddle->cancelSubscription(
        $request->id,
        effectiveFrom: $request->atPeriodEnd ? "next_billing_period" : "immediately",
    ) ?? throw new ProviderException(ProviderErrorCode::NotFound, "No subscription {$request->id}");

    return $this->toSubscription($subscription);
}

Refunds

use Voidhash\Provider\RefundTransactionRequest;
use Voidhash\Provider\Transaction;
use Voidhash\Provider\TransactionType;

public function refundTransaction(RefundTransactionRequest $request): Transaction
{
    $refund = $this->paddle->refund($request->transactionId, $request->amount, $request->reason);

    return new Transaction(
        id: $refund["id"],
        type: TransactionType::Refund,
        amount: $refund["amount"],
        currency: $refund["currency"],
        occurredAt: new DateTimeImmutable($refund["created_at"]),
        refundsTransactionId: $request->transactionId,
    );
}

Answer errors deliberately

Throw a ProviderException with ProviderErrorCode::NotFound, InvalidRequest or Unavailable.

throw new ProviderException(ProviderErrorCode::Unavailable, "Paddle is not responding");

Create the handler

ProviderHandler takes the source's slug, the endpoint signing secret and your class.

use Voidhash\Provider\ProviderHandler;

$handler = new ProviderHandler("paddle", getenv("VOIDHASH_PROVIDER_SECRET"), new PaddleProvider($paddleApi));

Mount the endpoint

Pass the raw body and the handler's status through unchanged.

ProviderHandler is a PSR-15 RequestHandlerInterface.

public/index.php
use Psr\Http\Message\ServerRequestInterface;

$app->post("/voidhash/provider", fn (ServerRequestInterface $request) => $handler->handle($request));

respondToGlobals() reads php://input and sends the answer.

public/voidhash-provider.php
use Voidhash\Provider\ProviderHandler;

require __DIR__ . "/../vendor/autoload.php";

$handler = new ProviderHandler("paddle", getenv("VOIDHASH_PROVIDER_SECRET"), new PaddleProvider($paddleApi));
$handler->respondToGlobals();

Bind ProviderHandler as a singleton and exclude the route from CSRF protection.

routes/api.php
use Illuminate\Http\Request;
use Voidhash\Provider\ProviderHandler;

Route::post("/voidhash/provider", function (Request $request, ProviderHandler $handler) {
    $answer = $handler->handlePayload($request->getContent(), $request->headers->all());

    return response((string) $answer->getBody(), $answer->getStatusCode())
        ->header("content-type", $answer->getHeaderLine("content-type"));
});
src/Controller/VoidhashProviderController.php
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Voidhash\Provider\ProviderHandler;

final class VoidhashProviderController
{
    public function __construct(private readonly ProviderHandler $handler)
    {
    }

    #[Route("/voidhash/provider", methods: ["POST"])]
    public function __invoke(Request $request): Response
    {
        $answer = $this->handler->handlePayload($request->getContent(), $request->headers->all());

        return new Response((string) $answer->getBody(), $answer->getStatusCode(), [
            "content-type" => $answer->getHeaderLine("content-type"),
        ]);
    }
}

Test your endpoint

ProviderTester::call() sends a signed request through the handler and returns its PSR-7 response.

tests/PaddleProviderTest.php
use PHPUnit\Framework\TestCase;
use Voidhash\PaymentSources\Environment;
use Voidhash\Provider\ProviderHandler;
use Voidhash\Provider\ProviderTester;

final class PaddleProviderTest extends TestCase
{
    public function testItListsTheCatalog(): void
    {
        $handler = new ProviderHandler("paddle", "vhps_test", new PaddleProvider(new FakePaddleApi()));

        $response = ProviderTester::call($handler, "catalog.list", ["cursor" => null]);
        $body = json_decode((string) $response->getBody(), true);

        self::assertSame(200, $response->getStatusCode());
        self::assertSame(["key" => "pri_monthly", "name" => "Pro monthly", "kind" => "subscription"], $body["result"]["items"][0]);
    }

    public function testAnUnknownChargeIsNotFound(): void
    {
        $handler = new ProviderHandler("paddle", "vhps_test", new PaddleProvider(new FakePaddleApi()));

        $response = ProviderTester::call(
            $handler,
            "transaction.refund",
            ["transactionId" => "txn_missing", "amount" => null, "reason" => null],
            Environment::Test,
        );

        self::assertSame(404, $response->getStatusCode());
        self::assertSame("not_found", json_decode((string) $response->getBody(), true)["error"]["code"]);
    }
}

Connect it in Studio

Set the signing secret as VOIDHASH_PROVIDER_SECRET and connect the endpoint.

Next steps

  • PHP backend example
  • Payment sources
  • Receive webhooks
  • Check access

Something wrong or missing on this page? Tell us by email or on Discord.

PreviousPersons and eventsNextGo library

On this page

Report paymentsA one-time purchaseA subscription paymentA refundRetry safelyReport subscription stateTranslate your provider's webhooksCancel and refund at the providerBuild a provider endpointProductsHistorySubscriptionsRefundsAnswer errors deliberatelyCreate the handlerMount the endpointTest your endpointConnect it in StudioNext steps