# Service Cancellation Hooks

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

The seven hooks running where a customer says "I no longer want this": the request, the approval, taking it back, and the cancellation itself.

## Overview

Cancelling has **two layers**, and mixing them up puts your code in the wrong place. The upper layer is **the request**: a customer asks, an operator approves, or the customer takes it back. The lower layer is **the work**: the service is truly closed.

The request layer keeps a record and talks to the customer. The work layer runs from a scheduled task and reaches the provider. A service can also be cancelled **with no request at all**: an unpaid invoice does that on its own.

## Reference

### Stopping a cancellation request

gateservice.cancellation_request

`ClientServices` the customer asks

Runs before the customer's cancellation request is created. Stopping it means **no request is recorded**.

Parameters 3

$servicearrayThe customer's own service. Domains do not pass this flow.

$urgencystringThe timing: `now`, or `period-ending` at the end of the term. Two very different things: one closes the service today, the other waits out a paid term.

$reasonKeystringThe reason key: `not-needed`, `too-expensive`, `switching`, `missing-features`, `other`.

Return 1

stringA non-empty string **stops** the request; it reaches the customer as the error.

Listener PHP

```php
Hook::add('gate:service.cancellation_request', 10,
    function ($service, $urgency, $reasonKey) {
        // A service under contract does not close before its term ends.
        if ($urgency === 'now' && Acme::underContract($service))
            return 'No immediate cancellation while the contract runs.';

        return null;
    });
```

### Following a cancellation request

actionservice.cancellation_requested

`ClientServices` it hands you a record id

Runs after the request was recorded. The service is **still running** at this point.

Parameters 5

$servicearrayThe service record.

$urgencystringThe timing: `now`, or `period-ending` at the end of the term. Two very different things: one closes the service today, the other waits out a paid term.

$reasonKeystringThe reason key: `not-needed`, `too-expensive`, `switching`, `missing-features`, `other`.

$reasonstringThe stored reason text: the label in the customer's language plus any free note. On `other` only the free text arrives.

$eventIdintThe id of the created request record. The approve and revoke hooks come back to the same one.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:service.cancellation_requested', 10,
    function ($service, $urgency, $reasonKey, $reason, $eventId) {
        // The service is STILL running: this is an intent, not a closing.
        Retention::opened($eventId, $reasonKey, $urgency);
    });
```

### Stopping a cancellation approval

gateservice.cancellation_accept

`AdminServices` the operator approves

Runs before the operator approves a cancellation request.

Parameters 2

$service_idintThe id of the service to be cancelled. An **id** arrives, not the service record.

$requestarrayThe request record: `id`, `owner_id` and the decoded `data` (timing, reason).

Return 1

stringA non-empty string **stops** the approval; it reaches the operator as the error.

Listener PHP

```php
Hook::add('gate:service.cancellation_accept', 10, function ($service_id, $request) {
    // Cancelling with an unpaid invoice open loses the money owed.
    if (Acme::hasUnpaid($service_id)) return 'Settle the unpaid invoice first.';

    return null;
});
```

### Following a cancellation approval

actionservice.cancellation.accepted

`AdminServices` approved

Runs after the operator approved the request.

Parameters 3

$serviceIdintThe service id.

$cancellationTypestringThe kind: now, or at the end of the term. Where the term end was chosen the service **does not close today**.

$requestDataarrayThe request data: the reason and who approved it.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:service.cancellation.accepted', 10,
    function ($serviceId, $cancellationType, $requestData) {
        // On a term-end cancellation nothing closes today; hold your counter.
        if ($cancellationType === 'now') Capacity::freed($serviceId);
    });
```

### Following a request taken back

actionservice.cancellation_revoked

`ClientServices` the customer changed their mind

Runs where the customer took their cancellation request back.

Parameters 3

$servicearrayThe service record.

$eventarrayThe request record **as it was before** deletion, holding the timing, the reason key, the reason text and any note. The record is gone, so read it here.

$uidintThe account id of the service owner.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:service.cancellation_revoked', 10,
    function ($service, $event, $uid) {
        // Taking it back is a retention win: close the record.
        Retention::closed((int) ($event['id'] ?? 0), 'revoked');
    });
