Skip to main content

Ping a Slack channel when a cancelled subscriber’s app polls a revoked token

Premium Beginner

When somebody cancels, their podcast app does not know. It carries on politely asking for a feed it can no longer have, sometimes for weeks. That is a winback signal arriving for free: a former subscriber whose app is still checking is a former subscriber who has not moved on.

benecaster_feed_request_dispatched runs after every feed poll and reports one of four token statuses: valid, over_limit, revoked, or not_found — plus over_limit_effect, which is always present and is 'demoted', 'locked', or null. This recipe watches for the revoked ones and posts to Slack, so the signal reaches a human in real time rather than waiting for the nightly analytics rollup.

over_limit is not a single outcome, and over_limit_effect is how you tell which one it was. 'demoted' is a paid token dropped to the public feed — HTTP 200, the listener is still getting episodes. 'locked' is a free token shut out under cap enforcement — HTTP 410, nothing at all.

A demotion and a lockout are not interchangeable, and a recipe that treats over_limit as one thing is the reason this key exists. One is a downgrade the listener may never notice; the other is a listener who has stopped receiving the podcast. 'locked' is the more urgent alert of the two — it is a person who is no longer getting the show, and the podcaster usually wants to know that today rather than at the next rollup.

Do not derive the distinction from TokenRepository::token_is_free(). It is public for the feed’s own use, is not developer surface, and has no deprecation path.

revoked, which is what the first example filters on, is unaffected by any of this.

The event fires whether or not feed request logging is switched on — the logging setting controls Benecaster’s own recording of requests, not the event itself, so you do not have to turn logging on to use this.

Watch for the unrecognised-token status instead and you get a different signal: a URL that never existed or has been rewritten, which can point at token sharing or a misbehaving app.

Alerting on a lockout instead

Same hook, different branch. A locked token is a listener receiving nothing, so this one is worth waking somebody for in a way a demotion is not:

add_action( 'benecaster_feed_request_dispatched', function ( array $ctx ): void {
    if ( 'over_limit' !== $ctx['token_status'] ) {
        return;
    }

    // 'demoted' is a paid token on the public feed - still receiving episodes.
    // 'locked' is a free token served a 410 - receiving nothing.
    if ( 'locked' !== $ctx['over_limit_effect'] ) {
        return;
    }

    $webhook = getenv( 'SLACK_WINBACK_WEBHOOK' );

    if ( ! $webhook ) {
        return;
    }

    wp_remote_post( $webhook, [
        'blocking' => false,
        'body'     => wp_json_encode( [
            'text' => sprintf(
                'Listener locked out (over cap) - show %d, prefix %s. They are receiving no feed at all.',
                $ctx['show_id'],
                $ctx['token_prefix'] ?? 'n/a'
            ),
        ] ),
    ] );
} );

Rate-limit this one. A locked app keeps polling, so a naive handler posts to Slack every few minutes per listener. Key a transient on token_prefix and post at most once a day.

Code

<?php
add_action( 'benecaster_feed_request_dispatched', function ( array $ctx ): void {
    if ( 'revoked' !== $ctx['token_status'] ) {
        return; // only interested in cancelled subscribers still polling
    }

    $webhook = getenv( 'SLACK_WINBACK_WEBHOOK' );
    if ( ! $webhook ) return;

    wp_remote_post( $webhook, [
        'blocking' => false,
        'body'     => wp_json_encode( [
            'text' => sprintf(
                'Revoked-token poll on show #%d — token %s from %s (%s)',
                $ctx['show_id'],
                $ctx['token_prefix'] ?? 'unknown',
                $ctx['country_code'] ?? '??',
                $ctx['user_agent'] ?? 'unknown UA'
            ),
        ] ),
        'headers'  => [ 'Content-Type' => 'application/json' ],
    ] );
} );

View on GitHub →

Hooks Used

Need this built rather than just documented? See our services →