Skip to content

Payment credentials & the gateway config

A gateway's driver and its config are separate — declare credential fields in the manifest, read them per call from the encrypted config, solve the webhook-has-no-Deposit problem with ownConfig(), and verify it all with a Test connection probe.

12 min readUpdated Aug 17, 2026
On this page

A payment driver has to authenticate to a processor's API — a Stripe secret key, a PayPal client id + secret, a webhook signing secret — and Core has to collect the right credentials to do it. None of that requires a Core edit. Two things are involved and they are deliberately separate: the driver (the signed extension, code only, no views) and the gateway config (the credentials one admin fills in under Auto Gateways). Your salieno.json's credentials list tells Core how to render the config form, and a helper trait reads those stored values back into your driver on every call — including the awkward case where a webhook arrives with no Deposit to read them from. This article covers both halves and how they meet at the "Test connection" probe.

The config: driver and credentials are separate#

A gateway's driver and its config are distinct records with distinct lifecycles. The driver is the signed marketplace extension — code, gated three ways before it is allowed to run (see Security & trust model). The config is a set of GatewayCurrency rows keyed by your slug — one row per currency the operator enables — and every credential the operator entered lives in the encrypted gateway_parameter column on those rows.

Installing the extension auto-provisions an unconfigured entry under admin → Payments → Auto Gateways, seeded from the credentials your manifest declared. It arrives empty: the operator opens it, enables the currencies they want to accept, fills in the credential fields, and clicks Test connection. Until then the gateway is installed but not usable — the fields exist, the values do not. Disabling or uninstalling the extension takes the config form with it.

Every driver method with a buyer in flight is handed a Deposit, and each deposit points at the GatewayCurrency it is being charged through. Your driver resolves the credentials per call from that context — PayPal fails fast when the currency was never configured:

php
if (! $deposit->gatewayCurrency()) {
    return $this->failCheckout(__('PayPal is not configured for this currency.'));
}
[$clientId, $clientSecret] = $this->credentials($deposit);

The driver never holds credentials in instance state — it is stateless (Core builds it once, with no constructor, and passes the Deposit to every method). Read the config on every call. See The driver contract for why.

The credentials list builds the config form#

Core renders the Auto Gateways config form straight from your manifest's credentials list. There is no per-gateway form in Core; the fields, labels, help text, which are required, and which are global all come from what you declare. Adding a gateway needs no Core changes. Here is Stripe's list in full:

json
"credentials": [
  { "key": "secret_key",      "label": "Secret Key",             "type": "password", "required": true,  "global": true,
    "help": "Your Stripe secret key (sk_live_… in production, sk_test_… for testing). Dashboard > Developers > API keys." },
  { "key": "publishable_key", "label": "Publishable Key",        "type": "text",     "required": false, "global": true,
    "help": "Your Stripe publishable key (pk_live_… / pk_test_…). Optional for the hosted Checkout flow." },
  { "key": "webhook_secret",  "label": "Webhook Signing Secret", "type": "password", "required": true,  "global": true,
    "help": "The signing secret (whsec_…) for a webhook endpoint pointed at /client/ipn/stripe … Payments will NOT settle without it." }
]

Each entry is a {key, label, type, required, global, help} object (select fields add options):

