Writing your own gateway
Everything needed to add a payment gateway this package doesn’t ship with — in your own app, or as a satellite package (fomvasss/laravel-billing-yourgateway). No core changes required either way.
The six built-in drivers (src/Gateways/) are the reference implementations; each one is a real, verified integration, so reading the closest match to your gateway is usually faster than starting from scratch.
Before you write code: verify against the official source
Section titled “Before you write code: verify against the official source”The single most expensive class of bug here is a wrong signature formula or a misread field name, and it’s almost always caused by trusting the wrong source. Two real examples from building this package’s own drivers:
- LiqPay’s own docs contradict themselves. The prose on the token-payment page says the signature is
sha3-256; every code sample on that same page (bash, php, java, python, ruby, go, …) usessha1. The SDK confirmssha1. We hit this twice, from two different pages, months apart. - Hutko looked like it had no tokenization at all. The official WooCommerce plugin — which is what we used as the reference, since the docs site doesn’t render for a plain fetch — has no card-attach call anywhere. Conclusion drawn: “no tokenization”. Wrong: it’s in the docs, as
rectokenin the shared response schema. “The reference implementation doesn’t show X” only proves X isn’t in that reference.
So, in order of trustworthiness: official SDK source > official docs code samples > official docs prose > someone else’s integration. When two disagree, the SDK wins.
Practical note: several Ukrainian gateway doc sites are JS-rendered and return an empty shell to curl/fetch. https://r.jina.ai/<url> renders them; curl against the SDK’s GitHub raw files works for the rest.
The required contract
Section titled “The required contract”Six methods, Fomvasss\Billing\Contracts\PaymentGatewayContract — two that do the work, four static ones that describe the gateway to a settings UI. That’s the whole entry ticket:
use Fomvasss\Billing\Contracts\PaymentGatewayContract;use Fomvasss\Billing\DTO\ChargeOptions;use Fomvasss\Billing\DTO\PaymentResult;use Fomvasss\Billing\DTO\WebhookResult;use Fomvasss\Billing\Models\Payment;use Fomvasss\Billing\Webhooks\BillingWebhookCall;
class AcmePayGateway implements PaymentGatewayContract{ public function charge(Payment $payment, ChargeOptions $options = new ChargeOptions()): PaymentResult; public function handleWebhook(BillingWebhookCall $webhookCall): WebhookResult;
public static function label(): string; // human name for a settings UI public static function credentialFields(): array; // field schema, see below public static function supportedCurrencies(): array; // ISO codes this gateway accepts public static function requiresDashboardWebhook(): bool; // true only if webhooks must be registered in the gateway's dashboard (Stripe-style)}Extend AbstractGateway instead of implementing the interface directly and you get a constructor ($credentials, $gatewayName), a debug logger, a requiresDashboardWebhook() default (false — most gateways take the callback URL per charge request), and the webhookUrl()/successUrl()/failUrl()/linkTtlMinutes()/persistPaymentMethod()/paidAmountMismatch()/feeFrom()/findPaymentByReference() helpers. Both paths are equally supported — the base class just saves boilerplate.
charge()
Section titled “charge()”Create the checkout on the gateway’s side and return where to send the customer:
public function charge(Payment $payment, ChargeOptions $options = new ChargeOptions()): PaymentResult{ $data = $this->http()->post('/checkout', [ 'amount' => $payment->amount, // ALWAYS minor units — see "Money" below 'currency' => $payment->currency, 'reference' => (string) $payment->id, // so the webhook can find this row again 'callback_url' => $this->webhookUrl($options), 'return_url' => $this->successUrl($payment, $options), 'description' => $options->description, ])->throw()->json();
return new PaymentResult( url: $data['checkout_url'], expiresAt: now()->addMinutes($this->linkTtlMinutes(60)), // see "Checkout-link TTL" below externalId: $data['id'], // written back onto $payment->external_id );}Checkout-link TTL. Fill expiresAt with a real value whenever you possibly can — hasActivePaymentUrl() and the permanent pay link’s re-issue logic (billing.pay) rest on it; a null means “unknown”, which the package has to treat as “still alive”, so a customer can be redirected to a checkout the gateway already killed. The convention the built-in drivers follow:
- If the gateway accepts a lifetime parameter in the request (Monobank
validity, WayForPayorderLifetime, Hutkolifetime) — send it, sourced fromAbstractGateway::linkTtlMinutes($default)(readslink_ttl_minutesfrom the driver’s credentials), and mirror the same value intoexpiresAt. Addlink_ttl_minutesto yourcredentialFields()so it’s configurable. - If the gateway reports the expiry itself (Stripe’s session
expires_at) — pass that through. - Only fall back to
expiresAt: nullwhen the gateway genuinely gives you no way to set or learn the lifetime.
$payment is passed rather than a bare amount because the driver needs $payment->id as the merchant reference it hands the gateway — that’s what makes the later webhook resolvable back to this row.
url vs form. Most gateways have a server-side “create checkout, get a link” call — return url. A few only accept a browser-submitted POST with signed fields and have no such call (LiqPay is the one built-in example) — return form: ['action' => ..., 'fields' => [...]] instead. BillingManager::charge() handles both, and either way $payment->payment_url ends up a plain redirectable link, so consumers never branch on which kind you returned.
Money is always minor units in $payment->amount (kopiykas/cents), package-wide. If your gateway wants decimal major units, convert inside the driver (number_format($minorUnits / 100, 2, '.', '')), never push that onto the caller.
handleWebhook()
Section titled “handleWebhook()”Runs queued, long after the HTTP request is gone — you get the stored payload, not a live Request. Signature verification already happened synchronously (see below), so by this point the payload is trusted.
public function handleWebhook(BillingWebhookCall $webhookCall): WebhookResult{ $payload = $webhookCall->payload;
// A callback for a payment this package didn't create (another integration on the same // merchant account) is Ignored, not a failed job. $payment = $this->findPaymentByReference($payload['reference'] ?? null);
if ($payment === null) { return new WebhookResult(type: WebhookEventType::Ignored, status: 'ignored', raw: $payload); }
$status = match ($payload['status'] ?? null) { 'approved' => PaymentStatus::Paid, 'declined' => PaymentStatus::Failed, 'expired' => PaymentStatus::Canceled, default => null, // intermediate/unknown — not a terminal state };
// A signed "paid" callback whose sum doesn't match this row (a stale checkout link paid after // the amount was edited) must not mark it paid. AbstractGateway ships this helper. if ($status === PaymentStatus::Paid && $this->paidAmountMismatch( $payment, isset($payload['amount']) ? (int) $payload['amount'] : null, // convert to minor units if your gateway sends decimals $payload['currency'] ?? null, )) { return new WebhookResult(type: WebhookEventType::Ignored, status: 'ignored', raw: $payload); }
if ($status === null) { return new WebhookResult(type: WebhookEventType::Ignored, status: 'ignored', raw: $payload); }
// transitionTo(), not update(): it refuses to move an already-paid row to any other status, // so a late/out-of-order delivery can't revert a paid payment. It returns false when it // refuses — report that delivery as Ignored. if (! $payment->transitionTo($status, [ 'external_id' => $payload['id'], // Optional: if the callback reports the gateway's commission, record it. feeFrom() // returns ['fee' => <minor units>] when the value is numeric, [] otherwise — an // unreported fee stays null ("unknown"), never a guessed 0. Pass decimal: true when // your gateway sends decimal amounts. ...$this->feeFrom($payload['fee'] ?? null), ])) { return new WebhookResult(type: WebhookEventType::Ignored, status: 'ignored', raw: $payload); }
return new WebhookResult( type: WebhookEventType::Payment, status: $status === PaymentStatus::Paid ? 'succeeded' : 'failed', payment: $payment, externalId: $payload['id'], // part of the dedup key, see below raw: $payload, );}Things worth getting right:
- Update the
Paymentyourself, throughtransitionTo(). The dispatcher fires events from yourWebhookResult, but it doesn’t write the status — the driver does. UsePayment::transitionTo($status, $attributes)rather than a bareupdate(): gateway deliveries are neither ordered nor unique, and it enforces the one invariant that matters — apaidrow is never moved tofailed/canceledby a late callback. It writes nothing and returnsfalsewhen it refuses, which is your cue to return anIgnoredresult. externalIdfeeds the dedup key. Return the gateway’s own reference for this event; the pipeline combines it with the event’s type+status (WebhookResult::dedupKey()) and claims it against aunique(name, external_id)index onbilling_webhook_calls. Net effect: a re-delivered “paid” callback never firesPaymentSucceededtwice, but “declined, then the customer retried the same checkout and paid” — the same reference with a different outcome — dispatches both events. You don’t have to make the reference unique per attempt.- Unknown/intermediate statuses are
Ignored, not errors. Gateways send more states than the package’s four (pending/paid/failed/canceled); anything non-terminal just means “nothing to do yet”. - Verify the paid amount (
paidAmountMismatch(), shown above) whenever the callback carries one. - Unknown references are
Ignoredtoo —AbstractGateway::findPaymentByReference(), neverfindOrFail()or a barePayment::find(): your merchant account may serve other integrations, and their callbacks must not become failed jobs. The helper also guards the reference against the uuid PK — on PostgreSQL a barefind('their-order-123')throws a cast error instead of returning null.
credentialFields()
Section titled “credentialFields()”The field schema an admin settings UI renders, and the same keys the default resolver reads from config('billing.gateways.acmepay.*'):
public static function credentialFields(): array{ return [ ['name' => 'api_key', 'type' => 'text', 'secret' => true, 'help' => 'API key from the AcmePay dashboard'], ['name' => 'merchant_id', 'type' => 'text', 'secret' => false, 'help' => 'Merchant ID'], ];}Read credentials via $this->credentials['api_key'] — never config() directly inside the driver, or you break per-tenant credential resolution.
Signature validation
Section titled “Signature validation”A separate class, run synchronously in WebhookController before the call is stored or queued:
use Fomvasss\Billing\Contracts\CredentialResolverContract;use Fomvasss\Billing\Contracts\SignatureValidator;use Fomvasss\Billing\Support\WebhookTenant;
class AcmePaySignatureValidator implements SignatureValidator{ public function isValid(Request $request): bool { // The name this webhook came in on, not a hardcoded 'acmepay': your gateway may be // registered twice (two merchant accounts), each name with its own credentials. $gateway = $request->route('gateway') ?? 'acmepay'; $secret = app(CredentialResolverContract::class)->resolve($gateway, WebhookTenant::fromRequest($request))['api_key'] ?? null;
// Fail closed: the webhook route exists even when the gateway isn't configured — with no // key an attacker could compute the "signature" themselves. if (! is_string($secret) || $secret === '') { return false; }
$expected = hash_hmac('sha256', $request->getContent(), $secret);
return hash_equals($expected, $request->header('X-Signature', '')); }}Things the built-in drivers learned the hard way:
- Fail closed on a missing secret — return
false, never verify against an empty string. - Use
hash_equals(), not===, for signature comparison. - Read fields through
Support\WebhookPayload::fromRequest(), not$request->input()/all(), if your signature covers body fields rather than the raw content. Two real reasons: WayForPay POSTs raw JSON under a form content type (the framework-parsed body is one garbled key), and$request->all()merges query-string extras (the package’swebhookUrlParamsrouting hints) into payload-wide signature schemes like Hutko’s. - If the gateway publishes a rotating key, cache it and retry once on failure rather than refetching per webhook — and throttle the refetch so a flood of garbage signatures can’t hammer the gateway’s API.
MonobankSignatureValidatoris the worked example. - Resolve the secret via
CredentialResolverContract, notconfig()directly — so a host that binds its own resolver keeps webhook verification working. Pass the gateway name from the route ($request->route('gateway')) and the tenant hint (WebhookTenant::fromRequest($request)), never literals: a second merchant account registered asacmepay_shop2, or a second tenant, would otherwise be verified against the first one’s secret and get a 403.
Note the asymmetry: this class runs before the payload is trusted, so it can’t look up per-tenant credentials keyed by anything in the payload. A genuinely multi-merchant setup needs a per-tenant webhook URL — see the note in MonobankSignatureValidator.
Registration
Section titled “Registration”One call, in your ServiceProvider::boot():
use Fomvasss\Billing\Facades\Billing;
Billing::extend('acmepay', AcmePayGateway::class) ->registerWebhook('acmepay', AcmePaySignatureValidator::class);That’s it — no route to declare, no config file to touch. Every gateway shares one wildcard route (POST /billing/webhooks/{gateway}), resolved through this registry at request time.
A class name, not a closure, so the package can call label()/credentialFields()/supportedCurrencies() statically — a settings UI can list your gateway before any credentials exist.
Custom webhook acknowledgment
Section titled “Custom webhook acknowledgment”Most gateways accept a bare 200. If yours needs a specific body, pass a third argument:
Billing::extend('acmepay', AcmePayGateway::class) ->registerWebhook('acmepay', AcmePaySignatureValidator::class, AcmePayWebhookResponder::class);use Fomvasss\Billing\Contracts\WebhookResponder;
class AcmePayWebhookResponder implements WebhookResponder{ public function respond(Request $request): Response { return response()->json(['received' => true]); }}Worth checking your gateway’s docs for this specifically — WayForPay retries a callback for four days if it doesn’t get back its expected signed acknowledgment, and a bare 200 looks like success from your side while the gateway thinks delivery failed. See WayForPayWebhookResponder.
Optional capabilities
Section titled “Optional capabilities”Implement only what your gateway actually does. BillingManager checks with instanceof and throws a clear NotSupportedException otherwise — never a “call to undefined method”.
| Contract | Add when the gateway supports |
|---|---|
RefundsPayments | Refunds via API (many require doing it by hand in the dashboard — then don’t implement this). Your refund() only makes the gateway call — the child Payment row and the PaymentRefunded event are BillingManager::refund()’s job |
ChecksPaymentStatus | Polling a payment’s current status — used by billing:reconcile-pending-payments as the fallback for a lost webhook |
ChecksGatewayHealth | A live, side-effect-free credentials/reachability probe (Billing::health(), billing:health). No introspection endpoint? Probe with the status of a nonexistent payment and tell “order not found” (credentials fine) apart from “invalid signature” — verify the discriminating error codes against the live API, the built-in drivers document theirs |
TokenizesPaymentMethod | Saved cards / off-session recurring charges |
StartsProviderSubscriptions, ManagesProviderSubscriptions | Subscriptions run on the gateway’s own side (Paddle, Stripe Billing) |
HasReceiptItems | (on your Payable, not the driver) fiscal basket line items — auto-fills into charge() AND chargePaymentMethod() alike, so build the basket in chargePaymentMethod() too if the gateway has one (array_filters the basket key away when $options->receiptItems is empty, same as charge()) |
Provider-managed subscriptions
Section titled “Provider-managed subscriptions”For gateways that run the subscription lifecycle themselves (Paddle, Stripe Billing). The row mirrors the provider; the package’s schedulers and its payment-outcome listener leave it alone once subscriptions.external_id is set (Subscription::isProviderManaged()).
StartsProviderSubscriptions::startSubscription($payment, $options)— called byBilling::startSubscription(), which does the same checkout bookkeeping ascharge().$payment->payableis the Subscription (incomplete); build the provider’s recurring checkout from its price and put the subscription’s id where the provider will echo it back.- Link and mirror through a snapshot. From
handleWebhook(), returnWebhookResult(type: Subscription, status: 'synced', subscription: $row, snapshot: new SubscriptionSnapshot(...), externalId: <the provider's EVENT id>). The dispatcher calls$row->applyProviderSnapshot()inside the dedup claim, which writes the state and fires the events from what changed — never write the row or fire subscription events yourself. The first snapshot carries the provider’s subscription id and is what links the row. Find rows withfindProviderSubscription()(by the provider’s id) orfindSubscriptionByReference()(by ours, before the link exists). externalIdmust identify the event, not the subscription — dedup is pertype:status:externalId, and one subscription reportssyncedmany times.currentPeriodEndsAtis the end of the PAID period. Moving it forward is what firesSubscriptionRenewed; a provider that advances its period before collecting (Paddle does) should report the new end only once the renewal is paid.occurredAtis the provider’s own timestamp for the state — a snapshot older than the last one applied is dropped. For the answers to forwarded calls, use the provider’s timestamp too if it has one.ManagesProviderSubscriptions—cancel(),pause(),resume(),swapPrice(): make the provider call, return the state it reports.Subscription::cancel()etc. apply it. ThrowNotSupportedExceptionfor what the provider can’t do.- Renewal payments are new
Paymentrows (payable = the Subscription), one per renewal charge. The package won’t advance the period from theirPaymentSucceeded— the snapshot does that.
SubscriptionGatewayContract is deprecated: its synchronous createSubscription() can’t fit a checkout-based provider, and nothing ever called it.
ChecksPaymentStatus
Section titled “ChecksPaymentStatus”Cheap to add and genuinely valuable — without it, a payment whose webhook got lost stays pending forever. Update the Payment inside checkStatus(), same as handleWebhook() does; returning a WebhookResult without persisting is a bug we shipped once and had to fix across four drivers at the same time.
ChecksGatewayHealth
Section titled “ChecksGatewayHealth”One method — a live, side-effect-free probe answering “do the configured credentials work and is the API up”. It powers Billing::health(), the billing:health command and the capabilities.health flag a settings UI reads to show a “test connection” button. Wrap the probe in AbstractGateway::probeHealth() — it times the call and turns any throw into a down() result, so a health check itself can never explode:
public function healthCheck(): GatewayHealth{ return $this->probeHealth(function () { // Best case: the gateway has an introspection endpoint (Stripe /v1/balance, // Monobank /api/merchant/details) — call it, return an "up" detail message: $data = $this->http()->retry(1)->get('/merchant/info')->throw()->json();
return $data['merchant_name'] ?? null; });}No introspection endpoint? Probe with the status of a nonexistent payment and discriminate by the error: an “order not found”-shaped response proves the request was authenticated and understood (credentials fine → return a message), an “invalid signature”-shaped one proves it wasn’t (→ throw with the gateway’s reason). Two hard rules: the probe must never create anything on the gateway’s side, and the discriminating error codes must be verified against the live API, not assumed from docs — the built-in LiqPay/WayForPay/Hutko drivers document their live-verified pairs (payment_not_found vs invalid_signature, 1127 vs 1113, 1018 vs 1014) as worked examples.
TokenizesPaymentMethod
Section titled “TokenizesPaymentMethod”Three shapes exist in the wild, and which one you get decides how you write it:
- Synchronous frontend token (Stripe’s SetupIntent path): the frontend SDK hands your backend a token,
attachPaymentMethod()persists it directly and dispatchesPaymentMethodAttacheditself. (Stripe also supports the no-frontend shape — hosted checkout withsetup_future_usage, the method pulled off the session’s intent insidehandleWebhook()— seeStripeGateway::attachFromCheckoutSession()for the worked example of a webhook-side attach that needs a follow-up API call.) - Async, separate webhook delivery: the token arrives in its own webhook, distinct from the payment-status one. Return
WebhookResult(type: PaymentMethod, status: 'attached', paymentMethod: $method, externalId: ...)fromhandleWebhook()and letWebhookResultDispatcherfire the event. (Monobank looks like this — the token comes in a later delivery of the invoice webhook — but nothing guarantees the token and the payment status arrive separately, so its driver treats it as shape 3.) - Async, same delivery as the payment status (LiqPay, WayForPay, Hutko, Monobank): the token rides along in the payment-status callback. That
WebhookResultis already reporting thePaymentoutcome, so persist the method as a side effect and dispatchPaymentMethodAttacheddirectly — there’s no second return value for the dispatcher to work with. Guard the dispatch with$method->wasRecentlyCreated: a direct dispatch runs before the job-level dedup claim, so without the guard a re-delivered callback fires the event again.
Use AbstractGateway::persistPaymentMethod() for the actual row-writing in all three cases; it demotes the previous default and upserts on (gateway, billable_type, billable_id, external_customer_id, external_id) — the billable is part of the key, since one physical card may be saved by two billables — deliberately without dispatching, precisely because the three cases dispatch at different times.
HTTP calls
Section titled “HTTP calls”Always bounded, never a bare Http::post() — a hung gateway shouldn’t hang a queue worker:
protected function http(): PendingRequest{ return Http::baseUrl(self::BASE_URL) ->withToken($this->credentials['api_key']) ->timeout(15) ->retry(2, 200);}One exception worth knowing: never retry anything that moves money — a charge or a refund — unless the gateway supports idempotency keys. Laravel’s retry() fires on a ConnectionException too, and a timeout tells you nothing about whether the bank already debited the card, so the second attempt is a second debit. Override the shared retry per call:
$response = $this->http()->retry(1)->post('/charge', $fields); // no idempotency key available$response = $this->http() // key available — retrying is safe ->withHeaders(['Idempotency-Key' => 'charge-' . $payment->id]) ->post('/charge', $fields);All built-in drivers do this. StripeGateway sends idempotency keys instead: a fresh one per refund() call (two deliberate partial refunds of the same amount both go through), charge-{payment id} for off-session charges, a fresh one per plan swap.
Relatedly, a declined card is a business outcome, not a transport failure — return it as a PaymentResult rather than throwing, so the caller can record the failure. Only genuine wiring errors (bad credentials, malformed request) should throw.
Testing without real credentials
Section titled “Testing without real credentials”You don’t need a merchant account to have real confidence. What the built-in drivers do:
Http::fake(['https://api.acmepay.test/checkout' => Http::response(['checkout_url' => 'https://pay.acmepay.test/abc', 'id' => 'tx_1'])]);
$result = Billing::driver('acmepay')->charge($payment);
$this->assertSame('https://pay.acmepay.test/abc', $result->url);Http::assertSent(fn ($request) => $request['reference'] === (string) $payment->id && $request['amount'] === 10000);And for the signature validator, recompute the expected signature independently in the test — don’t call the driver’s own sign() on both sides, or you’re only asserting that a function equals itself:
$body = json_encode(['status' => 'approved', 'reference' => $payment->id]);$signature = hash_hmac('sha256', $body, 'test-key'); // computed here, in the test
$response = $this->call('POST', route('billing.webhook', ['gateway' => 'acmepay']), [], [], [], [ 'HTTP_X-Signature' => $signature,], $body);
$response->assertOk();Add a tamper case (flip a byte in the body, expect rejection) and, if the gateway has a replay window, a stale-timestamp case.
Checklist
Section titled “Checklist”- Signature formula verified against the official SDK, not just docs prose
-
amountconverted if the gateway wants major units -
charge()puts$payment->idwhere the webhook will echo it back - If the gateway has a fiscal basket field,
chargePaymentMethod()builds it from$options->receiptItemstoo, not justcharge()(skip this if the gateway’s off-session API genuinely has no basket concept, like Stripe’s PaymentIntents) -
expiresAtfilled from a real TTL (linkTtlMinutes()+ the gateway’s lifetime param, or the gateway’s own reported expiry) —nullonly when there’s truly no way to know -
handleWebhook()updates thePaymentand returns a realexternalId - Non-terminal gateway statuses and unknown references return
Ignored, not an error - Paid callbacks checked against the payment’s amount/currency (
paidAmountMismatch()) - Gateway commission parsed into
feewhere the callback reports it (feeFrom()) - Credentials read from
$this->credentials, notconfig() - Charges and refunds are sent exactly once (
retry(1)) unless an idempotency key makes a retry safe - Validator fails closed on a missing secret;
hash_equals()for comparison; rotating keys cached with one throttled retry - Checked whether the gateway needs a specific acknowledgment body
- HTTP calls have
timeout(); charge attempts aren’t blindly retried - Only the capability contracts the gateway actually supports
-
healthCheck()(if implemented) is side-effect-free, and its error discriminators are verified against the live API -
Http::fake()tests forcharge(), independent signature recomputation for the validator