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
Runs before the customer's cancellation request is created. Stopping it means no request is recorded.
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.not-needed, too-expensive, switching, missing-features, other.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
Runs after the request was recorded. The service is still running at this point.
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.not-needed, too-expensive, switching, missing-features, other.other only the free text arrives.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
Runs before the operator approves a cancellation request.
id, owner_id and the decoded data (timing, reason).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
Runs after the operator approved the request.
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
Runs where the customer took their cancellation request back.
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
Runs before a service or add-on is truly cancelled. This gate sits below the request layer.
service or addon.status, type, module, duedate.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
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.
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
Runs when a customer takes back a cancellation request. This is where you stop the win-back flow you started.
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
Runs when a downgrade set for the due date is called off. The service carries on with the package it has.
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
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.
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.
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.
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
- Invoice and Payment Hooks
- Service Lifecycle Hooks
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.