The gateway contract
Every method your payment-gateway driver implements, with the exact array shape Core reads back — process, ipn, refund, testConnection — and the one settlement rule a driver must never break.
On this page
A payment gateway is one PHP class whose methods Core calls, and whose return values Core reads generically to drive checkout, the client invoice pay modal, and the admin refund modal. There are no views to write. The entire relationship between your gateway and Core is this method contract, and Core reads the return shapes exactly, so this page documents each one precisely. Keep to these shapes and your gateway lights up correctly across every payment surface.
If you are new to gateways, follow the quickstart first. This is the reference you return to.
One rule frames everything below and is the reason a payment extension is trusted with money at all: a driver never credits balance or marks an invoice paid. It confirms the money moved, stores the proof on the deposit, and calls $this->settle($deposit) — Core's single, idempotent, row-locked settlement boundary. That seam stays core-owned. A driver that settled itself would double-credit the moment a webhook and a browser-return both fired for the same payment. Every method below leads back to this rule.
The interface, the base class, and the trait#
Three types define everything you work with.
App\PaymentGateways\GatewayInterface is the contract. Your driver must be an instance of it. It declares the methods Core calls to move money: capabilities(), process(), ipn(), refund(), and chargeSaved() (off-session recurring). AbstractGateway gives every one a safe default, so you implement only the ones your gateway supports.
App\PaymentGateways\AbstractGateway is the base class you should extend. It implements the interface with safe "not supported" defaults for every method, pulls in the helper trait, adds testConnection(), and returns an empty capabilities() so nothing is shown until you opt in. You override only the methods your gateway actually implements. This is the recommended path.
<?php
namespace Salieno\Payment\Acme;
use App\Models\Deposit;
use App\PaymentGateways\AbstractGateway;
use App\PaymentGateways\PaymentCapability;
class Gateway extends AbstractGateway
{
public function capabilities(): array
{
return [PaymentCapability::CHARGE, PaymentCapability::REDIRECT, PaymentCapability::WEBHOOK];
}
public function process(Deposit $deposit): array { /* ... */ }
// override only what you implement; everything else stays "not supported"
}App\PaymentGateways\GatewayModuleTrait is the helper trait, inherited automatically through AbstractGateway. It gives you the pre-configured HTTP client, the encrypted-credential readers, the minor-unit converter, the checkout-dispatcher and webhook-response builders, and the settlement seam described throughout this page. Build your returns through these so behaviour stays consistent across gateways.
The driver is stateless: the registry instantiates it once with no constructor and passes the Deposit (or, for testConnection, the GatewayCurrency config) per call. Read your credentials and context per call through the trait helpers ($this->getParam($deposit, 'secret_key'), $this->isSandbox($deposit)) — never from instance state. Both reference drivers have no constructor; note how Stripe reads its secret per call:
protected function secretKey(Deposit|GatewayCurrency $ctx): ?string
{
return $this->getParam($ctx, 'secret_key') ?: $this->getParam($ctx, 'stripe_secret_key') ?: null;
}You can implement GatewayInterface directly instead of extending AbstractGateway, but then you own a correct implementation of every method (and lose testConnection, which lives on the base). Extending the base is simpler and safer.
capabilities()#
public function capabilities(): arrayReturns a subset of PaymentCapability::ALL. This is the one method you must always override when extending AbstractGateway, because the base declares nothing. Every UI surface asks capabilities() before it shows a control, so a capability you omit is a control Core hides, never a button that fails when clicked — a gateway that can't refund doesn't offer the "refund to gateway" path (the admin credits the wallet instead); a gateway that can't auto-bill isn't offered for renewals. Every automatic gateway must at least declare PaymentCapability::CHARGE.
public function capabilities(): array
{
return [
PaymentCapability::CHARGE,
PaymentCapability::REFUND,
PaymentCapability::PARTIAL_REFUND,
PaymentCapability::WEBHOOK,
PaymentCapability::REDIRECT,
PaymentCapability::THREE_D_SECURE,
];
}That is Stripe's real set. PayPal's is narrower — charge, refund, partial_refund, redirect — because it settles on the buyer's return rather than a webhook, so it does not declare WEBHOOK. The vocabulary is charge, refund, partial_refund, webhook, redirect, hosted_fields, recurring, crypto, 3ds; the full capability-to-UI mapping is in Capabilities. Your manifest's capabilities[] gates the UI cheaply — without loading the driver — and the driver's capabilities() is the runtime authority. Keep the two identical.
process()#
public function process(Deposit $deposit): arrayBegins collecting a payment for a Deposit that Core already created (status INITIATE, with the money math done: charge amount in $deposit->final_amount, currency $deposit->method_currency). You return the checkout-dispatcher contract — an array whose shape tells Core how to route the buyer. Build it with the trait helpers rather than hand-assembling:
['error' => true, 'message' => '...'] → checkout aborts to the deposit's failed_url
['redirect' => true, 'redirect_url' => 'https://...'] → buyer sent off-site to a hosted page (simplest)
['session' => <sdk-session>, ...] → Core renders the confirm view (client SDK)
['view' => 'user.payment.redirect', 'url' => .., 'method' => 'POST', 'val' => [...]] → auto-submit form
['view' => 'user.payment.crypto', 'address' => .., 'amount' => .., 'currency' => ..] → crypto QRThe redirect shape is the simplest and covers most gateways. Stripe creates a hosted Checkout Session and returns redirectTo($session->url):
public function process(Deposit $deposit): array
{
$secret = $this->secretKey($deposit);
if (! $secret) {
return $this->failCheckout(__('Stripe is not configured (missing secret key).'));
}
try {
$stripe = new \Stripe\StripeClient($secret);
$session = $stripe->checkout->sessions->create([
'line_items' => [[
'price_data' => [
// Integer minor unit — zero-decimal currencies (JPY/KRW/VND/…) must NOT be ×100.
'unit_amount' => $this->toMinorUnit((float) $deposit->final_amount, (string) $deposit->method_currency),
'currency' => strtolower((string) $deposit->method_currency),
'product_data' => ['name' => gs('site_name') ?: 'Payment'],
],
'quantity' => 1,
]],
'mode' => 'payment',
'cancel_url' => $deposit->failed_url,
'success_url' => $deposit->success_url,
'metadata' => ['deposit_id' => $deposit->id, 'trx' => $deposit->trx],
], [
// Retrying process() for the same attempt returns the same session, never a double charge.
'idempotency_key' => $deposit->trx,
]);
} catch (\Throwable $e) {
return $this->failCheckout($e->getMessage());
}
// Remember the session id so the webhook can resolve this exact attempt.
$deposit->btc_wallet = $session->id;
$deposit->save();
return ! empty($session->url)
? $this->redirectTo($session->url)
: $this->failCheckout(__('Stripe did not return a checkout URL.'));
}Three things in that method are the pattern for every gateway. Charge `final_amount` in `method_currency`, through `toMinorUnit()` — never multiply by 100 yourself, because zero-decimal currencies (JPY, KRW, VND, …) would overcharge 100×. Stash the gateway's own reference on `$deposit->btc_wallet` (here the session id; PayPal stashes the order id) so ipn() can resolve this exact attempt later. Make the call idempotent on `$deposit->trx`, so a retried process() never becomes a second charge. On any failure return failCheckout($message) — the dispatcher redirects the buyer to the deposit's failed_url.
PayPal's process() is the same skeleton against a different API — create an Orders v2 CAPTURE order, persist its id, and redirect to the approval link:
if (! empty($response['id'])) {
// Persist the PayPal order id so ipn() can resolve this deposit from ?token=<id>.
$deposit->btc_wallet = $response['id'];
$deposit->save();
foreach (($response['links'] ?? []) as $link) {
if (in_array($link['rel'] ?? '', ['payer-action', 'approve'], true)) {
return $this->redirectTo($link['href']);
}
}
}ipn()#
public function ipn(Illuminate\Http\Request $request): Symfony\Component\HttpFoundation\ResponseHandles an inbound webhook or a browser return at /client/ipn/{slug} — the endpoint has no CSRF and no auth, because it is reachable by both the PSP and the buyer. This is where money is confirmed, and it has three non-negotiable duties: verify authenticity (signature / HMAC / a server-side capture), store the proof on $deposit->detail, and settle only via `$this->settle($deposit)`. Return a JSON response for a webhook — ipnAck() (200) so the gateway stops re-delivering, or ipnReject() (4xx) for anything unverifiable — or a redirect for a browser return, returnSuccess($deposit) / returnFailed($deposit).
Stripe's ipn() is the webhook shape. Note that it verifies the signature before it trusts a single field, refuses an empty signing secret outright, and settles only once the money is genuinely captured:
public function ipn(Request $request): Response
{
$payload = $request->getContent();
$sig = $request->header('Stripe-Signature');
if (! $sig) {
return $this->ipnReject('No signature header');
}
// Fail closed: an empty signing secret would validate an attacker-forged event (empty-key HMAC),
// settling any deposit without payment.
$secret = $this->webhookSecret();
if (! $secret) {
return $this->ipnReject('Webhook signing secret is not configured');
}
try {
$event = \Stripe\Webhook::constructEvent($payload, $sig, $secret);
} catch (\UnexpectedValueException $e) {
return $this->ipnReject('Invalid payload');
} catch (\Stripe\Exception\SignatureVerificationException $e) {
return $this->ipnReject('Invalid signature');
}
$session = $event->data->object;
$deposit = isset($session->metadata->deposit_id) ? Deposit::find($session->metadata->deposit_id) : null;
$deposit ??= $this->depositByReference($session->id ?? null);
if (! $deposit) {
return $this->ipnReject('Deposit not found', 404);
}
if (in_array($event->type, ['checkout.session.completed', 'checkout.session.async_payment_succeeded'], true)) {
// checkout.session.completed ALSO fires for unpaid async sessions — settle only once money is captured.
$paid = in_array($session->payment_status ?? '', ['paid', 'no_payment_required'], true)
|| $event->type === 'checkout.session.async_payment_succeeded';
if ($paid && in_array((int) $deposit->status, [Status::PAYMENT_INITIATE, Status::PAYMENT_PENDING], true)) {
$deposit->detail = $session; // proof of payment (carries payment_intent for refunds)
$deposit->save();
$this->settle($deposit); // Core's idempotent, row-locked settlement boundary
}
}
return $this->ipnAck();
}PayPal has no webhook, so its ipn() is a browser return that must not trust the URL it was reached with. It resolves the deposit from ?token=<order-id>, then asks PayPal for the order's true state and captures server-side — only a COMPLETED capture settles:
$order = $provider->showOrderDetails($orderId);
$status = $order['status'] ?? null;
if ($status === 'COMPLETED') {
return $this->finishCapture($deposit, $order); // already captured — settle idempotently
}
if ($status !== 'APPROVED') {
return $this->returnFailed($deposit, __('Payment was not approved.'));
}
$provider->setRequestHeader('PayPal-Request-Id', 'cap_'.$deposit->trx); // idempotent capture
$response = $provider->capturePaymentOrder($orderId);
if (($response['status'] ?? null) === 'COMPLETED') {
return $this->finishCapture($deposit, $response);
}…and finishCapture() shows the canonical last three steps — verify the amount, store the proof, hand off:
protected function finishCapture(Deposit $deposit, $response): Response
{
if (! $this->amountMatches($deposit, $response)) {
return $this->returnFailed($deposit, __('Payment amount mismatch. Please contact support.'));
}
$deposit->detail = $response; // carries the capture id for refunds
$deposit->save();
$this->settle($deposit);
return $this->returnSuccess($deposit, __('Payment captured successfully.'));
}Two guards are worth copying verbatim. Gate the settle on the deposit's status (PAYMENT_INITIATE / PAYMENT_PENDING), so a late or duplicate callback for an already-settled deposit is a no-op even before settle()'s own idempotency. And never settle on the strength of a return URL alone — confirm with the gateway (Stripe verifies the signature and payment_status; PayPal re-reads and captures the order) so a spoofed or replayed callback settles nothing.
The settlement rule#
This is the most important line in the whole contract, so it gets its own section.
protected function settle(Deposit $deposit): voidsettle() routes to Core's one settlement boundary — PaymentController::userDataUpdate → PaymentService::finalizeSuccessfulPayment — which is idempotent and row-locked: it credits the wallet / marks the invoice paid exactly once, no matter how many times a webhook and a browser-return both fire for the same deposit. That is precisely why a driver must never touch balance or invoices itself: two callbacks are normal, and a driver that credited the wallet directly would double-pay. Your job in ipn() ends at three lines:
$deposit->detail = $proof; // 1. store what proves the money moved
$deposit->save(); // 2. persist it (detail is available to refund() later)
$this->settle($deposit); // 3. hand off — Core does the rest, onceEverything Core does after settlement — crediting the wallet, paying the invoice, provisioning the service, sending the receipt — hangs off that single call. Get these three lines right and the rest of the platform is Core's problem, not yours.
refund()#
public function refund(Deposit $deposit, float $amount): arrayReverses a captured payment at the gateway, full or partial. Core calls it only when you declare `PaymentCapability::REFUND` — and only reverses less than the full captured amount when you also declare PaymentCapability::PARTIAL_REFUND; without those the admin refund path credits the customer's wallet instead. It returns {success: bool, message: string, refund_id?: string, status?: string}, and it must be honest — Core records the refund and stops the service off the back of a true result, so return success only when the gateway actually accepted the reversal.
$amount is in the gateway's method currency — the currency the buyer was actually charged ($deposit->final_amount / $deposit->method_currency), not the invoice's base currency. Core has already converted it with the same rate the original charge used and clamped it to the captured amount, so refund it as given (through toMinorUnit() / formatAmount() for precision) — never re-convert it.
Return 'status' => 'pending' when the gateway accepted the reversal but has not yet settled it (some methods refund asynchronously). Core then holds the irreversible side-effects — releasing the coupon, suspending the service — until a confirming webhook, and walks the whole record back if a later webhook reports the refund failed (so a bounced refund never leaves an invoice showing money that never returned). If your gateway can report a failed refund, handle that event in ipn() and call app(RefundService::class)->reverseRefund($invoice, $amount, $ref).
The stored proof from ipn() is what makes a refund possible: read the gateway's payment reference back off $deposit->detail. Stripe pulls the payment_intent it saved, refunds the minor-unit amount idempotently, and reports honestly:
public function refund(Deposit $deposit, float $amount): array
{
$secret = $this->secretKey($deposit);
if (! $secret) {
return $this->error(__('Stripe secret key is not configured for this currency.'));
}
$detail = $deposit->detail;
$paymentIntent = is_object($detail) ? ($detail->payment_intent ?? null) : ($detail['payment_intent'] ?? null);
if (! $paymentIntent) {
return $this->error(__('No Stripe payment reference was found on the original payment to refund.'));
}
try {
$stripe = new \Stripe\StripeClient($secret);
$minor = $this->toMinorUnit($amount, (string) $deposit->method_currency);
$refund = $stripe->refunds->create(
['amount' => $minor, 'payment_intent' => $paymentIntent, 'reason' => 'requested_by_customer'],
['idempotency_key' => 'refund_'.$deposit->trx.'_'.$minor],
);
return in_array($refund->status ?? '', ['succeeded', 'pending'], true)
? $this->success(__('Stripe refund :status.', ['status' => $refund->status]), ['refund_id' => $refund->id ?? null])
: $this->error(__('Stripe refund returned status: :s', ['s' => $refund->status ?? 'unknown']));
} catch (\Throwable $e) {
return $this->error(__('Stripe refund failed: :m', ['m' => $e->getMessage()]));
}
}PayPal is identical in shape against the stored capture id, then COMPLETED/PENDING → success(..., ['refund_id' => ..., 'status' => ...]), anything else → error(...). The toMinorUnit() / formatAmount() currency care from process() applies here too: refund the amount in the gateway's expected precision, never a raw × 100. (A partial refund needs a per-refund idempotency key — key it on trx and the amount, not trx alone, or two legitimately-different partials collapse into one.)
chargeSaved()#
public function chargeSaved(Deposit $deposit, App\Models\PaymentMethod $method): arrayCharges a saved instrument off-session for automatic recurring billing — no buyer present. Core calls it only when you declare `PaymentCapability::RECURRING` and a PaymentMethod was captured for the customer. The saved card is captured during an ordinary interactive checkout: when the buyer has auto-billing enabled, set your gateway up to save the instrument for reuse (Stripe sets setup_future_usage = 'off_session' and customer_creation = 'always' on the Checkout Session), then in ipn() call the trait helper `$this->saveInstrument($deposit, [...])` with the gateway's own references — Core stores the PaymentMethod (you never write that model yourself, exactly like settle()).
For the recurring charge itself, Core has already built $deposit (status INITIATE, money math done in final_amount / method_currency). Charge that amount against $method->gateway_customer_id / $method->gateway_payment_method_id, and on a successful capture obey the settlement rule: store proof on $deposit->detail, call $this->settle($deposit), return success(...). A decline returns success:false so Core schedules the retry. Make it idempotent on $deposit->trx so a retried run within the gateway's window never double-charges.
public function chargeSaved(Deposit $deposit, \App\Models\PaymentMethod $method): array
{
$secret = $this->secretKey($deposit);
if (! $secret || empty($method->gateway_customer_id) || empty($method->gateway_payment_method_id)) {
return $this->error(__('No saved card on file for this customer.'));
}
try {
$intent = (new \Stripe\StripeClient($secret))->paymentIntents->create([
'amount' => $this->toMinorUnit((float) $deposit->final_amount, (string) $deposit->method_currency),
'currency' => strtolower((string) $deposit->method_currency),
'customer' => $method->gateway_customer_id,
'payment_method' => $method->gateway_payment_method_id,
'off_session' => true,
'confirm' => true,
], ['idempotency_key' => 'autopay_'.$deposit->trx]);
if (($intent->status ?? '') === 'succeeded') {
$deposit->detail = (object) ['payment_intent' => $intent->id];
$deposit->save();
$this->settle($deposit); // Core's idempotent, row-locked boundary
return $this->success(__('Card charged successfully.'));
}
return $this->error(__('Auto-charge not completed (:s).', ['s' => $intent->status ?? 'unknown']));
} catch (\Throwable $e) {
return $this->error(__('Auto-charge failed: :m', ['m' => $e->getMessage()]));
}
}AbstractGateway implements a safe default ("does not support off-session auto-billing"), so a gateway that doesn't declare RECURRING needs none of this — Core simply won't offer it for auto-billing.
testConnection()#
public function testConnection(App\Models\GatewayCurrency $config): arrayThis method is not part of GatewayInterface — it lives on AbstractGateway (default: "not supported"), and you override it for the admin "Test connection" button. It is the only method that takes a GatewayCurrency config instead of a Deposit, because there is no payment yet: you are validating stored credentials. Do the cheapest authenticated, side-effect-free probe that still exercises auth, so a pass proves the credentials work and a failure surfaces the real reason. It is not a capability; the button appears for any configured currency.
public function testConnection(GatewayCurrency $config): array
{
$secret = $this->getParam($config, 'secret_key') ?: $this->getParam($config, 'stripe_secret_key');
if (! $secret) {
return $this->error(__('Enter your Stripe secret key first.'));
}
try {
(new \Stripe\StripeClient($secret))->balance->retrieve();
return $this->success(__('Connected to Stripe successfully.'));
} catch (\Throwable $e) {
return $this->error(__('Stripe rejected the credentials: :m', ['m' => $e->getMessage()]));
}
}PayPal's probe just fetches an access token: no token → error(...), token → success('Connected to PayPal successfully.'). Both read credentials off the GatewayCurrency with the same getParam($config, ...) you use everywhere else — the probe and the live path share one credential reader, so a passing test means a working charge.
The objects Core passes you#
$deposit#
The payment attempt being processed (App\Models\Deposit). This is the whole money model — read the amount and currency, write your reference and proof:
| Field | Meaning |
|---|---|
$deposit->final_amount | the amount to charge, in method_currency — pass through toMinorUnit() |
$deposit->method_currency | the currency the charge is denominated in |
$deposit->amount | the base amount credited to the wallet on success (Core's concern, not yours) |
$deposit->trx | your idempotency key and transaction reference — use it for the PSP's idempotency header |
$deposit->btc_wallet | a free string to stash the gateway's own reference (session / order id) for the callback to resolve |
$deposit->detail | where you store the verified proof (encrypted object); refund() reads its payment reference back |
$deposit->success_url / failed_url | where to return the buyer after an off-site payment |
$deposit->status | Status::PAYMENT_INITIATE / PAYMENT_PENDING — gate your settle on these |
$deposit->user | the buyer (email, …) — e.g. for a hosted page's customer_email |
$deposit->gatewayCurrency() | the per-currency config row your credentials live on |
$deposit->save() | persist the btc_wallet and detail you set |
detail is deliberately the same field you write in ipn() and read in refund(): the proof you store at settlement is the reference you reverse against later. Store the whole gateway response object, not just an id.
$config#
The GatewayCurrency config row the operator filled in under Auto Gateways — the encrypted credentials from your manifest's credentials list, fanned across every currency row for the global ones. You never read it directly; the trait helpers do. testConnection() receives it in place of a deposit, because at that point there is no payment to resolve it from.
| Helper | What it reads |
|---|---|
getParam($ctx, 'secret_key', '') | one credential the operator entered ($ctx is a Deposit or a GatewayCurrency) |
isSandbox($ctx) | the connection's sandbox/test mode |
ownConfig() | this gateway's config row when no deposit is in hand (an inbound webhook) |
The helper trait#
Use these (inherited through AbstractGateway) so every gateway behaves consistently.
The settlement seam. settle($deposit) — the only way a driver may complete a payment; call it from ipn() after storing proof. Nothing else touches balance or invoices.
Credentials. getParam($ctx, $key, $default) reads one encrypted per-currency credential, unwrapping the stored {title, value} shape and tolerating snake_case/camelCase spellings; $ctx is a Deposit or a GatewayCurrency. isSandbox($ctx) is the connection's test-mode flag. Never hardcode a key — read it per call.
Self-resolution. A webhook arrives with no deposit, yet you need your global signing secret to verify it. Resolve your own config without hardcoding your slug: ownExtension() (the installed row backing this driver class), ownGateway() (its gateway definition), and ownConfig() (a currency row carrying the global credentials). Stripe's webhookSecret() does exactly this:
protected function webhookSecret(): ?string
{
$config = $this->ownConfig();
return $this->getParam($config, 'webhook_secret') ?: $this->getParam($config, 'stripe_webhook_secret') ?: null;
}Currency. toMinorUnit($amount, $currency) / fromMinorUnit($minor, $currency) are zero-decimal-aware — JPY, KRW, VND, XAF and the rest are not × 100. Use them anywhere a gateway takes integer minor units; get this wrong and you overcharge 100×.
Deposit lookup. depositByTrx($trx) resolves a callback by your transaction id (the safest handle when it round-trips); depositByReference($ref) resolves by the gateway reference you stashed on btc_wallet. Stripe uses both — metadata first, then depositByReference($session->id).
HTTP. http(array $headers = [], int $timeout = 30) — a pre-configured Laravel client with an identifiable User-Agent, a 15s connect timeout, and TLS verification on. Use it for raw REST gateways (the reference drivers use vendor SDKs instead, which is equally fine).
The dispatcher and response builders#
You never hand-assemble the contract arrays — the trait builds them, and matching these shapes is what lets Core route generically.
process() builders: redirectTo($url) → ['redirect' => true, 'redirect_url' => $url]; failCheckout($message) → ['error' => true, 'message' => $message] (Core sends the buyer to failed_url); renderConfirm($data) passes an SDK-session / self-post / crypto payload through to Core's confirm view.
ipn() builders: ipnAck($message = 'OK') (200, stop re-delivery) and ipnReject($message, $status = 400) (an unverifiable event must never 200) for webhooks; returnSuccess($deposit, $message) and returnFailed($deposit, $message) (redirects to the deposit's success_url / failed_url) for browser returns.
The success/error envelopes#
success($message = 'OK', array $data = []) returns ['success' => true, 'message' => $message] merged with `$data` at the top level — which is why refund() returns refund_id as a sibling key, ['refund_id' => ...], not nested. error($message, array $data = []) returns ['success' => false, 'message' => $message] and logs for the operator. Use these for refund() and testConnection(); use the dispatcher/IPN builders above for process() and ipn().
The trust model#
Payment extensions carry the same signed-distribution trust model as registrar and panel extensions, with one payment-specific twist. They are distributed only through marketplace.salieno.com, signed with the marketplace's Ed25519 key; a folder copied onto disk never runs. On every resolve Core runs a three-gate load: registered (an enabled gateway_extensions row), genuine (the stored artifact re-verifies against the pinned key and every on-disk file matches its recorded sha256 — code edited after install is refused), and entitled (the licence still owns it on the marketplace).
The payment twist: on an inbound webhook or return the entitlement gate is lenient — a payment the buyer already made must settle even if the licence lapsed in the meantime — but the genuine gate stays strict, and a disabled gateway (Gateway.status = false, the operator's containment lever for leaked credentials) resolves to a NullGateway on both checkout and callback. None of this relaxes the driver's own duty: verify or fail closed. An empty webhook signing secret is forgeable (empty-key HMAC), which is why Stripe's ipn() rejects it before doing anything else. Verify authenticity, then settle — never the other way round. The full model is in Security & trust.
Rules to remember#
- Return the exact shapes above. Core reads them generically; a missing or renamed key silently drops the value.
- Never settle in the driver. Verify → store proof on
$deposit->detail→$this->settle($deposit). That boundary credits exactly once; a driver that credits balance itself double-pays. - The driver is stateless — no constructor, no instance state. Read credentials per call via
getParam()/isSandbox(); read global creds for a webhook viaownConfig(). - Charge
final_amountinmethod_currencythroughtoMinorUnit()— never× 100yourself; zero-decimal currencies overcharge 100× if you do. - Stash the gateway's own reference on
$deposit->btc_walletinprocess(), soipn()can resolve the exact attempt. - Make
process()idempotent on$deposit->trx, so a retried start is not a second charge. - In
ipn(), verify authenticity and gate the settle onPAYMENT_INITIATE/PAYMENT_PENDING; never settle on the strength of a return URL alone. - Reject an empty webhook signing secret — fail closed.
ipnReject()for anything unverifiable, neveripnAck(). refund()must be honest —successonly when the gateway accepted the reversal; read the payment reference back off$deposit->detail.- Declare capabilities honestly, and keep the manifest's
capabilities[]identical to the driver'scapabilities()— a capability you don't declare is a control Core hides, so a partial gateway is first-class, not broken.
For the worked, full-featured examples every snippet here is drawn from, read the bundled Stripe (hosted redirect + signed webhook + refund) and PayPal (Orders v2 redirect + capture-on-return + refund) drivers. Next, map the capabilities to the UI in Capabilities, define your connection form in Credentials, and validate a live connection in Testing your gateway.