Custom payment providers
Report payments from Paddle, Lemon Squeezy or any other provider, and answer Voidhash's requests on a provider endpoint, with the PHP library.
A payment source records payments you take outside the App Store, Google Play and Stripe, so they grant access and count as revenue. This page shows how to do both halves of it with the PHP library: report what happens at your provider, and serve the endpoint Voidhash calls to import products and history, check subscriptions, cancel, refund and start checkouts.
Read Record payments from other sources first. It explains what a payment and a subscription state mean, and how to create the source and map its product keys in Studio. This page does not repeat those rules.
Before you start you need:
- The library set up with a secret key that has the
project:purchases:writescope, or full access. - The source's payment provider id. Its Payments API tab in Studio shows it in the
Transactions endpoint address, after
/payment-providers/. The examples read it fromVOIDHASH_PAYMENT_SOURCE_ID. - PHP 8.2 or newer. The payment source classes are readonly classes.
Report payments
Get a handle for the source from paymentSources. It takes the payment provider id, not the slug,
and makes no request of its own.
use Voidhash\VoidhashClient;
$voidhash = VoidhashClient::create(getenv("VOIDHASH_SECRET_KEY"));
$paddle = $voidhash->paymentSources->source(getenv("VOIDHASH_PAYMENT_SOURCE_ID"));Every report names the record by your provider's own id: the transaction id for a payment, the
subscription id for a state. Amounts are integers in minor units, and dates are
DateTimeImmutable values in any time zone.
A one-time purchase
A Charge without a subscription completes a one-time purchase of the product mapped to its
product key.
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,
));Name the buyer with distinctId, with customerId (your provider's id for the customer, sent as
customer.externalId), or with both. See
Customers without a distinct id.
A subscription payment
A charge that pays for a subscription period also names the subscription and the period. The first one starts the subscription, later ones renew it.
$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"),
));Report the subscription's state as well: the charge books revenue, the state decides access.
A refund
Report a refund your provider made under the refund's own id, naming the charge it returns money for. Its currency must be the charge's. A refund of less than the charge is partial; see Refunds.
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"),
));What comes back
charge() and refund() return a PaymentSourceTransaction:
| Getter | Returns |
|---|---|
getId() | The id you reported it under. |
getType() | charge or refund. |
getStatus() | applied when it was recorded, pending when it waits. |
getPendingReason() | product_not_mapped or charge_not_recorded while pending, else null. |
getPurchaseId() | The purchase it recorded or refunded, once applied. |
getBookedAmount() | The amount it recorded. A refund can record less than you sent. |
A pending payment needs nothing more from you: it is recorded on its own once its product key is
mapped or its charge arrives. When refunds of the same charge race, getBookedAmount() tells you
what each one actually returned.
Retry safely
Reporting is idempotent under your id. Sending the same id with the same values again returns the same result, so retry a report whenever it failed or you are unsure it arrived, for example when your provider redelivers a webhook. Sending the same id with different values throws: a recorded payment is never edited. Refund it and report the right payment under a new id instead. See Retries and corrections.
Every API error is a Voidhash\Exception\ApiException. Branch on getType() and getErrorCode().
A request that never reached Voidhash throws the HTTP client's PSR-18
Psr\Http\Client\ClientExceptionInterface instead; retrying it under the same id is safe too.
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());
}Sandbox payments
Report test payments with environment: Environment::Sandbox, from
Voidhash\PaymentSources\Environment. They grant access like real ones but stay out of production
revenue. Charge, Refund and SubscriptionState all take it; the default is
Environment::Production.
Report subscription state
Send the subscription's latest state whenever your provider changes it, under the subscription's id. A state carries no money; report each payment as a charge.
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",
));SubscriptionStatus has the cases Trialing, Active, PastDue, Canceled and Expired. Set
canceledAt with Canceled; it is when access ended. What each status does to access is described
under Subscriptions.
updatedAt orders the states. Use the time your provider last changed the subscription, not the
time you send the report, so that webhooks may arrive in any order. The call returns a
PaymentSourceSubscription:
| Getter | Returns |
|---|---|
getStatus() | applied, superseded when Voidhash already has a newer state, or pending. |
getPendingReason() | product_not_mapped or charge_not_recorded while pending, else null. |
getSubscriptionId() | Voidhash's id for the subscription, once there is one. |
A superseded state is not an error. It means a newer one arrived first, and nothing changed.
Translate your provider's webhooks
Most integrations report from the provider's webhooks. Verify the provider's signature, read the event, and turn it into the calls above. If reporting fails, answer with a 5xx so the provider redelivers; the repeated report is safe.
This sketch handles a provider with Paddle-like events. The PaddleWebhooks helper and the event's
field names stand in for your provider's SDK and payloads.
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,
},
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);Map your provider's statuses onto the five Voidhash statuses explicitly; they rarely match one to one.
Cancel and refund at the provider
cancelSubscription() and refundTransaction() work the other way round. Instead of reporting
what your provider did, they ask Voidhash to have your provider endpoint
do it, and record what the endpoint answers. They need an endpoint that implements the operation.
// 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");cancelSubscription() returns the PaymentSourceSubscription Voidhash recorded, and
refundTransaction() the refund's PaymentSourceTransaction. Do not confuse refundTransaction()
with refund(): refund() reports a refund your provider already made, refundTransaction() asks
for a new one. When your provider later sends you a webhook for the same cancellation or refund
and you report it, nothing is recorded twice.
When the endpoint cannot act, Voidhash records nothing and the call throws an ApiException
whose message says why:
getType() | getErrorCode() | Meaning |
|---|---|---|
conflict | payment_source_endpoint_not_configured | The source has no endpoint. |
conflict | payment_source_endpoint_unsupported | The endpoint does not implement the operation. |
conflict | payment_source_endpoint_rejected | The endpoint refused, such as with not_found. |
unavailable | payment_source_endpoint_unavailable | The endpoint did not answer in time or answered a 5xx. |
unavailable | payment_source_endpoint_invalid_response | The endpoint's answer broke the protocol. |
You can retry the two unavailable errors later. The conflict errors persist until you change
the endpoint or the request.
Build a provider endpoint
The endpoint is one HTTPS route that answers Voidhash's signed requests. ProviderHandler does
the protocol; you write a class that implements an interface for each operation your provider
supports. Implement only those you need.
| 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 |
CheckoutSource | createCheckout(CheckoutRequest): Checkout | checkout.create |
Every type lives in Voidhash\Provider, except SubscriptionStatus and Environment, which come
from Voidhash\PaymentSources. Requests and results are readonly classes. Every request carries
the environment Voidhash asks about, so a sandbox request can go to your provider's sandbox.
In the snippets below, $this->paddle stands for your own client for the provider's API. Add each
method to the same class, and its interface to the class's implements list.
Products
listCatalog() returns one page of products, at most 1000. Each product's key is the product
key its payments name, and kind is ProductKind::Subscription, OneTime or Consumable.
Return the next page's cursor in nextCursor, or null on the last page.
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
listHistory() pages through records created before $request->before. Voidhash asks for
HistoryKind::Subscriptions first, then HistoryKind::Transactions, with its own cursors. Return
only items of the requested kind: Subscription items for subscriptions, Transaction items for
transactions. Anything after before reaches Voidhash through your reports.
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"],
);
}Transaction has the fields of a reported charge or refund, plus id and type. Records you also
report are recorded once.
Subscriptions
getSubscription() returns a subscription's current state, or null when your provider does not
know it. Voidhash asks when a period ended without a renewal payment or a final state.
Subscription has the fields of a reported state plus id. Its renewal flag is called willRenew,
where SubscriptionState calls it 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,
},
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,
);
}Cancellations
cancelSubscription() cancels at the provider and returns the subscription's state afterwards.
$request->atPeriodEnd is true to stop renewal and keep access until the period ends, and
false to end it now. Voidhash records the state you return as if you had reported it.
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
refundTransaction() refunds the charge $request->transactionId: $request->amount in minor
units, or everything left of it when that is null. $request->reason is optional. Return the
refund as a Transaction with TransactionType::Refund and refundsTransactionId naming the
charge.
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,
);
}Checkouts
createCheckout() starts a checkout for $request->productKey and returns where to send the
buyer. The buyer is named by $request->distinctId, $request->customerId, both or neither. Pass
the distinct id on to your provider so it comes back in the payment's webhook, and report that
payment as usual. Set expiresAt when the link stops working. Checkouts always arrive as
production requests; see Test checkouts.
use Voidhash\Provider\Checkout;
use Voidhash\Provider\CheckoutRequest;
public function createCheckout(CheckoutRequest $request): Checkout
{
$session = $this->paddle->createCheckout(
priceId: $request->productKey,
customerId: $request->customerId,
customData: ["distinct_id" => $request->distinctId],
successUrl: $request->successUrl,
cancelUrl: $request->cancelUrl,
);
return new Checkout(url: $session["url"], expiresAt: new DateTimeImmutable($session["expires_at"]));
}Your web app starts the checkout with the web SDK's createCheckout(); see
Start a checkout.
Answer errors deliberately
Throw a ProviderException to fail a request with a code and message of your choice. Voidhash
receives both as they are.
ProviderErrorCode | When to use it |
|---|---|
NotFound | Your provider has nothing under the id, such as an unknown transaction. |
InvalidRequest | The input makes no sense to your provider, such as refunding too much. |
Unavailable | Your provider is down. Voidhash retries reads later. |
throw new ProviderException(ProviderErrorCode::Unavailable, "Paddle is not responding");Any other exception is answered as provider_error. Its message reaches Voidhash only for sandbox
requests; production requests get a generic message, so your internal errors stay private.
When you cancel or refund from Voidhash, NotFound and
InvalidRequest surface as payment_source_endpoint_rejected, Unavailable and other exceptions
as payment_source_endpoint_unavailable, and an invalid_result as
payment_source_endpoint_invalid_response.
What the handler does for you
Create the handler with 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));For each request it:
- checks the
x-voidhash-signatureagainst the raw body and refuses a timestamp more than 5 minutes from your server's clock, answeringinvalid_signature; - refuses a request for another source or protocol version, and input that breaks the protocol,
as
invalid_request; - answers
describewith the operations your class implements, which Test connection in Studio shows, andunsupported_operationfor the rest; - checks what your methods return before sending it. A result the protocol does not allow, such as
a page of more than 1000 items or a refund of type charge, is answered as
invalid_resultwith the field at fault.
Mount the endpoint
The handler must receive the raw request body exactly as Voidhash sent it. Don't let middleware parse and re-encode the JSON before it, or every signature check fails.
ProviderHandler is a PSR-15 RequestHandlerInterface, so any PSR-15 stack can route to it. With
Slim:
use Psr\Http\Message\ServerRequestInterface;
$app->post("/voidhash/provider", fn (ServerRequestInterface $request) => $handler->handle($request));respondToGlobals() reads php://input and the request headers, then sends the status, content
type and body.
use Voidhash\Provider\ProviderHandler;
require __DIR__ . "/../vendor/autoload.php";
$handler = new ProviderHandler("paddle", getenv("VOIDHASH_PROVIDER_SECRET"), new PaddleProvider($paddleApi));
$handler->respondToGlobals();handlePayload() takes the raw body and the headers, for frameworks that are not PSR-7. 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"),
]);
}
}Pass the handler's status through unchanged: Voidhash reads it and the error code in the body.
Test the endpoint
ProviderTester::call() sends a correctly signed request through the handler, without a server,
and returns its PSR-7 response. Give it the operation, its input, and optionally the environment.
Dates in the input may be DateTimeInterface values. Include every input field of the operation,
null where it has no value, as Voidhash does.
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::Sandbox,
);
self::assertSame(404, $response->getStatusCode());
self::assertSame("not_found", json_decode((string) $response->getBody(), true)["error"]["code"]);
}
}A successful answer is {"id": …, "result": …} and a failed one {"id": …, "error": {"code", "message"}}, with the status the code implies.
Connect it in Studio
Deploy the endpoint at a public HTTPS address. On the source's Provider endpoint tab, enter its
URL and save: Voidhash shows the signing secret once. Set it as VOIDHASH_PROVIDER_SECRET on your
server, then select Test connection. See
Connect an endpoint for timeouts, retries and
rotating the secret.
Complete example
The PHP backend example reports a stand-in provider's payments from its webhooks and serves every provider operation, with a script that signs requests the way Voidhash does.