# Service Transfer Hooks

https://dev.wisecp.com/es/service-transfer-hooks

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 PHP

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

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

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

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

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

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

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

## Related Articles

- [Service Status Hooks](https://dev.wisecp.com/en/service-status-hooks)
- [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks)
- [Service Lifecycle Hooks](https://dev.wisecp.com/en/service-lifecycle-hooks)