FieldWhat it does
keyThe storage key — the exact string your driver passes to getParam($ctx, 'secret_key'). Keep it stable across versions; renaming it orphans stored values (see the transition trick below).
labelThe field label in the config form. Use the processor's own vocabulary ("Secret Key", "Client ID") so it matches their dashboard.
type"text", "password", or "select" (see below).
requiredtrue marks the field mandatory. An optional field (false) may be left blank; give your driver a sensible default when reading it (Stripe's publishable_key is optional for hosted Checkout).
globaltrue = one value shared across every currency; false/absent = a value the operator sets per currency (see below).
helpInline help under the field: where in the processor's dashboard the value lives, and any hard requirement. This is the operator's only guidance — make it specific. Stripe's webhook_secret help says plainly, "Payments will NOT settle without it."
optionsselect only — a {value: label} map of the choices.

The manifest's credentials keys and your driver's getParam keys are the contract between the form and the code — they must agree. For the full manifest schema see The manifest.

text, password, select#

type picks the input and how the value is treated on screen:

  • "text" — a normal input, shown in the clear. Use it for non-secret identifiers: a client id, a merchant handle, a publishable key.
  • "password" — a masked input. Use it for anything that would let someone charge or refund as the account: the secret key, the client secret, the webhook signing secret.
  • "select" — a fixed dropdown, defined by an options map. PayPal uses one for its environment switch:
json
{ "key": "mode", "label": "Mode", "type": "select", "required": true, "global": true,
  "options": { "live": "Live", "sandbox": "Sandbox" },
  "help": "Use Sandbox with sandbox credentials to test, Live to take real payments." }

type is a UI hint, not the whole of the protection — every credential value is encrypted at rest regardless (see below). But mark secrets "password" so they are never shown on screen or shoulder-surfed while an operator edits the config.

global vs per-currency#

A gateway is configured per currency — one GatewayCurrency row for each currency the operator turns on. That raises a question for every credential: is it the same for every currency, or different per currency?

  • `"global": true` — the operator enters the value once, and Core fans it across every currency row. Use it for account-wide credentials that don't vary by currency: a Stripe secret key authenticates one account for all its currencies; a PayPal REST app's client id/secret and its mode are the same whichever currency you charge. Both reference gateways mark every field global: true.
  • `"global": false` (or omitted) — the value is stored per currency, and the operator sets it on each currency row separately. Reach for this only when the credential genuinely differs by currency — for example a processor where each currency settles to a different sub-account or merchant id.

Either way you read it the same way: getParam($ctx, $key) returns the value for whichever GatewayCurrency the current Deposit resolved to. global only changes how many rows the operator has to type the value into, not how the driver reads it.

Reading credentials in the driver#

Extend AbstractGateway and you inherit GatewayModuleTrait, which reads the stored config for you. The context you pass — $ctx — is either the Deposit in flight or, in the admin probe, a GatewayCurrency; both work.

getParam($ctx, $key, $default = null)#

Returns one credential the operator entered, decrypted, or your default when the key was left blank. Type your accessor Deposit|GatewayCurrency so the same reader serves process(), refund(), and testConnection(). Stripe's secret-key accessor is a one-liner:

php
protected function secretKey(Deposit|GatewayCurrency $ctx): ?string
{
    return $this->getParam($ctx, 'secret_key') ?: $this->getParam($ctx, 'stripe_secret_key') ?: null;
}

PayPal reads a pair the same way, trimming as it goes:

php
protected function credentials(Deposit|GatewayCurrency $ctx): array
{
    $id     = (string) ($this->getParam($ctx, 'client_id')     ?? '');
    $secret = (string) ($this->getParam($ctx, 'client_secret') ?? '');

    return [trim($id), trim($secret)];
}

(The reference drivers read a second legacy key with ?: — that is the safe way to rename a credential; see the gotchas below.)

isSandbox($ctx)#

isSandbox($ctx) reads Core's own per-config sandbox toggle — you do not declare a test_mode credential. Point at the processor's sandbox host when it is on, so an operator can run a real payment against test infrastructure before going live.

Not every gateway needs it, and the two reference drivers show why. *Stripe's key is the environment — an `sk_test_…` key hits Stripe's test mode and an `sk_live_…` key hits production, so Stripe never calls `isSandbox()` at all. PayPal made the switch explicit*, declaring a mode select (its base URL differs per environment while the app credentials do not) and normalising it defensively:

php
protected function resolveMode(Deposit|GatewayCurrency $ctx): string
{
    $mode = $this->getParam($ctx, 'mode') ?? 'live';   // default to live, never accidental sandbox
    $mode = strtolower(trim((string) $mode));

    return in_array($mode, ['sandbox', 'live'], true) ? $mode : 'live';
}

Reach for isSandbox($ctx) when your gateway uses one set of credentials against two different base URLs and the choice belongs on Core's toggle; declare a mode select when the environment is a first-class, operator-visible setting; and skip both when, like Stripe, the key already encodes the environment.

ownConfig() — the webhook has no Deposit#

Here is the payment-specific problem. A webhook does not arrive with a Deposit in hand — it arrives as a raw signed request, and you have to verify the signature before you can even find which deposit it belongs to. So in ipn() you cannot call getParam($deposit, 'webhook_secret'): there is no $deposit yet.

ownConfig() solves it. It self-resolves your own gateway's config — a representative GatewayCurrency row for your slug — without you hardcoding that slug anywhere. Read your global secret from it. Stripe's webhook-secret accessor:

php
protected function webhookSecret(): ?string
{
    $config = $this->ownConfig();

    return $this->getParam($config, 'webhook_secret') ?: $this->getParam($config, 'stripe_webhook_secret') ?: null;
}

Because ownConfig() returns one representative row, a credential you read this way must be `global: true` — a per-currency webhook secret has no meaning when you don't yet know the currency. This is exactly why Stripe's webhook_secret is marked global.

The same self-resolution family gives you ownGateway() and ownExtension(). Stripe uses ownGateway()?->code to scope a dashboard-refund reconciliation to its own deposits, again without naming its slug:

php
$code = $this->ownGateway()?->code;
if ($code !== null) {
    $query->where('method_code', $code);
}

Credentials are encrypted and never logged#

gateway_currencies.gateway_parameter is cast encrypted:object and the model hides it from serialization, logs, and Livewire snapshots — so a config's credentials do not leak into a stack trace, a debug dump, or the admin page's wire state. Your side of that contract:

  • Never hardcode a secret in the driver. Every credential is read from the config with getParam — never baked into a constant. A copied folder never runs (the code is signed and hash-pinned), but even inside a genuine install a hardcoded key ships identically to every operator and can never be rotated. Read it; don't embed it.
  • Never log a credential. Not the key, not the whole config object. When you log an error, log the processor's message, not the request that carried the secret. (PayPal logs deposit #id and the gateway's own message — never the credentials.)
  • Use `type: "password"` for every secret field so it is masked in the form.
  • Fail closed on an empty secret. An empty signing secret is forgeable — an empty-key HMAC validates an attacker-crafted event, settling any deposit without payment. Reject it before you verify anything:
