Payment Gateway Hooks

1 views Markdown

The eight hooks talking to a payment gateway: settlement, callbacks, stored cards and subscription collection.

Overview

Talking to a gateway runs both ways. We ask for a charge, and the gateway answers either at once or later as a callback. That second route can arrive from a browser or straight from their servers.

On stored cards the card number never reaches us: the gateway keeps a token and we hold only the display details (last four digits, brand, expiry). Those are all the hooks receive.

Reference

Following a settlement

actionpayment.settled
PaymentGatewayModule it can be pending

Runs after the settlement with the gateway completed.

Parameters 4
$modulePaymentGatewayModuleThe module object that settled.
$checkoutarrayThe checkout record after the write: its status is paid, with the settlement time and transaction number inside. settled_status can be pending, meaning the money is not final yet.
$resultarrayThe module's raw result: status, message, the paid mark, subscription details. It speaks the gateway's own language.
$responsearrayThe answer going back to the core: the status, the pending mark, the redirect address.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:payment.settled', 10,
    function ($module, $checkout, $result, $response) {
        // It can be PENDING: open nothing before the money is final.
        if (($checkout['data']['settled_status'] ?? '') !== 'successful') return;

        Accounting::gatewaySettled($module->name, $checkout);
    });

Following a callback

actionpayment.callback_returned
PaymentGatewayModule the record can be empty

Runs after the gateway's callback was interpreted.

Parameters 4
$modulePaymentGatewayModuleThe module that read the callback.
$checkoutarrayThe checkout record. Where the module could not resolve it, an empty array arrives: the callback may be forged or unmatched.
$settlearrayThe settlement result: status, redirect, message and the already mark. already means "this callback was handled before".
$is_s2sboolWhether the callback came straight from their servers. With false the customer's browser brought it, and they may never have opened that page.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:payment.callback_returned', 10,
    function ($module, $checkout, $settle, $is_s2s) {
        // An empty checkout is an unmatched callback; check the repeat mark too.
        if (!$checkout || !empty($settle['already'])) return;

        Ops::note('gateway-callback', $module->name, $is_s2s ? 'server' : 'browser');
    });

Stopping a card being stored

gatepayment.card_add
AccountCards no number arrives

Runs while a customer stores a card. The card number never reaches this hook; you decide on the account and module alone.

Parameters 3
$uidintThe account storing the card. Confirmed to be their own.
$moduleNamestringThe gateway module storing the card.
$autoPayboolWhether the card joins the automatic payment chain. Always false where the module does not support it.
Return 1
stringA non-empty string stops the card being stored.
Listener
Hook::add('gate:payment.card_add', 10, function ($uid, $moduleName, $autoPay) {
    // The card number NEVER arrives here: decide on the account.
    if (Acme::cardCount($uid) >= 5) return 'At most 5 cards are kept.';

    return null;
});

Following a card stored

actionpayment.card_stored
AccountCards display details only

Runs after the card was stored at the gateway.

Parameters 4
$userIdintThe card's owner.
$cardIdintThe id of the stored card record.
$modulestringThe module that stored it.
$cardarrayDisplay details only: the last four digits, brand, type, expiry month and year. The card number, the security code and the gateway token are not here and never will be.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:payment.card_stored', 10, function ($userId, $cardId, $module, $card) {
    // Display details only; never the number or the token.
    Notify::securityEvent($userId, 'card-added', $card['ln4'] ?? '');
});

Following the default card

actionpayment.card_default_set
AccountCards auto-payment uses this one

Runs after a card was made the default.

Parameters 2
$uidintThe card's owner.
$cardIdintThe id of the card made default. Automatic payment tries this one from now on.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:payment.card_default_set', 10, function ($uid, $cardId) {
    Audit::note('card-default', (string) $uid, (string) $cardId);
});

Following a card removed

actionpayment.card_removed
AccountCards auto-payment is affected

Runs after a stored card was removed.

Parameters 2
$uidintThe card's owner.
$cardIdintThe id of the removed card.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:payment.card_removed', 10, function ($uid, $cardId) {
    // With the last card gone auto-payment stops quietly: tell the customer.
    if (Acme::cardCount($uid) === 0) Notify::noCardLeft($uid);
});

Stopping a subscription cancellation

gatepayment.subscription_cancel
AccountCards the agreement at the gateway

Runs while a customer cancels the recurring agreement at the gateway.

Parameters 2
$uidintThe agreement's owner.
$idintThe id of the agreement being cancelled.
Return 1
stringA non-empty string stops the cancellation.
Listener
Hook::add('gate:payment.subscription_cancel', 10, function ($uid, $id) {
    // Cancelling the agreement closes no service, it stops the payments: warn first.
    if (Acme::activeServices($uid) > 0 && !Acme::confirmed($uid))
        return 'You have live services; please confirm the cancellation.';

    return null;
});

Following a subscription poll

actionpayment.subscription_polled
cronjobs/SubscriptionPoll it runs on a schedule

Runs after the scheduled task queried the agreements at the gateway.

Parameters 2
$subscriptionarrayThe agreement record that was polled.
$resultarrayThe result from the gateway. The poll runs on a schedule and arrives many times for one agreement.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:payment.subscription_polled', 10, function ($subscription, $result) {
    // It runs on a schedule: react to a CHANGE, not to every poll.
    Acme::syncSubscription($subscription, $result);
});

Following gateway settings being saved

actionpayment.settings_saved
PaymentGatewayModule after the write

