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.
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.
| Interface | Method | Operation |
|---|---|---|
CatalogSource | listCatalog(CatalogRequest): CatalogPage | catalog.list |
HistorySource | listHistory(HistoryRequest): HistoryPage | history.list |
SubscriptionSource | getSubscription(SubscriptionRequest): ?Subscription | subscription.get |
SubscriptionCancellation | cancelSubscription(CancelSubscriptionRequest): Subscription | subscription.cancel |
TransactionRefunds | refundTransaction(RefundTransactionRequest): Transaction | transaction.refund |
Products
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.
use Psr\Http\Message\ServerRequestInterface;
$app->post("/voidhash/provider", fn (ServerRequestInterface $request) => $handler->handle($request));respondToGlobals() reads php://input and sends the answer.
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.
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"));
});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.
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
Something wrong or missing on this page? Tell us by email or on Discord.