```

### Stopping the cancellation work

gateservice.cancel

`cronjobs/ServiceCancel` a veto still cancels

Runs before a service or add-on is truly cancelled. This gate sits **below** the request layer.

Parameters 4

$target_typestring`service` or `addon`.

$target_idintThe id of what is being cancelled.

$servicearrayThe live row: `status`, `type`, `module`, `duedate`.

$user_idintThe service owner.

Return 1

stringA non-empty string **vetoes** the cancel — and the job still turns into a **cancel signal**, with your reason recorded. Same behaviour as the suspend gate: a veto is not "nothing happens".

Listener PHP

```php
Hook::add('gate:service.cancel', 10,
    function ($target_type, $target_id, $service, $user_id) {
        // With data still moving you may want the cancel held.
        if (Acme::migrationRunning($target_id)) return 'A migration is running.';

        return null;
    });
```

### Following an add-on cancellation request

actionservice.addon.cancellation_requested

`ClientServices` at period end

Runs when a customer asks for an add-on to end at the close of its period. The request is recorded but **the add-on is still live** and keeps working until the due date.

Parameters 5

$servicearrayThe service the add-on belongs to.

$addonarrayThe add-on being cancelled. At this moment the row is still active; the cancellation lands on the due date.

$reasonstringWhat the customer wrote. The field is optional, so it **can arrive empty**. It is cut at 300 characters.

$eventIdintThe id of the cancellation request record.

$uidintThe account that owns the add-on. A sub-user may have done the clicking; that is a different id.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:service.addon.cancellation_requested', 10,
    function ($service, $addon, $reason, $eventId, $uid) {
        // Start the win-back offer: the add-on still runs, there is time.
        Acme::offerRetention($uid, $addon['addon_name'] ?? '', $reason);
    });
```

### Following a cancellation being taken back

actionservice.addon.cancellation_revoked

`ClientServices` changed their mind

Runs when a customer takes back a cancellation request. This is where you stop the win-back flow you started.

Parameters 4

$servicearrayThe service the add-on belongs to.

$addonarrayThe add-on whose cancellation was taken back.

$eventarrayThe **deleted** request record, reason and approval included. By the time the hook runs that row is gone from the database; this is the last copy of it.

$uidintThe account that owns the service.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:service.addon.cancellation_revoked', 10,
    function ($service, $addon, $event, $uid) {
        Acme::stopRetention($uid, $addon['addon_name'] ?? '');
    });
```

### Following a scheduled downgrade being cancelled

actionservice.scheduled_downgrade_cancelled

`ClientServices` changed their mind

Runs when a downgrade set for the due date is called off. The service carries on with the package it has.

Parameters 3

$servicearrayThe service record. The package **did not change**: the downgrade was planned, never applied.

$scheduledarrayThe cancelled plan, **as it was before the update**. Its status field still holds the old value.

$uidintThe account that owns the service.

Return 1

voidThe return is ignored.

Listener PHP

```php
Hook::add('action:service.scheduled_downgrade_cancelled', 10,
    function ($service, $scheduled, $uid) {
        // Take the downgrade back out of your capacity forecast.
        Acme::releaseForecast((int) ($service['id'] ?? 0));
    });
```

## Pitfalls

> **A request and a cancellation are not the same**
> 
> The request hooks run where a customer **asks**; the service is still up. The real closing happens on the cancel hooks below. Freeing capacity, deleting data or raising a final invoice from the request hook leaves damage you cannot undo **where the customer takes it back**.

> **A term-end cancellation closes nothing today**
> 
> The kind on the approval hook is either **now** or **the end of the term**. On the second the service runs to the end of the paid period. A listener assuming "cancelled" without reading it counts a service the customer is still using as closed.

> **A cancel veto does not prevent the cancel**
> 
> Returning text at the cancel gate does not stop the work; the signal **still turns into a cancel** and only your reason is recorded. The same trap as the suspend gate. To truly prevent it, act on **the request layer**.

> **Not every cancellation comes from a request**
> 
> An unpaid invoice takes a service to cancellation **with no request**. A system listening only to the request hooks never sees those. To see every cancellation, listen to the status hook on the layer below.

## Related Articles

- [Service Status Hooks](https://dev.wisecp.com/en/service-status-hooks)
- Invoice and Payment Hooks
- [Service Lifecycle Hooks](https://dev.wisecp.com/en/service-lifecycle-hooks)