Runs after the settings of a payment module are saved.

Parameters 2
$module_namestringThe name of the module whose settings were saved.
$configarrayThe whole configuration written to disk. It holds API keys and secrets: keep it out of your own records and your logs.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:payment.settings_saved', 10, function ($module_name, $config) {
    // Carry the fact that it changed, NOT the configuration itself.
    Acme::notifyOps('payment settings changed: ' . $module_name);
});
filterpayment.bank_logo
Payment pattern map

Runs while the logo of the detected bank is chosen. Add your own bank list here.

Parameters 3
$bank_namestringThe bank name detected from the card.
$card_brandstringThe card brand.
$card_typestringThe card type: debit or credit.
Return 1
arrayYou return a map: the key is a fragment the bank name will contain, the value is the logo address. Returns are merged into the existing list. Matching is a substring search, not an exact one: a short key catches banks you did not mean.
Listener
Hook::add('filter:payment.bank_logo', 10,
    function ($bank_name, $card_brand, $card_type) {
        // The key is a substring search: a short one catches other banks too.
        return ['Acme Bank' => Acme::assetUrl('acme-bank.svg')];
    });

Taking over the card lookup

filterpayment.bin_lookup
Payment passed by link

Runs while the bank and type are resolved from the first digits of a card. Fill it in and the outside service is never called.

Parameters 2
$resultarray|falseby linkThe lookup result. Expected fields: country, card type, scheme, bank name and brand.
$bin_numberstringThe first six digits of the card.
Return 1
voidThe return is ignored; you write over the result. Leave it alone and the outside lookup carries on as normal. Wiring your own cache here saves a call on every payment.
Listener
Hook::add('filter:payment.bin_lookup', 10, function (&$result, $bin_number) {
    // With a cache of your own there is no need to go outside.
    $cached = Acme::binCache($bin_number);
    if ($cached) $result = $cached;
});

Replacing the payment pane with your own

filterpayment.gateway_pane
Checkout passed by link

Runs while the gateway pane is prepared at the payment step. Put your own screen here and the classic payment page is skipped.

Parameters 2
$htmlstringby linkWhat will be drawn in the pane.
$ctxarrayby linkThe context.
Return 1
voidThe return is ignored. Leave the content filled and your screen is shown; leave it empty and the classic payment page takes over. Emptying it is a decision too.
Listener
Hook::add('filter:payment.gateway_pane', 10, function (&$html, &$ctx) {
    // Filled means your screen, empty means the classic page.
    $html = Acme::renderPane($ctx);
});

Following an agreement being cancelled

actionpayment.subscription_cancelled
AccountSubscriptions after cancellation

Runs after a payment agreement is cancelled. No further charge arrives from it.

Parameters 2
$subscription_idintThe id of the cancelled agreement.
$subarrayThe agreement row as it was before the cancellation: owner, module, identifier, currency and period.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:payment.subscription_cancelled', 10, function ($subscription_id, $sub) {
    // The services behind it now have no automatic charge.
    Acme::warnUnpaidRisk((int) ($sub['user_id'] ?? 0));
});

Stopping a member being taken off an agreement

gatepayment.subscription_member_remove
AccountSubscriptions before removal

Runs before a service or add-on is taken off a payment agreement.

Parameters 6
$uidintThe owner of the agreement.
$subscription_idintThe id of the agreement.
$typestringservice or addon.
$midintThe id of the member.
$memberarrayThe member row about to be removed. On an add-on the owner field holds the id of the parent service, not the user: read it accordingly.
$subarrayThe agreement row.
Return 1
string|nullA non-empty text blocks the removal and is shown as the error. An empty return lets it carry on.
Listener
Hook::add('gate:payment.subscription_member_remove', 10,
    function ($uid, $subscription_id, $type, $mid, $member, $sub) {
        // No removal while the commitment period still runs.
        if (Acme::underCommitment($mid, $type)) return 'It cannot leave before the commitment ends.';

        return null;
    });

Following a member being taken off

actionpayment.subscription_member_removed
AccountSubscriptions after removal

Runs after the member is taken off the agreement.

Parameters 5
$typestringservice or addon.
$idintThe id of the member.
$memberarrayThe member row as it was before removal. Its agreement field still holds the old value: that is where "which agreement did it leave" is answered.
$subarrayThe agreement row before the change; its status and amount are the old ones.
$historyarrayWhat the operation produced, the same record written to the history.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:payment.subscription_member_removed', 10,
    function ($type, $id, $member, $sub, $history) {
        // "Which agreement did it leave" lives in the old value.
        Acme::detached($type, $id, (int) ($member['subscription_id'] ?? 0));
    });

Pitfalls

A settlement is not "the money arrived"

The settlement record can carry a pending status: the gateway took the transaction and has not finalised it. A listener opening a service without checking hands out a free service for a payment that later fails.

The record can be empty on a callback

Where the module cannot tie a callback to a checkout, the hook receives an empty array. That marks a forged or unmatched callback. The same callback can also arrive again; a listener ignoring the "handled before" mark does its work twice.

The card number reaches no hook

The stored-card hooks carry display details only: the last four digits, the brand, the expiry. The number, the security code and the token never reach us. You cannot write a rule that reads the card; build the decision on the account.

A browser return is not a reliable signal

Where a callback came from their servers, the gateway is speaking. From a browser, the customer may never have opened that page — closed it, lost the network, missed the redirect. Rest work that involves money on the server callback.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.