php
$secret = $this->webhookSecret();
if (! $secret) {
    return $this->ipnReject('Webhook signing secret is not configured');
}

try {
    $event = \Stripe\Webhook::constructEvent($payload, $sig, $secret);
} catch (\Stripe\Exception\SignatureVerificationException $e) {
    return $this->ipnReject('Invalid signature');
}

An unverifiable webhook must reject, never settle. Verification is not optional and the driver never settles on its own — it hands a verified deposit to $this->settle(), Core's single idempotent boundary. See The driver contract.

Real-world credential gotchas#

  • The webhook secret is a required, no-context credential. It has no natural per-call Deposit, so it must be global and read through ownConfig() (above), and its help should say out loud that nothing settles without it — because a missing one is silent until the first payment fails to credit. Stripe's help does exactly that.
  • Rename a credential without orphaning stored values. The key is the storage join; change it in the manifest and every value an operator already saved is orphaned. When you must migrate a key, read both with ?: for a release or two — this is why Stripe reads secret_key ?: stripe_secret_key and PayPal reads client_id ?? paypal_client_id. New installs use the new key; upgraded installs keep working until the operator re-saves.
  • Default a `mode`/environment field to live, and validate the set. An unrecognised or blank value must resolve to live, never to an accidental sandbox that silently stops taking real money (PayPal's resolveMode, above).
  • Surface actionable errors, not raw failures. Catch a missing credential before the processor does and return a plain sentence — in checkout via failCheckout(), in a probe via error():
php
$secret = $this->secretKey($deposit);
if (! $secret) {
    return $this->failCheckout(__('Stripe is not configured (missing secret key).'));
}

And when the processor does reject the call, pass its real reason through rather than flattening it to "payment failed" — but never echo the credential itself back in the message:

php
return $this->error(__('Stripe rejected the credentials: :m', ['m' => $e->getMessage()]));

The testConnection probe#

testConnection(GatewayCurrency $config): array is the "Test connection" button on the config. It is the one place credentials, the environment, and your API client meet as a single testable path — so implement it as a lightweight, side-effect-free, authenticated call that proves the stored credentials actually work. Stripe retrieves the account balance; PayPal mints an access token:

php
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()]));
    }
}
php
public function testConnection(GatewayCurrency $config): array
{
    [$clientId, $clientSecret] = $this->credentials($config);
    if ($clientId === '' || $clientSecret === '') {
        return $this->error(__('Enter your PayPal client id and secret first.'));
    }
    // … mints an access token; success only when one comes back
}

Note the shape: it is handed a `GatewayCurrency`, not a Deposit, and it reads the same stored credentials via getParam($config, …) — closing the loop from form → encrypted config → driver. It returns ['success' => bool, 'message' => …]; the admin sees the message under the button. Pick the cheapest authenticated call the processor offers so a success proves the config is usable and a failure surfaces the true reason (bad key, wrong mode) verbatim.

testConnection is not a capability — it lives on AbstractGateway (not the interface), it is not gated, and every gateway should override it. The full method contract (process, ipn, refund, and the all-important settlement rule) lives in The driver contract; this probe is what ties your credentials to it. Once it returns success against sandbox, walk the same path against live and you have a working gateway — see Testing your gateway.

Was this article helpful?
Still stuck?Contact support
Payment credentials & the gateway config · Salieno Docs