Service Transfer Hooks

1 views Markdown

The seven hooks moving a service to a new owner: the request, the recipient's approval, a refusal, and calling it off.

Overview

A transfer wants consent from both sides: the current owner starts it and the recipient approves. A pending record sits between them and either side can call it off.

These hooks do not tell types apart: hosting, servers and domains all pass the same flow. Writing a rule meant for domains, check the service type yourself.

Reference

Stopping a transfer request

gateservice.transfer_request
ClientServices domains pass here too

Runs where the current owner starts the transfer, before the pending record is written.

Parameters 3
$servicearrayThe service about to move. No type filtering happens: the type field can be domain too.
$from_idintThe current owner. The account starting the transfer.
$to_idintThe target owner. Resolved from an e-mail address and confirmed to be a registered customer.
Return 1
stringA non-empty string stops the transfer; the text is thrown as the error and shown to the customer.
Listener
Hook::add('gate:service.transfer_request', 10,
    function ($service, $from_id, $to_id) {
        // A free developer licence does not move: put your own rule here.
        if (Acme::nonTransferable($service))
            return 'This service cannot move to another account.';

        return null;
    });

Following a transfer request

actionservice.transfer.requested
ClientServices the payload, not the record

Runs after the pending transfer record was created. The service is still with the old owner.

Parameters 3
$servicearrayThe service about to move, still with its current owner.
$dataarrayThe transfer payload: the service name, the sending and receiving sides, the verification token. The parameter in the same slot on the sibling hooks is the record row itself; this one is the data inside it. Mixing them up reads empty fields.
$uidintThe id of the owner starting the transfer.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:service.transfer.requested', 10,
    function ($service, $data, $uid) {
        // $data IS THE PAYLOAD (not the event row): read its fields directly.
        Audit::transferOpened((int) ($service['id'] ?? 0),
            (int) ($data['from_id'] ?? 0), (int) ($data['to_id'] ?? 0));
    });

Stopping a transfer approval

gateservice.transfer_approve
ClientServices the recipient approves

Runs where the recipient accepts, before ownership moves.

Parameters 3
$servicearrayThe service record about to move.
$from_idintThe current owner.
$to_idintThe target owner.
Return 1
stringA non-empty string stops the transfer; the text is thrown as the error and shown to the customer.
Listener
Hook::add('gate:service.transfer_approve', 10,
    function ($service, $from_id, $to_id) {
        // Check the recipient too: no service moves to an unverified account.
        if (!Acme::verifiedAccount($to_id))
            return 'The receiving account is not verified.';

        return null;
    });

Learning that a transfer completed

actionservice.transfer.approved
ClientServices ownership moved

Runs after ownership moved to the new account.

Parameters 4
$serviceIdintThe id of the moved service.
$fromIdintThe previous owner.
$toIdintThe new owner.
$newOwnerarrayThe new owner's record: id, full_name, email.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:service.transfer.approved', 10,
    function ($serviceId, $fromId, $toId, $newOwner) {
        // Cut the old owner off: the service is no longer theirs.
        Acme::revokeAccess($fromId, $serviceId);
        Acme::grantAccess($toId, $serviceId);
    });

Following a refused transfer

actionservice.transfer.rejected
ClientServices no service record arrives

Runs where the recipient refused the transfer.

Parameters 2
$meintThe account id of the recipient who refused.
$dataarrayThe transfer data: service_id, service_name, the sending and receiving sides. This hook hands you no service record; take the id from here where you need it.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:service.transfer.rejected', 10, function ($me, $data) {
    // No service record arrives: take the id from the payload.
    Crm::transferRefused((int) ($data['service_id'] ?? 0), (int) ($data['from_id'] ?? 0));
});

Following a transfer called off

actionservice.transfer.cancelled
ClientServices the sender called it off

Runs where the current owner called off a pending transfer.

Parameters 2
$servicearrayThe service record, still with its current owner.
$pendingarrayThe cancelled pending record row, holding the recipient details and the verification token. The parameter in the same slot on the request hook was the payload; this one is the row, with the data inside it.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:service.transfer.cancelled', 10, function ($service, $pending) {
    // Here $pending IS THE RECORD ROW: the payload sits one level in.
    $payload = $pending['data'] ?? [];
    Audit::transferClosed((int) ($service['id'] ?? 0), (int) ($payload['to_id'] ?? 0));
});

Following an invitation sent again

actionservice.transfer.resent
ClientServices the same record

Runs where the owner sent the transfer invitation again. The pending record does not change; only the message is repeated.

Parameters 2
$servicearrayThe service record.
$pendingarrayThe pending transfer record.
Return 1
voidThe return is ignored.
Listener
Hook::add('action:service.transfer.resent', 10, function ($service, $pending) {
    // It repeats: hold back the resends on your own side.
    Acme::countResend((int) ($service['id'] ?? 0));
});

Pitfalls

The same slot carries two different things

On the transfer request the second parameter is the payload; on the called-off and resent hooks the same slot carries the record row, with the payload one level in. Taking one for the other reads empty fields: no error, no value.

Domains pass this flow too

The transfer hooks do not look at the service type. A rule you wrote for hosting also lands on domain transfers. Targeting one type, check the type field on the service record in your first line.

The refusal hook hands you no service record

The refusal hook hands you the refusing account and the transfer data alone; there is no service record. A listener used to the other transfer hooks looks for the service name and finds nothing. Take the id from the payload and read the record yourself.

Two gates guard two different sides

The request gate guards the sending side and the approval gate the receiving one. "This service cannot move" belongs at the request gate; "this account cannot receive" belongs at the approval gate. Writing both in one place leaves the other end open.

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.