webpatser / resonate-channel-meter
Record billable and observable channel occupancy periods from Resonate webhooks
Package info
github.com/webpatser/resonate-channel-meter
pkg:composer/webpatser/resonate-channel-meter
Requires
- php: ^8.5
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/http: ^13.0
- illuminate/routing: ^13.0
- illuminate/support: ^13.0
Requires (Dev)
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0
README
A Laravel package that records billable and observable channel occupancy periods from webpatser/resonate-webhooks events. It turns "this room was occupied for 12 minutes" from a polling problem into a Pusher webhook your backend just reacts to.
It is a webhook consumer, not a Resonate plugin. It runs in your Laravel app.
Where it fits
Resonate process Laravel app
──────────────── ───────────
RedisRosterPlugin webhook
WebhookPlugin ─────────────► WebhookController + VerifyPusherSignature
│
▼
EventHandler
│
▼
channel_meter_periods table
│
▼
$chat->totalChannelMeterSeconds(...)
The roster makes occupancy cluster-correct. The webhooks plugin pushes the edges out as signed HTTP POSTs. This package receives them and stores them as durable periods you can query and bill on.
How it works
One period per occupancy
A channel_occupied event opens a channel_meter_periods row with started_at = time_ms and ended_at = null. The matching channel_vacated closes it. Both are idempotent: a redelivered webhook never duplicates a period or leaves an orphan close.
Channel to model resolution
The channel_meter_periods table carries a polymorphic model_type / model_id. A small resolver maps a channel name to the domain entity it represents, using a {id} placeholder:
// config/resonate-channel-meter.php 'patterns' => [ 'presence-chat.{id}' => \App\Models\Chat::class, 'presence-call.{id}' => \App\Models\Call::class, ],
presence-chat.42 resolves to (Chat::class, '42'), and the period is stored with that link. A channel that matches no pattern is still recorded with the channel name; it just has no model attached. Swap the default ConfigChannelResolver for your own by binding ChannelResolver::class in a service provider if you need richer rules (tenants, composite keys, lookups).
Models pull their own periods
Apply the HasChannelMeter trait to any model the resolver maps to:
use Webpatser\ResonateChannelMeter\Concerns\HasChannelMeter; class Chat extends Model { use HasChannelMeter; }
You get:
$chat->channelMeterPeriods; // every period $chat->openChannelMeterPeriods; // periods currently in progress $chat->totalChannelMeterSeconds(); // sum of closed durations $chat->totalChannelMeterSeconds($from, $to); // clamped to a window
Installation
composer require webpatser/resonate-channel-meter
The package migration is auto-loaded. Run it:
php artisan migrate
Publish the config to register channel patterns:
php artisan vendor:publish --tag=resonate-channel-meter-config
Registering the webhook endpoint
The package does not auto-register a route, so you mount the controller wherever you want, behind the signature middleware:
use Webpatser\ResonateChannelMeter\Http\Controllers\WebhookController; use Webpatser\ResonateChannelMeter\Http\Middleware\VerifyPusherSignature; Route::post('/webhooks/resonate', WebhookController::class) ->middleware(VerifyPusherSignature::class);
Then point the webhooks plugin at that URL:
// config/resonate-webhooks.php 'endpoints' => [ [ 'url' => env('APP_URL').'/webhooks/resonate', 'app_id' => '*', 'events' => ['channel_occupied', 'channel_vacated'], ], ],
The middleware verifies the X-Pusher-Signature against the app secret in reverb.apps, so an unsigned or forged request is rejected with 401. An unknown X-Pusher-Key is rejected with 422.
Configuration reference
| Key | Default | Purpose |
|---|---|---|
patterns |
[] |
Channel-to-model patterns. Each maps a channel-name shape with a {id} placeholder to an Eloquent model class. |
Notes and caveats
- Only occupancy events. This package processes
channel_occupiedandchannel_vacated; member and client events are ignored. Build a separate consumer if you want to record them. - Idempotent. Receiving the same webhook twice never duplicates a period.
time_mscomes from the server. Thestarted_atandended_attimestamps are taken from the webhook envelope'stime_ms, not the receiver's local clock, so a queued retry still records the original moment.- Open periods are excluded from totals.
totalChannelMeterSeconds()ignores any period whoseended_atis still null. Close stale periods explicitly if you need to bill an in-progress session.
Requirements
- PHP 8.5+
- Laravel 13
webpatser/resonate-webhooksrunning against a Resonate server
Testing
composer test
License
MIT. See LICENSE.