# Payment Gateway Hooks

https://dev.wisecp.com/es/gateway-hooks

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 PHP

```php
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 PHP

```php
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 PHP

```php
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.

$cardarray**Display 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 PHP

```php
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 PHP

```php
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 PHP

```php
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 PHP

```php
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 PHP

```php
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 PHP

```php
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);
});
```

### Adding a bank logo

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 PHP

```php
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 PHP

```php
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 PHP

```php
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 PHP

```php
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.

$typestring`service` 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|null**A non-empty text blocks the removal** and is shown as the error. An empty return lets it carry on.

Listener PHP

```php
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

$typestring`service` 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 PHP

```php
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**.

## Related Articles

- [Payment Hooks](https://dev.wisecp.com/en/payment-hooks)
- [Invoice Lifecycle Hooks](https://dev.wisecp.com/en/invoice-lifecycle-hooks)
- Order Flow Hooks
